From 7f24c9070ee40588cc09e5b5855c0ec88450637f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 07:07:00 +0000 Subject: [PATCH] =?UTF-8?q?feat(claim):=20=E9=A0=98=E5=8F=96=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=85=EF=BC=8C=E4=B8=8A=E9=8E=96=E3=80=81=E8=B2=BC?= =?UTF-8?q?=E6=A8=99=E7=B1=A4=E3=80=81=E8=B5=B7=E9=8C=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 鎖用 assignee 加標籤,不用碼錶——Gitea 只讓人讀自己的錶,看不到別人的,拿它當鎖會漏判。 碼錶在這裡只有一個用途:發現自己忘了停掉上一顆。 四種狀態的處置:他人已認領擋;自己的錶跑在本議題擋;跑在別的議題擋;沒有鎖放行。 後者包含「自己已認領但沒起錶」,那正是中斷後重跑的情形,重跑不會產生第二把鎖。 兩種碼錶的錯誤碼分開,因為使用者的下一步不同——一個是「你已經在做了」, 另一個是「你忘了停掉那一顆」。 會擋的判斷全部做在任何寫入之前,包含「repo 上有沒有『進行中』標籤」:本 plugin 不自動 建標籤,缺了就整件事不做,不要只設一半的鎖。錶則留到最後才起,前面任一步失敗時不該 留下一顆還在跑的碼錶。 --dry-run 走同一條路,只停在寫入之前。它印出的是這一顆此刻真正缺的那幾步, 不是一份手寫的固定清單——後者會跟實作走鐘,也說不出「這顆已經是你的了」。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/claim.js | 152 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 scripts/claim.js diff --git a/scripts/claim.js b/scripts/claim.js new file mode 100644 index 0000000..8d58f6a --- /dev/null +++ b/scripts/claim.js @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/** + * 領取一顆工作包:上鎖、貼標籤、起錶。 + * + * 鎖用 assignee 加標籤,不用碼錶——Gitea 只讓人讀自己的碼錶(`/user/stopwatches`), + * 看不到別人的錶,拿它當鎖會漏判。碼錶在這裡只有一個用途:發現自己忘了停掉上一顆。 + * + * 四種狀態的處置: + * - 他人已認領 → 擋。不會兩個人做同一件事。 + * - 自己的錶跑在本議題 → 擋。這顆你已經在做了,別重複起錶。 + * - 自己的錶跑在別的議題 → 擋。先去停掉那一顆,否則工時會記錯地方。 + * - 沒有鎖(含自己已認領沒錶)→ 放行。後者正是中斷後重跑的情形。 + * + * 所有會擋的判斷都做在任何寫入之前:擋下來卻已經改了一半,比直接放行更難收拾。 + * `--dry-run` 走的是同一條路,只是停在寫入之前——它印出的是這一顆此刻真正缺的那幾步, + * 而不是一份手寫的固定清單。 + * + * 用法: + * node scripts/claim.js --repo owner/name --index 11 [--host <網址>] [--dry-run] + */ +import { + ScriptError, + expectOk, + fetchIssue, + giteaRequest, + listLabels, + main, + parseFlags, + parseIndex, + parseRepo, + preflight, + resolveLogin, +} 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); + } + // 錶最後才起:前面任一步失敗時,不該留下一顆還在跑的碼錶 + planned.push({ method: 'POST', path: `${issuePath}/stopwatch/start`, body: {} }); + + 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, + 碼錶中: true, + 已認領過, + }; +}); + + +/** 他人已認領就擋。自己也在裡面但還有別人時一樣擋——那是兩個人都以為自己在做。 */ +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 path = '/user/stopwatches'; + const watches = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; + if (watches.length === 0) return; + + const here = watches.find( + (watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index, + ); + if (here) { + 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} 上,領取前請先手動停錶,否則工時會記到那一顆去`, + ); +}