340 lines
17 KiB
JavaScript
340 lines
17 KiB
JavaScript
'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 });
|
||
}
|
||
|
||
/**
|
||
* 在 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()` 的 `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 - 要被阻擋的 issue/PR 編號(相依方)。
|
||
* @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,
|
||
};
|