diff --git a/scripts/issue-create.js b/scripts/issue-create.js new file mode 100644 index 0000000..78f60e6 --- /dev/null +++ b/scripts/issue-create.js @@ -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)); +}