#!/usr/bin/env node /** * 領取一顆工作包:上鎖、貼標籤。 * * 鎖用 assignee 加標籤,不用碼錶——Gitea 只讓人讀自己的碼錶(`/user/stopwatches`), * 看不到別人的錶,拿它當鎖會漏判。碼錶在這裡只有一個用途:發現自己忘了停掉上一顆。 * * **錶不在這一步起**。它等工作樹建好之後才由 timer.js 起動(見 branch-prep.js): * 工作樹建立失敗會中止整個領取,錶要是先起了,使用者就被計了一段什麼都沒做的時間。 * * 四種狀態的處置: * - 他人已認領 → 擋。不會兩個人做同一件事。 * - 自己的錶跑在本議題 → 擋。這顆你已經在做了,別重複起錶。 * - 自己的錶跑在別的議題 → 擋。先去停掉那一顆,否則工時會記錯地方。 * - 沒有鎖(含自己已認領沒錶)→ 放行。後者正是中斷後重跑的情形。 * * 所有會擋的判斷都做在任何寫入之前:擋下來卻已經改了一半,比直接放行更難收拾。 * `--dry-run` 走的是同一條路,只是停在寫入之前——它印出的是這一顆此刻真正缺的那幾步, * 而不是一份手寫的固定清單。 * * 用法: * node scripts/claim.js --repo owner/name --index 11 [--host <網址>] [--dry-run] */ import { ScriptError, expectOk, fetchIssue, giteaRequest, listLabels, listStopwatches, main, parseFlags, parseIndex, parseRepo, preflight, resolveLogin, stopwatchOnIssue, } from './lib.js'; /** 領取鎖的另一半。本 plugin 不自動建立標籤,這個名字要在 repo 上先存在。 */ const IN_PROGRESS = '進行中'; main(async () => { const flags = parseFlags(process.argv.slice(2), { required: ['repo', 'index'], optional: ['host'], booleans: ['dry-run'], }); const repo = parseRepo(flags.repo); const index = parseIndex(flags.index); const issuePath = `/repos/${repo}/issues/${index}`; const dryRun = flags['dry-run'] === true; const login = resolveLogin({ host: flags.host }); // 試跑照樣讀現況:手寫一份固定的清單會跟實作走鐘,也說不出「這顆已經是你的了」。 // 前置檢查第二層本來就問過「我是誰」,實跑時直接用它的答案,不再問第二次。 const checked = dryRun ? null : await preflight(login, repo); const me = (checked?.user ?? expectOk(await giteaRequest(login, 'GET', '/user'), 'GET /user')).login; const issue = await fetchIssue(login, repo, index); const assignees = (issue.assignees ?? []).map((user) => user.login); const labels = (issue.labels ?? []).map((label) => label.name); // 所有會擋的判斷都做完才輪到寫入,試跑與實跑走同一條路—— // 試跑印得出漂亮的計畫、實跑卻被擋下來,那種落差最難查 checkClaimable(assignees, me, index); await checkNoStopwatch(login, repo, index); // 本 plugin 不建標籤,缺了就整件事不做,不要只設一半的鎖 const inProgress = (await listLabels(login, repo)).find((label) => label.name === IN_PROGRESS); if (!inProgress) { throw new ScriptError( 'LABEL_NOT_FOUND', `repo ${repo} 上沒有「${IN_PROGRESS}」標籤,領取鎖貼不上去;` + '請先在該 repo 的 Issues → Labels 建立它(本工具不自動建立標籤)', ); } const 已認領過 = assignees.includes(me); const planned = []; if (!已認領過) { planned.push({ method: 'PATCH', path: issuePath, body: { assignees: [me] } }); } if (!labels.includes(IN_PROGRESS)) { planned.push({ method: 'POST', path: `${issuePath}/labels`, body: { labels: [inProgress.id] }, }); labels.push(IN_PROGRESS); } if (dryRun) { return { dryRun: true, repo, index, title: issue.title, requests: planned, 已認領過 }; } for (const { method, path, body } of planned) { expectOk(await giteaRequest(login, method, path, { body }), `${method} ${path}`); } return { repo, index: issue.number, title: issue.title, url: issue.html_url, assignee: me, labels, // 鎖上好了,錶還沒起:它等工作樹建好之後才由 timer.js 起動 碼錶中: false, 已認領過, }; }); /** 他人已認領就擋。自己也在裡面但還有別人時一樣擋——那是兩個人都以為自己在做。 */ function checkClaimable(assignees, me, index) { const others = assignees.filter((login) => login !== me); if (others.length > 0) { throw new ScriptError( 'CLAIMED_BY_OTHER', `議題 #${index} 已由 ${others.join('、')} 認領;` + '請改領別顆,或先與對方確認後由對方取消認領', ); } } /** * 自己的錶跑在任何議題上都擋,需要手動停錶後再領。 * * 不代勞停錶:那一段時間該記在哪顆議題上只有人知道,腳本自作主張會把工時記錯地方。 * 錯誤碼分兩種,因為使用者的下一步不同——跑在本議題是「你已經在做了」, * 跑在別的議題是「你忘了停掉那一顆」。 */ async function checkNoStopwatch(login, repo, index) { const watches = await listStopwatches(login); if (watches.length === 0) return; if (stopwatchOnIssue(watches, repo, index)) { throw new ScriptError( 'STOPWATCH_ON_THIS_ISSUE', `你的碼錶已經跑在議題 #${index} 上,這顆你正在做;` + '若要重新計時,請先在 Gitea 上手動停錶再執行一次', ); } const elsewhere = watches[0]; throw new ScriptError( 'STOPWATCH_ON_OTHER_ISSUE', `你的碼錶正跑在 ${elsewhere.repo_owner_name}/${elsewhere.repo_name} 的議題 ` + `#${elsewhere.issue_index} 上,領取前請先手動停錶,否則工時會記到那一顆去`, ); }