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' };
}
// ── 四層前置檢查 ───────────────────────────────────────────────────
/**