Files
tea-sdlc/scripts/issue-update.js
jiantw83andClaude Opus 5 586b746b55 feat(issue-update): 以 --tick 勾待辦,並在沒東西可改時不送空的 PATCH
--tick 收抽取契約交出的那一整行 raw,--section 指出它在哪一個段落。四種擋下來的情況
各有錯誤碼,因為使用者的下一步不同:找不到(抽取結果過期,重抽)、同段落出現多次
(請改寫議題上重複的說法)、那一項沒有方框(去議題上補)、段落不存在(對照輸出確認)。
一律報錯不盲改——改壞了議題的進度條會說謊,而沒有人會去比對 body 的編輯紀錄。

--tick 的輸入只驗「是不是清單項」,不要求方框。抽取端會把忘了寫 checkbox 的項目也收成
一項待辦,那種 raw 要走到 tickLine 才拿得到「去議題上補成 checkbox」這句話;
在入口就擋掉,使用者只會得到一個看不出該怎麼辦的格式錯誤。

沒有任何欄位要改時整個 PATCH 都不送:空的 PATCH 會把議題的 updated_at 推新,在列表上
浮起來像是有人動過。這個判斷做在試跑分支之前——放在後面的話,試跑會預告一個實跑根本
不會發的請求,而那是最難查的那種落差。

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

239 lines
8.3 KiB
JavaScript
Raw Permalink 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 白話總覽一字不動——網頁是補充,不是取代。
*
* 以及勾待辦:`--tick` 收抽取契約交出的那一整行 `raw`,只把它的方框換成已勾。
* 認不出是哪一行、或那一行已經不在議題上時一律報錯,不盲改——改壞了議題的進度條會說謊,
* 而沒有人會去比對 body 的編輯紀錄。
*
* 用法:
* node scripts/issue-update.js --repo owner/name --index 12
* [--milestone <名稱>] [--due-date YYYY-MM-DD] [--estimate-days N]
* [--overview-url <網址>] [--tick '<raw 那一行>' [--section 待辦]]
* [--host <網址>] [--dry-run]
*/
import {
ScriptError,
expectOk,
giteaRequest,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
} from './lib.js';
import { isListItem, tickLine, 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', 'tick', 'section', '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']);
const tick = parseTick(flags.tick, flags.section);
if (
flags.milestone === undefined &&
dueDate === null &&
days === null &&
overviewUrl === null &&
tick === null
) {
throw new ScriptError(
'NOTHING_TO_UPDATE',
'至少要指定 --milestone、--due-date、--estimate-days、--overview-url 或 --tick 其中一個',
);
}
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`;
}
let current = null;
let 勾起的那一行 = null;
let 已經勾過 = false;
if (days !== null || overviewUrl !== null || tick !== null) {
current = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`);
let body = current.body ?? '';
if (days !== null) body = upsertLineInSection(body, '關聯', `估算人天:${days}`);
if (overviewUrl !== null) {
body = upsertLineInSection(body, '總覽', `圖解版總覽:${overviewUrl}${PRIVACY_NOTE}`);
}
if (tick !== null) {
const result = applyTick(body, tick, flags.section);
body = result.body;
勾起的那一行 = result.line;
已經勾過 = result.已經勾過;
}
if (body !== current.body) payload.body = body;
}
// 全部都已經是現在這個樣子就不送:空的 PATCH 會把議題的 updated_at 推新,
// 在列表上浮起來像是有人動過。這個判斷要做在試跑分支之前,
// 否則試跑會預告一個實跑根本不會發的請求。
const noop = Object.keys(payload).length === 0;
const requests = noop ? [] : [{ method: 'PATCH', path, body: payload }];
if (flags['dry-run']) {
return { dryRun: true, repo, index, 勾起的那一行, 已經勾過, requests };
}
if (noop) {
return { repo, index, updated: [], 勾起的那一行, 已經勾過, url: current.html_url };
}
const issue = expectOk(await giteaRequest(login, 'PATCH', path, { body: payload }), `PATCH ${path}`);
return {
repo,
index,
updated: Object.keys(payload),
勾起的那一行,
已經勾過,
url: issue.html_url,
};
});
/**
* 把 tickLine 的結果轉成這一層的錯誤碼。
* 認不出是哪一行就報錯而不是猜——精確替換的價值全在這裡。
*/
function applyTick(body, raw, section) {
const result = tickLine(body, raw, section);
if (result.status === 'no-section') {
throw new ScriptError(
'SECTION_NOT_FOUND',
`議題上沒有「${section}」這個段落;請確認 --section 的名稱與議題上的 \`## 標題\` 完全一致`,
);
}
if (result.status === 'no-checkbox') {
throw new ScriptError(
'NOT_A_CHECKBOX',
`議題上這一項沒有 checkbox,沒有方框可以勾:${raw.trim()};` +
'請先在議題上把它補成 `- [ ] …` 的寫法',
);
}
if (result.status === 'not-found') {
throw new ScriptError(
'RAW_NOT_FOUND',
`議題上找不到這一行:${raw.trim()};` +
'手上的抽取結果可能已經過期(議題被改過),請重新執行 wp-extract 再試',
);
}
if (result.status === 'ambiguous') {
throw new ScriptError(
'RAW_AMBIGUOUS',
`這一行在議題上出現了 ${result.count} 次,分不出要勾哪一個:${raw.trim()};` +
'請把議題上重複的那幾項改寫成看得出差別的說法,再重新抽取',
);
}
return {
body: result.status === 'ticked' ? result.body : body,
line: result.line,
已經勾過: result.status === 'already',
};
}
/**
* `--tick` 收的是一整行 raw,不是一段文字——精確替換的前提是它逐字等於議題上的那一行。
* 判斷用 issue-body 導出的同一份文法:抽取端收得下的,這裡就要收得下。
*/
function parseTick(value, section) {
if (value === undefined) {
if (section !== undefined) {
throw new ScriptError('MISSING_FLAG', '--section 是給 --tick 用的,單獨指定沒有作用');
}
return null;
}
if (value.includes('\n')) {
throw new ScriptError('BAD_RAW', '--tick 一次只勾一行,收到的內容夾帶了換行');
}
if (!isListItem(value)) {
throw new ScriptError(
'BAD_RAW',
`--tick 需要一整行清單項(例如「- [ ] 解析九個段落」),收到的是 ${value}`,
);
}
return value;
}
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;
}