'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>} 所有頁面合併後的完整資料陣列;無資料時為空陣列。 * @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} 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} 建立成功的留言物件(含 `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} 建立成功的留言物件(含 `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} 標籤物件陣列(每筆含 `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} 建立成功的 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} 建立成功的相依關係物件(依 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>} 留言物件陣列(含 `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} 編輯後的留言物件(依 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} comments - 行內留言陣列,每筆掛在特定檔案與行號上 * (欄位依 Gitea review comment 格式,由呼叫端組裝)。 * @returns {Promise} 建立成功的 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>} 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>} 行內留言物件陣列(含 `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} 標記成功回傳 `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, };