沿用既有的 upsertLineInSection:連結以固定前綴獨佔總覽段落裡的一行,重跑時就地 更新,不會長出第二個連結;議題原本的 markdown 白話總覽一字不動——網頁是補充, 不是取代。 連結旁自動附上「此連結預設為私有,組織外無法開啟」。讀到的人多半會想轉寄給組織外 的人,這句話寫在議題上比寫在文件裡有用。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
135 lines
4.6 KiB
JavaScript
135 lines
4.6 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 更新議題的時程相關欄位:Milestone、截止日、人天估算。
|
||
*
|
||
* 三件事經同一個 PATCH 送出,因為排時程時它們幾乎總是一起改。
|
||
*
|
||
* 人天估算只寫進 body 的人類可讀一行。Gitea 1.27 的 API 沒有任何請求定義接受
|
||
* `time_estimate`——它只出現在 Issue 的回應裡——所以議題的估算欄位無法由 API 寫入。
|
||
*
|
||
* Milestone 只認既有的:指到不存在的就中止並列出可選項目,本工具不建立 Milestone。
|
||
*
|
||
* 也負責把圖解版總覽的網址寫回議題:連結以固定前綴獨佔一行,重跑時就地更新,
|
||
* 議題原本的 markdown 白話總覽一字不動——網頁是補充,不是取代。
|
||
*
|
||
* 用法:
|
||
* node scripts/issue-update.js --repo owner/name --index 12
|
||
* [--milestone <名稱>] [--due-date YYYY-MM-DD] [--estimate-days N]
|
||
* [--overview-url <網址>] [--host <網址>] [--dry-run]
|
||
*/
|
||
import {
|
||
ScriptError,
|
||
expectOk,
|
||
giteaRequest,
|
||
main,
|
||
parseFlags,
|
||
parseIndex,
|
||
parseRepo,
|
||
preflight,
|
||
resolveLogin,
|
||
} from './lib.js';
|
||
import { upsertLineInSection } from './issue-body.js';
|
||
|
||
main(async () => {
|
||
const flags = parseFlags(process.argv.slice(2), {
|
||
required: ['repo', 'index'],
|
||
optional: ['milestone', 'due-date', 'estimate-days', 'overview-url', 'host'],
|
||
booleans: ['dry-run'],
|
||
});
|
||
const repo = parseRepo(flags.repo);
|
||
const index = parseIndex(flags.index);
|
||
const dueDate = parseDueDate(flags['due-date']);
|
||
const days = parseDays(flags['estimate-days']);
|
||
const overviewUrl = parseOverviewUrl(flags['overview-url']);
|
||
|
||
if (flags.milestone === undefined && dueDate === null && days === null && overviewUrl === null) {
|
||
throw new ScriptError(
|
||
'NOTHING_TO_UPDATE',
|
||
'至少要指定 --milestone、--due-date、--estimate-days 或 --overview-url 其中一個',
|
||
);
|
||
}
|
||
|
||
const login = resolveLogin({ host: flags.host });
|
||
if (!flags['dry-run']) await preflight(login, repo);
|
||
|
||
const path = `/repos/${repo}/issues/${index}`;
|
||
const payload = {};
|
||
|
||
if (flags.milestone !== undefined) {
|
||
payload.milestone = await resolveMilestoneId(login, repo, flags.milestone);
|
||
}
|
||
if (dueDate !== null) {
|
||
payload.due_date = `${dueDate}T00:00:00Z`;
|
||
}
|
||
if (days !== null || overviewUrl !== null) {
|
||
const issue = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`);
|
||
let body = issue.body ?? '';
|
||
|
||
if (days !== null) body = upsertLineInSection(body, '關聯', `估算人天:${days}`);
|
||
if (overviewUrl !== null) {
|
||
body = upsertLineInSection(body, '總覽', `圖解版總覽:${overviewUrl}${PRIVACY_NOTE}`);
|
||
}
|
||
// 沒變就不塞進 PATCH:無謂改寫 body 會在議題上留下一筆沒有內容的編輯紀錄
|
||
if (body !== issue.body) payload.body = body;
|
||
}
|
||
|
||
if (flags['dry-run']) {
|
||
return { dryRun: true, repo, index, requests: [{ method: 'PATCH', path, body: payload }] };
|
||
}
|
||
|
||
const issue = expectOk(await giteaRequest(login, 'PATCH', path, { body: payload }), `PATCH ${path}`);
|
||
return {
|
||
repo,
|
||
index,
|
||
updated: Object.keys(payload),
|
||
url: issue.html_url,
|
||
};
|
||
});
|
||
|
||
|
||
function parseDueDate(value) {
|
||
if (value === undefined) return null;
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||
throw new ScriptError('BAD_DUE_DATE', `--due-date 需為 YYYY-MM-DD,收到的是 ${value}`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
/** artifact 預設私有,組織外開不起來——這件事要跟著連結一起留在議題上 */
|
||
const PRIVACY_NOTE = '(此連結預設為私有,組織外無法開啟)';
|
||
|
||
function parseOverviewUrl(value) {
|
||
if (value === undefined) return null;
|
||
if (!/^https?:\/\/\S+$/.test(value)) {
|
||
throw new ScriptError('BAD_OVERVIEW_URL', `--overview-url 需為 http(s) 網址,收到的是 ${value}`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function parseDays(value) {
|
||
if (value === undefined) return null;
|
||
const days = Number(value);
|
||
if (!Number.isFinite(days) || days <= 0) {
|
||
throw new ScriptError('BAD_ESTIMATE', `--estimate-days 需為正數,收到的是 ${value}`);
|
||
}
|
||
return days;
|
||
}
|
||
|
||
async function resolveMilestoneId(login, repo, title) {
|
||
const path = `/repos/${repo}/milestones`;
|
||
const milestones = expectOk(
|
||
await giteaRequest(login, 'GET', path, { query: { state: 'all' } }),
|
||
`GET ${path}`,
|
||
) ?? [];
|
||
|
||
const hit = milestones.find((milestone) => milestone.title === title);
|
||
if (!hit) {
|
||
throw new ScriptError(
|
||
'UNKNOWN_MILESTONE',
|
||
`${repo} 沒有名為「${title}」的 Milestone。本工具不建立 Milestone,` +
|
||
`請改挑既有的:${milestones.map((m) => m.title).join('、') || '(這個 repo 目前沒有 Milestone)'}`,
|
||
);
|
||
}
|
||
return hit.id;
|
||
}
|