Merge pull request 'feat/claim-and-branch-prep/main' (#37) from feat/claim-and-branch-prep/main into master
Reviewed-on: #37 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #37.
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
name: sdlc-feat
|
||||||
|
description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、起錶、備妥分支,逐項實作並勾選待辦,最後分批提交並開立 PR。
|
||||||
|
|
||||||
|
# sdlc-feat
|
||||||
|
|
||||||
|
拿一顆工作包,從領取到開出 PR。
|
||||||
|
|
||||||
|
第一段**領取與開工準備**:把工作包安全地認領下來,開始計時,備妥開工的分支。
|
||||||
|
這一段不改任何一行程式碼——它只負責讓後面的實作有個乾淨的起點。
|
||||||
|
|
||||||
|
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
|
||||||
|
|
||||||
|
## 輸入
|
||||||
|
|
||||||
|
一個工作包議題編號。
|
||||||
|
|
||||||
|
## 第一段:領取與開工準備
|
||||||
|
|
||||||
|
### 1. 讀工作包
|
||||||
|
|
||||||
|
```
|
||||||
|
node scripts/wp-extract.js --repo <owner/name> --index <編號>
|
||||||
|
```
|
||||||
|
|
||||||
|
拿到的是結構化欄位:待辦與它自己的驗收、範圍邊界、介面契約、相依、repo 列表。
|
||||||
|
不必再讀整份議題全文。
|
||||||
|
|
||||||
|
**先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被
|
||||||
|
整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,建議先執行 `/sdlc-sync`
|
||||||
|
把它們整併回描述,再回來實作。使用者堅持要繼續就繼續,但要記下這件事,
|
||||||
|
並在最後的 PR 描述裡註明「實作基於未整併留言前的描述」。
|
||||||
|
|
||||||
|
**再看 `相依.depends`。** 裡面還有沒關閉的議題,代表這顆的前置還沒做完。照樣先說出來,
|
||||||
|
讓使用者決定要不要現在做。
|
||||||
|
|
||||||
|
### 2. 領取工作包
|
||||||
|
|
||||||
|
先試跑,看清楚會做什麼:
|
||||||
|
|
||||||
|
```
|
||||||
|
node scripts/claim.js --repo <owner/name> --index <編號> --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
確認無誤後拿掉旗標再跑一次。放行時它會設 assignee、貼「進行中」標籤、起錶——三件事
|
||||||
|
一起構成領取鎖,錶則是工時的來源。
|
||||||
|
|
||||||
|
領取鎖有四種狀態,三種擋、一種放行。被擋下來時**不要繞過去**,照著錯誤碼告訴使用者
|
||||||
|
發生什麼事、下一步是什麼:
|
||||||
|
|
||||||
|
| 狀態 | 錯誤碼 | 下一步 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 別人已經認領這顆 | `CLAIMED_BY_OTHER` | 改領別顆,或先跟對方確認 |
|
||||||
|
| 你的錶已經跑在這顆上 | `STOPWATCH_ON_THIS_ISSUE` | 這顆你正在做;要重新計時請先手動停錶 |
|
||||||
|
| 你的錶跑在別的議題上 | `STOPWATCH_ON_OTHER_ISSUE` | 多半是忘了停上一顆;先去停掉再回來 |
|
||||||
|
| 沒有鎖 | —— | 放行。自己已認領但沒起錶也算沒有鎖,那正是中斷後重跑的情形 |
|
||||||
|
|
||||||
|
碼錶一律由使用者自己停。哪一段時間該記在哪顆議題上只有他知道,代勞會把工時記錯地方。
|
||||||
|
|
||||||
|
鎖以外還有一個前置條件:repo 上要有「進行中」標籤。缺了會得到 `LABEL_NOT_FOUND`,
|
||||||
|
請使用者自己去建立——**不要自己建**,標籤體系不該在多個 repo 之間長出雜草。
|
||||||
|
|
||||||
|
### 3. 問來源分支
|
||||||
|
|
||||||
|
**一次問一題。** 新分支要從哪裡長出來,只有使用者知道,不要替他決定。
|
||||||
|
給兩個選項,並附上你判斷的理由:
|
||||||
|
|
||||||
|
- **建議** — 你的答案。多數情況是開發分支(`master`/`main`/`develop`);
|
||||||
|
但若這顆工作包明顯是某個既有功能分支的一部分,就建議那一支,並說明為什麼。
|
||||||
|
- **手動輸入** — 讓使用者自己填分支名。
|
||||||
|
|
||||||
|
### 4. 把議題標題翻成英文
|
||||||
|
|
||||||
|
分支名的中段要用英文,中文會讓 CI 與 URL 出問題。把工作包議題的標題翻成
|
||||||
|
**小寫英文 kebab、40 字元以內**,例如「建立工作包的抽取契約」→ `wp-extract-contract`。
|
||||||
|
|
||||||
|
翻譯要保留原意而不是逐字直譯,寧可用一個更短的說法,也不要把長句截斷成看不懂的字串。
|
||||||
|
|
||||||
|
### 5. 備妥分支
|
||||||
|
|
||||||
|
分支開在**工作包的 `repos` 列出的那些 repo** 上,不是開在本 plugin 的目錄裡。
|
||||||
|
`repos` 只有一顆就用那一顆;**有多顆時逐一確認**要在哪幾個開分支,
|
||||||
|
再對每一個各跑一次 `branch-prep`,分支名在每個 repo 都相同。
|
||||||
|
|
||||||
|
```
|
||||||
|
node scripts/branch-prep.js --path <目標專案路徑> --source <來源分支> \
|
||||||
|
--slug <英文-kebab> [--type feat] --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
`--type` 只在來源是開發分支時要給(`feat`/`fix`/`chore`…);從功能分支長出時,
|
||||||
|
類型與需求描述沿用來源,不必也不能再指定。
|
||||||
|
|
||||||
|
試跑會印出將執行的 git 指令與算出來的分支名。確認無誤後拿掉旗標再跑一次。
|
||||||
|
|
||||||
|
它保證三件事,都是為了不弄丟別人的東西:工作區不乾淨時**先擋下來**,免得把不相干的
|
||||||
|
改動帶進這顆工作包的分支;來源分支在遠端已存在時是 **pull 而不是重建**;目標分支已經
|
||||||
|
存在時是**接上去而不是蓋掉**。
|
||||||
|
|
||||||
|
工作區不乾淨(`DIRTY_WORKTREE`)時,把 git 回報的檔案念給使用者聽,讓他決定要提交、
|
||||||
|
`git stash` 還是丟掉——**不要自己選**。
|
||||||
|
|
||||||
|
### 6. 回報
|
||||||
|
|
||||||
|
印出一份開工前的現況,不寫回議題:
|
||||||
|
|
||||||
|
- 工作包標題與網址、這一顆有幾項待辦
|
||||||
|
- 認領結果(是否本來就是自己的)、碼錶已起
|
||||||
|
- 來源分支、新分支名、分支是新建還是接上既有
|
||||||
|
- 未處理留言數與未關閉的先決議題(若有)
|
||||||
|
|
||||||
|
## 邊界
|
||||||
|
|
||||||
|
- 第一段**不改任何一行程式碼**、不勾待辦、不提交、不開 PR——那些是後面幾段的事。
|
||||||
|
- 不自行建立標籤。缺「進行中」標籤時中止並請使用者建立。
|
||||||
|
- 不代替使用者停錶,也不在被鎖擋下時繞過去。
|
||||||
|
- 不替使用者決定來源分支。
|
||||||
|
- **不寫任何本機狀態檔。** 進度完全由 Gitea 上的 assignee、標籤、碼錶與 git 本身推導,
|
||||||
|
換一台機器或換一個 agent 都要能直接接手。
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 備妥開工的分支。
|
||||||
|
*
|
||||||
|
* 命名規則(議題 #1 的正本):
|
||||||
|
* - 從開發分支長出 → `{類型}/{英文-kebab-需求描述}/main`
|
||||||
|
* - 從功能分支長出 → `{類型}/{需求描述}/{功能描述}`,前兩段沿用來源,
|
||||||
|
* 子分支才會留在同一棵樹下。
|
||||||
|
*
|
||||||
|
* 需求描述由議題標題翻譯——那是 agent 的事,不是腳本的事,所以這裡只收 `--slug`
|
||||||
|
* 並驗格式:英文 kebab、≤40 字元。中文分支名會讓 CI 與 URL 出問題,擋在建立之前。
|
||||||
|
*
|
||||||
|
* 三處「不弄丟別人的東西」:
|
||||||
|
* - 工作區不乾淨就不動手,免得把不相干的改動帶進這顆工作包的分支。
|
||||||
|
* - 來源分支在遠端已存在時 pull 而不是重建。
|
||||||
|
* - 目標分支已存在時接上去而不是從來源蓋掉。
|
||||||
|
* 三種情況都在任何 git 寫入之前判斷完:試跑印得出漂亮的計畫、實跑卻中途炸掉,
|
||||||
|
* 是最難查的那種落差。
|
||||||
|
*
|
||||||
|
* 不寫任何本機狀態檔:換機器或換 agent 都能接手,進度只從 Gitea 與 git 本身推導。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node scripts/branch-prep.js --source <來源分支> --slug <英文-kebab>
|
||||||
|
* [--type feat] [--path <目標專案>] [--dry-run]
|
||||||
|
*/
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { ScriptError, main, parseFlags, runGit } from './lib.js';
|
||||||
|
|
||||||
|
/** 需求描述的長度上限。超過就換一個短的說法,不要靠截斷。 */
|
||||||
|
const SLUG_MAX = 40;
|
||||||
|
|
||||||
|
/** 功能分支長這樣:三段、前兩段非空。開發分支(master/main/develop)不合這個樣式。 */
|
||||||
|
const FEATURE_BRANCH = /^([a-z]+)\/([^/]+)\/([^/]+)$/;
|
||||||
|
|
||||||
|
main(async () => {
|
||||||
|
const flags = parseFlags(process.argv.slice(2), {
|
||||||
|
required: ['source', 'slug'],
|
||||||
|
optional: ['type', 'path'],
|
||||||
|
booleans: ['dry-run'],
|
||||||
|
});
|
||||||
|
const path = flags.path ?? process.cwd();
|
||||||
|
const source = flags.source;
|
||||||
|
const branch = buildBranchName(source, flags.slug, flags.type);
|
||||||
|
|
||||||
|
if (!existsSync(join(path, '.git'))) {
|
||||||
|
throw new ScriptError('NOT_A_GIT_REPO', `${path} 不是 git repo;請用 --path 指向目標專案的根目錄`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const git = (...args) => runGit(args, { cwd: path });
|
||||||
|
checkClean(git, path);
|
||||||
|
|
||||||
|
const hasOrigin = git('remote').split('\n').includes('origin');
|
||||||
|
/**
|
||||||
|
* 比對用全名 `refs/heads/<ref>`:`ls-remote --heads origin main` 的樣式比對吃的是
|
||||||
|
* ref 的尾段,而本 repo 的命名慣例讓每一支分支都以 `/main` 結尾——用短名比對,
|
||||||
|
* 拿 main 當開發分支的專案會整個誤判成「遠端已經有這一支」。
|
||||||
|
*/
|
||||||
|
const onRemote = (ref) =>
|
||||||
|
hasOrigin && git('ls-remote', '--heads', 'origin', `refs/heads/${ref}`).trim() !== '';
|
||||||
|
const onLocal = (ref) => git('branch', '--list', ref).trim() !== '';
|
||||||
|
|
||||||
|
const sourcePlan = syncPlan(source, onRemote(source), onLocal(source), {
|
||||||
|
missing: () => {
|
||||||
|
throw new ScriptError(
|
||||||
|
'SOURCE_NOT_FOUND',
|
||||||
|
`來源分支 ${source} 在本地與遠端都不存在;請確認分支名,或先把它推上遠端`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const branchPlan = syncPlan(branch, onRemote(branch), onLocal(branch), {
|
||||||
|
// 目標分支不存在是常態:這就是開一支新分支
|
||||||
|
missing: () => ({ commands: [['checkout', '-b', branch]], 動作: '從來源建立' }),
|
||||||
|
onRemote: '接上遠端既有',
|
||||||
|
onLocal: '切換到本地既有',
|
||||||
|
});
|
||||||
|
|
||||||
|
const commands = [...sourcePlan.commands, ...branchPlan.commands];
|
||||||
|
const 來源 = { 位置: sourcePlan.位置, 動作: sourcePlan.動作 };
|
||||||
|
const 分支 = { 動作: branchPlan.動作 };
|
||||||
|
|
||||||
|
if (flags['dry-run']) {
|
||||||
|
return {
|
||||||
|
dryRun: true,
|
||||||
|
path,
|
||||||
|
source,
|
||||||
|
branch,
|
||||||
|
來源,
|
||||||
|
分支,
|
||||||
|
commands: commands.map((args) => `git ${args.join(' ')}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const args of commands) runGitStep(git, args, source);
|
||||||
|
|
||||||
|
return { path, source, branch, 來源, 分支 };
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依來源分支的型態組出新分支名。
|
||||||
|
* 純字串規則,不碰 git——命名錯了會一路帶到 PR 標題與 CI,這一段要能單獨驗。
|
||||||
|
*/
|
||||||
|
function buildBranchName(source, slug, type) {
|
||||||
|
checkSlug(slug);
|
||||||
|
const feature = source.match(FEATURE_BRANCH);
|
||||||
|
|
||||||
|
if (!feature) {
|
||||||
|
if (type === undefined) {
|
||||||
|
throw new ScriptError(
|
||||||
|
'MISSING_FLAG',
|
||||||
|
`從開發分支 ${source} 長出新分支需要 --type(例如 feat、fix、chore)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
checkType(type);
|
||||||
|
return `${type}/${slug}/main`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, sourceType, requirement] = feature;
|
||||||
|
if (type !== undefined && type !== sourceType) {
|
||||||
|
throw new ScriptError(
|
||||||
|
'TYPE_FROM_SOURCE',
|
||||||
|
`從功能分支 ${source} 長出的子分支沿用它的類型 ${sourceType},不接受 --type ${type};` +
|
||||||
|
'要換類型請改從開發分支長出',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${sourceType}/${requirement}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 英文 kebab,且不超過長度上限。中文、大寫、底線、頭尾或連續的連字號都不算。 */
|
||||||
|
function checkSlug(value) {
|
||||||
|
if (value.length > SLUG_MAX) {
|
||||||
|
throw new ScriptError(
|
||||||
|
'BAD_SLUG',
|
||||||
|
`--slug 長度 ${value.length} 超過上限 ${SLUG_MAX};請換一個更短的說法,不要靠截斷`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isKebab(value)) {
|
||||||
|
throw new ScriptError(
|
||||||
|
'BAD_SLUG',
|
||||||
|
`--slug 需為英文小寫 kebab(例如 claim-work-package),收到的是 ${value};` +
|
||||||
|
'中文或大寫會讓 CI 與 URL 出問題',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 類型是分支名的第一段,只認小寫英文——它同時是 commit 訊息的分類,不該長得千奇百怪。 */
|
||||||
|
function checkType(value) {
|
||||||
|
if (!/^[a-z]+$/.test(value)) {
|
||||||
|
throw new ScriptError(
|
||||||
|
'BAD_TYPE',
|
||||||
|
`--type 需為小寫英文(feat、fix、chore…),收到的是 ${value}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKebab(value) {
|
||||||
|
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作區必須乾淨才開工。
|
||||||
|
*
|
||||||
|
* 未提交的改動與未追蹤的檔案都會跟著 checkout 走到新分支上,混進這顆工作包的 commit
|
||||||
|
* 裡;而目標分支已存在時,git 還會在 checkout 那一步才拒絕,屆時 fetch 與 merge
|
||||||
|
* 都已經跑掉了,留下做到一半的狀態。寧可一開始就擋。
|
||||||
|
*/
|
||||||
|
function checkClean(git, path) {
|
||||||
|
const dirty = git('status', '--porcelain');
|
||||||
|
if (dirty !== '') {
|
||||||
|
const files = dirty
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.slice(3))
|
||||||
|
.join('、');
|
||||||
|
throw new ScriptError(
|
||||||
|
'DIRTY_WORKTREE',
|
||||||
|
`${path} 的工作區還有未處理的變更(${files});` +
|
||||||
|
'請先提交、暫存(git stash)或清掉,再來開分支',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 算出要把一支 ref 弄到手需要哪幾個 git 指令。
|
||||||
|
*
|
||||||
|
* 來源分支與目標分支的處理是同一個形狀——遠端有就 pull、只有本地就切過去——
|
||||||
|
* 差別只在「兩邊都沒有」時怎麼辦,所以那一段由呼叫端給。
|
||||||
|
*
|
||||||
|
* 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令,
|
||||||
|
* 而不是另外維護一份描述——兩邊分開寫就會走鐘。
|
||||||
|
*
|
||||||
|
* @param {(ref: string) => {commands: string[][], 動作: string}} handlers.missing 兩邊都沒有時
|
||||||
|
*/
|
||||||
|
function syncPlan(ref, onRemote, onLocal, handlers) {
|
||||||
|
if (onRemote) {
|
||||||
|
// 遠端已經有了就 pull,不重建:別人推上去的進度要帶進來
|
||||||
|
const commands = [['fetch', 'origin', ref]];
|
||||||
|
commands.push(onLocal ? ['checkout', ref] : ['checkout', '-b', ref, `origin/${ref}`]);
|
||||||
|
if (onLocal) commands.push(['merge', '--ff-only', `origin/${ref}`]);
|
||||||
|
return { commands, 位置: '遠端', 動作: handlers.onRemote ?? 'pull' };
|
||||||
|
}
|
||||||
|
if (onLocal) {
|
||||||
|
return {
|
||||||
|
commands: [['checkout', ref]],
|
||||||
|
位置: '本地',
|
||||||
|
動作: handlers.onLocal ?? '用本地既有',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return handlers.missing(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跑一個 git 步驟,並把已知會發生的失敗換成看得懂的錯誤碼。
|
||||||
|
* `merge --ff-only` 失敗幾乎都是同一件事:本地有沒推上去的 commit,而遠端也往前走了。
|
||||||
|
* 原始的 git 訊息說得不夠白,使用者需要知道下一步是 rebase 還是先推。
|
||||||
|
*/
|
||||||
|
function runGitStep(git, args, source) {
|
||||||
|
try {
|
||||||
|
return git(...args);
|
||||||
|
} catch (error) {
|
||||||
|
if (args[0] === 'merge') {
|
||||||
|
throw new ScriptError(
|
||||||
|
'SOURCE_DIVERGED',
|
||||||
|
`${source} 的本地與遠端已經分歧,無法直接快轉;` +
|
||||||
|
'請先把本地的 commit 推上去或 rebase 到遠端之後,再執行一次',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} 上,領取前請先手動停錶,否則工時會記到那一顆去`,
|
||||||
|
);
|
||||||
|
}
|
||||||
+14
-6
@@ -365,7 +365,14 @@ function safeJson(text) {
|
|||||||
*/
|
*/
|
||||||
export function runGit(args, { cwd } = {}) {
|
export function runGit(args, { cwd } = {}) {
|
||||||
try {
|
try {
|
||||||
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
// stderr 要收進來而不是繼承:git 把 checkout/fetch 的進度訊息全寫在 stderr,
|
||||||
|
// 不收的話它們會混進腳本的輸出,而腳本的契約是「stdout 一行 JSON、stderr 乾淨」。
|
||||||
|
// 失敗時這些內容仍讀得到(error.stderr),錯誤訊息不會因此變模糊。
|
||||||
|
return execFileSync('git', args, {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = (error.stderr || error.message || '').toString().trim();
|
const detail = (error.stderr || error.message || '').toString().trim();
|
||||||
throw new ScriptError('GIT_FAILED', `git ${args.join(' ')} 失敗:${detail}`);
|
throw new ScriptError('GIT_FAILED', `git ${args.join(' ')} 失敗:${detail}`);
|
||||||
@@ -379,15 +386,16 @@ export function runGit(args, { cwd } = {}) {
|
|||||||
* 前一層沒過就不往下打,避免把一個設定問題報成四個。
|
* 前一層沒過就不往下打,避免把一個設定問題報成四個。
|
||||||
* @param {{base: string, token: string}} login
|
* @param {{base: string, token: string}} login
|
||||||
* @param {string} repo owner/name
|
* @param {string} repo owner/name
|
||||||
* @returns {Promise<object>} 通過時回傳 repo 資訊,省下呼叫端再查一次
|
* @returns {Promise<{repo: object, user: object}>} 通過時把查到的 repo 與帳號一起交回去,
|
||||||
|
* 省下呼叫端再查一次——第二層本來就已經問過「我是誰」了
|
||||||
*/
|
*/
|
||||||
export async function preflight(login, repo) {
|
export async function preflight(login, repo) {
|
||||||
checkEnvironment();
|
checkEnvironment();
|
||||||
await checkLogin(login);
|
const user = await checkLogin(login);
|
||||||
const info = await fetchRepo(login, repo);
|
const info = await fetchRepo(login, repo);
|
||||||
await checkIssueWrite(login, repo, info);
|
await checkIssueWrite(login, repo, info);
|
||||||
checkTimeTracker(info);
|
checkTimeTracker(info);
|
||||||
return info;
|
return { repo: info, user };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 第一層:執行環境。node 由「正在執行」本身證明,git 與 tea 則實際到 PATH 上找。 */
|
/** 第一層:執行環境。node 由「正在執行」本身證明,git 與 tea 則實際到 PATH 上找。 */
|
||||||
@@ -458,7 +466,7 @@ export function onPath(binary) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 第二層:Gitea 登入是否有效 */
|
/** 第二層:Gitea 登入是否有效。順手把帳號交出去,呼叫端常要知道「我是誰」。 */
|
||||||
async function checkLogin(login) {
|
async function checkLogin(login) {
|
||||||
const response = await giteaRequest(login, 'GET', '/user');
|
const response = await giteaRequest(login, 'GET', '/user');
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
@@ -467,7 +475,7 @@ async function checkLogin(login) {
|
|||||||
`Gitea 不接受目前的 token(HTTP ${response.status});請重新執行 tea login add 更新登入`,
|
`Gitea 不接受目前的 token(HTTP ${response.status});請重新執行 tea login add 更新登入`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
expectOk(response, 'GET /user');
|
return expectOk(response, 'GET /user');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRepo(login, repo) {
|
async function fetchRepo(login, repo) {
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
/**
|
||||||
|
* 備妥開工的分支。
|
||||||
|
*
|
||||||
|
* 兩件事各自要驗:
|
||||||
|
* 1. **分支命名**是純字串規則,表格驅動,成本最低、回歸價值最高。
|
||||||
|
* 規則錯了會一路帶到 PR 標題與 CI,事後改名很痛。
|
||||||
|
* 2. **不覆蓋他人進度**。來源分支在遠端已存在時要 pull 而不是重建,
|
||||||
|
* 目標分支已存在時要切過去而不是從來源蓋掉。這兩件事沒有真的遠端就驗不出來,
|
||||||
|
* 所以測試在臨時 repo 上跑真的 git。
|
||||||
|
*/
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||||
|
import { makeTempRepoWithRemote } from './helpers/temp-repo.js';
|
||||||
|
|
||||||
|
/** 開一個有遠端的臨時 repo,並登記在測試結束時清掉 */
|
||||||
|
function withRepo(t) {
|
||||||
|
const repo = makeTempRepoWithRemote();
|
||||||
|
t.after(() => repo.cleanup());
|
||||||
|
return repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = (repo, args) => runScript('branch-prep.js', ['--path', repo.dir, ...args]);
|
||||||
|
|
||||||
|
/** 目前 checkout 在哪一支 */
|
||||||
|
const currentBranch = (repo) => repo.git('rev-parse', '--abbrev-ref', 'HEAD');
|
||||||
|
|
||||||
|
// ── 分支命名規則 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const NAMING = [
|
||||||
|
{
|
||||||
|
name: '從開發分支長出:{類型}/{需求描述}/main',
|
||||||
|
source: 'master',
|
||||||
|
args: ['--type', 'feat', '--slug', 'claim-and-branch-prep'],
|
||||||
|
expected: 'feat/claim-and-branch-prep/main',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '開發分支叫 main 時同樣成立',
|
||||||
|
source: 'main',
|
||||||
|
args: ['--type', 'fix', '--slug', 'crlf-parsing'],
|
||||||
|
expected: 'fix/crlf-parsing/main',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '開發分支叫 develop 時同樣成立',
|
||||||
|
source: 'develop',
|
||||||
|
args: ['--type', 'chore', '--slug', 'bump-node'],
|
||||||
|
expected: 'chore/bump-node/main',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '從功能分支長出:類型與需求描述沿用來源,第三段換成這次的描述',
|
||||||
|
source: 'feat/wp-extract-contract/main',
|
||||||
|
args: ['--slug', 'nested-todo-parser'],
|
||||||
|
expected: 'feat/wp-extract-contract/nested-todo-parser',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '從功能分支的子分支長出時,仍掛在同一個需求描述底下',
|
||||||
|
source: 'feat/wp-extract-contract/nested-todo-parser',
|
||||||
|
args: ['--slug', 'crlf-fix'],
|
||||||
|
expected: 'feat/wp-extract-contract/crlf-fix',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, source, args, expected } of NAMING) {
|
||||||
|
test(`命名:${name}`, async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
if (source !== 'master') repo.git('checkout', '-q', '-B', source);
|
||||||
|
|
||||||
|
const { code, json } = await run(repo, ['--source', source, ...args]);
|
||||||
|
|
||||||
|
assert.equal(code, 0, json.error?.message);
|
||||||
|
assert.equal(json.data.branch, expected);
|
||||||
|
assert.equal(currentBranch(repo), expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 命名規則的輸入驗證 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('需求描述含中文時擋下:CI 與 URL 會出問題', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
|
||||||
|
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', '領取工作包']);
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.error.code, 'BAD_SLUG');
|
||||||
|
assert.match(json.error.message, /英文/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('需求描述超過 40 字元時擋下', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
const long = 'a'.repeat(41);
|
||||||
|
|
||||||
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', long]);
|
||||||
|
|
||||||
|
assert.equal(json.error.code, 'BAD_SLUG');
|
||||||
|
assert.match(json.error.message, /40/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('剛好 40 字元是可以的', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
const exactly = 'a'.repeat(40);
|
||||||
|
|
||||||
|
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', exactly]);
|
||||||
|
|
||||||
|
assert.equal(code, 0, json.error?.message);
|
||||||
|
assert.equal(json.data.branch, `feat/${exactly}/main`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('大寫、底線、連續連字號都不是 kebab', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
|
||||||
|
for (const slug of ['Claim-Work', 'claim_work', 'claim--work', '-claim', 'claim-']) {
|
||||||
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', slug]);
|
||||||
|
assert.equal(json.error?.code, 'BAD_SLUG', `${slug} 應該被擋下`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('從開發分支長出卻沒給 --type 時,指名缺的是哪一個', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
|
||||||
|
const { json } = await run(repo, ['--source', 'master', '--slug', 'claim-work']);
|
||||||
|
|
||||||
|
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||||
|
assert.match(json.error.message, /--type/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('從功能分支長出時給 --type 會被擋,避免子分支跑到別棵樹下', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
repo.git('checkout', '-q', '-B', 'feat/wp-extract-contract/main');
|
||||||
|
|
||||||
|
const { json } = await run(repo, [
|
||||||
|
'--source', 'feat/wp-extract-contract/main', '--type', 'fix', '--slug', 'crlf',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(json.error.code, 'TYPE_FROM_SOURCE');
|
||||||
|
assert.match(json.error.message, /feat/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 來源分支:pull 而不是重建 ─────────────────────────────────────
|
||||||
|
|
||||||
|
test('來源分支在遠端已存在時執行 pull,帶進別人的進度', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
repo.pushFromElsewhere('master', 'theirs.txt', '別人的進度\n');
|
||||||
|
|
||||||
|
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||||
|
|
||||||
|
assert.equal(code, 0, json.error?.message);
|
||||||
|
assert.ok(existsSync(join(repo.dir, 'theirs.txt')), '別人推上去的檔案要被帶進來');
|
||||||
|
assert.equal(json.data.來源.動作, 'pull');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('來源分支只在本地時照樣可用,不會因為遠端沒有就報錯', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
repo.git('checkout', '-q', '-B', 'feat/local-only/main');
|
||||||
|
repo.git('checkout', '-q', 'master');
|
||||||
|
|
||||||
|
const { code, json } = await run(repo, [
|
||||||
|
'--source', 'feat/local-only/main', '--slug', 'sub-feature',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(code, 0, json.error?.message);
|
||||||
|
assert.equal(json.data.來源.動作, '用本地既有');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('來源分支本地與遠端都沒有時,回可區分的錯誤碼', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
|
||||||
|
const { code, json } = await run(repo, [
|
||||||
|
'--source', 'feat/不存在/main', '--slug', 'whatever',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.error.code, 'SOURCE_NOT_FOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('遠端分支的比對是全名,不是尾段', async (t) => {
|
||||||
|
// git ls-remote --heads origin main 會匹配到 refs/heads/feat/x/main ——
|
||||||
|
// 而本 repo 的命名慣例讓每一支分支都以 /main 結尾,拿 main 當開發分支的專案
|
||||||
|
// 會整個誤判成「遠端已經有這一支」。
|
||||||
|
const repo = withRepo(t);
|
||||||
|
repo.pushFromElsewhere('feat/x/main', 'theirs.txt', '別人的分支\n');
|
||||||
|
|
||||||
|
const { code, json } = await run(repo, ['--source', 'main', '--type', 'feat', '--slug', 'mine']);
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.error.code, 'SOURCE_NOT_FOUND', '遠端沒有 main 這一支,不該被 feat/x/main 冒名頂替');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 開工前的工作區必須乾淨 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
test('工作區有未提交的改動時擋下,不把它們帶進新分支', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
writeFileSync(join(repo.dir, 'README.md'), '改到一半的東西\n');
|
||||||
|
|
||||||
|
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.error.code, 'DIRTY_WORKTREE');
|
||||||
|
assert.equal(currentBranch(repo), 'master', '擋下來就不該已經切過分支');
|
||||||
|
assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '也不該已經建好分支');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未追蹤的檔案同樣算不乾淨:它會跟著被帶到新分支上', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n');
|
||||||
|
|
||||||
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||||
|
|
||||||
|
assert.equal(json.error.code, 'DIRTY_WORKTREE');
|
||||||
|
assert.match(json.error.message, /stray\.txt/, '要指名是哪些檔案擋住了');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('工作區不乾淨時,--dry-run 也要照樣說出來', async (t) => {
|
||||||
|
// 試跑印得出漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差
|
||||||
|
const repo = withRepo(t);
|
||||||
|
writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n');
|
||||||
|
|
||||||
|
const { json } = await run(repo, [
|
||||||
|
'--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(json.error.code, 'DIRTY_WORKTREE');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 來源分支與遠端分歧 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('本地來源分支與遠端分歧時,回可區分的錯誤碼而不是 git 的原始訊息', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
// 本地有一顆沒推的 commit,遠端也往前走了一顆
|
||||||
|
writeFileSync(join(repo.dir, 'mine.txt'), '我的\n');
|
||||||
|
repo.git('add', '-A');
|
||||||
|
repo.git('commit', '-qm', '本地未推的 commit');
|
||||||
|
repo.pushFromElsewhere('master', 'theirs.txt', '別人的\n');
|
||||||
|
|
||||||
|
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.error.code, 'SOURCE_DIVERGED');
|
||||||
|
assert.match(json.error.message, /master/);
|
||||||
|
assert.equal(currentBranch(repo), 'master', '不該留在半途的狀態');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 目標分支:已存在就切過去,不覆蓋 ───────────────────────────────
|
||||||
|
|
||||||
|
test('目標分支已在遠端時切過去,保留上面已有的進度', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
repo.pushFromElsewhere('feat/mine/main', 'progress.txt', '已經做了一半\n');
|
||||||
|
|
||||||
|
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||||
|
|
||||||
|
assert.equal(code, 0, json.error?.message);
|
||||||
|
assert.equal(currentBranch(repo), 'feat/mine/main');
|
||||||
|
assert.ok(
|
||||||
|
existsSync(join(repo.dir, 'progress.txt')),
|
||||||
|
'遠端已有的分支要接上去,不是從來源重建一個空的蓋掉',
|
||||||
|
);
|
||||||
|
assert.equal(json.data.分支.動作, '接上遠端既有');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('目標分支只在本地時切過去,不重建', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
repo.git('checkout', '-q', '-b', 'feat/mine/main');
|
||||||
|
repo.git('checkout', '-q', 'master');
|
||||||
|
|
||||||
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||||
|
|
||||||
|
assert.equal(json.data.分支.動作, '切換到本地既有');
|
||||||
|
assert.equal(currentBranch(repo), 'feat/mine/main');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('目標分支不存在時從來源建立', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
|
||||||
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'brand-new']);
|
||||||
|
|
||||||
|
assert.equal(json.data.分支.動作, '從來源建立');
|
||||||
|
assert.equal(currentBranch(repo), 'feat/brand-new/main');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 不留本機狀態檔 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('跑完不在目標專案裡留下任何狀態檔', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
|
||||||
|
await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'no-state']);
|
||||||
|
|
||||||
|
const status = repo.git('status', '--porcelain');
|
||||||
|
assert.equal(status, '', '工作區要是乾淨的:進度只從 Gitea 推導,不寫本機狀態檔');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('git 自己的進度訊息不漏到 stderr', async (t) => {
|
||||||
|
// checkout 與 fetch 的訊息 git 一律寫在 stderr。腳本的輸出契約是「stdout 一行 JSON、
|
||||||
|
// stderr 乾淨」,漏出去的話呼叫端就得去分辨哪幾行是雜訊。
|
||||||
|
const repo = withRepo(t);
|
||||||
|
|
||||||
|
const { stderr } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'quiet']);
|
||||||
|
|
||||||
|
assert.equal(stderr, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 路徑 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('--path 指向的不是 git repo 時,回可區分的錯誤碼', async (t) => {
|
||||||
|
const { json } = await runScript('branch-prep.js', [
|
||||||
|
'--path', tmpRoot, '--source', 'master', '--type', 'feat', '--slug', 'whatever',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(json.error.code, 'NOT_A_GIT_REPO');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('回報實際動到的是哪一個目錄,讓人確認沒搞錯專案', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
|
||||||
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||||
|
|
||||||
|
assert.equal(json.data.path, repo.dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── --dry-run ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('--dry-run 印出將執行的 git 指令,且完全不動 repo', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
const before = repo.git('rev-parse', 'HEAD');
|
||||||
|
|
||||||
|
const { code, json } = await run(repo, [
|
||||||
|
'--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.equal(json.data.dryRun, true);
|
||||||
|
assert.equal(json.data.branch, 'feat/mine/main');
|
||||||
|
assert.ok(json.data.commands.length > 0);
|
||||||
|
assert.ok(
|
||||||
|
json.data.commands.every((c) => c.startsWith('git ')),
|
||||||
|
'預覽的是 git 指令本身,不是自創的描述',
|
||||||
|
);
|
||||||
|
assert.equal(currentBranch(repo), 'master', '試跑不該切分支');
|
||||||
|
assert.equal(repo.git('rev-parse', 'HEAD'), before);
|
||||||
|
assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '試跑不該建分支');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--dry-run 連分支命名都先算出來,看得到才叫預覽', async (t) => {
|
||||||
|
const repo = withRepo(t);
|
||||||
|
repo.git('checkout', '-q', '-B', 'feat/wp-extract-contract/main');
|
||||||
|
|
||||||
|
const { json } = await run(repo, [
|
||||||
|
'--source', 'feat/wp-extract-contract/main', '--slug', 'crlf-fix', '--dry-run',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(json.data.branch, 'feat/wp-extract-contract/crlf-fix');
|
||||||
|
});
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* 領取工作包的鎖。
|
||||||
|
*
|
||||||
|
* 這一支的價值全在「什麼時候擋下來」:放行的路徑只有一條,擋的理由有四種,
|
||||||
|
* 而擋錯的代價是兩個人做同一件事、或是工時記到別顆議題上。所以決策表的四種狀態
|
||||||
|
* 各有測試,而且每一種都要驗「一個字都沒寫進 Gitea」——擋下來卻已經改了一半,
|
||||||
|
* 比直接放行更難收拾。
|
||||||
|
*/
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { runScript } from './helpers/run-script.js';
|
||||||
|
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
|
||||||
|
|
||||||
|
const REPO = 'plugins/tea-sdlc';
|
||||||
|
const INDEX = 11;
|
||||||
|
const ME = 'tester';
|
||||||
|
|
||||||
|
/** 議題上跑著的碼錶長什麼樣 */
|
||||||
|
const stopwatchOn = (index, repo = REPO) => ({
|
||||||
|
issue_index: index,
|
||||||
|
repo_owner_name: repo.split('/')[0],
|
||||||
|
repo_name: repo.split('/')[1],
|
||||||
|
});
|
||||||
|
|
||||||
|
function routes(overrides = {}, options = {}) {
|
||||||
|
const { assignees = [], labels = [], stopwatches = [], repoLabels } = options;
|
||||||
|
|
||||||
|
const base = healthyRoutes(REPO, {
|
||||||
|
'GET /api/v1/user': { status: 200, body: { login: ME } },
|
||||||
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
number: INDEX,
|
||||||
|
title: '以 sdlc-feat 領取工作包、起錶並備妥分支',
|
||||||
|
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`,
|
||||||
|
assignees: assignees.map((login) => ({ login })),
|
||||||
|
labels: labels.map((name, i) => ({ id: 60 + i, name })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches },
|
||||||
|
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 201, body: {} },
|
||||||
|
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/labels`]: { status: 200, body: [] },
|
||||||
|
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`]: { status: 201, body: {} },
|
||||||
|
});
|
||||||
|
if (repoLabels !== undefined) {
|
||||||
|
base[`GET /api/v1/repos/${REPO}/labels`] = {
|
||||||
|
status: 200,
|
||||||
|
body: repoLabels.map((name, i) => ({ id: 55 + i, name })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...base, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
|
||||||
|
|
||||||
|
const run = (args, stub) =>
|
||||||
|
runScript('claim.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
||||||
|
env: envFor(stub),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 會改動 Gitea 的請求;擋下來的情境裡這些一個都不該出現。
|
||||||
|
* 前置檢查對 `issues/0` 的那一發 PATCH 不算數——它是探權限用的,打在一顆不存在的議題上,
|
||||||
|
* 不會改動任何東西(見 lib.js 的 checkIssueWrite)。
|
||||||
|
*/
|
||||||
|
const writes = (stub) =>
|
||||||
|
stub.requests.filter((r) => r.method !== 'GET').filter((r) => !r.path.endsWith('/issues/0'));
|
||||||
|
|
||||||
|
// ── 決策表:無鎖 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('沒有鎖時放行:設 assignee、貼進行中、起錶', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent', '進行中'] });
|
||||||
|
|
||||||
|
const { code, json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.equal(json.data.assignee, ME);
|
||||||
|
assert.deepEqual(json.data.labels, ['進行中']);
|
||||||
|
assert.equal(json.data.碼錶中, true);
|
||||||
|
assert.equal(json.data.已認領過, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('放行時三個寫入請求都發出,且順序為先上鎖再起錶', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
|
||||||
|
|
||||||
|
await run([], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
writes(stub).map((r) => `${r.method} ${r.path}`),
|
||||||
|
[
|
||||||
|
`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`,
|
||||||
|
`POST /api/v1/repos/${REPO}/issues/${INDEX}/labels`,
|
||||||
|
`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`,
|
||||||
|
],
|
||||||
|
'錶要最後才起:前面任一步失敗時,不該留下一顆還在跑的碼錶',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assignee 送的是自己的帳號,標籤送的是 id 不是名字', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent', '進行中'] });
|
||||||
|
|
||||||
|
await run([], stub);
|
||||||
|
|
||||||
|
const patch = writes(stub).find((r) => r.method === 'PATCH');
|
||||||
|
assert.deepEqual(patch.body.assignees, [ME]);
|
||||||
|
|
||||||
|
const label = writes(stub).find((r) => r.path.endsWith('/labels'));
|
||||||
|
assert.deepEqual(label.body.labels, [56], '進行中在假 repo 上的 id 是 56');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 決策表:他人已認領 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('他人已認領時擋下,並指名是誰', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, { assignees: ['someone-else'], repoLabels: ['進行中'] });
|
||||||
|
|
||||||
|
const { code, json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.error.code, 'CLAIMED_BY_OTHER');
|
||||||
|
assert.match(json.error.message, /someone-else/);
|
||||||
|
assert.deepEqual(writes(stub), [], '擋下來就不該寫進任何東西');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自己在 assignee 裡但還有別人時,一樣擋', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, { assignees: [ME, 'someone-else'], repoLabels: ['進行中'] });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.error.code, 'CLAIMED_BY_OTHER');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 決策表:自己的碼錶在跑 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
test('自己碼錶跑在本議題時擋下,要求先手動停錶', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, {
|
||||||
|
stopwatches: [stopwatchOn(INDEX)],
|
||||||
|
repoLabels: ['進行中'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { code, json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.error.code, 'STOPWATCH_ON_THIS_ISSUE');
|
||||||
|
assert.match(json.error.message, /停/, '要說清楚下一步是手動停錶');
|
||||||
|
assert.deepEqual(writes(stub), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自己碼錶跑在別的議題時擋下,並指出是哪一顆', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, {
|
||||||
|
stopwatches: [stopwatchOn(7)],
|
||||||
|
repoLabels: ['進行中'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { code, json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
|
||||||
|
assert.match(json.error.message, /#7/, '忘了停掉的是哪一顆,要指名');
|
||||||
|
assert.deepEqual(writes(stub), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('別的 repo 上的同號碼錶也算自己有錶在跑', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, {
|
||||||
|
stopwatches: [stopwatchOn(INDEX, 'plugins/別的專案')],
|
||||||
|
repoLabels: ['進行中'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
|
||||||
|
assert.match(json.error.message, /別的專案/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 冪等:中斷後重跑 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('自己已認領但沒有錶時放行,並如實說這顆本來就是自己的', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, { assignees: [ME], repoLabels: ['進行中'] });
|
||||||
|
|
||||||
|
const { code, json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.equal(json.data.已認領過, true);
|
||||||
|
assert.equal(json.data.碼錶中, true, '錶還是要起,中斷重跑就是為了接上這件事');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('進行中標籤已經在議題上時不重複貼', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, {
|
||||||
|
assignees: [ME],
|
||||||
|
labels: ['進行中'],
|
||||||
|
repoLabels: ['進行中'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.data.已認領過, true);
|
||||||
|
assert.deepEqual(json.data.labels, ['進行中']);
|
||||||
|
assert.equal(
|
||||||
|
writes(stub).some((r) => r.path.endsWith('/labels')),
|
||||||
|
false,
|
||||||
|
'已經貼著的標籤不必再貼一次',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 標籤:本 plugin 不自動建立標籤 ─────────────────────────────────
|
||||||
|
|
||||||
|
test('repo 上沒有進行中標籤時擋在寫入之前,並指出該去建哪一個', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent'] });
|
||||||
|
|
||||||
|
const { code, json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.error.code, 'LABEL_NOT_FOUND');
|
||||||
|
assert.match(json.error.message, /進行中/);
|
||||||
|
assert.deepEqual(writes(stub), [], '標籤缺了就整件事不做,不要只設一半的鎖');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 錯誤 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('議題不存在時回傳可區分的錯誤碼', async (t) => {
|
||||||
|
const stub = await withStub(t, {
|
||||||
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } },
|
||||||
|
}, { repoLabels: ['進行中'] });
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--index 不是正整數時擋在打 Gitea 之前', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
|
||||||
|
|
||||||
|
const { json } = await runScript('claim.js', ['--repo', REPO, '--index', '0'], {
|
||||||
|
env: envFor(stub),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(json.error.code, 'BAD_INDEX');
|
||||||
|
assert.equal(stub.requests.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── --dry-run ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('--dry-run 印出將發出的寫入,但一個字都不寫進去', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
|
||||||
|
|
||||||
|
const { code, json } = await run(['--dry-run'], stub);
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.equal(json.data.dryRun, true);
|
||||||
|
assert.deepEqual(
|
||||||
|
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||||
|
[
|
||||||
|
`PATCH /repos/${REPO}/issues/${INDEX}`,
|
||||||
|
`POST /repos/${REPO}/issues/${INDEX}/labels`,
|
||||||
|
`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(writes(stub), [], '預覽不得真的寫入');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--dry-run 會先讀現況:預覽出來的是這一顆實際的處境', async (t) => {
|
||||||
|
// 手寫一份固定的清單很容易跟實作走鐘,而且說不出「這顆已經是你的了」這種事
|
||||||
|
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
|
||||||
|
|
||||||
|
await run(['--dry-run'], stub);
|
||||||
|
|
||||||
|
const reads = stub.requests.filter((r) => r.method === 'GET').map((r) => r.path);
|
||||||
|
assert.ok(reads.includes('/api/v1/user'));
|
||||||
|
assert.ok(reads.includes(`/api/v1/repos/${REPO}/issues/${INDEX}`));
|
||||||
|
assert.ok(reads.includes('/api/v1/user/stopwatches'));
|
||||||
|
assert.ok(reads.includes(`/api/v1/repos/${REPO}/labels`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--dry-run 略過已經做好的部分,不謊報將發出的請求', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, {
|
||||||
|
assignees: [ME],
|
||||||
|
labels: ['進行中'],
|
||||||
|
repoLabels: ['進行中'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { json } = await run(['--dry-run'], stub);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||||
|
[`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`],
|
||||||
|
'assignee 與標籤都已經到位,只差起錶',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--dry-run 在鎖擋得住的情況下照樣擋,這才是預覽的用處', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, { assignees: ['someone-else'], repoLabels: ['進行中'] });
|
||||||
|
|
||||||
|
const { code, json } = await run(['--dry-run'], stub);
|
||||||
|
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.equal(json.error.code, 'CLAIMED_BY_OTHER');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--dry-run 遇到缺標籤一樣報錯,不會等到實跑才發現', async (t) => {
|
||||||
|
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent'] });
|
||||||
|
|
||||||
|
const { json } = await run(['--dry-run'], stub);
|
||||||
|
|
||||||
|
assert.equal(json.error.code, 'LABEL_NOT_FOUND');
|
||||||
|
});
|
||||||
+69
-11
@@ -13,19 +13,77 @@ import { tmpRoot } from './run-script.js';
|
|||||||
export function makeTempRepo() {
|
export function makeTempRepo() {
|
||||||
mkdirSync(tmpRoot, { recursive: true });
|
mkdirSync(tmpRoot, { recursive: true });
|
||||||
const dir = mkdtempSync(join(tmpRoot, 'repo-'));
|
const dir = mkdtempSync(join(tmpRoot, 'repo-'));
|
||||||
const git = (...args) =>
|
const git = gitIn(dir);
|
||||||
execFileSync('git', args, {
|
|
||||||
cwd: dir,
|
|
||||||
encoding: 'utf8',
|
|
||||||
env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' },
|
|
||||||
});
|
|
||||||
|
|
||||||
git('init', '-q', '-b', 'master');
|
git('init', '-q', '-b', 'master');
|
||||||
git('config', 'user.name', 'tester');
|
seed(git, dir, 'tester', 'tester@example.com');
|
||||||
git('config', 'user.email', 'tester@example.com');
|
|
||||||
writeFileSync(join(dir, 'README.md'), '# temp\n');
|
|
||||||
git('add', '-A');
|
|
||||||
git('commit', '-qm', 'initial');
|
|
||||||
|
|
||||||
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 綁在某個目錄上的 git 執行器。
|
||||||
|
* 全域與系統設定一律切掉:測試的結果不該隨開發者機器上的 git 設定而變。
|
||||||
|
*/
|
||||||
|
function gitIn(cwd) {
|
||||||
|
return (...args) =>
|
||||||
|
execFileSync('git', args, {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' },
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 設好身分並放一顆起始 commit,讓 repo 有東西可以長分支 */
|
||||||
|
function seed(git, dir, name, email) {
|
||||||
|
git('config', 'user.name', name);
|
||||||
|
git('config', 'user.email', email);
|
||||||
|
writeFileSync(join(dir, 'README.md'), '# temp\n');
|
||||||
|
git('add', '-A');
|
||||||
|
git('commit', '-qm', 'initial');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 開一個「有遠端」的臨時 repo:一個 bare 當 origin,一個工作用的 clone。
|
||||||
|
*
|
||||||
|
* branch-prep 的重點行為都繞著遠端打轉——來源分支在遠端已存在時要 pull 而不是重建、
|
||||||
|
* 目標分支已存在時不能覆蓋他人進度。這些事沒有遠端就驗不出來。
|
||||||
|
* git 不做 mock:在臨時 repo 上跑真的 git 比假的 git 可信,成本也低。
|
||||||
|
*
|
||||||
|
* @returns {{dir: string, origin: string, git: Function, pushFromElsewhere: Function, cleanup: Function}}
|
||||||
|
* dir 為工作用 clone,origin 為 bare repo,pushFromElsewhere 用來模擬別人推東西上去
|
||||||
|
*/
|
||||||
|
export function makeTempRepoWithRemote() {
|
||||||
|
mkdirSync(tmpRoot, { recursive: true });
|
||||||
|
const base = mkdtempSync(join(tmpRoot, 'remote-'));
|
||||||
|
const origin = join(base, 'origin.git');
|
||||||
|
const dir = join(base, 'work');
|
||||||
|
|
||||||
|
execFileSync('git', ['init', '-q', '--bare', '-b', 'master', origin]);
|
||||||
|
execFileSync('git', ['clone', '-q', origin, dir]);
|
||||||
|
|
||||||
|
const git = gitIn(dir);
|
||||||
|
seed(git, dir, 'tester', 'tester@example.com');
|
||||||
|
git('push', '-q', 'origin', 'master');
|
||||||
|
|
||||||
|
return {
|
||||||
|
dir,
|
||||||
|
origin,
|
||||||
|
git,
|
||||||
|
/** 模擬「別人推了東西上去」:在 origin 上直接造一顆 commit */
|
||||||
|
pushFromElsewhere: (branch, filename, content) => {
|
||||||
|
const other = join(base, `other-${filename}`);
|
||||||
|
execFileSync('git', ['clone', '-q', origin, other]);
|
||||||
|
const otherGit = gitIn(other);
|
||||||
|
otherGit('config', 'user.name', 'someone-else');
|
||||||
|
otherGit('config', 'user.email', 'other@example.com');
|
||||||
|
otherGit('checkout', '-q', '-B', branch);
|
||||||
|
writeFileSync(join(other, filename), content);
|
||||||
|
otherGit('add', '-A');
|
||||||
|
otherGit('commit', '-qm', `別人的進度:${filename}`);
|
||||||
|
otherGit('push', '-q', 'origin', branch);
|
||||||
|
rmSync(other, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
cleanup: () => rmSync(base, { recursive: true, force: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 正本第一段「領取與開工準備」的規則。
|
||||||
|
*
|
||||||
|
* 這些檔案是文件不是程式,但它們是指令實際交付的東西:決策表寫錯,被鎖擋下來的人
|
||||||
|
* 就會得到錯的下一步;邊界寫漏,第一段就會去做後面幾段的事。靠人記不牢,用測試釘住。
|
||||||
|
*/
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
|
||||||
|
|
||||||
|
const prompt = readPrompt('sdlc-feat');
|
||||||
|
/** 第一段的內容,避免把邊界段的字樣誤認成這一段的規則 */
|
||||||
|
const phase1 = prompt.slice(prompt.indexOf('## 第一段'), prompt.indexOf('## 邊界'));
|
||||||
|
|
||||||
|
test('正本平台中立,description 前綴正確', () => {
|
||||||
|
assertNeutralPrompt(prompt, 'sdlc-feat');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('第一段指名三支腳本,順序為先讀再領再備分支', () => {
|
||||||
|
const order = ['wp-extract.js', 'claim.js', 'branch-prep.js'];
|
||||||
|
const positions = order.map((name) => phase1.indexOf(name));
|
||||||
|
assert.equal(positions.every((p) => p >= 0), true, '三支腳本都要被指名');
|
||||||
|
assert.deepEqual([...positions].sort((a, b) => a - b), positions, '領取之前要先讀得懂這顆在做什麼');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未處理留言不是 0 時要先停下來提示整併', () => {
|
||||||
|
assert.match(phase1, /未處理留言數/);
|
||||||
|
assert.match(phase1, /先停下來/);
|
||||||
|
assert.match(phase1, /sdlc-sync/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('領取鎖的四種狀態各自交代了下一步,含放行那一種', () => {
|
||||||
|
for (const code of ['CLAIMED_BY_OTHER', 'STOPWATCH_ON_THIS_ISSUE', 'STOPWATCH_ON_OTHER_ISSUE']) {
|
||||||
|
assert.match(phase1, new RegExp(code), `${code} 要出現在決策表裡`);
|
||||||
|
}
|
||||||
|
assert.match(phase1, /沒有鎖/, '第四種狀態(放行)也要在表上,否則只剩擋的那幾種');
|
||||||
|
assert.match(phase1, /不要繞過去/, '被擋下來的處置要明講,不能靠 agent 自由發揮');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('缺標籤是前置條件,不混進領取鎖的四種狀態裡', () => {
|
||||||
|
const table = phase1.slice(phase1.indexOf('| 狀態'), phase1.indexOf('碼錶一律由使用者自己停'));
|
||||||
|
assert.equal(table.includes('LABEL_NOT_FOUND'), false, '它不是鎖的狀態,別讓四種變五種');
|
||||||
|
assert.match(phase1, /LABEL_NOT_FOUND/, '但仍要交代它,否則使用者不知道怎麼辦');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('工作包跨多個 repo 時怎麼開分支,有交代', () => {
|
||||||
|
assert.match(phase1, /repos/);
|
||||||
|
assert.match(phase1, /有多顆時逐一確認/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('工作區不乾淨時的處置寫明了,且不替使用者決定', () => {
|
||||||
|
assert.match(phase1, /DIRTY_WORKTREE/);
|
||||||
|
assert.match(phase1, /不要自己選/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('碼錶只由使用者自己停,並說明為什麼不代勞', () => {
|
||||||
|
assert.match(phase1, /由使用者自己停/);
|
||||||
|
assert.match(phase1, /工時記錯地方/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('來源分支要問過使用者,且一次一題、附理由與手動輸入', () => {
|
||||||
|
assert.match(phase1, /一次問一題/);
|
||||||
|
assert.match(phase1, /手動輸入/);
|
||||||
|
assert.match(phase1, /不要替他決定|不要替使用者決定/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('翻譯規則釘住 kebab 與 40 字元上限,並舉出可照抄的例子', () => {
|
||||||
|
assert.match(phase1, /kebab/);
|
||||||
|
assert.match(phase1, /40/);
|
||||||
|
assert.match(phase1, /wp-extract-contract/, '要有一個真的例子,不要只說規則');
|
||||||
|
assert.match(phase1, /不要把長句截斷/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--type 什麼時候要給、什麼時候不能給,寫清楚了', () => {
|
||||||
|
assert.match(phase1, /`--type` 只在來源是開發分支時要給/);
|
||||||
|
assert.match(phase1, /沿用來源/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('兩處「不覆蓋他人進度」的保證都有寫出來', () => {
|
||||||
|
assert.match(phase1, /pull 而不是重建/);
|
||||||
|
assert.match(phase1, /接上去而不是蓋掉/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('三支腳本的寫入都要求先試跑', () => {
|
||||||
|
const dryRuns = phase1.match(/--dry-run/g) ?? [];
|
||||||
|
assert.ok(dryRuns.length >= 2, `兩支寫入型腳本各要先試跑,只找到 ${dryRuns.length} 處`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔', () => {
|
||||||
|
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
|
||||||
|
assert.match(boundary, /不改任何一行程式碼/);
|
||||||
|
assert.match(boundary, /不勾待辦/);
|
||||||
|
assert.match(boundary, /不開 PR/);
|
||||||
|
assert.match(boundary, /不自行建立標籤/);
|
||||||
|
assert.match(boundary, /不寫任何本機狀態檔/);
|
||||||
|
assert.match(boundary, /換一台機器或換一個 agent/, '要說明為什麼不留狀態檔');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user