From 28182f1709b4cf3303de58ec9a3939069d0b28ca Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 13:55:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(project-add):=20=E6=8A=8A=E8=AD=B0?= =?UTF-8?q?=E9=A1=8C=E6=94=BE=E9=80=B2=E7=9C=8B=E6=9D=BF=EF=BC=8C=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF=20id=20=E9=9D=A0=E6=8E=83=E8=AD=B0=E9=A1=8C=E5=8F=8D?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea 1.27 沒有「列出專案」的 endpoint,名稱只能反查:掃最近 50 筆議題,從它們 身上的 projects 欄位湊出 id→名稱對照。全都沒掛看板時湊不出來,這時請使用者直接 貼專案網址,結尾即 id。 projects 欄位是整份取代不是附加,所以要先讀出議題既有的看板再聯集——漏掉就等於 把這顆議題踢出原本的看板。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/project-add.js | 115 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 scripts/project-add.js diff --git a/scripts/project-add.js b/scripts/project-add.js new file mode 100644 index 0000000..18461af --- /dev/null +++ b/scripts/project-add.js @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * 把議題放進 Gitea 專案看板。 + * + * Gitea 1.27 沒有「列出專案」的 endpoint,所以 `--project` 給名稱時只能反查: + * 掃最近 50 筆議題,從它們身上的 projects 欄位湊出 id→名稱對照。全都沒掛看板時 + * 就湊不出來,這時請使用者直接貼專案網址(結尾即 id)。 + * + * 本工具不建立專案——API 也沒有這個能力。 + * + * 用法: + * node scripts/project-add.js --repo owner/name --index 12 --project <名稱或網址> + * [--host <網址>] [--dry-run] + */ +import { + ScriptError, + expectOk, + giteaRequest, + main, + parseFlags, + parseRepo, + preflight, + resolveLogin, +} from './lib.js'; + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo', 'index', 'project'], + optional: ['host'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const index = parseIndex(flags.index); + const path = `/repos/${repo}/issues/${index}`; + + const login = resolveLogin({ host: flags.host }); + if (!flags['dry-run']) await preflight(login, repo); + + const project = await resolveProject(login, repo, flags.project); + const issue = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`); + const current = (issue.projects ?? []).map((item) => item.id); + + if (current.includes(project.id)) { + return { repo, index, project, alreadyThere: true }; + } + + // projects 是整份取代,不是附加:漏掉既有的等於把這顆議題踢出原本的看板 + const payload = { projects: [...current, project.id] }; + + if (flags['dry-run']) { + return { + dryRun: true, + repo, + index, + project, + requests: [{ method: 'PATCH', path, body: payload }], + }; + } + + expectOk(await giteaRequest(login, 'PATCH', path, { body: payload }), `PATCH ${path}`); + return { repo, index, project, alreadyThere: false }; +}); + +function parseIndex(value) { + if (!/^[1-9]\d*$/.test(value)) { + throw new ScriptError('BAD_INDEX', `--index 需為正整數,收到的是 ${value}`); + } + return Number(value); +} + +/** + * 把 `--project` 解析成 {id, title}。 + * 看起來像網址就取結尾的 id;否則掃議題反查名稱。 + */ +async function resolveProject(login, repo, value) { + if (/^https?:\/\//.test(value)) { + const id = value.replace(/\/+$/, '').match(/(\d+)$/)?.[1]; + if (!id) { + throw new ScriptError('PROJECT_NOT_FOUND', `專案網址的結尾必須是專案 id:${value}`); + } + return { id: Number(id), title: null }; + } + + const known = await scanProjects(login, repo); + const hit = known.find((project) => project.title === value); + if (hit) return hit; + + const seen = known.map((project) => project.title).join('、'); + throw new ScriptError( + 'PROJECT_NOT_FOUND', + seen + ? `反查不到名為「${value}」的看板。掃到的有:${seen}。若它不在其中,請直接貼專案網址(結尾即 id)` + : `最近的議題沒有任何一顆掛在看板上,無法反查看板 id。請直接貼專案網址(結尾即 id)`, + ); +} + +/** + * 掃最近 50 筆議題,湊出這個 repo 用過的看板。 + * Gitea 沒給列出專案的 endpoint,這是唯一拿得到 id→名稱對照的辦法。 + */ +async function scanProjects(login, repo) { + const path = `/repos/${repo}/issues`; + const issues = expectOk( + await giteaRequest(login, 'GET', path, { query: { state: 'all', limit: 50 } }), + `GET ${path}`, + ) ?? []; + + const byId = new Map(); + for (const issue of issues) { + for (const project of issue.projects ?? []) { + byId.set(project.id, { id: project.id, title: project.title }); + } + } + return [...byId.values()]; +}