#!/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 '' [--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; }