feat/sdlc-plan-requirement-issue/main #21

Merged
admin merged 6 commits from feat/sdlc-plan-requirement-issue/main into master 2026-09-17 04:51:03 +00:00
Showing only changes of commit 3f4e1a683b - Show all commits
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* 建立議題。
*
* 兩個刻意的限制:
* - 標籤只能指定 repo 上已經存在的,指到不存在的就中止。本工具不建立標籤。
* - 以標題查重,同名議題已存在就回傳既有那一顆,讓中斷後重跑不產生重複議題。
*
* `--dry-run` 不寫入,但會讀:要讓預覽忠實反映將送出的請求,就得先把標籤名稱換成 id、
* 也得先查過重——否則預覽看起來會建一顆議題,實跑卻是 no-op,或反過來實跑才爆標籤錯字。
*
* 用法:
* node scripts/issue-create.js --repo owner/name --title <標題> --body-file <路徑>
* [--labels a,b] [--host <網址>] [--dry-run]
*/
import { existsSync, readFileSync } from 'node:fs';
import {
ScriptError,
expectOk,
findIssueByTitle,
giteaRequest,
listLabels,
main,
parseFlags,
parseRepo,
preflight,
resolveLogin,
} from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'title', 'body-file'],
optional: ['labels', 'host'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const title = flags.title.trim();
const body = readBody(flags['body-file']);
const labelNames = splitLabels(flags.labels);
const path = `/repos/${repo}/issues`;
const dryRun = flags['dry-run'] === true;
const login = resolveLogin({ host: flags.host });
// 試跑不做前置檢查:那一層擋的是寫入能力與時間追蹤,而試跑本來就不寫。
if (!dryRun) await preflight(login, repo);
// 先驗標籤再查重:參數打錯要立刻講,不要等到重跑時才發現
const labelIds = labelNames.length > 0 ? await resolveLabelIds(login, repo, labelNames) : null;
const existing = await findIssueByTitle(login, repo, title);
const payload = { title, body };
if (labelIds) payload.labels = labelIds;
if (dryRun) {
return {
dryRun: true,
repo,
labels: labelNames,
existing: existing ? { number: existing.number, url: existing.html_url } : null,
// 同名議題已存在時實跑是 no-op,預覽就不該顯示將建立議題
requests: existing ? [] : [{ method: 'POST', path, body: payload }],
};
}
if (existing) {
return {
repo,
number: existing.number,
url: existing.html_url,
title: existing.title,
created: false,
};
}
const issue = expectOk(await giteaRequest(login, 'POST', path, { body: payload }), `POST ${path}`);
return {
repo,
number: issue.number,
url: issue.html_url,
title: issue.title,
labels: (issue.labels ?? []).map((label) => label.name),
created: true,
};
});
function readBody(bodyFile) {
if (!existsSync(bodyFile)) {
throw new ScriptError('BODY_FILE_MISSING', `找不到 --body-file 指定的檔案 ${bodyFile}`);
}
return readFileSync(bodyFile, 'utf8');
}
function splitLabels(value) {
if (value === undefined) return [];
return value
.split(',')
.map((name) => name.trim())
.filter((name) => name !== '');
}
/**
* 把標籤名稱換成 repo 上既有標籤的 id。指到不存在的標籤一律中止 ——
* 本工具不建立標籤,錯字應該當場講清楚,而不是悄悄少貼一個。
*/
async function resolveLabelIds(login, repo, names) {
const labels = await listLabels(login, repo);
const byName = new Map(labels.map((label) => [label.name, label.id]));
const missing = names.filter((name) => !byName.has(name));
if (missing.length > 0) {
throw new ScriptError(
'UNKNOWN_LABEL',
`${repo} 沒有這些標籤:${missing.join('、')}。` +
`本工具不建立標籤,請改挑既有的:${[...byName.keys()].join('、') || '(這個 repo 目前沒有任何標籤)'}`,
);
}
return names.map((name) => byName.get(name));
}