feat(claim): 領取工作包,上鎖、貼標籤、起錶
鎖用 assignee 加標籤,不用碼錶——Gitea 只讓人讀自己的錶,看不到別人的,拿它當鎖會漏判。 碼錶在這裡只有一個用途:發現自己忘了停掉上一顆。 四種狀態的處置:他人已認領擋;自己的錶跑在本議題擋;跑在別的議題擋;沒有鎖放行。 後者包含「自己已認領但沒起錶」,那正是中斷後重跑的情形,重跑不會產生第二把鎖。 兩種碼錶的錯誤碼分開,因為使用者的下一步不同——一個是「你已經在做了」, 另一個是「你忘了停掉那一顆」。 會擋的判斷全部做在任何寫入之前,包含「repo 上有沒有『進行中』標籤」:本 plugin 不自動 建標籤,缺了就整件事不做,不要只設一半的鎖。錶則留到最後才起,前面任一步失敗時不該 留下一顆還在跑的碼錶。 --dry-run 走同一條路,只停在寫入之前。它印出的是這一顆此刻真正缺的那幾步, 不是一份手寫的固定清單——後者會跟實作走鐘,也說不出「這顆已經是你的了」。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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} 上,領取前請先手動停錶,否則工時會記到那一顆去`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user