feat(issue-update): 掛 Milestone、寫截止日、記人天估算
三件事經同一個 PATCH 送出,因為排時程時它們幾乎總是一起改,分開發等於多兩次往返。 Milestone 只認既有的:指到不存在的就中止並列出可選項目,本工具不建立 Milestone。 人天估算只寫進 body 的人類可讀一行。Gitea 1.27 的 API 沒有任何請求定義接受 time_estimate——它只出現在 Issue 的回應裡——所以議題的估算欄位無法由 API 寫入。 新增的 upsertLineInSection 負責就地更新那一行:重跑改估算不會累積成兩行,值沒變 就不把 body 塞進 PATCH,免得在議題上留下一筆沒有內容的編輯紀錄。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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, '\\$&');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user