feat(ai-code-review): 新增 AI 多角色 code review action(攻防審查、findings 保存、建問題模式)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6a0573b7c0
commit
d08b97bd87
@@ -0,0 +1,312 @@
|
||||
'use strict';
|
||||
|
||||
// Gitea REST API 客戶端:以 Node 內建 fetch 呼叫(零相依),認證用 token header。
|
||||
|
||||
/**
|
||||
* 呼叫 Gitea REST API 的共用底層函式(以 Node 內建 fetch 實作,零相依)。
|
||||
* 使用 token header 認證,並將回應內容嘗試解析為 JSON;非 2xx 一律丟出帶狀態碼的錯誤。
|
||||
*
|
||||
* @param {object} ctx - 執行環境 context。此函式必要欄位:
|
||||
* `apiBase`(Gitea API 基底 URL,例如 `https://gitea.example.com/api/v1`)、
|
||||
* `token`(Gitea access token,用於 `Authorization: token ...` header)。
|
||||
* @param {string} method - HTTP method(如 `'GET'`、`'POST'`、`'PATCH'`)。
|
||||
* @param {string} apiPath - API 路徑(接在 `ctx.apiBase` 之後,例如 `/user`)。
|
||||
* @param {object} [body] - 選填的 request body;為 `undefined` 時不送 body,
|
||||
* 否則以 `JSON.stringify` 序列化後送出。
|
||||
* @returns {Promise<*>} 解析後的回應內容:JSON 物件/陣列、空回應時為 `null`、
|
||||
* 無法解析為 JSON 時為原始文字字串。
|
||||
* @throws {Error} 回應非 2xx 時丟出錯誤,訊息含 method、路徑與 HTTP 狀態碼,
|
||||
* 並附加 `status`(HTTP 狀態碼)與 `data`(回應內容)屬性供呼叫端診斷。
|
||||
* @remarks 使用情境:本模組所有對外函式(如 `whoAmI`、`createIssueComment`)
|
||||
* 皆透過此函式發出請求;呼叫端可捕捉錯誤並依 `error.status` 判斷失敗原因
|
||||
* (例如 404 表示該 endpoint 於目前 Gitea 版本不存在)。
|
||||
* 本函式未匯出,僅供模組內部使用。
|
||||
*/
|
||||
async function api(ctx, method, apiPath, body) {
|
||||
const res = await fetch(`${ctx.apiBase}${apiPath}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `token ${ctx.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const error = new Error(`Gitea API ${method} ${apiPath} -> HTTP ${res.status}`);
|
||||
error.status = res.status;
|
||||
error.data = data;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐頁撈取清單型 Gitea API 的全部資料(每頁 limit=50),合併為單一陣列回傳。
|
||||
* 當某頁回傳非陣列、空陣列或筆數不足 50 時即停止翻頁。
|
||||
*
|
||||
* @param {object} ctx - 執行環境 context。必要欄位:`apiBase`、`token`
|
||||
* (由底層 `api` 使用;`apiPath` 若含 owner/repo 等資訊需由呼叫端自行帶入路徑)。
|
||||
* @param {string} apiPath - 清單型 API 路徑;可自帶查詢字串
|
||||
* (函式會自動以 `?` 或 `&` 附加 `page` 與 `limit` 參數)。
|
||||
* @returns {Promise<Array<object>>} 所有頁面合併後的完整資料陣列;無資料時為空陣列。
|
||||
* @throws {Error} 任一頁請求失敗(非 2xx)時,由底層 `api` 丟出帶 `status`、`data` 的錯誤。
|
||||
* @remarks 使用情境:`listIssueComments`、`listReviews` 等需要完整清單
|
||||
* (而非單頁)的查詢皆透過此函式,避免 PR 留言或 review 數量超過單頁上限時漏抓。
|
||||
* 本函式未匯出,僅供模組內部使用。
|
||||
*/
|
||||
async function listAll(ctx, apiPath) {
|
||||
const all = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const sep = apiPath.includes('?') ? '&' : '?';
|
||||
const batch = await api(ctx, 'GET', `${apiPath}${sep}page=${page}&limit=50`);
|
||||
if (!Array.isArray(batch) || batch.length === 0) break;
|
||||
all.push(...batch);
|
||||
if (batch.length < 50) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得目前 token 對應的使用者資訊(`GET /user`),即本 action 的 bot 身分。
|
||||
*
|
||||
* @param {object} ctx - 執行環境 context。必要欄位:`apiBase`、`token`。
|
||||
* @returns {Promise<object>} Gitea 使用者物件(含 `id`、`login` 等欄位,
|
||||
* 依 Gitea API 回應而定)。
|
||||
* @throws {Error} 請求失敗(非 2xx,例如 token 無效時 401)由底層 `api` 丟出。
|
||||
* @remarks 使用情境:action 步驟 8 先查出 bot 自己的帳號,
|
||||
* 之後比對 PR 留言的作者,辨識哪些留言是本 action 先前發出的
|
||||
* (例如要將舊留言標註為已過時)。
|
||||
*/
|
||||
function whoAmI(ctx) {
|
||||
return api(ctx, 'GET', '/user');
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定編號的 issue(或 PR;Gitea 中兩者共用留言機制)上新增一則一般留言。
|
||||
* 對應 endpoint:`POST /repos/{owner}/{repo}/issues/{issueNumber}/comments`。
|
||||
*
|
||||
* @param {object} ctx - 執行環境 context。必要欄位:`apiBase`、`token`、
|
||||
* `owner`(repo 擁有者)、`repo`(repo 名稱)。
|
||||
* @param {number} issueNumber - 目標 issue(或 PR)編號。
|
||||
* @param {string} body - 留言內容(Markdown 文字)。
|
||||
* @returns {Promise<object>} 建立成功的留言物件(含 `id`、`body`、`user` 等欄位,
|
||||
* 依 Gitea API 回應而定)。
|
||||
* @throws {Error} 請求失敗(非 2xx)由底層 `api` 丟出,錯誤附 `status`、`data`。
|
||||
* @remarks 使用情境:建問題模式(input: create-issue)下,
|
||||
* `createIssueWithFindings` 建立 issue 後,逐條把 finding 明細留言到該 issue;
|
||||
* 另外 `createIssueComment` 也委派本函式對 `ctx.prNumber` 留言。
|
||||
*/
|
||||
function createCommentOnIssue(ctx, issueNumber, body) {
|
||||
return api(ctx, 'POST', `/repos/${ctx.owner}/${ctx.repo}/issues/${issueNumber}/comments`, { body });
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 PR(Gitea 中 PR 與 issue 共用留言機制)上新增一則一般留言。
|
||||
* 為 {@link createCommentOnIssue} 的便捷包裝:固定以 `ctx.prNumber` 為目標編號。
|
||||
* 對應 endpoint:`POST /repos/{owner}/{repo}/issues/{prNumber}/comments`。
|
||||
*
|
||||
* @param {object} ctx - 執行環境 context。必要欄位:`apiBase`、`token`、
|
||||
* `owner`(repo 擁有者)、`repo`(repo 名稱)、`prNumber`(PR 編號)。
|
||||
* @param {string} body - 留言內容(Markdown 文字)。
|
||||
* @returns {Promise<object>} 建立成功的留言物件(含 `id`、`body`、`user` 等欄位,
|
||||
* 依 Gitea API 回應而定)。
|
||||
* @throws {Error} 請求失敗(非 2xx)由底層 `api` 丟出,錯誤附 `status`、`data`。
|
||||
* @remarks 使用情境:AI review 各步驟把審查摘要、角色登場、問題彙整等內容
|
||||
* 以一般留言形式張貼到本次 PR 上(`main()` 的 `postComment` 閉包即以本函式實作)。
|
||||
*/
|
||||
function createIssueComment(ctx, body) {
|
||||
return createCommentOnIssue(ctx, ctx.prNumber, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出存取庫(repository)可用的全部標籤。
|
||||
* 對應 endpoint:`GET /repos/{owner}/{repo}/labels`(由 `listAll` 逐頁撈取,每頁 50 筆)。
|
||||
*
|
||||
* @param {object} ctx - 執行環境 context。必要欄位:`apiBase`、`token`、
|
||||
* `owner`(repo 擁有者)、`repo`(repo 名稱)。
|
||||
* @returns {Promise<object[]>} 標籤物件陣列(每筆含 `id`、`name`、`color` 等欄位,
|
||||
* 依 Gitea API 回應而定);存取庫無標籤時為空陣列。
|
||||
* @throws {Error} 任一頁請求失敗(非 2xx)由底層 `api` 丟出,錯誤附 `status`、`data`。
|
||||
* @remarks 使用情境:建問題模式(input: create-issue)下,
|
||||
* `createIssueWithFindings` 先以本函式取得可用標籤,再交給 `selectLabels`
|
||||
* 讓 AI 從中挑選適合掛在新 issue 上的標籤子集合。
|
||||
*/
|
||||
function listLabels(ctx) {
|
||||
return listAll(ctx, `/repos/${ctx.owner}/${ctx.repo}/labels`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在存取庫(repository)建立一個新 issue。
|
||||
* 對應 endpoint:`POST /repos/{owner}/{repo}/issues`。
|
||||
* `labels` 僅在非空陣列時帶入 request body(省略時不掛任何標籤)。
|
||||
*
|
||||
* @param {object} ctx - 執行環境 context。必要欄位:`apiBase`、`token`、
|
||||
* `owner`(repo 擁有者)、`repo`(repo 名稱)。
|
||||
* @param {object} params - issue 內容(解構參數)。
|
||||
* @param {string} params.title - issue 標題。
|
||||
* @param {string} params.body - issue 本文(Markdown 文字)。
|
||||
* @param {number[]} [params.labels] - 要掛上的標籤 id 陣列;省略或空陣列時不帶此欄位。
|
||||
* @returns {Promise<object>} 建立成功的 issue 物件(含 `number`、`title`、
|
||||
* `html_url` 等欄位,依 Gitea API 回應而定)。
|
||||
* @throws {Error} 請求失敗(非 2xx)由底層 `api` 丟出,錯誤附 `status`、`data`。
|
||||
* @remarks 使用情境:建問題模式(input: create-issue)下,
|
||||
* `createIssueWithFindings` 以 PR 標題/描述為 issue 標題與本文、
|
||||
* 配上 `selectLabels` 挑出的標籤 id,呼叫本函式建立追蹤問題的 issue,
|
||||
* 再逐條把 finding 明細留言到該 issue。
|
||||
*/
|
||||
function createIssue(ctx, { title, body, labels }) {
|
||||
return api(ctx, 'POST', `/repos/${ctx.owner}/${ctx.repo}/issues`, {
|
||||
title,
|
||||
body,
|
||||
...(labels && labels.length > 0 ? { labels } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出 PR 上的全部一般留言(自動分頁撈取,每頁 50 筆直到取完)。
|
||||
* 對應 endpoint:`GET /repos/{owner}/{repo}/issues/{prNumber}/comments`。
|
||||
*
|
||||
* @param {object} ctx - 執行環境 context。必要欄位:`apiBase`、`token`、
|
||||
* `owner`、`repo`、`prNumber`。
|
||||
* @returns {Promise<Array<object>>} 留言物件陣列(含 `id`、`body`、`user` 等欄位);
|
||||
* 無留言時為空陣列。
|
||||
* @throws {Error} 任一頁請求失敗(非 2xx)由底層 `api` 丟出,錯誤附 `status`、`data`。
|
||||
* @remarks 使用情境:步驟 8 重跑 review 前,先撈出 PR 全部留言並搭配 `whoAmI`
|
||||
* 比對作者,找出本 action(bot)先前發過的留言,以便編輯標註為已過時。
|
||||
*/
|
||||
function listIssueComments(ctx) {
|
||||
return listAll(ctx, `/repos/${ctx.owner}/${ctx.repo}/issues/${ctx.prNumber}/comments`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 編輯 PR 上既有的一般留言,以新內容整段覆寫。
|
||||
* 對應 endpoint:`PATCH /repos/{owner}/{repo}/issues/comments/{commentId}`
|
||||
* (留言 id 於 repo 層級即可定位,路徑不需 PR 編號)。
|
||||
*
|
||||
* @param {object} ctx - 執行環境 context。必要欄位:`apiBase`、`token`、
|
||||
* `owner`、`repo`。
|
||||
* @param {number|string} commentId - 要編輯的留言 id。
|
||||
* @param {string} body - 覆寫後的留言內容(Markdown 文字)。
|
||||
* @returns {Promise<object>} 編輯後的留言物件(依 Gitea API 回應而定)。
|
||||
* @throws {Error} 請求失敗(非 2xx)由底層 `api` 丟出,錯誤附 `status`、`data`。
|
||||
* @remarks 使用情境:重跑 review 時,將本 action(bot)先前發出的舊摘要留言
|
||||
* 改寫為標註「〔已過時〕」的內容,避免讀者誤信舊結果。
|
||||
*/
|
||||
function editIssueComment(ctx, commentId, body) {
|
||||
return api(ctx, 'PATCH', `/repos/${ctx.owner}/${ctx.repo}/issues/comments/${commentId}`, { body });
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 PR 上建立一個 code review(`event` 固定為 `COMMENT`,不核准也不要求變更),
|
||||
* 並將逐條程式碼留言掛在對應檔案的行號上。
|
||||
* 對應 endpoint:`POST /repos/{owner}/{repo}/pulls/{prNumber}/reviews`。
|
||||
*
|
||||
* @param {object} ctx - 執行環境 context。必要欄位:`apiBase`、`token`、
|
||||
* `owner`、`repo`、`prNumber`。
|
||||
* @param {string} body - review 的整體說明文字(Markdown)。
|
||||
* @param {Array<object>} comments - 行內留言陣列,每筆掛在特定檔案與行號上
|
||||
* (欄位依 Gitea review comment 格式,由呼叫端組裝)。
|
||||
* @returns {Promise<object>} 建立成功的 review 物件(含 `id` 等欄位,
|
||||
* 依 Gitea API 回應而定)。
|
||||
* @throws {Error} 請求失敗(非 2xx,例如留言指向的行號不在 PR diff 內)
|
||||
* 由底層 `api` 丟出,錯誤附 `status`、`data`。
|
||||
* @remarks 使用情境:步驟 9 將嚴重 findings 一次以單一 review 送出,
|
||||
* 讓每條建議直接顯示在 PR 對應的程式碼行上、開發者可逐條回覆。
|
||||
*/
|
||||
function createReview(ctx, body, comments) {
|
||||
return api(ctx, 'POST', `/repos/${ctx.owner}/${ctx.repo}/pulls/${ctx.prNumber}/reviews`, {
|
||||
event: 'COMMENT',
|
||||
body,
|
||||
comments,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出 PR 上的全部 review(自動分頁撈取,每頁 50 筆直到取完)。
|
||||
* 對應 endpoint:`GET /repos/{owner}/{repo}/pulls/{prNumber}/reviews`。
|
||||
*
|
||||
* @param {object} ctx - 執行環境 context。必要欄位:`apiBase`、`token`、
|
||||
* `owner`、`repo`、`prNumber`。
|
||||
* @returns {Promise<Array<object>>} review 物件陣列(含 `id`、`user`、`body` 等欄位);
|
||||
* 無 review 時為空陣列。
|
||||
* @throws {Error} 任一頁請求失敗(非 2xx)由底層 `api` 丟出,錯誤附 `status`、`data`。
|
||||
* @remarks 使用情境:步驟 8 重跑 review 前,先找出 PR 上既有 review,
|
||||
* 再以 `listReviewComments` 取出其行內留言做後續解決標記。
|
||||
*/
|
||||
function listReviews(ctx) {
|
||||
return listAll(ctx, `/repos/${ctx.owner}/${ctx.repo}/pulls/${ctx.prNumber}/reviews`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出指定 review 底下的全部程式碼(行內)留言。
|
||||
* 對應 endpoint:
|
||||
* `GET /repos/{owner}/{repo}/pulls/{prNumber}/reviews/{reviewId}/comments`
|
||||
* (單次呼叫,未分頁)。
|
||||
*
|
||||
* @param {object} ctx - 執行環境 context。必要欄位:`apiBase`、`token`、
|
||||
* `owner`、`repo`、`prNumber`。
|
||||
* @param {number|string} reviewId - 目標 review 的 id(可由 `listReviews` 取得)。
|
||||
* @returns {Promise<Array<object>>} 行內留言物件陣列(含 `id`、`path`、`body` 等欄位,
|
||||
* 依 Gitea API 回應而定)。
|
||||
* @throws {Error} 請求失敗(非 2xx,例如 review 不存在時 404)
|
||||
* 由底層 `api` 丟出,錯誤附 `status`、`data`。
|
||||
* @remarks 使用情境:先以 `listReviews` 找出 PR 上的 review,
|
||||
* 再用本函式取出其中每條行內留言,
|
||||
* 搭配 `tryResolveReviewComment` 嘗試標記為已解決。
|
||||
*/
|
||||
function listReviewComments(ctx, reviewId) {
|
||||
return api(ctx, 'GET', `/repos/${ctx.owner}/${ctx.repo}/pulls/${ctx.prNumber}/reviews/${reviewId}/comments`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 嘗試將 review 的某條程式碼留言標記為已解決(resolve)。
|
||||
* 對應 endpoint:
|
||||
* `POST /repos/{owner}/{repo}/pulls/{prNumber}/reviews/{reviewId}/comments/{commentId}/resolve`。
|
||||
*
|
||||
* 注意(需人工確認):此 resolve endpoint 依 Gitea 版本不一定存在,
|
||||
* 屬版本相依的 API;本函式因此設計為「盡力嘗試」——任何失敗
|
||||
* (含 endpoint 不存在的 404)一律吞掉例外並回傳 `false`,不會丟錯。
|
||||
*
|
||||
* @param {object} ctx - 執行環境 context。必要欄位:`apiBase`、`token`、
|
||||
* `owner`、`repo`、`prNumber`。
|
||||
* @param {number|string} reviewId - 留言所屬 review 的 id。
|
||||
* @param {number|string} commentId - 要標記為已解決的行內留言 id。
|
||||
* @returns {Promise<boolean>} 標記成功回傳 `true`;任何失敗
|
||||
* (版本不支援、權限不足、留言不存在等)一律回傳 `false`,不丟出例外。
|
||||
* @remarks 使用情境:步驟 8 嘗試把舊回合的行內留言標記為已解決;若回傳 `false`
|
||||
* (例如目標 Gitea 版本無此 API),呼叫端應停止嘗試並記 WRN
|
||||
* (由 `resolveOldComments` 實作此降級)。
|
||||
*/
|
||||
async function tryResolveReviewComment(ctx, reviewId, commentId) {
|
||||
try {
|
||||
await api(
|
||||
ctx,
|
||||
'POST',
|
||||
`/repos/${ctx.owner}/${ctx.repo}/pulls/${ctx.prNumber}/reviews/${reviewId}/comments/${commentId}/resolve`,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
whoAmI,
|
||||
createIssueComment,
|
||||
createCommentOnIssue,
|
||||
listLabels,
|
||||
createIssue,
|
||||
listIssueComments,
|
||||
editIssueComment,
|
||||
createReview,
|
||||
listReviews,
|
||||
listReviewComments,
|
||||
tryResolveReviewComment,
|
||||
};
|
||||
Reference in New Issue
Block a user