From 45bfd99d803ec4314258018e38ccc21b1f4ce3b9 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(issue-update):=20=E6=8E=9B=20Milestone?= =?UTF-8?q?=E3=80=81=E5=AF=AB=E6=88=AA=E6=AD=A2=E6=97=A5=E3=80=81=E8=A8=98?= =?UTF-8?q?=E4=BA=BA=E5=A4=A9=E4=BC=B0=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三件事經同一個 PATCH 送出,因為排時程時它們幾乎總是一起改,分開發等於多兩次往返。 Milestone 只認既有的:指到不存在的就中止並列出可選項目,本工具不建立 Milestone。 人天估算只寫進 body 的人類可讀一行。Gitea 1.27 的 API 沒有任何請求定義接受 time_estimate——它只出現在 Issue 的回應裡——所以議題的估算欄位無法由 API 寫入。 新增的 upsertLineInSection 負責就地更新那一行:重跑改估算不會累積成兩行,值沒變 就不把 body 塞進 PATCH,免得在議題上留下一筆沒有內容的編輯紀錄。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/issue-body.js | 37 +++++++++++++ scripts/issue-update.js | 119 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 scripts/issue-update.js diff --git a/scripts/issue-body.js b/scripts/issue-body.js index f1cc834..972a282 100644 --- a/scripts/issue-body.js +++ b/scripts/issue-body.js @@ -141,3 +141,40 @@ function splitRow(line) { function isSeparator(cells) { return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell)); } + +/** + * 在指定段落裡就地更新(或補上)一行「前綴+值」。 + * + * 用於人天估算這種「議題上只該有一行、重跑要覆蓋而不是累積」的欄位。 + * 段落不存在時補在 body 結尾——寧可放錯位置,也不要讓值靜靜消失。 + * + * @param {string} body 議題 body + * @param {string} section 段落名稱,例如 '關聯' + * @param {string} line 完整的一行,例如 '估算人天:3' + * @returns {string} 更新後的 body;內容沒有變動時回傳原字串 + */ +export function upsertLineInSection(body, section, line) { + const prefix = line.slice(0, line.indexOf(':') + 1); + const existing = new RegExp(`^${escapeRegExp(prefix)}.*$`, 'm'); + + if (existing.test(body)) { + const updated = body.replace(existing, line); + return updated; + } + + const heading = `## ${section}`; + const start = body.indexOf(heading); + if (start === -1) { + return `${body.replace(/\n*$/, '')}\n\n${line}\n`; + } + + // 段落的結尾是下一個標題之前,或整份 body 的結尾 + const after = body.indexOf('\n## ', start + heading.length); + const end = after === -1 ? body.length : after; + const head = body.slice(0, end).replace(/\n*$/, ''); + return `${head}\n${line}\n${body.slice(end).replace(/^\n+/, '\n')}`; +} + +function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/scripts/issue-update.js b/scripts/issue-update.js new file mode 100644 index 0000000..8dd0eda --- /dev/null +++ b/scripts/issue-update.js @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * 更新議題的時程相關欄位:Milestone、截止日、人天估算。 + * + * 三件事經同一個 PATCH 送出,因為排時程時它們幾乎總是一起改。 + * + * 人天估算只寫進 body 的人類可讀一行。Gitea 1.27 的 API 沒有任何請求定義接受 + * `time_estimate`——它只出現在 Issue 的回應裡——所以議題的估算欄位無法由 API 寫入。 + * + * Milestone 只認既有的:指到不存在的就中止並列出可選項目,本工具不建立 Milestone。 + * + * 用法: + * node scripts/issue-update.js --repo owner/name --index 12 + * [--milestone <名稱>] [--due-date YYYY-MM-DD] [--estimate-days N] + * [--host <網址>] [--dry-run] + */ +import { + ScriptError, + expectOk, + giteaRequest, + main, + parseFlags, + 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', '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']); + + if (flags.milestone === undefined && dueDate === null && days === null) { + throw new ScriptError( + 'NOTHING_TO_UPDATE', + '至少要指定 --milestone、--due-date 或 --estimate-days 其中一個', + ); + } + + 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) { + const issue = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`); + const updated = upsertLineInSection(issue.body ?? '', '關聯', `估算人天:${days}`); + // 沒變就不塞進 PATCH:無謂改寫 body 會在議題上留下一筆沒有內容的編輯紀錄 + if (updated !== issue.body) payload.body = updated; + } + + 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 parseIndex(value) { + if (!/^[1-9]\d*$/.test(value)) { + throw new ScriptError('BAD_INDEX', `--index 需為正整數,收到的是 ${value}`); + } + return Number(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 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; +}