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
Showing only changes of commit 35887f1a68 - Show all commits
+73 -25
View File
@@ -84,13 +84,17 @@ function latestCommitSubject(cwd) {
* 解析 PR base 分支與目前 HEAD 的 merge-base commit SHA。 * 解析 PR base 分支與目前 HEAD 的 merge-base commit SHA。
* *
* 先以 refspec 明確更新 `origin/<baseRef>`,再以 `git merge-base origin/<baseRef> HEAD` * 先以 refspec 明確更新 `origin/<baseRef>`,再以 `git merge-base origin/<baseRef> HEAD`
* 取得共同祖先。若 checkout 是淺層歷史而導致 merge-base 失敗,會補抓完整或更深 * 取得共同祖先。若 checkout 是淺層歷史而導致 merge-base 失敗,會依序補抓完整的
* base/head 歷史後重試,避免 PR workflow 因 checkout 預設深度不足而中斷。 * base/head 歷史;**每個補抓策略成功後立即重試 merge-base,一成功即回傳**
* 避免在已補到足夠歷史後仍多做無謂的 fetch 往返(例如 `--unshallow` 成功就不再 deepen)。
* 各策略採資料驅動依序執行;全部用盡仍失敗時,丟出彙整了「哪個策略成功/失敗」診斷的錯誤,
* 方便維護者判斷是哪一步補抓不足(診斷僅含策略名與成敗,不含 git 原始輸出以免洩漏遠端資訊)。
* *
* @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。 * @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。
* @param {string} baseRef - PR 目標(base)分支名稱,例如 'master' 或 'develop';不含 'origin/' 前綴。 * @param {string} baseRef - PR 目標(base)分支名稱,例如 'master' 或 'develop';不含 'origin/' 前綴。
* @returns {string} merge-base 的 commit SHA40 碼十六進位字串)。 * @returns {string} merge-base 的 commit SHA40 碼十六進位字串)。
* @throws {Error} 補抓歷史後仍無法取得共同祖先時,丟出含 baseRef 的明確錯誤 * @throws {Error} 補抓歷史後仍無法取得共同祖先時,丟出含 baseRef 與各策略診斷的明確錯誤
* `error.cause` 保留首次 merge-base 失敗的原始錯誤)。
* @remarks * @remarks
* 使用情境:AI code review 以此結果作為 diff 比較基準—— * 使用情境:AI code review 以此結果作為 diff 比較基準——
* 先 `resolveMergeBase(cwd, pr.base.ref)` 取得基準 SHA * 先 `resolveMergeBase(cwd, pr.base.ref)` 取得基準 SHA
@@ -99,24 +103,56 @@ function latestCommitSubject(cwd) {
*/ */
function resolveMergeBase(cwd, baseRef) { function resolveMergeBase(cwd, baseRef) {
const remoteBase = `origin/${baseRef}`; const remoteBase = `origin/${baseRef}`;
tryGit(cwd, 'fetch', '--no-tags', 'origin', `+refs/heads/${baseRef}:refs/remotes/${remoteBase}`); const diagnostics = [];
try { // 執行一個 fetch 策略並記錄成敗(只記策略名與成敗,不含 git 原始輸出,避免洩漏遠端資訊)。
return gitTrim(cwd, 'merge-base', remoteBase, 'HEAD'); const runFetch = (label, ...args) => {
} catch (firstError) { const ok = tryGit(cwd, ...args);
const isShallow = gitTrim(cwd, 'rev-parse', '--is-shallow-repository') === 'true'; diagnostics.push(`${label}${ok ? '成功' : '失敗'}`);
if (isShallow) { return ok;
tryGit(cwd, 'fetch', '--no-tags', '--unshallow', 'origin'); };
} // 每個補抓策略後重試 merge-base:成功回傳 SHA,失敗記診斷並回傳 null。
tryGit(cwd, 'fetch', '--no-tags', '--deepen=1000', 'origin', `+refs/heads/${baseRef}:refs/remotes/${remoteBase}`); const tryMergeBase = (label) => {
tryGit(cwd, 'fetch', '--no-tags', '--deepen=1000', 'origin', 'HEAD');
try { try {
return gitTrim(cwd, 'merge-base', remoteBase, 'HEAD'); return gitTrim(cwd, 'merge-base', remoteBase, 'HEAD');
} catch { } catch {
const error = new Error(`無法解析 origin/${baseRef} 與 HEAD 的 merge-base;請確認 checkout 有足夠歷史,或設定 checkout fetch-depth: 0。`); diagnostics.push(`merge-base${label}):失敗`);
error.cause = firstError; return null;
throw error;
} }
};
// 先明確更新 origin/<baseRef>,再嘗試 merge-base。
runFetch(`fetch base(${baseRef})`, 'fetch', '--no-tags', 'origin', `+refs/heads/${baseRef}:refs/remotes/${remoteBase}`);
let firstError;
try {
return gitTrim(cwd, 'merge-base', remoteBase, 'HEAD');
} catch (err) {
firstError = err;
diagnostics.push('merge-base(首次):失敗');
} }
// 資料驅動的補抓策略:淺層才 unshallow;其後依序 deepen base 與 HEAD。
// 每個策略成功後立即重試 merge-base,成功即回傳,避免多餘往返。
const strategies = [];
if (gitTrim(cwd, 'rev-parse', '--is-shallow-repository') === 'true') {
strategies.push(['unshallow', 'fetch', '--no-tags', '--unshallow', 'origin']);
}
strategies.push([
'deepen base',
'fetch', '--no-tags', '--deepen=1000', 'origin', `+refs/heads/${baseRef}:refs/remotes/${remoteBase}`,
]);
strategies.push(['deepen HEAD', 'fetch', '--no-tags', '--deepen=1000', 'origin', 'HEAD']);
for (const [label, ...args] of strategies) {
if (!runFetch(label, ...args)) continue; // fetch 失敗就換下一個策略。
const sha = tryMergeBase(`${label}`);
if (sha) return sha;
}
const error = new Error(
`無法解析 origin/${baseRef} 與 HEAD 的 merge-base;請確認 checkout 有足夠歷史,或設定 checkout fetch-depth: 0。診斷:${diagnostics.join('')}`,
);
error.cause = firstError;
throw error;
} }
/** /**
@@ -187,7 +223,9 @@ function fileLastUpdatedIso(cwd, file) {
* 若目前 HEAD 不在 PR head commit(例如 checkout 停在 merge commit), * 若目前 HEAD 不在 PR head commit(例如 checkout 停在 merge commit),
* 會先 `git checkout --detach <headSha>` 站上 head,避免把 merge 內容推回來源分支。 * 會先 `git checkout --detach <headSha>` 站上 head,避免把 merge 內容推回來源分支。
* commit 以 `-c` 臨時覆寫 user.name / user.email,不改動 repo 的 git 設定。 * commit 以 `-c` 臨時覆寫 user.name / user.email,不改動 repo 的 git 設定。
* push 先走 origin;失敗(遠端未帶認證)時改用帶 token 的 URL 重試。 * push 策略:提供 `pushToken`PAT)時直接以該 token 的 URL 推送(略過 origin)——因為 origin
* 帶的是不會再觸發 CI 的自動 token,改以 PAT 身分推送才會讓 PR 的 synchronize 事件再觸發 CI
* 未提供 `pushToken` 時先走 origin,失敗(遠端未帶認證)再改用帶 `token` 的 URL 重試。
* *
* @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。 * @param {string} cwd - git 工作目錄(repo 的 checkout 路徑)。
* @param {object} options - 提交與推送設定。 * @param {object} options - 提交與推送設定。
@@ -195,7 +233,8 @@ function fileLastUpdatedIso(cwd, file) {
* @param {string} [options.headSha] - PR head 的 commit SHA;有提供且與目前 HEAD 不同時會先 detach 到此 commit。可省略(falsy 時不 detach,直接於目前 HEAD 上 commit)。 * @param {string} [options.headSha] - PR head 的 commit SHA;有提供且與目前 HEAD 不同時會先 detach 到此 commit。可省略(falsy 時不 detach,直接於目前 HEAD 上 commit)。
* @param {string} options.message - commit 訊息。 * @param {string} options.message - commit 訊息。
* @param {string[]} options.files - 要加入 commit 的檔案路徑清單(相對 repo 根目錄);全數無實際變更時不 commit、回傳 false。 * @param {string[]} options.files - 要加入 commit 的檔案路徑清單(相對 repo 根目錄);全數無實際變更時不 commit、回傳 false。
* @param {string} options.token - 具該 repo push 權限的 Gitea access token僅在 origin push 失敗用於組出帶認證的重試 URL。 * @param {string} options.token - 具該 repo push 權限的 Gitea access token未提供 pushToken 時,於 origin push 失敗用於組出帶認證的重試 URL。
* @param {string} [options.pushToken] - 專用推送 tokenPAT);提供時直接以此 token 的 URL 推送(略過 origin),使 push 以 PAT 身分進行以再觸發 CI;未提供時走 origin、失敗再退回 `token`。
* @param {string} options.serverUrl - Gitea 伺服器根網址(例如 https://gitea.example.com),須為合法 URL。 * @param {string} options.serverUrl - Gitea 伺服器根網址(例如 https://gitea.example.com),須為合法 URL。
* @param {string} options.repository - repo 完整名稱(owner/repo 格式),與 serverUrl 組成 clone URL。 * @param {string} options.repository - repo 完整名稱(owner/repo 格式),與 serverUrl 組成 clone URL。
* @returns {boolean} true=有變更且已 commit 並 push 到來源分支;false=暫存區與 HEAD 無差異,略過 commit/push。 * @returns {boolean} true=有變更且已 commit 並 push 到來源分支;false=暫存區與 HEAD 無差異,略過 commit/push。
@@ -210,7 +249,7 @@ function fileLastUpdatedIso(cwd, file) {
* 絕對不得將此 URL 輸出到日誌、錯誤訊息或任何 action 輸出,以免洩漏 token * 絕對不得將此 URL 輸出到日誌、錯誤訊息或任何 action 輸出,以免洩漏 token
* 若需記錄重試行為,只能記載「改用帶認證 URL 重試」而不得包含 URL 本身。 * 若需記錄重試行為,只能記載「改用帶認證 URL 重試」而不得包含 URL 本身。
*/ */
function commitAndPushFindings(cwd, { headRef, headSha, message, files, token, serverUrl, repository }) { function commitAndPushFindings(cwd, { headRef, headSha, message, files, token, pushToken, serverUrl, repository }) {
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);
@@ -228,15 +267,24 @@ function commitAndPushFindings(cwd, { headRef, headSha, message, files, token, s
'-c', 'user.email=ai-review-bot@noreply.gitea', '-c', 'user.email=ai-review-bot@noreply.gitea',
'commit', '-m', message, 'commit', '-m', message,
); );
try { if (pushToken) {
git(cwd, 'push', 'origin', `HEAD:refs/heads/${headRef}`); // 有專用 PAT → 直接以帶 token 的 URL 推送(略過 origin,因 origin 帶的是不會再觸發 CI 的自動 token);
} catch { // 以 PAT 身分推送才會讓 PR 的 synchronize 事件再觸發 CI。不得把含 token 的 URL 輸出到日誌。
// 遠端未帶認證(checkout 未保留 credentials)時,改用帶 token 的 URL 重試。
// 注意:不得把這個 URL 輸出到日誌,避免洩漏 token。
const url = new URL(`${serverUrl}/${repository}.git`); const url = new URL(`${serverUrl}/${repository}.git`);
url.username = 'ai-review-bot'; url.username = 'ai-review-bot';
url.password = token; url.password = pushToken;
git(cwd, 'push', url.toString(), `HEAD:refs/heads/${headRef}`); git(cwd, 'push', url.toString(), `HEAD:refs/heads/${headRef}`);
} else {
try {
git(cwd, 'push', 'origin', `HEAD:refs/heads/${headRef}`);
} catch {
// 遠端未帶認證(checkout 未保留 credentials)時,改用帶 token 的 URL 重試。
// 注意:不得把這個 URL 輸出到日誌,避免洩漏 token。
const url = new URL(`${serverUrl}/${repository}.git`);
url.username = 'ai-review-bot';
url.password = token;
git(cwd, 'push', url.toString(), `HEAD:refs/heads/${headRef}`);
}
} }
return true; return true;
} }