feat(ai-code-review): 建問題模式導向 issue,並整併流程調整、文件與 CI #6

Merged
admin merged 79 commits from ai-review-resolve/develop-20260717-185330 into develop 2026-07-21 09:39:48 +00:00
4 changed files with 71 additions and 31 deletions
Showing only changes of commit 6a26984bee - Show all commits
+4 -4
View File
@@ -210,7 +210,7 @@ async function main() {
};
/**
* 建問題模式:建立追蹤 issue(標題=PR 標題、本文=PR 描述+回溯 PR 的引言,連同挑好的標籤一次建立),
* 並把 `issueBuffer` 內暫存的情境留言依序寫入 issue;設定閉包變數 `issue` 供後續留言直接發到 issue。
* 並把 `issueBuffer` 內暫存的情境留言批次寫入 issue;設定閉包變數 `issue` 供後續留言直接發到 issue。
* 僅於「確定有保留問題」時呼叫一次。標籤於建立時一次帶入,省去「先建空標籤 issue 再補掛」的多餘 API 往返。
*
* @param {number[]} [labelIds] - 建立 issue 時要一併掛上的標籤 id 陣列(由 `review.selectLabels` 事先挑選);
@@ -224,9 +224,9 @@ async function main() {
labels: labelIds,
});
log('建問題', 'INF', `已建立追蹤 issue #${issue.number},寫入 ${issueBuffer.length} 則情境留言。`);
for (const body of issueBuffer) {
await gitea.createCommentOnIssue(ctx, issue.number, body);
}
await Promise.all(
issueBuffer.map((body) => gitea.createCommentOnIssue(ctx, issue.number, body)),
);
issueBuffer.length = 0;
};
+42 -4
View File
@@ -4,6 +4,34 @@ const { execFileSync } = require('child_process');
// git 操作工具:一律以 execFileSync 呼叫 git(不經 shell,避免注入),輸出以 UTF-8 回傳。
/**
* 驗證遠端分支名稱可安全用於 refspec 與 refs/remotes/origin/*。
*
* @param {string} refName - 使用者或事件 payload 提供的分支名稱。
* @param {string} fieldName - 錯誤訊息中的欄位名稱。
* @returns {string} 原樣回傳通過驗證的分支名稱。
* @throws {Error} 分支名稱空白、含路徑穿越,或不符合 git 分支 ref 規則時拋出。
* @remarks
* 使用情境:`resolveMergeBase` 的 `baseRef` 與 `commitAndPushFindings` 的
* `headRef` 會被組進 refspec;先驗證可避免惡意 payload 影響本地 refs 路徑。
*/
function assertSafeBranchRef(refName, fieldName) {
const value = String(refName || '').trim();
if (!value) throw new Error(`${fieldName} 不可為空。`);
if (value.includes('..') || value.startsWith('/') || value.endsWith('/') || value.includes('\\')) {
throw new Error(`${fieldName} 不是安全的分支名稱:${value}`);
}
try {
execFileSync('git', ['check-ref-format', '--branch', value], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch {
throw new Error(`${fieldName} 不是合法的 git 分支名稱:${value}`);
}
return value;
}
/**
* 同步執行 git 指令並回傳原始 stdout 輸出。
*
@@ -102,6 +130,7 @@ function latestCommitSubject(cwd) {
* 避免把 base 分支後續演進誤算進 diff。
*/
function resolveMergeBase(cwd, baseRef) {
baseRef = assertSafeBranchRef(baseRef, 'baseRef');
const remoteBase = `origin/${baseRef}`;
const diagnostics = [];
// 執行一個 fetch 策略並記錄成敗(只記策略名與成敗,不含 git 原始輸出,避免洩漏遠端資訊)。
@@ -251,6 +280,7 @@ function fileLastUpdatedIso(cwd, file) {
* 例外把命令列(含 token)回顯到 CI log 或程序清單。
*/
function commitAndPushFindings(cwd, { headRef, headSha, message, files, token, serverUrl, repository }) {
headRef = assertSafeBranchRef(headRef, 'headRef');
const current = gitTrim(cwd, 'rev-parse', 'HEAD');
if (headSha && current !== headSha) {
git(cwd, 'checkout', '--detach', headSha);
@@ -294,17 +324,22 @@ function commitAndPushFindings(cwd, { headRef, headSha, message, files, token, s
*
* @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。
* @param {string} remoteUrl - 不含帳密的遠端 URL(形如 `https://host/owner/repo.git`)。
* @param {string} secret - 具 push 權限的 tokenPAT(作為 Basic 認證的密碼)。
* @param {string} token - 具 push 權限的 tokenPAT(作為 Basic 認證的密碼)。
* @param {string} refspec - push 的 refspec(形如 `HEAD:refs/heads/<branch>`)。
* @param {string} serverUrl - Gitea 伺服器根網址(用於定位 checkout 持久化 extraheader 的 scope)。
* @returns {void} 成功即返回;失敗拋出不含 URL/argv/token 的固定錯誤。
* @throws {Error} 推送失敗時拋出固定訊息(已隱藏遠端 URL 與認證資訊)。
* @remarks 本函式未匯出,僅供 {@link commitAndPushFindings} 使用。
*/
function pushWithCredential(cwd, remoteUrl, secret, refspec, serverUrl) {
const basic = Buffer.from(`ai-review-bot:${secret}`).toString('base64');
function pushWithCredential(cwd, remoteUrl, token, refspec, serverUrl) {
const server = new URL(serverUrl);
const remote = new URL(remoteUrl);
if (remote.origin !== server.origin || !remote.pathname.endsWith('.git')) {
throw new Error('推送遠端 URL 與 Gitea 伺服器不相符,已停止推送。');
}
const basic = Buffer.from(`ai-review-bot:${token}`).toString('base64');
// checkout 持久化自動 token 的 scope 為 `http.<serverUrl>/.extraheader`(結尾帶斜線)。
const headerScope = `http.${serverUrl.replace(/\/+$/, '')}/.extraheader`;
const headerScope = `http.${server.origin}/.extraheader`;
try {
execFileSync('git', ['push', remoteUrl, refspec], {
cwd,
@@ -333,4 +368,7 @@ module.exports = {
fileDiff,
fileLastUpdatedIso,
commitAndPushFindings,
__test: {
assertSafeBranchRef,
},
};
+22 -20
View File
@@ -36,7 +36,7 @@ const AGENT_DIAGNOSTIC_OUTPUT_LIMIT = 500;
function redactSecrets(text) {
return String(text ?? '')
.replace(/[\r\n\t\v\f\x00-\x1f\x7f]+/g, ' ')
.replace(/(authorization\s*[:=]\s*)\S+/gi, '$1***')
.replace(/(authorization\s*[:=]\s*)(?:bearer\s+)?\S+/gi, '$1***')
.replace(/((?:api[_-]?key|token|password|secret|bearer)\s*[:=]\s*)\S+/gi, '$1***')
.replace(/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/gi, '$1***:***@')
.replace(/\bgh[pousr]_[A-Za-z0-9]{16,}\b/g, '***')
@@ -48,10 +48,9 @@ function redactSecrets(text) {
* 從 `runAgent` 的失敗結果組出可診斷的一行摘要:退出碼/訊號為主,原始輸出預設隱藏。
*
* 安全考量:AI CLI 失敗時可能在 stderr/stdout 回顯提示內容、環境資訊、token、PII 或
* 原始碼祕密,這些會被長期保存並供多人讀取的 CI log 收錄。權衡「可除錯性」後:本函式
* 於失敗時**預設**附上經 {@link redactSecrets} 遮罩且去除控制字元的 **stderr 與 stdout**
* 片段(各先截去過長輸入再取前 500 字)——只印 exit code 幾乎無從判斷 CLI 為何失敗,
* 且部分 CLI(如 claude-code 的 `-p` 模式)將錯誤寫到 stdout 而非 stderr。純函式、不拋例外。
* 原始碼祕密,這些會被長期保存並供多人讀取的 CI log 收錄。因此本函式預設只輸出退出碼、
* 訊號與逾時狀態;只有 `ACTIONS_STEP_DEBUG=true` 時才附上經 {@link redactSecrets}
* 遮罩且去除控制字元的 stderr/stdout 片段(各先截去過長輸入再取前 500 字)。
*
* @param {{error: (Error & {code?: number|string, signal?: string, killed?: boolean})|null, stderr?: string, output?: string}} res
* `runAgent` 的回傳物件。
@@ -59,7 +58,7 @@ function redactSecrets(text) {
* @remarks
* 使用情境:{@link runAttackers}{@link runDefenders}{@link fillPurposes}{@link selectLabels}
* 判定 `!res.ok` 時,以本函式把失敗細節寫進 WRN log,讓 CI 記錄能看出 AI CLI 為何失敗;
* 需要原始輸出診斷時,於 workflow 設定 secret `ACTIONS_STEP_DEBUG=true` 再重跑。
* 需要輸出片段輔助診斷時,於 workflow 設定 `ACTIONS_STEP_DEBUG=true` 再重跑。
* 本函式未匯出,僅供模組內部使用。
*/
function agentFailureDetail(res) {
@@ -71,14 +70,13 @@ function agentFailureDetail(res) {
else if (err.code) parts.push(`code ${err.code}`);
else if (err.signal) parts.push(`signal ${err.signal}`);
}
// 先截去過長輸入(INPUT_LIMIT)再遮罩,最終仍截為 AGENT_DIAGNOSTIC_OUTPUT_LIMIT;長度政策常數見模組頂層
// 預設即附上「經 redactSecrets 遮罩+去控制字元+限長」的 stderr 與 stdout 片段——CLI 失敗時
// 只印 exit code 幾乎無從除錯(見 test-claude 秒失敗案例);且部分 CLI(如 claude-code 的
// -p 模式)會把錯誤寫到 stdout 而非 stderr,故兩者都輸出。redactSecrets 為盡力防線。
const stderr = redactSecrets(String((res && res.stderr) || '').slice(0, INPUT_LIMIT));
if (stderr) parts.push(`stderr${stderr.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`);
const stdout = redactSecrets(String((res && res.output) || '').slice(0, INPUT_LIMIT));
if (stdout) parts.push(`stdout${stdout.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`);
// 失敗輸出可能含 token 或 PII,預設不寫入長期 CI log;debug 模式才輸出遮罩後片段
if (process.env.ACTIONS_STEP_DEBUG === 'true') {
const stderr = redactSecrets(String((res && res.stderr) || '').slice(0, INPUT_LIMIT));
if (stderr) parts.push(`stderr${stderr.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`);
const stdout = redactSecrets(String((res && res.output) || '').slice(0, INPUT_LIMIT));
if (stdout) parts.push(`stdout${stdout.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`);
}
if (parts.length === 0) {
parts.push((err && err.message && redactSecrets(err.message)) || 'AI CLI 執行失敗(無診斷輸出)');
}
@@ -739,9 +737,9 @@ ${JSON.stringify(brief)}
* 不使用 {@link templates.othersComment} 的單一表格——表格僅用於一般模式(PR)。
*/
async function postSevereToIssue({ ctx, gitea, issueNumber, severe }) {
for (const finding of severe) {
await gitea.createCommentOnIssue(ctx, issueNumber, templates.issueFindingComment(finding));
}
await Promise.all(
severe.map((finding) => gitea.createCommentOnIssue(ctx, issueNumber, templates.issueFindingComment(finding))),
);
log('步驟9', 'INF', `已將 ${severe.length} 條嚴重問題留言到 issue #${issueNumber}`);
}
@@ -765,9 +763,9 @@ async function postSevereToIssue({ ctx, gitea, issueNumber, severe }) {
* 以本函式把警告+建議逐條留言到追蹤 issue,確保 issue 上每條問題都是可個別回覆的留言。
*/
async function postOthersToIssue({ ctx, gitea, issueNumber, others }) {
for (const finding of others) {
await gitea.createCommentOnIssue(ctx, issueNumber, templates.issueFindingComment(finding));
}
await Promise.all(
others.map((finding) => gitea.createCommentOnIssue(ctx, issueNumber, templates.issueFindingComment(finding))),
);
log('步驟10', 'INF', `已將 ${others.length} 條警告+建議逐條留言到 issue #${issueNumber}`);
}
@@ -933,4 +931,8 @@ module.exports = {
postOthersToIssue,
resolveOldComments,
postSevereComments,
__test: {
agentFailureDetail,
redactSecrets,
},
};
+3 -3
View File
@@ -334,9 +334,9 @@ ${body || 'PR 無描述)'}
* @param {string} [finding.suggestedCode] - 建議寫法程式碼;有值才輸出「建議寫法」區塊。
* @returns {string} 完整留言 Markdown 字串(含 MARK 隱藏標記)。
* @remarks
* 使用情境:建問題模式下 `review.postSevereToIssue`src/lib/review.js)把每條嚴重 finding
* 以本函式產生留言內容、經 `gitea.createCommentOnIssue` 發布到追蹤 issue 上,
* 作為問題明細的追蹤紀錄。
* 使用情境:建問題模式下 `review.postSevereToIssue` 與 `review.postOthersToIssue`
* 把每條 finding 以本函式產生留言內容、經 `gitea.createCommentOnIssue`
* 發布到追蹤 issue 上,作為問題明細的追蹤紀錄。
*/
function issueFindingComment(finding) {
const emoji = SEVERITY_EMOJI[finding.severity] || '🔵';