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>
This commit is contained in:
+108
-11
@@ -12,10 +12,15 @@
|
|||||||
* 也負責把圖解版總覽的網址寫回議題:連結以固定前綴獨佔一行,重跑時就地更新,
|
* 也負責把圖解版總覽的網址寫回議題:連結以固定前綴獨佔一行,重跑時就地更新,
|
||||||
* 議題原本的 markdown 白話總覽一字不動——網頁是補充,不是取代。
|
* 議題原本的 markdown 白話總覽一字不動——網頁是補充,不是取代。
|
||||||
*
|
*
|
||||||
|
* 以及勾待辦:`--tick` 收抽取契約交出的那一整行 `raw`,只把它的方框換成已勾。
|
||||||
|
* 認不出是哪一行、或那一行已經不在議題上時一律報錯,不盲改——改壞了議題的進度條會說謊,
|
||||||
|
* 而沒有人會去比對 body 的編輯紀錄。
|
||||||
|
*
|
||||||
* 用法:
|
* 用法:
|
||||||
* node scripts/issue-update.js --repo owner/name --index 12
|
* node scripts/issue-update.js --repo owner/name --index 12
|
||||||
* [--milestone <名稱>] [--due-date YYYY-MM-DD] [--estimate-days N]
|
* [--milestone <名稱>] [--due-date YYYY-MM-DD] [--estimate-days N]
|
||||||
* [--overview-url <網址>] [--host <網址>] [--dry-run]
|
* [--overview-url <網址>] [--tick '<raw 那一行>' [--section 待辦]]
|
||||||
|
* [--host <網址>] [--dry-run]
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
ScriptError,
|
ScriptError,
|
||||||
@@ -28,7 +33,7 @@ import {
|
|||||||
preflight,
|
preflight,
|
||||||
resolveLogin,
|
resolveLogin,
|
||||||
} from './lib.js';
|
} from './lib.js';
|
||||||
import { upsertLineInSection } from './issue-body.js';
|
import { isListItem, tickLine, upsertLineInSection } from './issue-body.js';
|
||||||
|
|
||||||
/** artifact 預設私有,組織外開不起來——這件事要跟著連結一起留在議題上 */
|
/** artifact 預設私有,組織外開不起來——這件事要跟著連結一起留在議題上 */
|
||||||
const PRIVACY_NOTE = '(此連結預設為私有,組織外無法開啟)';
|
const PRIVACY_NOTE = '(此連結預設為私有,組織外無法開啟)';
|
||||||
@@ -36,7 +41,7 @@ const PRIVACY_NOTE = '(此連結預設為私有,組織外無法開啟)';
|
|||||||
main(async () => {
|
main(async () => {
|
||||||
const flags = parseFlags(process.argv.slice(2), {
|
const flags = parseFlags(process.argv.slice(2), {
|
||||||
required: ['repo', 'index'],
|
required: ['repo', 'index'],
|
||||||
optional: ['milestone', 'due-date', 'estimate-days', 'overview-url', 'host'],
|
optional: ['milestone', 'due-date', 'estimate-days', 'overview-url', 'tick', 'section', 'host'],
|
||||||
booleans: ['dry-run'],
|
booleans: ['dry-run'],
|
||||||
});
|
});
|
||||||
const repo = parseRepo(flags.repo);
|
const repo = parseRepo(flags.repo);
|
||||||
@@ -44,11 +49,18 @@ main(async () => {
|
|||||||
const dueDate = parseDueDate(flags['due-date']);
|
const dueDate = parseDueDate(flags['due-date']);
|
||||||
const days = parseDays(flags['estimate-days']);
|
const days = parseDays(flags['estimate-days']);
|
||||||
const overviewUrl = parseOverviewUrl(flags['overview-url']);
|
const overviewUrl = parseOverviewUrl(flags['overview-url']);
|
||||||
|
const tick = parseTick(flags.tick, flags.section);
|
||||||
|
|
||||||
if (flags.milestone === undefined && dueDate === null && days === null && overviewUrl === null) {
|
if (
|
||||||
|
flags.milestone === undefined &&
|
||||||
|
dueDate === null &&
|
||||||
|
days === null &&
|
||||||
|
overviewUrl === null &&
|
||||||
|
tick === null
|
||||||
|
) {
|
||||||
throw new ScriptError(
|
throw new ScriptError(
|
||||||
'NOTHING_TO_UPDATE',
|
'NOTHING_TO_UPDATE',
|
||||||
'至少要指定 --milestone、--due-date、--estimate-days 或 --overview-url 其中一個',
|
'至少要指定 --milestone、--due-date、--estimate-days、--overview-url 或 --tick 其中一個',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,20 +76,38 @@ main(async () => {
|
|||||||
if (dueDate !== null) {
|
if (dueDate !== null) {
|
||||||
payload.due_date = `${dueDate}T00:00:00Z`;
|
payload.due_date = `${dueDate}T00:00:00Z`;
|
||||||
}
|
}
|
||||||
if (days !== null || overviewUrl !== null) {
|
let current = null;
|
||||||
const issue = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`);
|
let 勾起的那一行 = null;
|
||||||
let body = issue.body ?? '';
|
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 (days !== null) body = upsertLineInSection(body, '關聯', `估算人天:${days}`);
|
||||||
if (overviewUrl !== null) {
|
if (overviewUrl !== null) {
|
||||||
body = upsertLineInSection(body, '總覽', `圖解版總覽:${overviewUrl}${PRIVACY_NOTE}`);
|
body = upsertLineInSection(body, '總覽', `圖解版總覽:${overviewUrl}${PRIVACY_NOTE}`);
|
||||||
}
|
}
|
||||||
// 沒變就不塞進 PATCH:無謂改寫 body 會在議題上留下一筆沒有內容的編輯紀錄
|
if (tick !== null) {
|
||||||
if (body !== issue.body) payload.body = body;
|
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']) {
|
if (flags['dry-run']) {
|
||||||
return { dryRun: true, repo, index, requests: [{ method: 'PATCH', path, body: payload }] };
|
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}`);
|
const issue = expectOk(await giteaRequest(login, 'PATCH', path, { body: payload }), `PATCH ${path}`);
|
||||||
@@ -85,11 +115,78 @@ main(async () => {
|
|||||||
repo,
|
repo,
|
||||||
index,
|
index,
|
||||||
updated: Object.keys(payload),
|
updated: Object.keys(payload),
|
||||||
|
勾起的那一行,
|
||||||
|
已經勾過,
|
||||||
url: issue.html_url,
|
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) {
|
function parseDueDate(value) {
|
||||||
if (value === undefined) return null;
|
if (value === undefined) return null;
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user