Files
tea-sdlc/scripts/issue-update.js
T
jiantw83andClaude Opus 5 197de62570 fix(issue-update): 網址夾帶私有提醒時擋下,並修正 TDZ 錯誤
把上一次寫回的整行一起複製貼上是很常見的手誤,放行的話那句提醒會在議題上出現
兩次。

順帶修掉一個自己造出來的錯:PRIVACY_NOTE 原本宣告在 main() 之後,而新的檢查在
main() 的同步段就要用到它,於是每次執行都以「Cannot access 'PRIVACY_NOTE' before
initialization」收場。常數移到 main() 之前。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:22:30 +00:00

142 lines
4.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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';
/** artifact 預設私有,組織外開不起來——這件事要跟著連結一起留在議題上 */
const PRIVACY_NOTE = '(此連結預設為私有,組織外無法開啟)';
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;
}
function parseOverviewUrl(value) {
if (value === undefined) return null;
if (!/^https?:\/\/\S+$/.test(value)) {
throw new ScriptError('BAD_OVERVIEW_URL', `--overview-url 需為 http(s) 網址,收到的是 ${value}`);
}
// 連上一次寫回的整行一起複製貼上是很常見的手誤,放行的話那句提醒會在議題上出現兩次
if (value.includes(PRIVACY_NOTE)) {
throw new ScriptError(
'BAD_OVERVIEW_URL',
`--overview-url 夾帶了上一次寫回的提醒文字,請只給網址本身:${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;
}