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