Merge pull request 'feat/pr-watch-and-cleanup/main' (#48) from feat/pr-watch-and-cleanup/main into master
Reviewed-on: #48 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #48.
This commit is contained in:
@@ -306,6 +306,27 @@ repo**(錶停在那裡)。兩者常常不是同一個——議題在需求
|
||||
- 碼錶是否已停;沒停的話把腳本回的那句說明一起帶出來
|
||||
- 議題上還有沒有沒勾完的待辦(理論上應該沒有;有的話要說出來)
|
||||
|
||||
### 17. 告訴使用者之後怎麼查
|
||||
|
||||
PR 開出去之後就交給 reviewer 了。**把下面這件事講給使用者聽,不要自己反覆跑**:
|
||||
|
||||
```
|
||||
node scripts/pr-watch.js --repo <owner/name> --index <PR 編號>
|
||||
```
|
||||
|
||||
問一次答一次:PR 狀態、還有幾則留言沒處理、工作樹在哪、裡面有沒有沒提交的東西,
|
||||
以及固定列舉值的 `suggestedAction`(`run-sdlc-fix`/`cleanup`/`nothing-to-do`/
|
||||
`blocked-dirty`)。多久跑一次由使用者自己排(cron 或他自己的循環機制),
|
||||
本工具不長出排程器。
|
||||
|
||||
PR 合併或關閉時它會順手清掉那棵工作樹,**本機分支與遠端分支都留著**;工作樹裡還有
|
||||
沒提交的東西就會擋下來(`blocked-dirty`),由使用者自己處理。永遠不會被合併也不會被
|
||||
關閉的那些 PR,用手動出口清:
|
||||
|
||||
```
|
||||
node scripts/worktree-remove.js --repo <owner/name> --branch <分支名>
|
||||
```
|
||||
|
||||
## 邊界
|
||||
|
||||
- 第一段**不改任何一行程式碼**、不勾待辦、不提交、不開 PR——那些是後面幾段的事。
|
||||
@@ -321,6 +342,8 @@ repo**(錶停在那裡)。兩者常常不是同一個——議題在需求
|
||||
- 不把實作規範或註解格式寫進目標專案的任何檔案。
|
||||
- 不改與待辦無關的程式碼;順手想修的東西記下來說出來,不要摸進這次的變更裡。
|
||||
- 不為了勾選在議題上留留言。
|
||||
- **不自動反覆執行 `pr-watch`**,也不因為它建議了 `run-sdlc-fix` 就自己去跑 `/sdlc-fix`——
|
||||
流程只由使用者明確叫用。
|
||||
- 不自行建立標籤。缺「進行中」標籤時中止並請使用者建立。
|
||||
- 不代替使用者停錶,也不在被鎖擋下時繞過去。
|
||||
- 不替使用者決定來源分支。
|
||||
|
||||
+73
-1
@@ -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' };
|
||||
}
|
||||
|
||||
// ── 四層前置檢查 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
+14
-173
@@ -5,35 +5,29 @@
|
||||
* 三類分散在三個端點,漏掉任何一類就會有 reviewer 的意見沒被處理——而那正是
|
||||
* `/sdlc-fix` 存在的理由。這一支只讀不寫,分類(必改/建議)由讀到內容的人判斷。
|
||||
*
|
||||
* 「已處理」在三類上的機制不同:
|
||||
* - 一般留言、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 {
|
||||
ScriptError,
|
||||
expectOk,
|
||||
giteaRequest,
|
||||
main,
|
||||
pages,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
|
||||
/** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */
|
||||
const DRAFT = 'PENDING';
|
||||
import {
|
||||
COMMENT_REQUEST_NOTE,
|
||||
fetchPull,
|
||||
plannedRequests,
|
||||
readPullComments,
|
||||
unhandledCount,
|
||||
} from './pr-threads.js';
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
@@ -43,23 +37,14 @@ main(async () => {
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
const index = parseIndex(flags.index);
|
||||
const pullPath = `/repos/${repo}/pulls/${index}`;
|
||||
|
||||
if (flags['dry-run']) {
|
||||
return {
|
||||
dryRun: true,
|
||||
repo,
|
||||
index,
|
||||
requests: [
|
||||
{ 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` },
|
||||
],
|
||||
note:
|
||||
'每則一般留言還會各查一次 reaction、每個 review 還會各查一次它的行內留言;' +
|
||||
'次數取決於留言數,事前無法列舉。',
|
||||
requests: plannedRequests(repo, index),
|
||||
note: COMMENT_REQUEST_NOTE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,11 +52,8 @@ main(async () => {
|
||||
await preflight(login, repo);
|
||||
|
||||
const me = expectOk(await giteaRequest(login, 'GET', '/user'), 'GET /user').login;
|
||||
const pull = await fetchPull(login, repo, index, pullPath);
|
||||
const 留言 = [
|
||||
...(await readGeneral(login, repo, index, me)),
|
||||
...(await readReviews(login, repo, index, pullPath, me)),
|
||||
];
|
||||
const pull = await fetchPull(login, repo, index);
|
||||
const 留言 = await readPullComments(login, repo, index, me);
|
||||
|
||||
return {
|
||||
repo,
|
||||
@@ -80,148 +62,7 @@ main(async () => {
|
||||
url: pull.html_url,
|
||||
state: pull.state,
|
||||
留言,
|
||||
未處理數: 留言.filter((comment) => !comment.已處理).length,
|
||||
未處理數: unhandledCount(留言),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
async function fetchPull(login, repo, index, path) {
|
||||
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 的留言端點。
|
||||
* 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。
|
||||
*/
|
||||
async function readGeneral(login, repo, index, me) {
|
||||
const path = `/repos/${repo}/issues/${index}/comments`;
|
||||
const 留言 = [];
|
||||
|
||||
for await (const comments of pages(login, path, {
|
||||
limitCode: 'COMMENT_LIMIT',
|
||||
limitHint: `${path} 的留言太多,讀不完整份清單`,
|
||||
})) {
|
||||
for (const comment of comments) {
|
||||
if ((comment.body ?? '').trim() === '') continue;
|
||||
留言.push({
|
||||
id: comment.id,
|
||||
類型: '一般',
|
||||
作者: comment.user?.login ?? '',
|
||||
內容: comment.body,
|
||||
已處理: await markedByMe(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 markedByMe(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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 這一則是不是「我」標記過已處理。
|
||||
*
|
||||
* Gitea 的留言物件不含 reaction,只能逐則再查一次。認的是自己打的 `+1`:
|
||||
* reviewer 對留言按讚是「我同意」,當成已處理會讓那一則被靜靜跳過。
|
||||
*/
|
||||
async function markedByMe(login, repo, id, me) {
|
||||
const path = `/repos/${repo}/issues/comments/${id}/reactions`;
|
||||
const reactions = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
|
||||
|
||||
return reactions.some((reaction) => reaction.content === '+1' && reaction.user?.login === me);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 讀 PR 上的三類留言:一般留言、review 總評、行內留言。
|
||||
*
|
||||
* 兩支腳本共用這一份:`pr-comments` 把整份交給 `/sdlc-fix` 逐則處理,
|
||||
* `pr-watch` 只數還有幾則沒處理。判定「已處理」的規則只能有一份——兩邊各寫一次,
|
||||
* 遲早會一邊認自己打的 `+1`、另一邊認任何人的,而那個差異要等到有留言被靜靜跳過
|
||||
* 才會被發現。
|
||||
*
|
||||
* 「已處理」在三類上的機制不同:
|
||||
* - 一般留言、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 對留言按讚是「我同意」,不是
|
||||
* 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。
|
||||
*/
|
||||
import { ScriptError, expectOk, giteaRequest, 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 readGeneral(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` },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* `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 的留言端點。
|
||||
* 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。
|
||||
*/
|
||||
async function readGeneral(login, repo, index, me) {
|
||||
const path = `/repos/${repo}/issues/${index}/comments`;
|
||||
const 留言 = [];
|
||||
|
||||
for await (const comments of pages(login, path, {
|
||||
limitCode: 'COMMENT_LIMIT',
|
||||
limitHint: `${path} 的留言太多,讀不完整份清單`,
|
||||
})) {
|
||||
for (const comment of comments) {
|
||||
if ((comment.body ?? '').trim() === '') continue;
|
||||
留言.push({
|
||||
id: comment.id,
|
||||
類型: '一般',
|
||||
作者: comment.user?.login ?? '',
|
||||
內容: comment.body,
|
||||
已處理: await markedByMe(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 markedByMe(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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 這一則是不是「我」標記過已處理。
|
||||
*
|
||||
* Gitea 的留言物件不含 reaction,只能逐則再查一次。認的是自己打的 `+1`:
|
||||
* reviewer 對留言按讚是「我同意」,當成已處理會讓那一則被靜靜跳過。
|
||||
*/
|
||||
async function markedByMe(login, repo, id, me) {
|
||||
const path = `/repos/${repo}/issues/comments/${id}/reactions`;
|
||||
const reactions = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
|
||||
|
||||
return reactions.some((reaction) => reaction.content === '+1' && reaction.user?.login === me);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 留下的);` +
|
||||
'請自己確認裡面沒有還沒保存的東西之後移除它',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* 回報 PR 現況,並在它結束時清掉工作樹。
|
||||
*
|
||||
* 這一支的價值在**四種 PR 狀態各自的處置**:merged 與 closed 是終止狀態,工作樹清掉;
|
||||
* open 繼續監看;draft 尤其要盯住——被退回草稿代表還要繼續改,這時候那棵工作樹更需要
|
||||
* 留著,清掉它等於把人做到一半的環境收走。
|
||||
*
|
||||
* 另外兩件事各有測試:建議動作是**列舉值**(呼叫端要程式化判斷,不是去讀一段文字),
|
||||
* 以及清理的守門(有未提交變更就擋下,絕不 `--force`)。
|
||||
*
|
||||
* 一次性、無狀態:不與上次的結果比較,也不讀寫任何游標或狀態檔——「已處理」的判定
|
||||
* 基準是 Gitea 上的 `+1` 與 resolve,現況快照本身就足以回答「還有沒有事要做」。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||
import { makeTempRepoWithRemote } from './helpers/temp-repo.js';
|
||||
import { healthyRoutes, stubEnv, withStubGitea } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const INDEX = 46;
|
||||
const SLUG = 'pr-watch-and-cleanup';
|
||||
const BRANCH = `feat/${SLUG}/main`;
|
||||
|
||||
/**
|
||||
* 一顆 PR 的現場:狀態由 options 決定,留言預設沒有。
|
||||
* 一般留言只有在沒有自己打的 `+1` 時才算未處理。
|
||||
*/
|
||||
function routes({ state = 'open', merged = false, draft = false, general = [] } = {}) {
|
||||
const base = healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}`]: {
|
||||
status: 200,
|
||||
body: {
|
||||
number: INDEX,
|
||||
title: BRANCH,
|
||||
html_url: `https://gitea.jsc.idv.tw/${REPO}/pulls/${INDEX}`,
|
||||
state,
|
||||
merged,
|
||||
draft,
|
||||
head: { ref: BRANCH },
|
||||
},
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
||||
status: 200,
|
||||
body: general.map((c, i) => ({ id: 100 + i, body: c.body, user: { login: 'reviewer' } })),
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: [] },
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, body: [] },
|
||||
});
|
||||
general.forEach((c, i) => {
|
||||
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
|
||||
status: 200,
|
||||
body: (c.reactions ?? []).map((content) => ({
|
||||
content,
|
||||
user: { login: c.reactedBy ?? 'tester' },
|
||||
})),
|
||||
};
|
||||
});
|
||||
return base;
|
||||
}
|
||||
|
||||
/** 備好一棵真的工作樹(由 branch-prep 建)與一台假 Gitea */
|
||||
async function withScene(t, pr = {}) {
|
||||
const repo = makeTempRepoWithRemote();
|
||||
t.after(() => repo.cleanup());
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const home = mkdtempSync(join(tmpRoot, 'home-'));
|
||||
t.after(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
const prep = await runScript(
|
||||
'branch-prep.js',
|
||||
['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', SLUG],
|
||||
{ env: { TEA_SDLC_HOME: home } },
|
||||
);
|
||||
assert.equal(prep.json.ok, true, prep.json.error?.message);
|
||||
|
||||
const stub = await withStubGitea(t, routes(pr));
|
||||
const run = (args = []) =>
|
||||
runScript('pr-watch.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
||||
env: { ...stubEnv(stub), TEA_SDLC_HOME: home },
|
||||
});
|
||||
|
||||
return { repo, home, stub, run, worktree: prep.json.data.worktree };
|
||||
}
|
||||
|
||||
// ── PR 狀態到處置的對照表 ─────────────────────────────────────────
|
||||
|
||||
const DISPOSITION = [
|
||||
{
|
||||
name: 'open:繼續監看,工作樹留著',
|
||||
pr: { state: 'open' },
|
||||
expected: { state: 'open', terminal: false, cleaned: false, suggestedAction: 'nothing-to-do' },
|
||||
工作樹還在: true,
|
||||
},
|
||||
{
|
||||
name: 'merged:終止,工作樹清掉',
|
||||
pr: { state: 'closed', merged: true },
|
||||
expected: { state: 'merged', terminal: true, cleaned: true, suggestedAction: 'nothing-to-do' },
|
||||
工作樹還在: false,
|
||||
},
|
||||
{
|
||||
name: 'closed:同樣終止,工作樹清掉',
|
||||
pr: { state: 'closed', merged: false },
|
||||
expected: { state: 'closed', terminal: true, cleaned: true, suggestedAction: 'nothing-to-do' },
|
||||
工作樹還在: false,
|
||||
},
|
||||
{
|
||||
name: 'draft:不終止也不清理——退回草稿代表還要繼續改',
|
||||
pr: { state: 'open', draft: true },
|
||||
expected: { state: 'draft', terminal: false, cleaned: false, suggestedAction: 'nothing-to-do' },
|
||||
工作樹還在: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, pr, expected, 工作樹還在 } of DISPOSITION) {
|
||||
test(`狀態:${name}`, async (t) => {
|
||||
const { run, worktree } = await withScene(t, pr);
|
||||
|
||||
const { code, json } = await run();
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.state, expected.state);
|
||||
assert.equal(json.data.terminal, expected.terminal);
|
||||
assert.equal(json.data.cleaned, expected.cleaned);
|
||||
assert.equal(json.data.suggestedAction, expected.suggestedAction);
|
||||
assert.equal(existsSync(worktree), 工作樹還在);
|
||||
});
|
||||
}
|
||||
|
||||
test('清理只移除工作樹,本機分支留著', async (t) => {
|
||||
const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true });
|
||||
|
||||
await run();
|
||||
|
||||
assert.equal(existsSync(worktree), false);
|
||||
assert.equal(
|
||||
repo.git('branch', '--list', BRANCH).trim().replace(/^\*?\s*/, ''),
|
||||
BRANCH,
|
||||
'本機分支要留著,之後還能回頭看那段歷史',
|
||||
);
|
||||
});
|
||||
|
||||
// ── 未處理留言 ─────────────────────────────────────────────────────
|
||||
|
||||
test('有未處理留言時建議去跑 sdlc-fix,但不自己執行', async (t) => {
|
||||
const { run, stub } = await withScene(t, { general: [{ body: '這裡少了錯誤處理' }] });
|
||||
|
||||
const { json } = await run();
|
||||
|
||||
assert.equal(json.data.未處理留言數, 1);
|
||||
assert.equal(json.data.suggestedAction, 'run-sdlc-fix');
|
||||
assert.deepEqual(
|
||||
stub.requests.filter((r) => r.method !== 'GET' && !r.path.endsWith('/issues/0')),
|
||||
[],
|
||||
'只通知不動手:監看不該替使用者回覆或標記任何東西',
|
||||
);
|
||||
});
|
||||
|
||||
test('自己打過 +1 的留言算已處理,不再催', async (t) => {
|
||||
const { run } = await withScene(t, {
|
||||
general: [{ body: '這裡少了錯誤處理', reactions: ['+1'], reactedBy: 'tester' }],
|
||||
});
|
||||
|
||||
const { json } = await run();
|
||||
|
||||
assert.equal(json.data.未處理留言數, 0);
|
||||
assert.equal(json.data.suggestedAction, 'nothing-to-do');
|
||||
});
|
||||
|
||||
test('別人打的 +1 不算已處理:那是「我同意」,不是「我處理過了」', async (t) => {
|
||||
const { run } = await withScene(t, {
|
||||
general: [{ body: '這裡少了錯誤處理', reactions: ['+1'], reactedBy: 'reviewer' }],
|
||||
});
|
||||
|
||||
const { json } = await run();
|
||||
|
||||
assert.equal(json.data.未處理留言數, 1);
|
||||
});
|
||||
|
||||
test('draft 上也照樣數留言,繼續監看', async (t) => {
|
||||
const { run, worktree } = await withScene(t, {
|
||||
state: 'open',
|
||||
draft: true,
|
||||
general: [{ body: '這段先別急著合併' }],
|
||||
});
|
||||
|
||||
const { json } = await run();
|
||||
|
||||
assert.equal(json.data.state, 'draft');
|
||||
assert.equal(json.data.suggestedAction, 'run-sdlc-fix');
|
||||
assert.equal(json.data.terminal, false);
|
||||
assert.equal(existsSync(worktree), true, '被退回草稿時更需要那棵工作樹');
|
||||
});
|
||||
|
||||
// ── 清理的守門 ─────────────────────────────────────────────────────
|
||||
|
||||
test('工作樹裡有未提交變更時擋下清理,並報出路徑', async (t) => {
|
||||
const { run, worktree } = await withScene(t, { state: 'closed', merged: true });
|
||||
writeFileSync(join(worktree, 'wip.txt'), '做到一半\n');
|
||||
|
||||
const { code, json } = await run();
|
||||
|
||||
assert.equal(code, 0, '這是一份現況回報,擋下清理不等於整件事失敗');
|
||||
assert.equal(json.data.terminal, true);
|
||||
assert.equal(json.data.cleaned, false);
|
||||
assert.equal(json.data.suggestedAction, 'blocked-dirty');
|
||||
assert.equal(json.data.工作樹.有未提交變更, true);
|
||||
assert.deepEqual(json.data.工作樹.檔案, ['wip.txt']);
|
||||
assert.equal(existsSync(join(worktree, 'wip.txt')), true, '絕不 --force:沒提交的東西救不回來');
|
||||
});
|
||||
|
||||
test('工作樹早就不在時不當成失敗,也不說自己清了', async (t) => {
|
||||
const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true });
|
||||
repo.git('worktree', 'remove', worktree);
|
||||
|
||||
const { code, json } = await run();
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.工作樹.存在, false);
|
||||
assert.equal(json.data.cleaned, false);
|
||||
assert.equal(json.data.suggestedAction, 'nothing-to-do');
|
||||
});
|
||||
|
||||
test('路徑上是別的 clone 留下的東西時,說出來而不是靜靜跳過', async (t) => {
|
||||
const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true });
|
||||
repo.git('worktree', 'remove', worktree);
|
||||
mkdirSync(join(worktree, '.git'), { recursive: true });
|
||||
|
||||
const { code, json } = await run();
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.工作樹.是工作樹, false, '.git 是目錄的是獨立 clone,不是工作樹');
|
||||
assert.equal(json.data.cleaned, false);
|
||||
assert.equal(json.data.suggestedAction, 'cleanup', '要人動手,而手動出口會說出確切的原因');
|
||||
assert.equal(existsSync(join(worktree, '.git')), true, '不是我們建的東西就不碰');
|
||||
});
|
||||
|
||||
test('清不掉的路徑,--dry-run 不會預告一行實跑會拒絕的指令', async (t) => {
|
||||
// 試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差
|
||||
const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true });
|
||||
repo.git('worktree', 'remove', worktree);
|
||||
mkdirSync(worktree, { recursive: true });
|
||||
writeFileSync(join(worktree, '別人的東西.txt'), 'x\n');
|
||||
|
||||
const { json } = await run(['--dry-run']);
|
||||
|
||||
assert.deepEqual(json.data.commands, []);
|
||||
assert.equal(json.data.工作樹.是工作樹, false);
|
||||
});
|
||||
|
||||
// ── 回報內容 ───────────────────────────────────────────────────────
|
||||
|
||||
test('回報內容含 PR 狀態、未處理留言數與工作樹現況', async (t) => {
|
||||
const { run, worktree } = await withScene(t, { general: [{ body: '一則意見' }] });
|
||||
|
||||
const { json } = await run();
|
||||
|
||||
assert.equal(json.data.index, INDEX);
|
||||
assert.equal(json.data.url, `https://gitea.jsc.idv.tw/${REPO}/pulls/${INDEX}`);
|
||||
assert.equal(json.data.branch, BRANCH, '工作樹是從 PR 的 head 分支推導的,要說出用的是哪一支');
|
||||
assert.equal(json.data.未處理留言數, 1);
|
||||
assert.deepEqual(json.data.工作樹, {
|
||||
路徑: worktree,
|
||||
存在: true,
|
||||
是工作樹: true,
|
||||
有未提交變更: false,
|
||||
檔案: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('建議動作是固定的列舉值,呼叫端才能程式化判斷', async (t) => {
|
||||
const 列舉 = ['run-sdlc-fix', 'cleanup', 'nothing-to-do', 'blocked-dirty'];
|
||||
const { run } = await withScene(t);
|
||||
|
||||
const { json } = await run();
|
||||
|
||||
assert.ok(列舉.includes(json.data.suggestedAction), `不在列舉裡:${json.data.suggestedAction}`);
|
||||
});
|
||||
|
||||
// ── 無狀態 ─────────────────────────────────────────────────────────
|
||||
|
||||
test('跑兩次結果一樣,而且不留下任何游標或狀態檔', async (t) => {
|
||||
const { repo, home, run } = await withScene(t, { general: [{ body: '一則意見' }] });
|
||||
|
||||
const first = await run();
|
||||
const second = await run();
|
||||
|
||||
assert.deepEqual(second.json, first.json, '不與上次比較,同樣的現況就該得到同樣的答案');
|
||||
assert.deepEqual(readdirSync(home), ['worktrees'], '家目錄底下只該有工作樹本身');
|
||||
assert.equal(repo.git('status', '--porcelain'), '', '目標專案裡不留任何東西');
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出將執行的 git 指令與將發出的請求,且不清理', async (t) => {
|
||||
const { run, worktree } = await withScene(t, { state: 'closed', merged: true });
|
||||
|
||||
const { code, json } = await run(['--dry-run']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.deepEqual(json.data.commands, [`git worktree remove ${worktree}`]);
|
||||
assert.ok(json.data.requests.some((r) => r.path.endsWith(`/pulls/${INDEX}`)));
|
||||
assert.equal(json.data.cleaned, false);
|
||||
assert.equal(json.data.suggestedAction, 'cleanup', '試跑不動手,該做的事要說出來');
|
||||
assert.equal(existsSync(worktree), true);
|
||||
});
|
||||
|
||||
test('--dry-run 在還不該清理的狀態下不印 git 指令', async (t) => {
|
||||
const { run } = await withScene(t, { state: 'open' });
|
||||
|
||||
const { json } = await run(['--dry-run']);
|
||||
|
||||
assert.deepEqual(json.data.commands, []);
|
||||
});
|
||||
|
||||
// ── 錯誤 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('PR 不存在時回可區分的錯誤碼', async (t) => {
|
||||
const { stub, run } = await withScene(t);
|
||||
stub.requests.length = 0;
|
||||
|
||||
const { json } = await runScript(
|
||||
'pr-watch.js',
|
||||
['--repo', REPO, '--index', '999'],
|
||||
{ env: stubEnv(stub) },
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'PULL_NOT_FOUND');
|
||||
});
|
||||
|
||||
test('--index 不是正整數時擋在打 Gitea 之前', async (t) => {
|
||||
const { stub } = await withScene(t);
|
||||
const before = stub.requests.length;
|
||||
|
||||
const { json } = await runScript('pr-watch.js', ['--repo', REPO, '--index', '0'], {
|
||||
env: stubEnv(stub),
|
||||
});
|
||||
|
||||
assert.equal(json.error.code, 'BAD_INDEX');
|
||||
assert.equal(stub.requests.length, before);
|
||||
});
|
||||
@@ -133,6 +133,19 @@ test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔',
|
||||
assert.match(boundary, /換一台機器或換一個 agent/, '要說明為什麼不留狀態檔');
|
||||
});
|
||||
|
||||
test('第三段收尾時告訴使用者之後怎麼查 PR,但不自己反覆跑', () => {
|
||||
assert.match(phase3, /pr-watch\.js/);
|
||||
assert.match(phase3, /worktree-remove\.js/, '手動清理的出口也要講,否則沒人知道它在');
|
||||
assert.match(phase3, /不要自己反覆跑/);
|
||||
assert.match(phase3, /suggestedAction/, '建議動作是列舉值,要讓使用者知道有這個東西可以判斷');
|
||||
});
|
||||
|
||||
test('邊界擋住「監看報了就自己去跑 sdlc-fix」', () => {
|
||||
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
|
||||
assert.match(boundary, /不自動反覆執行 `pr-watch`/);
|
||||
assert.match(boundary, /只由使用者明確叫用/);
|
||||
});
|
||||
|
||||
// ── 第二段:逐項實作 ───────────────────────────────────────────────
|
||||
|
||||
test('第二段明講改的是工作樹裡的檔案,不是主工作區', () => {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 手動清理工作樹。
|
||||
*
|
||||
* 這一支的重點全在**守門**:清理會被 pr-watch 自動執行,而自動執行的東西只能做
|
||||
* 可逆的事。工作樹裡還有沒提交的東西就中止,絕不 `--force`——刪掉的檔案救不回來。
|
||||
* 移除只動工作樹,本機分支留著,使用者還能回頭看那段歷史。
|
||||
*
|
||||
* 路徑由 `owner/repo/分支名` 推導,不查表也不讀狀態檔,所以這裡連帶驗它與
|
||||
* branch-prep 算出來的是同一條。
|
||||
*
|
||||
* git 不做替身:在臨時 git repo 上跑真的 git,以本機裸 repo 充當遠端,不需網路。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||
import { makeTempRepoWithRemote } from './helpers/temp-repo.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
|
||||
/**
|
||||
* 備好一棵真的工作樹:路徑由 branch-prep 建,測試不自己算,
|
||||
* 兩支腳本推導出同一條路徑這件事本身就是要驗的東西。
|
||||
*/
|
||||
async function withWorktree(t, slug = 'mine') {
|
||||
const repo = makeTempRepoWithRemote();
|
||||
t.after(() => repo.cleanup());
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const home = mkdtempSync(join(tmpRoot, 'home-'));
|
||||
t.after(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
const { json } = await runScript(
|
||||
'branch-prep.js',
|
||||
['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', slug],
|
||||
{ env: { TEA_SDLC_HOME: home } },
|
||||
);
|
||||
assert.equal(json.ok, true, json.error?.message);
|
||||
return { repo, home, branch: json.data.branch, worktree: json.data.worktree };
|
||||
}
|
||||
|
||||
const run = (home, args) => runScript('worktree-remove.js', args, { env: { TEA_SDLC_HOME: home } });
|
||||
|
||||
// ── 乾淨時移除 ─────────────────────────────────────────────────────
|
||||
|
||||
test('工作樹乾淨時移除它,本機分支留著', async (t) => {
|
||||
const { repo, home, branch, worktree } = await withWorktree(t);
|
||||
|
||||
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.removed, true);
|
||||
assert.equal(existsSync(worktree), false, '工作樹要真的不見了');
|
||||
assert.equal(
|
||||
repo.git('branch', '--list', branch).trim().replace(/^\*?\s*/, ''),
|
||||
branch,
|
||||
'本機分支要留著:不佔什麼空間,而使用者還會回頭看那段歷史',
|
||||
);
|
||||
});
|
||||
|
||||
test('回報的路徑就是 branch-prep 建的那一條', async (t) => {
|
||||
const { home, branch, worktree } = await withWorktree(t);
|
||||
|
||||
const { json } = await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
assert.equal(json.data.worktree, worktree, '兩支腳本要從同一個輸入推導出同一條路徑');
|
||||
});
|
||||
|
||||
// ── 守門:有未提交變更就中止 ───────────────────────────────────────
|
||||
|
||||
test('工作樹裡有未提交的變更時中止,並報出路徑', async (t) => {
|
||||
const { home, branch, worktree } = await withWorktree(t);
|
||||
writeFileSync(join(worktree, 'README.md'), '改到一半的東西\n');
|
||||
|
||||
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'WORKTREE_DIRTY');
|
||||
assert.match(json.error.message, new RegExp(worktree), '要指名是哪一條路徑,人才找得到它');
|
||||
assert.match(json.error.message, /README\.md/, '也要說出是哪些檔案擋住了');
|
||||
assert.equal(existsSync(worktree), true, '擋下來就不該已經刪掉');
|
||||
assert.equal(existsSync(join(worktree, 'README.md')), true);
|
||||
});
|
||||
|
||||
test('未追蹤的檔案同樣算未提交:它一樣會被刪掉', async (t) => {
|
||||
const { home, branch, worktree } = await withWorktree(t);
|
||||
writeFileSync(join(worktree, 'notes.txt'), '還沒加進版控的筆記\n');
|
||||
|
||||
const { json } = await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
assert.equal(json.error.code, 'WORKTREE_DIRTY');
|
||||
assert.match(json.error.message, /notes\.txt/);
|
||||
});
|
||||
|
||||
// ── 冪等 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('工作樹本來就不在時安靜地成功,重跑不會失敗', async (t) => {
|
||||
const { home, branch } = await withWorktree(t);
|
||||
await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.removed, false);
|
||||
assert.equal(json.data.已經不在, true, '要說清楚是本來就不在,不是這次刪的');
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出將執行的 git 指令,且不移除任何東西', async (t) => {
|
||||
const { home, branch, worktree } = await withWorktree(t);
|
||||
|
||||
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch, '--dry-run']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.deepEqual(json.data.commands, [`git worktree remove ${worktree}`]);
|
||||
assert.equal(
|
||||
json.data.commands.some((c) => c.includes('--force')),
|
||||
false,
|
||||
'絕不 --force:自動執行的東西只能做可逆的事',
|
||||
);
|
||||
assert.equal(existsSync(worktree), true, '試跑不該真的刪掉');
|
||||
});
|
||||
|
||||
test('--dry-run 也照樣把守門的結果說出來', async (t) => {
|
||||
// 試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差
|
||||
const { home, branch, worktree } = await withWorktree(t);
|
||||
writeFileSync(join(worktree, 'wip.txt'), '做到一半\n');
|
||||
|
||||
const { json } = await run(home, ['--repo', REPO, '--branch', branch, '--dry-run']);
|
||||
|
||||
assert.equal(json.error.code, 'WORKTREE_DIRTY');
|
||||
});
|
||||
|
||||
// ── flag ──────────────────────────────────────────────────────────
|
||||
|
||||
test('缺 --branch 時指名缺的是哪一個', async (t) => {
|
||||
const { home } = await withWorktree(t);
|
||||
|
||||
const { json } = await run(home, ['--repo', REPO]);
|
||||
|
||||
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||
assert.match(json.error.message, /--branch/);
|
||||
});
|
||||
|
||||
test('--repo 格式不是 owner/name 時失敗', async (t) => {
|
||||
const { home } = await withWorktree(t);
|
||||
|
||||
const { json } = await run(home, ['--repo', 'tea-sdlc', '--branch', 'feat/mine/main']);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_REPO');
|
||||
});
|
||||
|
||||
test('路徑上是一個獨立的 clone 時也擋下:對它下 worktree remove 只會得到一句 git 的原始錯誤', async (t) => {
|
||||
const { repo, home, branch, worktree } = await withWorktree(t);
|
||||
repo.git('worktree', 'remove', worktree);
|
||||
mkdirSync(join(worktree, '.git'), { recursive: true });
|
||||
|
||||
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'NOT_A_WORKTREE');
|
||||
assert.equal(existsSync(join(worktree, '.git')), true);
|
||||
});
|
||||
|
||||
test('推導出來的路徑上是別人的東西時不碰它', async (t) => {
|
||||
const { repo, home, branch, worktree } = await withWorktree(t);
|
||||
repo.git('worktree', 'remove', worktree);
|
||||
mkdirSync(worktree, { recursive: true });
|
||||
writeFileSync(join(worktree, '別人的東西.txt'), 'x\n');
|
||||
|
||||
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'NOT_A_WORKTREE');
|
||||
assert.equal(existsSync(join(worktree, '別人的東西.txt')), true);
|
||||
});
|
||||
Reference in New Issue
Block a user