Files
ai-code-review/src/lib/gitea.js
T

340 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 步驟 2 先查出 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)下,`main()` 建立 issue 後,
* 把工具/diff/角色情境留言與 `review.postSevereToIssue` 的嚴重問題明細留言到該 issue;
* 另外 `createIssueComment` 也委派本函式對 `ctx.prNumber` 留言。
*/
function createCommentOnIssue(ctx, issueNumber, body) {
return api(ctx, 'POST', `/repos/${ctx.owner}/${ctx.repo}/issues/${issueNumber}/comments`, { body });
}
/**
* 在 PRGitea 中 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()` 的 `queueOrPostComment` 閉包即以本函式實作)。
*/
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)下,`main()` 於確定有保留問題後
* 先以本函式取得可用標籤,再交給 `review.selectLabels` 讓 AI 挑出適合的標籤子集合,
* 最後於建立追蹤 issue 時(`createIssue`)一次帶入這些標籤。
*/
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)下,`main()` 的 `createIssueAndFlushBufferedComments`
* 以 PR 標題/描述為 issue 標題與本文,並帶入 `review.selectLabels` 事先挑好的標籤 id
* 呼叫本函式一次建立追蹤問題的 issue(連同標籤),之後再把審查內容逐條留言到該 issue。
*/
function createIssue(ctx, { title, body, labels }) {
return api(ctx, 'POST', `/repos/${ctx.owner}/${ctx.repo}/issues`, {
title,
body,
...(labels && labels.length > 0 ? { labels } : {}),
});
}
/**
* 建立「問題相依」關係:讓 URL 上的 issue/PR 相依於(被阻擋於)表單指定的 issue。
* 對應 endpoint`POST /repos/{owner}/{repo}/issues/{issueNumber}/dependencies`
* body 為 IssueMeta`{index, owner, repo}`)。
*
* 語義:URL 的 issue`blockedIssueNumber`)相依於 body 的 issue`blockingIssueNumber`)——
* 在 `blockingIssueNumber` 關閉前,`blockedIssueNumber` 無法合併/關閉。本 endpoint 需 repo 啟用
* 「問題相依(issue dependencies)」功能,屬版本/設定相依;未啟用或不支援時 API 會回非 2xx。
*
* @param {object} ctx - 執行環境 context。必要欄位:`apiBase`、`token`、
* `owner`repo 擁有者)、`repo`repo 名稱)。
* @param {number|string} blockedIssueNumber - 要被阻擋的 issuePR 編號(相依方)。
* @param {number} blockingIssueNumber - 作為阻擋來源的 issue 編號(同一 repo)。
* @returns {Promise<object>} 建立成功的相依關係物件(依 Gitea API 回應而定)。
* @throws {Error} 請求失敗(非 2xx,例如未啟用問題相依功能)由底層 `api` 丟出,錯誤附 `status`、`data`。
* @remarks 使用情境:建問題模式(input: create-issue)下,`main()` 建立追蹤 issue 後,
* 以本函式把「PR`ctx.prNumber`)相依於追蹤 issue」,讓 issue 完成/關閉前 PR 無法合併;
* 呼叫端以 try/catch 降級(功能未啟用時記 WRN、不阻斷流程)。
*/
function addIssueDependency(ctx, blockedIssueNumber, blockingIssueNumber) {
return api(ctx, 'POST', `/repos/${ctx.owner}/${ctx.repo}/issues/${blockedIssueNumber}/dependencies`, {
index: blockingIssueNumber,
owner: ctx.owner,
repo: ctx.repo,
});
}
/**
* 列出 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 使用情境:步驟 2 重跑 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 使用情境:步驟 2 重跑 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 使用情境:步驟 2 嘗試把舊回合的行內留言標記為已解決;若回傳 `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,
addIssueDependency,
listIssueComments,
editIssueComment,
createReview,
listReviews,
listReviewComments,
tryResolveReviewComment,
};