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