merge(pr-留言): 把議題支援套到 pr-threads 的共用結構上

#48 把留言讀取抽成 pr-threads 給 pr-watch 與 pr-comments 共用,本分支則在修「純議題讀不了」
與「已處理的判定有兩份」。兩邊動到同一塊,但要的其實是同一件事——#48 的檔頭就寫著
「規則寫兩份遲早會各自演化,一邊認自己打的 +1、另一邊認任何人的」,而那正是本分支在
lib 與 pr-comments 之間發現的那個 bug。

合併的方向是保留 pr-threads 的結構,把修正套進去:

- readGeneral 改名 readGeneralComments 並導出,pr-comments 判斷出是純議題時只叫它。
  先讀 /issues/{index} 再決定要不要翻 review——每個 PR 都是議題,反過來不成立。
- pr-threads 自己那份 markedByMe 拿掉,改用 lib 的 mergedByMe。抽取契約數未整併則數
  用的是同一條規則,現在三處共用一份,#48 擔心的分歧不會再發生。
- pr-comments 的試跑改印 commonRequests:它收得下兩種輸入,而試跑階段還沒讀過議題、
  不知道是哪一種。與其假設是 PR 而列出五個(對純議題有三個根本不會發),不如只列一定
  會發的,其餘交給 note。pr-watch 的輸入一定是 PR,繼續用 plannedRequests。

lib.js 與 sdlc-feat.md 兩邊改的是不同區域,三方合併無衝突。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 09:16:34 +00:00
co-authored by Claude Opus 5
parent 767ca69c12
commit 42f5b86299
9 changed files with 1094 additions and 149 deletions
+73 -1
View File
@@ -13,7 +13,7 @@
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { accessSync, constants, existsSync, readFileSync } from 'node:fs';
import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -446,6 +446,78 @@ export function openGitRepo(path) {
return (...args) => runGit(args, { cwd: path });
}
/**
* 看一棵工作樹現在是什麼狀況,並直接說出「能不能清掉、不能的話卡在哪」。
*
* 判斷寫在這裡而不是各呼叫端:試跑與實跑、自動與手動都要擋在同一個地方,
* 兩份判斷遲早會分岔成「試跑說清得掉、實跑卻拒絕」。
*
* 那條路徑上的東西不一定是工作樹:路徑由 owner/repo/分支名 推導,不含本機 clone 的
* 位置,所以別的 clone 也可能在同一條路徑上留下東西。
*
* @param {string} worktree 推導出的工作樹路徑
* @returns {{path: string, exists: boolean, isWorktree: boolean, dirty: boolean,
* files: string[], reason: 'missing'|'foreign'|'dirty'|'removable'}}
*/
export function inspectWorktree(worktree) {
const 空的 = { path: worktree, exists: false, isWorktree: false, dirty: false, files: [] };
if (!existsSync(worktree)) return { ...空的, reason: 'missing' };
if (!linkedWorktree(worktree)) return { ...空的, exists: true, reason: 'foreign' };
const files = runGit(['status', '--porcelain'], { cwd: worktree })
.split('\n')
.filter((line) => line !== '')
// 狀態欄是一到兩個字元,後面接空白才是檔名。不能固定切掉前三個字元——
// runGit 修掉了整段輸出的前後空白,第一行的「已修改」那個前導空白也跟著沒了,
// 切太多會讓檔名少一個字(README.md 變成 EADME.md),人照著去找會找不到。
.map((line) => line.replace(/^\s*\S{1,2}\s+/, ''));
return {
path: worktree,
exists: true,
isWorktree: true,
dirty: files.length > 0,
files,
reason: files.length > 0 ? 'dirty' : 'removable',
};
}
/**
* 這條路徑是不是一棵「連結出去的」工作樹。
* 認的是 `.git` 為**檔案**(裡面一行 gitdir 指回主 repo)——獨立 clone 的 `.git` 是目錄,
* 對它下 `git worktree remove` 只會得到一句 git 的原始錯誤,而那不是使用者要的答案。
*/
function linkedWorktree(worktree) {
try {
return statSync(join(worktree, '.git')).isFile();
} catch {
return false;
}
}
/**
* 移除一棵工作樹。自動清理與手動出口共用這一份實作,不互相開子行程。
*
* **絕不 `--force`。** 這件事會被 pr-watch 自動執行,而自動執行的東西只能做可逆的事:
* 工作樹重建得回來,被刪掉的未提交變更救不回來。所以有東西沒提交時就回報擋下的原因,
* 由呼叫端決定要報成錯誤(手動清理)還是一個待處理的建議(自動監看)。
*
* 只移除工作樹,**本機分支與遠端分支都保留**:本機分支不佔什麼空間,留著讓使用者
* 還能回頭看那段歷史;遠端分支要不要刪是 Gitea 合併時的選項,由使用者自己決定。
*
* @param {string} worktree 推導出的工作樹路徑
* @returns {{removed: boolean, reason: 'removed'|'missing'|'dirty'|'foreign', files: string[], path: string}}
* reason 由 inspectWorktree 給,兩支腳本與試跑、實跑都擋在同一個判斷上
*/
export function removeWorktree(worktree) {
const state = inspectWorktree(worktree);
if (state.reason !== 'removable') return { ...state, removed: false };
// 在工作樹自己裡面執行:它的 .git 指得回主 repo,呼叫端因此不必知道主 clone 在哪
runGit(['worktree', 'remove', worktree], { cwd: worktree });
return { ...state, removed: true, reason: 'removed' };
}
// ── 四層前置檢查 ───────────────────────────────────────────────────
/**
+15 -148
View File
@@ -10,39 +10,28 @@
* 看它有沒有 `pull_request` 才決定要不要去翻 review;反過來先打 `/pulls/{index}`,
* 對純議題會 404,整個流程在讀到第一則留言之前就斷了。
*
* 這一支只讀不寫,分類(必改/建議、是不是決策)由讀到內容的人判斷。
*
* 「已處理」在三類上的機制不同:
* - 一般留言、review 總評 → 自己打的 `+1` reaction
* - 行內留言 → 有沒有被 resolve(只有 review comment 有 resolve 端點)
*
* **總評的 reaction 掛在它的 issue comment id 上,不是 review id。** Gitea 的 review
* 總評在 issue comment 表裡也有一份,兩個 id 不同命名空間——拿 review id 去打
* reaction 會 404。那一份的 id 由 timeline 給(`type: 'review'` 的項目帶 `review_id`)。
*
* reaction 要是**自己**打的才算已處理:reviewer 對留言按讚是「我同意」,不是
* 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。
* 讀取本身與「已處理」的判定在 `pr-threads.js`——`pr-watch` 要數同一件事,
* 規則寫兩份遲早會各自演化。
*
* 用法:
* node scripts/pr-comments.js --repo owner/name --index 45 [--host <網址>] [--dry-run]
*/
import {
expectOk,
fetchIssue,
giteaRequest,
listIssueComments,
main,
mergedByMe,
pages,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
} from './lib.js';
/** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */
const DRAFT = 'PENDING';
import {
ISSUE_OR_PULL_NOTE,
commonRequests,
readGeneralComments,
readPullComments,
unhandledCount,
} from './pr-threads.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
@@ -52,21 +41,14 @@ main(async () => {
});
const repo = parseRepo(flags.repo);
const index = parseIndex(flags.index);
const issuePath = `/repos/${repo}/issues/${index}`;
const pullPath = `/repos/${repo}/pulls/${index}`;
if (flags['dry-run']) {
return {
dryRun: true,
repo,
index,
requests: [
{ method: 'GET', path: issuePath },
{ method: 'GET', path: `${issuePath}/comments` },
],
note:
'每則留言還會各查一次 reaction;是 PR 的話還會再讀 review 清單、每個 review 的' +
'行內留言與 timeline。次數取決於留言數,事前無法列舉。',
requests: commonRequests(repo, index),
note: ISSUE_OR_PULL_NOTE,
};
}
@@ -77,11 +59,9 @@ main(async () => {
// 先讀議題:PR 也是議題,反過來不成立。這一步同時決定要不要去翻 review
const issue = await fetchIssue(login, repo, index);
const isPull = issue.pull_request != null;
const 留言 = [
...(await readGeneral(login, repo, index, me)),
...(isPull ? await readReviews(login, repo, index, pullPath, me) : []),
];
const 留言 = isPull
? await readPullComments(login, repo, index, me)
: await readGeneralComments(login, repo, index, me);
return {
repo,
@@ -91,120 +71,7 @@ main(async () => {
url: issue.html_url,
state: issue.state,
留言,
未處理數: 留言.filter((comment) => !comment.已處理).length,
未處理數: unhandledCount(留言),
};
});
/**
* 一般留言。PR 在 Gitea 裡也是 issue,所以走 issue 的留言端點。
* 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。
*/
async function readGeneral(login, repo, index, me) {
const 留言 = [];
for await (const comment of listIssueComments(login, repo, index)) {
if ((comment.body ?? '').trim() === '') continue;
留言.push({
id: comment.id,
review: null,
類型: '一般',
作者: comment.user?.login ?? '',
內容: comment.body,
已處理: await mergedByMe(login, repo, comment.id, me),
可標記: true,
});
}
return 留言;
}
/**
* review 的總評與它底下的行內留言。
*
* 總評的 id 要用它在 issue comment 表裡的那一份(timeline 給),reaction 才打得上去;
* 行內留言則要用 review 自己的 id 去查。兩個 id 都要,所以兩邊都讀。
*/
async function readReviews(login, repo, index, pullPath, me) {
const path = `${pullPath}/reviews`;
const commentIds = await reviewCommentIds(login, repo, index);
const 留言 = [];
for await (const reviews of pages(login, path, {
limitCode: 'REVIEW_LIMIT',
limitHint: `${path} 的 review 太多,讀不完整份清單`,
})) {
for (const review of reviews) {
if (review.state === DRAFT) continue;
const commentId = commentIds.get(review.id);
if ((review.body ?? '').trim() !== '') {
留言.push({
id: commentId ?? review.id,
review: review.id,
類型: '總評',
作者: review.user?.login ?? '',
內容: review.body,
// 找不到它在 issue comment 表裡的那一份就標不了——那時如實說,不要假裝可以
已處理: commentId === undefined ? false : await mergedByMe(login, repo, commentId, me),
可標記: commentId !== undefined,
});
}
const commentsPath = `${path}/${review.id}/comments`;
for await (const comments of pages(login, commentsPath, {
limitCode: 'REVIEW_COMMENT_LIMIT',
limitHint: `${commentsPath} 的行內留言太多,讀不完整份清單`,
})) {
for (const comment of comments) 留言.push(inlineComment(comment, review.id));
}
}
}
return 留言;
}
/**
* 一則行內留言。
*
* 位置分兩側:留在新檔那一側用 `position`,留在被刪掉的那一行用 `original_position`,
* Gitea 只會填其中一個。只讀 position 的話,留在刪除行的留言會得到 undefined,
* 回覆時位置就送錯欄位、落到別的地方去。
*/
function inlineComment(comment, reviewId) {
const onNew = (comment.position ?? 0) > 0;
return {
id: comment.id,
review: reviewId,
類型: '行內',
作者: comment.user?.login ?? '',
內容: comment.body,
檔案: comment.path,
行: onNew ? comment.position : comment.original_position,
側: onNew ? '新' : '舊',
// 帶上 diff 片段:沒有它,agent 只看得到「這裡少了錯誤處理」而不知道哪裡
diff: comment.diff_hunk ?? '',
// 回覆要落在同一個 commit 上,否則 PR 之後又推了新 commit 時行號對不上
commit: comment.commit_id ?? comment.original_commit_id ?? null,
已處理: comment.resolver != null,
可標記: true,
};
}
/**
* review id → 它在 issue comment 表裡的那一則 id。
* 總評的 reaction 掛在後者上,而 reviews 端點只給得出前者。
*/
async function reviewCommentIds(login, repo, index) {
const path = `/repos/${repo}/issues/${index}/timeline`;
const ids = new Map();
for await (const entries of pages(login, path, {
limitCode: 'TIMELINE_LIMIT',
limitHint: `${path} 的項目太多,對不齊總評的 reaction`,
})) {
for (const entry of entries) {
if (entry.type === 'review' && entry.review_id) ids.set(entry.review_id, entry.id);
}
}
return ids;
}
+217
View File
@@ -0,0 +1,217 @@
/**
* 讀 PR 上的三類留言:一般留言、review 總評、行內留言。
*
* 兩支腳本共用這一份:`pr-comments` 把整份交給 `/sdlc-fix` 逐則處理,
* `pr-watch` 只數還有幾則沒處理。判定「已處理」的規則只能有一份——兩邊各寫一次,
* 遲早會一邊認自己打的 `+1`、另一邊認任何人的,而那個差異要等到有留言被靜靜跳過
* 才會被發現。
*
* 「已處理」在三類上的機制不同:
* - 一般留言、review 總評 → 自己打的 `+1` reaction(判定在 `lib.js` 的 mergedByMe,
* 抽取契約數未整併則數時用的是同一條規則)
* - 行內留言 → 有沒有被 resolve(只有 review comment 有 resolve 端點)
*
* **總評的 reaction 掛在它的 issue comment id 上,不是 review id。** Gitea 的 review
* 總評在 issue comment 表裡也有一份,兩個 id 不同命名空間——拿 review id 去打
* reaction 會 404。那一份的 id 由 timeline 給(`type: 'review'` 的項目帶 `review_id`)。
*
* reaction 要是**自己**打的才算已處理:reviewer 對留言按讚是「我同意」,不是
* 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。
*/
import { ScriptError, expectOk, giteaRequest, listIssueComments, mergedByMe, pages } from './lib.js';
/** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */
const DRAFT = 'PENDING';
/**
* 讀齊三類留言。
* @param {{base: string, token: string}} login
* @param {string} repo owner/name
* @param {number} index PR 編號
* @param {string} me 自己的帳號,用來認「這則是我標的」
* @returns {Promise<object[]>}
*/
export async function readPullComments(login, repo, index, me) {
const pullPath = `/repos/${repo}/pulls/${index}`;
return [
...(await readGeneralComments(login, repo, index, me)),
...(await readReviews(login, repo, index, pullPath, me)),
];
}
/**
* 讀這三類留言會發出哪些請求。兩支腳本的 `--dry-run` 都印它——預告與實際發出的請求
* 分開寫,加一個端點就會有一邊忘了改,而預告錯了等於沒有預告。
* @returns {{method: string, path: string}[]}
*/
export function plannedRequests(repo, index) {
const pullPath = `/repos/${repo}/pulls/${index}`;
return [
{ method: 'GET', path: '/user' },
{ method: 'GET', path: pullPath },
{ method: 'GET', path: `/repos/${repo}/issues/${index}/comments` },
{ method: 'GET', path: `${pullPath}/reviews` },
{ method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` },
];
}
/**
* 不確定是議題還是 PR 時,一定會發的那兩個請求。
*
* `pr-comments` 收得下兩種輸入,而它在試跑階段還沒讀過議題、不知道是哪一種。
* 與其假設是 PR 而列出五個(對純議題有三個根本不會發),不如只列一定會發的,
* 其餘交給 note 說明。`pr-watch` 的輸入一定是 PR,繼續用 plannedRequests。
*/
export function commonRequests(repo, index) {
return [
{ method: 'GET', path: `/repos/${repo}/issues/${index}` },
{ method: 'GET', path: `/repos/${repo}/issues/${index}/comments` },
];
}
/** `commonRequests` 列不完的那部分:是 PR 的話還要再讀三處。 */
export const ISSUE_OR_PULL_NOTE =
'每則留言還會各查一次 reaction;是 PR 的話還會再讀 review 清單、每個 review 的行內留言' +
'與 timeline。次數取決於留言數,事前無法列舉。';
/**
* `plannedRequests` 列不完的那部分。與 readPullComments 同進退——說明的是它發出的請求。
*/
export const COMMENT_REQUEST_NOTE =
'每則一般留言還會各查一次 reaction、每個 review 還會各查一次它的行內留言;' +
'次數取決於留言數,事前無法列舉。';
/** 還沒被處理的則數。`/sdlc-fix` 要做的量,也是 `pr-watch` 的建議動作的依據。 */
export function unhandledCount(留言) {
return 留言.filter((comment) => !comment.已處理).length;
}
/**
* 讀一顆 PR。「不存在」與「沒有讀取權」要分得開——前者是編號打錯,後者是權限沒開。
* @returns {Promise<object>}
*/
export async function fetchPull(login, repo, index) {
const path = `/repos/${repo}/pulls/${index}`;
const response = await giteaRequest(login, 'GET', path);
if (response.status === 404) {
throw new ScriptError('PULL_NOT_FOUND', `${repo} 沒有編號 ${index} 的 PR`);
}
if (response.status === 403) {
throw new ScriptError('NO_READ_ACCESS', `目前的帳號沒有 ${repo} 的 PR ${index} 的讀取權`);
}
return expectOk(response, `GET ${path}`);
}
/**
* 一般留言。PR 在 Gitea 裡也是 issue,所以走 issue 的留言端點——
* 純議題也只有這一類,`/sdlc-sync` 要的就是它。
* 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。
*/
export async function readGeneralComments(login, repo, index, me) {
const 留言 = [];
for await (const comment of listIssueComments(login, repo, index)) {
if ((comment.body ?? '').trim() === '') continue;
留言.push({
id: comment.id,
review: null,
類型: '一般',
作者: comment.user?.login ?? '',
內容: comment.body,
已處理: await mergedByMe(login, repo, comment.id, me),
可標記: true,
});
}
return 留言;
}
/**
* review 的總評與它底下的行內留言。
*
* 總評的 id 要用它在 issue comment 表裡的那一份(timeline 給),reaction 才打得上去;
* 行內留言則要用 review 自己的 id 去查。兩個 id 都要,所以兩邊都讀。
*/
async function readReviews(login, repo, index, pullPath, me) {
const path = `${pullPath}/reviews`;
const commentIds = await reviewCommentIds(login, repo, index);
const 留言 = [];
for await (const reviews of pages(login, path, {
limitCode: 'REVIEW_LIMIT',
limitHint: `${path} 的 review 太多,讀不完整份清單`,
})) {
for (const review of reviews) {
if (review.state === DRAFT) continue;
const commentId = commentIds.get(review.id);
if ((review.body ?? '').trim() !== '') {
留言.push({
id: commentId ?? review.id,
review: review.id,
類型: '總評',
作者: review.user?.login ?? '',
內容: review.body,
// 找不到它在 issue comment 表裡的那一份就標不了——那時如實說,不要假裝可以
已處理: commentId === undefined ? false : await mergedByMe(login, repo, commentId, me),
可標記: commentId !== undefined,
});
}
const commentsPath = `${path}/${review.id}/comments`;
for await (const comments of pages(login, commentsPath, {
limitCode: 'REVIEW_COMMENT_LIMIT',
limitHint: `${commentsPath} 的行內留言太多,讀不完整份清單`,
})) {
for (const comment of comments) 留言.push(inlineComment(comment, review.id));
}
}
}
return 留言;
}
/**
* 一則行內留言。
*
* 位置分兩側:留在新檔那一側用 `position`,留在被刪掉的那一行用 `original_position`,
* Gitea 只會填其中一個。只讀 position 的話,留在刪除行的留言會得到 undefined,
* 回覆時位置就送錯欄位、落到別的地方去。
*/
function inlineComment(comment, reviewId) {
const onNew = (comment.position ?? 0) > 0;
return {
id: comment.id,
review: reviewId,
類型: '行內',
作者: comment.user?.login ?? '',
內容: comment.body,
檔案: comment.path,
行: onNew ? comment.position : comment.original_position,
側: onNew ? '新' : '舊',
// 帶上 diff 片段:沒有它,agent 只看得到「這裡少了錯誤處理」而不知道哪裡
diff: comment.diff_hunk ?? '',
// 回覆要落在同一個 commit 上,否則 PR 之後又推了新 commit 時行號對不上
commit: comment.commit_id ?? comment.original_commit_id ?? null,
已處理: comment.resolver != null,
可標記: true,
};
}
/**
* review id → 它在 issue comment 表裡的那一則 id。
* 總評的 reaction 掛在後者上,而 reviews 端點只給得出前者。
*/
async function reviewCommentIds(login, repo, index) {
const path = `/repos/${repo}/issues/${index}/timeline`;
const ids = new Map();
for await (const entries of pages(login, path, {
limitCode: 'TIMELINE_LIMIT',
limitHint: `${path} 的項目太多,對不齊總評的 reaction`,
})) {
for (const entry of entries) {
if (entry.type === 'review' && entry.review_id) ids.set(entry.review_id, entry.id);
}
}
return ids;
}
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
/**
* 回報一顆 PR 的現況,並在它結束時清掉工作樹。
*
* 一次性、無狀態、冪等:問一次答一次,**不做變化偵測**。「已處理」的判定基準是留言上
* 自己打的 `+1` 與行內留言的 resolve,那個狀態已經存在 Gitea 上,所以一份現況快照就
* 足以回答「還有沒有事要做」——不必跟上次的結果比較,也就不必在本機留任何游標或狀態檔。
*
* **不是常駐程序也不是 daemon。** 「每隔多久跑一次」由呼叫端決定(cron、或 agent 工具
* 自己的循環),本工具不長出排程器:daemon 要 pidfile,而本專案明定不在本機留狀態檔;
* 常駐前景程序雖然不留檔,卻把「監看中」綁在一個終端機 session 上。
*
* **只通知,不動手。** 偵測到有未處理留言時只把建議動作放進輸出,不自動執行 `/sdlc-fix`
* ——流程不該被模型自動觸發,而 `/sdlc-fix` 要求「不確定時詢問使用者」,非互動模式下
* 那個詢問無處可去,agent 只能自行決定,等於把一條驗收標準做成謊言。
*
* 唯一會自動執行的副作用是清理工作樹,而且完全可逆(隨時能重建)。它只在 PR 已合併或
* 已關閉時才發生;**被退回草稿時不清理**——那代表還要繼續改,這時候那棵工作樹更需要留著。
* 有未提交變更就擋下並如實回報,絕不 `--force`。
*
* 建議動作是**列舉值**而不是一段文字:呼叫端要能程式化判斷,而不是去解讀句子。
*
* 用法:
* node scripts/pr-watch.js --repo owner/name --index 46 [--host <網址>] [--dry-run]
*/
import {
ScriptError,
expectOk,
giteaRequest,
inspectWorktree,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
removeWorktree,
resolveLogin,
worktreePath,
} from './lib.js';
import {
COMMENT_REQUEST_NOTE,
fetchPull,
plannedRequests,
readPullComments,
unhandledCount,
} from './pr-threads.js';
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 dryRun = flags['dry-run'] === true;
const login = resolveLogin({ host: flags.host });
// 試跑照樣讀現況:這一支的輸出本來就是一份現況,手寫一份固定的清單等於什麼都沒回報
if (!dryRun) await preflight(login, repo);
const me = expectOk(await giteaRequest(login, 'GET', '/user'), 'GET /user').login;
const pull = await fetchPull(login, repo, index);
const state = stateOf(pull);
const 未處理留言數 = unhandledCount(await readPullComments(login, repo, index, me));
// 工作樹由 PR 自己的 head 分支推導,不必另外給——同一顆工作包算出來的永遠是同一條路徑
const branch = pull.head?.ref;
if (!branch) {
throw new ScriptError(
'PULL_HEAD_MISSING',
`PR #${index} 讀不到 head 分支(來源分支可能已經被刪掉),推導不出工作樹在哪;` +
'請改用 worktree-remove --branch 指名要清哪一棵',
);
}
const worktree = worktreePath(repo, branch);
const terminal = state === 'merged' || state === 'closed';
// 終止狀態才清理。試跑只說要跑哪一行,不真的跑。
const 清理 = terminal && !dryRun ? removeWorktree(worktree) : null;
const 工作樹 = 清理 ?? inspectWorktree(worktree);
const cleaned = 清理?.removed === true;
// 試跑要預告的那一行,條件與實跑完全同一個:reason 由 lib 算,兩邊不各判一次
const 清得掉 = 工作樹.reason === 'removable';
const 報告 = {
repo,
index: pull.number,
title: pull.title,
url: pull.html_url,
branch,
state,
未處理留言數,
工作樹: {
路徑: worktree,
// 清掉之後這幾個欄位講的是清理之前的狀況:cleaned 已經說了現在還在不在
存在: cleaned ? false : 工作樹.exists,
// 路徑上有東西卻不是工作樹(多半是別的 clone 留下的)時,清理不會發生也不該
// 靜靜跳過——手動出口會給出 NOT_A_WORKTREE,這個欄位是它的前情提要
是工作樹: 工作樹.isWorktree,
有未提交變更: 工作樹.dirty,
檔案: 工作樹.files,
},
terminal,
cleaned,
suggestedAction: suggest({ terminal, cleaned, 清得掉, 未處理留言數, 工作樹 }),
};
if (!dryRun) return 報告;
return {
dryRun: true,
...報告,
requests: plannedRequests(repo, index),
note: COMMENT_REQUEST_NOTE,
// 讀取是冪等的,試跑照樣發;會改變東西的只有這一行,所以只有它被留到這裡
commands: terminal && 清得掉 ? [`git worktree remove ${worktree}`] : [],
};
});
/**
* PR 的四種狀態。
* Gitea 的 `state` 只有 open/closed,合併與草稿各是另一個布林值——三個欄位湊成一種
* 狀態,而處置是看那一種,不是看 `state`:merged 與 closed 都終止,draft 則要繼續監看。
*/
function stateOf(pull) {
if (pull.merged === true) return 'merged';
if (pull.state === 'closed') return 'closed';
return pull.draft === true ? 'draft' : 'open';
}
/**
* 下一步該做什麼,固定四個值。
*
* 終止的 PR 只問清理這件事:有沒提交的東西卡著就 blocked-dirty(要人自己處理),
* 清掉了或本來就不在就沒事了,其餘都還有一棵樹等著清——試跑不動手,路徑上是別的
* clone 留下的東西也一樣,兩種都落在 cleanup,由手動出口給出確切的原因。
*
* 還沒終止的 PR 只問留言:有沒處理完的就建議去跑 /sdlc-fix,但只是建議。
*/
function suggest({ terminal, cleaned, 清得掉, 未處理留言數, 工作樹 }) {
if (!terminal) return 未處理留言數 > 0 ? 'run-sdlc-fix' : 'nothing-to-do';
if (工作樹.reason === 'dirty') return 'blocked-dirty';
if (cleaned || 工作樹.reason === 'missing') return 'nothing-to-do';
return 清得掉 || 工作樹.reason === 'foreign' ? 'cleanup' : 'nothing-to-do';
}
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
/**
* 手動清掉一棵工作樹。
*
* pr-watch 會在 PR 合併或關閉時自動清理,這一支是給那些**永遠不會被合併也不會被關閉**
* 的 PR 用的出口——沒有它,那些工作樹只能靠使用者自己記得去刪。
*
* 兩條路共用 lib 的 `removeWorktree`,不互相開子行程:守門的規則只有一份,
* 自動的那條與手動的這條不該長出兩種行為。
*
* 只移除工作樹,本機分支與遠端分支都留著。工作樹裡還有沒提交的東西就中止並報出路徑,
* **絕不 `--force`**:工作樹重建得回來,被刪掉的未提交變更救不回來。
*
* 路徑由 `owner/repo/分支名` 推導,所以輸入是這兩個而不是一條路徑——要刪哪一棵由
* 「哪顆工作包」決定,使用者不必自己去記 12 碼的雜湊目錄名。
*
* 用法:
* node scripts/worktree-remove.js --repo owner/name --branch <分支名> [--dry-run]
*/
import {
ScriptError,
inspectWorktree,
main,
parseFlags,
parseRepo,
removeWorktree,
worktreePath,
} from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'branch'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const branch = flags.branch;
const worktree = worktreePath(repo, branch);
// 試跑與實跑走同一條守門:試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差
if (flags['dry-run']) {
const state = inspectWorktree(worktree);
checkRemovable(state, worktree);
return {
dryRun: true,
repo,
branch,
worktree,
已經不在: state.reason === 'missing',
commands: state.reason === 'removable' ? [`git worktree remove ${worktree}`] : [],
};
}
const result = removeWorktree(worktree);
checkRemovable(result, worktree);
return {
repo,
branch,
worktree,
removed: result.removed,
// 本來就不在不算失敗:重跑這一支是常態,而結果一樣是「那棵樹不在了」
已經不在: result.reason === 'missing',
};
});
/** 擋下來的兩種情況各有各的下一步,錯誤碼要分得開 */
function checkRemovable(result, worktree) {
if (result.reason === 'dirty') {
throw new ScriptError(
'WORKTREE_DIRTY',
`工作樹 ${worktree} 裡還有沒提交的東西(${result.files.join('、')});` +
'請先提交、暫存(git stash)或確認可以丟掉再自己刪除——' +
'本工具不會加 --force,刪掉的未提交變更救不回來',
);
}
if (result.reason === 'foreign') {
throw new ScriptError(
'NOT_A_WORKTREE',
`${worktree} 上有東西,但它不是一棵 git 工作樹(可能是別的 clone 留下的);` +
'請自己確認裡面沒有還沒保存的東西之後移除它',
);
}
}