Files
ai-code-review/app/gitea.js

289 lines
13 KiB
JavaScript
Raw Permalink 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.
import axios from 'axios';
import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_SHA, PR_HEAD_BRANCH, getInsecureHttpsAgent } from './config.js';
import { line, warn } from './log.js';
const httpsAgent = getInsecureHttpsAgent();
/**
* 產生呼叫 Gitea API 所需的 HTTP headers(含 Gitea token 授權與 JSON content-type)。
* 授權格式為 Gitea 專用的 `token <token>`,並非 OAuth Bearer。
* @param {string} [token=GITEA_TOKEN] - Gitea access token;讀取類用預設 token,留言/寫入類通常傳入 GITEA_COMMENT_TOKEN。
* @returns {{Authorization: string, 'Content-Type': string}} 可直接給 axios 的 headers 物件。
*/
const headers = (token = GITEA_TOKEN) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
/**
* 將相對路徑組成 Gitea REST API v1 的完整 URL(自動去除 server URL 結尾斜線)。
* @param {string} path - 以斜線開頭的 API 子路徑,例如 `/repos/owner/repo/pulls/1.diff`。
* @returns {string} 形如 `<server>/api/v1<path>` 的完整 URL。
*/
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
/**
* 從 Gitea commit 相關 API 的回應中萃取 commit message,相容多種巢狀結構。
* 依序嘗試 `message`、`commit.message`、`commit.commit.message`,皆無則回空字串。
* @param {object|null|undefined} payload - Gitea API 回傳的物件(如 git/commits 或 branch 回應)。
* @returns {string} commit 訊息,找不到時為空字串。
*/
function extractCommitMessage(payload) {
return payload?.message
|| payload?.commit?.message
|| payload?.commit?.commit?.message
|| '';
}
/**
* 解析文字中的 `[ai-review-bot][success|failure]` 標記,判斷上一次自動審查結果。
* 用於 commit 訊息或留言內容;無標記或無後綴時視為未知。
* @param {string} message - 待解析的 commit 訊息或留言文字。
* @returns {'success'|'failure'|'unknown'} 解析出的審查結果。
*/
export function getBotReviewOutcome(message) {
const match = String(message || '').match(/\[ai-review-bot\](?:\[(success|failure)\])?/i);
return match?.[1]?.toLowerCase() || 'unknown';
}
/**
* 取得目前 PR 的完整 Git diff,並排除 CI/文件等不需審查的路徑(.gitea/、.github/、README.md、TODO.md)。
* 透過 Gitea `GET /repos/{repo}/pulls/{index}.diff`(純文字 diff),授權使用 GITEA_TOKEN。
* @returns {Promise<string>} 過濾後的 diff 文字。
* @throws {Error} 當 Gitea API 請求失敗(網路錯誤、逾時或非 2xx 狀態)時拋出 axios 例外。
*/
export async function getPRDiff() {
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
return filterDiff(resp.data, [
'.gitea/',
'.github/',
'README.md',
'TODO.md',
]);
}
/**
* 依 commit SHA 向 Gitea 查詢該 commit 的訊息(`GET /repos/{repo}/git/commits/{sha}`)。
* 失敗或 sha 為空時不拋例外,僅記錄警告並回傳空字串,方便呼叫端做容錯判斷。
* @param {string} sha - commit 的完整或縮寫 SHA。
* @returns {Promise<string>} commit 訊息;查無、sha 空或請求失敗時為空字串。
*/
export async function getCommitMessageBySha(sha) {
if (!sha) return '';
try {
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/git/commits/${encodeURIComponent(sha)}`), {
headers: headers(),
timeout: 30000,
httpsAgent,
});
return extractCommitMessage(resp.data);
} catch (e) {
warn(`取得 commit 訊息失敗: sha=${sha} error=${e.message}`);
return '';
}
}
/**
* 取得指定分支 head commit 的訊息(先查 `GET /repos/{repo}/branches/{branch}` 取 SHA,再查該 commit)。
* 失敗或 branch 為空時不拋例外,記錄警告並回傳空字串。
* @param {string} [branch=PR_HEAD_BRANCH] - 分支名稱。
* @returns {Promise<string>} 該分支 head commit 的訊息;查無或失敗時為空字串。
*/
export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) {
if (!branch) return '';
try {
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/branches/${encodeURIComponent(branch)}`), {
headers: headers(),
timeout: 30000,
httpsAgent,
});
const sha = resp.data?.commit?.id || resp.data?.commit?.sha || '';
return await getCommitMessageBySha(sha);
} catch (e) {
warn(`取得分支 head 訊息失敗: branch=${branch} error=${e.message}`);
return '';
}
}
/**
* 判斷目前 PR headcommit 或分支 head)的訊息是否帶 `[ai-review-bot]` 標記;
* 若是,代表本次變更為 bot 自動提交,呼叫端應跳過審查以避免自我審查迴圈。
* @param {object} [options]
* @param {string} [options.sha=PR_HEAD_SHA||process.env.GITHUB_SHA] - 要檢查的 commit SHA。
* @param {string} [options.branch=PR_HEAD_BRANCH] - 要檢查的分支名稱。
* @returns {Promise<boolean>} true 表示應跳過審查。
* @remarks 內部查詢失敗會被降級為空字串(視為未命中),因此正常情況下不會拋出例外。
*/
export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GITHUB_SHA, branch = PR_HEAD_BRANCH } = {}) {
const shaMessage = await getCommitMessageBySha(sha);
if (sha && shaMessage.includes('[ai-review-bot]')) return true;
const branchMessage = await getBranchHeadCommitMessage(branch);
if (branch && branchMessage.includes('[ai-review-bot]')) return true;
return false;
}
/**
* 過濾 unified diff,移除檔案路徑前綴命中 excludePrefixes 的區塊。
* 以每個 `diff --git ` 行為界切割,對每個區塊用 `diff --git a/<prefix>` 做 startsWith 比對。
* @param {string} diff - 完整的 unified diff 文字。
* @param {string[]} excludePrefixes - 要排除的路徑前綴陣列(資料夾以 `/` 結尾,如 `.gitea/`)。
* @returns {string} 過濾後重新接合的 diff 文字。
*/
export function filterDiff(diff, excludePrefixes) {
return diff.split(/(?=^diff --git )/m)
.filter(block => !excludePrefixes.some(p => {
const prefix = `diff --git a/${p}`;
const singleFile = `diff --git a/${p} b/${p}`;
return block.startsWith(prefix) || block.startsWith(singleFile);
}))
.join('');
}
/**
* 在目前 PR 下發布一則一般留言(Gitea 以 issue comment 形式處理 PR 留言)。
* 透過 `POST /repos/{repo}/issues/{index}/comments`,優先使用 GITEA_COMMENT_TOKEN 授權。
* @param {string} body - 留言內容(支援 Markdown)。
* @returns {Promise<object>} Gitea 建立的 comment 物件。
* @throws {Error} 請求失敗(網路、逾時或非 2xx)時拋出 axios 例外。
*/
export async function postComment(body) {
const resp = await axios.post(
api(`/repos/${GITEA_REPOSITORY}/issues/${PR_NUMBER}/comments`),
{ body },
{ headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent },
);
return resp.data;
}
/**
* 在 PR 指定檔案的指定新版行號發布一筆行內 review comment(建立一個只含單一 comment 的 COMMENT review)。
* 以 `new_position` 對應新檔行號;該行不在 diff 範圍時 Gitea 會回錯誤而拋例外,呼叫端可降級為一般留言。
* @param {object} params
* @param {string} params.path - 檔案路徑(PR 內的相對路徑)。
* @param {number} params.line - 新版檔案中的行號(diff 右側行)。
* @param {string} params.body - 行內留言內容。
* @returns {Promise<object>} Gitea 建立的 review 物件。
* @throws {Error} 請求失敗或行號超出 diff 範圍時拋出 axios 例外。
*/
export async function postPullReviewComment({ path: filePath, line, body }) {
const resp = await axios.post(
api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}/reviews`),
{
commit_id: PR_HEAD_SHA || undefined,
event: 'COMMENT',
body: '',
comments: [{ path: filePath, body, new_position: line }],
},
{ headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent },
);
return resp.data;
}
/**
* 建立一個 PR review:本文放摘要,comments 批次放多筆行內 review comments。
* 透過 `POST /repos/{repo}/pulls/{index}/reviews`event=COMMENT),優先使用 GITEA_COMMENT_TOKEN。
* @param {object} params
* @param {string} params.body - review 本文(通常為統計摘要)。
* @param {Array<{path:string, body:string, new_position?:number}>} [params.comments=[]] - 行內 comment 陣列。
* @returns {Promise<object>} Gitea 建立的 review 物件。
* @throws {Error} 請求失敗(如某筆 comment 行號不在 diff 範圍)時拋出 axios 例外。
*/
export async function postPullReview({ body, comments = [] }) {
const resp = await axios.post(
api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}/reviews`),
{
commit_id: PR_HEAD_SHA || undefined,
event: 'COMMENT',
body,
comments,
},
{ headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent },
);
return resp.data;
}
/**
* 取得目前 PR 上所有 review`GET /repos/{repo}/pulls/{index}/reviews`)。
* 回應非陣列時回傳空陣列以保證型別一致。
* @returns {Promise<object[]>} review 物件陣列。
* @throws {Error} 請求失敗時拋出 axios 例外。
*/
export async function listPullReviews() {
const resp = await axios.get(
api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}/reviews`),
{ headers: headers(), timeout: 30000, httpsAgent },
);
return Array.isArray(resp.data) ? resp.data : [];
}
/**
* 取得指定 review 底下的所有行內 comment`GET /repos/{repo}/pulls/{index}/reviews/{id}/comments`)。
* @param {number|string} reviewId - review 的 ID。
* @returns {Promise<object[]>} comment 物件陣列;非陣列回應時為空陣列。
* @throws {Error} 請求失敗時拋出 axios 例外。
*/
export async function getPullReviewComments(reviewId) {
const resp = await axios.get(
api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${reviewId}/comments`),
{ headers: headers(), timeout: 30000, httpsAgent },
);
return Array.isArray(resp.data) ? resp.data : [];
}
/**
* 取得目前 PR 上所有 review 的行內 comment 並展平為單一陣列。
* 單一 review 取 comment 失敗時記錄警告並略過,不中斷整體流程;最後輸出統計日誌。
* @returns {Promise<object[]>} 所有行內 comment 的展平陣列。
* @throws {Error} 當 listPullReviews 取得 review 清單失敗時拋出例外。
*/
export async function listAllReviewComments() {
const reviews = await listPullReviews();
const all = [];
for (const review of reviews) {
if (!review?.id) continue;
try {
all.push(...await getPullReviewComments(review.id));
} catch (e) {
warn(`取得 review #${review.id} 的 comments 失敗(略過): ${e.message}`);
}
}
line(`取得 PR review comments: reviews=${reviews.length} comments=${all.length}`);
return all;
}
/**
* 解決(resolve)指定 review comment 所屬的對話。
* 對應 Gitea API `POST /repos/{repo}/pulls/comments/{id}/resolve`,使用 GITEA_COMMENT_TOKEN 授權。
* @param {number|string} commentId - 要解決的 review comment ID。
* @returns {Promise<object>} Gitea API 回應內容。
* @throws {Error} 請求失敗時拋出 axios 例外。
*/
export async function resolvePullReviewComment(commentId) {
const resp = await axios.post(
api(`/repos/${GITEA_REPOSITORY}/pulls/comments/${commentId}/resolve`),
{},
{ headers: headers(GITEA_COMMENT_TOKEN || GITEA_TOKEN), timeout: 30000, httpsAgent },
);
return resp.data;
}
/**
* 取得指定 ref(預設 PR head)下某檔案的文字內容。
* 透過 Gitea contents API`GET /repos/{repo}/contents/{path}`),base64 內容會自動解碼為 UTF-8 字串。
* 檔案不存在、非文字或請求失敗時不拋例外,記錄警告並回傳空字串。
* @param {string} filePath - 檔案在 repo 中的相對路徑。
* @param {string} [ref=PR_HEAD_SHA||PR_HEAD_BRANCH] - commit SHA 或分支名稱;空值時不帶 ref。
* @returns {Promise<string>} 檔案文字內容;查無或失敗時為空字串。
*/
export async function getFileContentAtRef(filePath, ref = PR_HEAD_SHA || PR_HEAD_BRANCH) {
try {
const resp = await axios.get(
api(`/repos/${GITEA_REPOSITORY}/contents/${encodeURIComponent(filePath).replace(/%2F/g, '/')}`),
{ headers: headers(), params: ref ? { ref } : undefined, timeout: 30000, httpsAgent },
);
const { content, encoding } = resp.data || {};
if (typeof content !== 'string') return '';
return encoding === 'base64' ? Buffer.from(content, 'base64').toString('utf8') : content;
} catch (e) {
warn(`取得檔案內容失敗(視為空): ${filePath}@${ref || 'head'} error=${e.message}`);
return '';
}
}