feat(overview-artifact): 產生可預覽總覽與截圖 fallback

This commit is contained in:
2026-09-18 15:59:34 +08:00
parent c545f11ec1
commit 867a23497f
16 changed files with 605 additions and 324 deletions
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* Upload overview screenshots to a Gitea issue and update its attachment index.
* The manifest is produced by the preview step and contains local PNG paths and hashes.
*/
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
ScriptError,
expectOk,
fetchIssue,
giteaRequest,
giteaUpload,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
} from './lib.js';
import { upsertLineInSection } from './issue-body.js';
const INDEX_LINE = '圖解版總覽附件:';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'index', 'manifest'],
optional: ['host'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const index = parseIndex(flags.index);
const manifest = JSON.parse(readFileSync(resolve(flags.manifest), 'utf8'));
const screenshots = manifest.screenshots;
if (!Array.isArray(screenshots) || screenshots.length === 0) {
throw new ScriptError('MANIFEST_INVALID', 'manifest 必須包含至少一張 screenshots');
}
for (const item of screenshots) {
if (!item.role || !item.path || !item.fileName || !item.sha256 || !existsSync(resolve(item.path))) {
throw new ScriptError('MANIFEST_INVALID', '每張截圖都必須有 role、path、fileName、sha256 且檔案存在');
}
}
const login = resolveLogin({ host: flags.host });
const issuePath = `/repos/${repo}/issues/${index}`;
const assetsPath = `${issuePath}/assets`;
if (flags['dry-run']) {
return {
dryRun: true,
repo,
index,
requests: [
{ method: 'GET', path: issuePath },
{ method: 'GET', path: assetsPath },
...screenshots.map((item) => ({ method: 'POST', path: assetsPath, file: item.path })),
],
};
}
await preflight(login, repo);
const issue = await fetchIssue(login, repo, index);
const assets = expectOk(await giteaRequest(login, 'GET', assetsPath), `GET ${assetsPath}`);
const existing = Array.isArray(assets) ? assets : [];
const indexed = parseIndexedAssets(issue.body ?? '');
const uploaded = [];
for (const item of screenshots) {
const indexedAsset = indexed.find((asset) => asset.fileName === item.fileName && asset.sha256 === item.sha256);
const existingAsset = existing.find((asset) => asset.name === item.fileName && asset.sha256 === item.sha256);
const asset = indexedAsset ?? existingAsset;
if (asset) {
uploaded.push({ ...item, url: asset.url ?? asset.browser_download_url ?? asset.download_url, reused: true });
continue;
}
const response = expectOk(
await giteaUpload(login, `${assetsPath}?name=${encodeURIComponent(item.fileName)}`, resolve(item.path), item.fileName),
`POST ${assetsPath}`,
);
uploaded.push({ ...item, url: response.browser_download_url ?? response.download_url, reused: false });
}
const body = updateIndex(issue.body ?? '', uploaded);
const update = body === issue.body ? null : expectOk(await giteaRequest(login, 'PATCH', issuePath, { body: { body } }), `PATCH ${issuePath}`);
return { repo, index, uploaded, updated: update !== null, url: update?.html_url ?? issue.html_url };
});
function parseIndexedAssets(body) {
const line = body.split('\n').find((row) => row.startsWith(INDEX_LINE)) ?? '';
return [...line.matchAll(/([^=;\s]+)=([^;\s]+)\s+sha256=([a-f0-9]{64})\s+(\S+)/g)].map((match) => ({
role: match[1],
fileName: match[2],
sha256: match[3],
url: match[4],
}));
}
function updateIndex(body, assets) {
const lines = assets.map((asset) => `${asset.role}=${asset.fileName} sha256=${asset.sha256} ${asset.url ?? '(網址未回傳)'}`);
return upsertLineInSection(body, '總覽', `${INDEX_LINE}${lines.join(';')}`);
}