From d42ccd8b082efd38e066f05eab91e29ba83b9616 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:42:05 +0800 Subject: [PATCH 01/24] =?UTF-8?q?feat(ai-pull-request):=20=E4=BB=A5=20open?= =?UTF-8?q?code=20=E5=88=86=E6=9E=90=20diff=20=E8=87=AA=E5=8B=95=E7=94=A2?= =?UTF-8?q?=E7=94=9F=E4=B8=A6=E5=BB=BA=E7=AB=8B=20Pull=20Request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 22 +++-- action.yaml | 35 ++++--- app/index.js | 185 +++++++++++++++++++++++++++++++++++ app/lib/git.js | 163 +++++++++++++++++++++++++++++++ app/lib/gitea.js | 98 +++++++++++++++++++ app/lib/inputs.js | 81 ++++++++++++++++ app/lib/opencode.js | 230 ++++++++++++++++++++++++++++++++++++++++++++ app/lib/util.js | 72 ++++++++++++++ app/package.json | 14 +++ entrypoint.sh | 14 ++- 10 files changed, 887 insertions(+), 27 deletions(-) create mode 100644 app/index.js create mode 100644 app/lib/git.js create mode 100644 app/lib/gitea.js create mode 100644 app/lib/inputs.js create mode 100644 app/lib/opencode.js create mode 100644 app/lib/util.js create mode 100644 app/package.json diff --git a/Dockerfile b/Dockerfile index af3dacb..eec32a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,20 @@ -FROM alpine:latest +FROM node:20-bookworm-slim + +# 安裝必要工具:git(操作分支/合併)、bash、ca-certificates、curl(安裝 opencode) +RUN apt-get update \ + && apt-get install -y --no-install-recommends git bash ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +# 安裝 opencode CLI(用於分析 git diff 產生 PR 標題與描述) +RUN npm install -g opencode-ai + +# 複製 Node.js 應用程式 +COPY app/ /app/ + +# 應用程式無第三方相依套件,僅在有 package-lock 時安裝 +RUN if [ -f /app/package-lock.json ]; then cd /app && npm ci --omit=dev; fi -# 安裝必要的工具 -RUN apk add --no-cache --no-check-certificate bash - COPY entrypoint.sh /entrypoint.sh - RUN chmod +x /entrypoint.sh -ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["/entrypoint.sh"] diff --git a/action.yaml b/action.yaml index 2000f7c..5970182 100644 --- a/action.yaml +++ b/action.yaml @@ -1,22 +1,31 @@ -name: 'Docker Action Template' -description: 'Docker Action 範本' +name: 'AI Pull Request' +description: '使用 opencode 分析 git diff 產生 PR 標題與描述,並透過 Gitea token 建立 Pull Request;遇衝突時自動建立解衝突分支' author: 'Jeffery' inputs: - gitea_token: - description: 'Gitea Token' + source_branch: + description: '來源分支' + required: true + target_branch: + description: '目標分支' + required: true + opencode_base_url: + description: 'opencode 使用的模型服務 base URL(OpenAI 相容端點)' + required: true + opencode_model: + description: 'opencode 使用的模型名稱' + required: true + opencode_provider: + description: 'opencode provider 名稱' required: true - text: - description: '輸入的文字' - required: false - default: 'Hello, World!' -outputs: - text: - description: '輸出的文字' runs: using: 'docker' image: 'Dockerfile' env: GITEA_SERVER_URL: ${{ gitea.server_url }} GITEA_REPOSITORY: ${{ gitea.repository }} - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || inputs.gitea_token }} - TEXT: ${{ inputs.text }} \ No newline at end of file + GITEA_TOKEN: ${{ gitea.token }} + SOURCE_BRANCH: ${{ inputs.source_branch }} + TARGET_BRANCH: ${{ inputs.target_branch }} + OPENCODE_BASE_URL: ${{ inputs.opencode_base_url }} + OPENCODE_MODEL: ${{ inputs.opencode_model }} + OPENCODE_PROVIDER: ${{ inputs.opencode_provider }} diff --git a/app/index.js b/app/index.js new file mode 100644 index 0000000..81b4c3d --- /dev/null +++ b/app/index.js @@ -0,0 +1,185 @@ +import { loadInputs, logInputs } from './lib/inputs.js'; +import { Git } from './lib/git.js'; +import { GiteaClient } from './lib/gitea.js'; +import { OpenCode } from './lib/opencode.js'; +import { log } from './lib/util.js'; + +async function main() { + const inputs = loadInputs(); + logInputs(inputs); + + const remoteUrl = `${inputs.serverUrl}/${inputs.owner}/${inputs.repo}.git`; + + const git = new Git({ cwd: inputs.workspace, remoteUrl, token: inputs.token }); + const gitea = new GiteaClient({ + serverUrl: inputs.serverUrl, + owner: inputs.owner, + repo: inputs.repo, + token: inputs.token, + }); + const opencode = new OpenCode({ ...inputs.opencode, language: inputs.language }); + + // 1. 準備 git 環境並抓取兩個分支 + log.step('準備 git 環境'); + git.configure(); + git.fetchBranches([inputs.sourceBranch, inputs.targetBranch]); + + // 2. 確認來源分支相對目標分支有變更 + const ahead = git.countAheadCommits(inputs.targetBranch, inputs.sourceBranch); + if (ahead === 0) { + log.warn(`來源分支 ${inputs.sourceBranch} 相對 ${inputs.targetBranch} 沒有新的 commit,無需建立 PR`); + return; + } + log.info(`來源分支領先 ${ahead} 個 commit`); + + // 3. 蒐集 diff 內容 + log.step('蒐集 git diff'); + const commitMessages = git.getCommitMessages(inputs.targetBranch, inputs.sourceBranch); + const diffStat = git.getDiffStat(inputs.targetBranch, inputs.sourceBranch); + const fullDiff = git.getDiff(inputs.targetBranch, inputs.sourceBranch); + const { diff, truncated } = truncateDiff(fullDiff, inputs.maxDiffChars); + if (truncated) log.warn(`diff 過大,已截斷至 ${inputs.maxDiffChars} 字元`); + + // 4. 使用 opencode 產生標題與描述(失敗則 fallback) + log.step('使用 opencode 產生 PR 標題與描述'); + let summary = await opencode.summarize({ + sourceBranch: inputs.sourceBranch, + targetBranch: inputs.targetBranch, + commitMessages, + diffStat, + diff, + }); + if (!summary) { + log.warn('改用 commit/stat 自動產生標題與描述'); + summary = fallbackSummary({ + source: inputs.sourceBranch, + target: inputs.targetBranch, + commitMessages, + diffStat, + }); + } + log.success(`標題: ${summary.title}`); + + // 5. 偵測合併衝突 + log.step('偵測合併衝突'); + const { hasConflict, files } = git.detectConflict(inputs.targetBranch, inputs.sourceBranch); + + if (!hasConflict) { + // 5a. 無衝突:直接建立 來源 → 目標 的 PR + log.success('無衝突,建立來源分支 → 目標分支的 PR'); + const { pull, created } = await gitea.createPull({ + head: inputs.sourceBranch, + base: inputs.targetBranch, + title: summary.title, + body: summary.description, + }); + reportPull(pull, created); + return; + } + + // 5b. 有衝突:從目標分支建立解衝突分支,合併來源分支後 PR 回來源分支 + log.warn(`偵測到衝突檔案 (${files.length}): ${files.join(', ')}`); + const resolveBranch = buildResolveBranchName(inputs.targetBranch, inputs.sourceBranch); + + log.step('建立解衝突分支並合併來源分支'); + const { files: conflictFiles } = git.createResolveBranch({ + target: inputs.targetBranch, + source: inputs.sourceBranch, + resolveBranch, + }); + + const body = buildResolveBody({ + source: inputs.sourceBranch, + target: inputs.targetBranch, + resolveBranch, + files: conflictFiles.length ? conflictFiles : files, + summary, + }); + + log.step('建立解衝突分支 → 來源分支的 PR'); + const { pull, created } = await gitea.createPull({ + head: resolveBranch, + base: inputs.sourceBranch, + title: `解衝突: 將 ${inputs.targetBranch} 合併回 ${inputs.sourceBranch}`, + body, + }); + reportPull(pull, created); +} + +/** 把 diff 截斷至上限字元數。 */ +function truncateDiff(diff, maxChars) { + if (!diff || diff.length <= maxChars) return { diff: diff || '', truncated: false }; + return { + diff: `${diff.slice(0, maxChars)}\n\n... (diff 已截斷,僅顯示前 ${maxChars} 字元) ...`, + truncated: true, + }; +} + +/** opencode 不可用時,用 commit 訊息與 stat 產生簡單摘要。 */ +function fallbackSummary({ source, target, commitMessages, diffStat }) { + const firstCommit = (commitMessages || '') + .split('\n') + .map((l) => l.replace(/^- /, '').trim()) + .find(Boolean); + const title = firstCommit || `Merge ${source} into ${target}`; + const description = [ + `## 變更摘要`, + ``, + `將 \`${source}\` 合併到 \`${target}\`。`, + ``, + `### Commits`, + commitMessages || '(無)', + ``, + `### 變更檔案`, + '```', + diffStat || '(無)', + '```', + ].join('\n'); + return { title, description }; +} + +/** 解衝突分支名稱。 */ +function buildResolveBranchName(target, source) { + const runId = process.env.GITHUB_RUN_NUMBER || process.env.GITHUB_RUN_ID || ''; + const safe = (s) => s.replace(/[^a-zA-Z0-9._/-]/g, '-'); + const suffix = runId ? `-${runId}` : ''; + return `resolve-conflict/${safe(target)}-into-${safe(source)}${suffix}`; +} + +/** 解衝突 PR 的描述。 */ +function buildResolveBody({ source, target, resolveBranch, files, summary }) { + return [ + `## ⚠️ 自動解衝突 PR`, + ``, + `來源分支 \`${source}\` 合併到目標分支 \`${target}\` 時偵測到衝突,`, + `已自動從 \`${target}\` 建立解衝突分支 \`${resolveBranch}\` 並合併 \`${source}\`。`, + ``, + `**此 PR 會將 \`${resolveBranch}\` 合併回 \`${source}\`,請在合併前手動解決下列檔案的衝突標記(\`<<<<<<<\`、\`=======\`、\`>>>>>>>\`):**`, + ``, + ...files.map((f) => `- \`${f}\``), + ``, + `解決並合併此 PR 後,\`${source}\` 即可順利合併進 \`${target}\`。`, + ``, + `---`, + ``, + `### AI 變更摘要`, + ``, + summary.description || '(無)', + ].join('\n'); +} + +/** 印出 PR 結果。 */ +function reportPull(pull, created) { + const url = pull?.html_url || pull?.url || ''; + const number = pull?.number || ''; + if (created) { + log.success(`已建立 PR #${number}: ${url}`); + } else { + log.info(`PR 已存在 #${number}: ${url}`); + } +} + +main().catch((err) => { + log.error(err?.stack || err?.message || String(err)); + process.exit(1); +}); diff --git a/app/lib/git.js b/app/lib/git.js new file mode 100644 index 0000000..28b9bfd --- /dev/null +++ b/app/lib/git.js @@ -0,0 +1,163 @@ +import { run, runOrThrow, log, maskSecrets } from './util.js'; + +/** + * 封裝這個 action 需要的 git 操作。所有對遠端的操作都透過 + * http.extraheader 帶上 Gitea token,避免 token 寫進 remote URL。 + */ +export class Git { + /** + * @param {object} opts + * @param {string} opts.cwd 工作目錄(已 checkout 的 repo) + * @param {string} opts.remoteUrl 不含認證資訊的 repo HTTPS URL + * @param {string} opts.token Gitea token + */ + constructor({ cwd, remoteUrl, token }) { + this.cwd = cwd; + this.remoteUrl = remoteUrl; + this.token = token; + // Gitea 接受 "Authorization: token " + this.authArgs = ['-c', `http.extraheader=Authorization: token ${token}`]; + } + + /** 帶 token 的 git 執行(用於遠端操作),不會把 args 印進日誌。 */ + _authGit(args, { throwOnError = true } = {}) { + const full = [...this.authArgs, ...args]; + const result = run('git', full, { cwd: this.cwd }); + if (throwOnError && result.status !== 0) { + const detail = maskSecrets(result.stderr || result.stdout, [this.token]).trim(); + throw new Error(`git ${args.join(' ')} 失敗 (${result.status}):\n${detail}`); + } + return result; + } + + /** 不帶 token 的本地 git 執行。 */ + _git(args, opts = {}) { + return run('git', args, { cwd: this.cwd, ...opts }); + } + + /** 初始化必要的 git 設定(safe.directory、user.name/email)。 */ + configure() { + run('git', ['config', '--global', '--add', 'safe.directory', this.cwd]); + run('git', ['config', '--global', '--add', 'safe.directory', '*']); + // 解衝突分支需要建立 merge commit,必須有身份 + this._git(['config', 'user.name', process.env.GIT_AUTHOR_NAME || 'ai-pull-request[bot]']); + this._git(['config', 'user.email', process.env.GIT_AUTHOR_EMAIL || 'ai-pull-request@users.noreply.gitea']); + } + + /** + * 從遠端抓取 source 與 target 分支到本地追蹤分支 refs/remotes/pr/。 + * + * @param {string[]} branches + */ + fetchBranches(branches) { + const refspecs = branches.map((b) => `+refs/heads/${b}:refs/remotes/pr/${b}`); + log.info(`抓取分支: ${branches.join(', ')}`); + this._authGit(['fetch', '--no-tags', this.remoteUrl, ...refspecs]); + } + + /** 取得分支的 commit 數量差異(source 比 target 多幾個 commit)。 */ + countAheadCommits(target, source) { + const result = this._git(['rev-list', '--count', `refs/remotes/pr/${target}..refs/remotes/pr/${source}`]); + return result.status === 0 ? parseInt(result.stdout.trim(), 10) || 0 : 0; + } + + /** 取得 source 相對 target 的 commit 訊息清單。 */ + getCommitMessages(target, source, limit = 50) { + const result = this._git([ + 'log', + `--max-count=${limit}`, + '--pretty=format:- %s', + `refs/remotes/pr/${target}..refs/remotes/pr/${source}`, + ]); + return result.status === 0 ? result.stdout.trim() : ''; + } + + /** 取得 diff 統計(--stat)。 */ + getDiffStat(target, source) { + const result = this._git([ + 'diff', + '--stat', + `refs/remotes/pr/${target}...refs/remotes/pr/${source}`, + ]); + return result.status === 0 ? result.stdout.trim() : ''; + } + + /** 取得完整 diff(three-dot,等同 PR 在 merge base 之後的變更)。 */ + getDiff(target, source) { + const result = this._git([ + 'diff', + `refs/remotes/pr/${target}...refs/remotes/pr/${source}`, + ]); + return result.status === 0 ? result.stdout : ''; + } + + /** + * 偵測 source 合併進 target 是否會衝突(不會留下任何變更)。 + * + * @returns {{ hasConflict: boolean, files: string[] }} + */ + detectConflict(target, source) { + // 建立暫時的本地 target 分支,嘗試以 --no-commit 合併 source + const tmp = `__conflict_check_${target}`; + this._git(['checkout', '-B', tmp, `refs/remotes/pr/${target}`]); + + const merge = this._git(['merge', '--no-commit', '--no-ff', `refs/remotes/pr/${source}`]); + let hasConflict = merge.status !== 0; + let files = []; + + if (hasConflict) { + const unmerged = this._git(['diff', '--name-only', '--diff-filter=U']); + files = unmerged.stdout.split('\n').map((s) => s.trim()).filter(Boolean); + } + + // 還原工作區 + this._git(['merge', '--abort']); + this._git(['checkout', '--detach']); + this._git(['branch', '-D', tmp]); + + return { hasConflict, files }; + } + + /** + * 建立解衝突分支:以 target 為基礎,合併 source(保留衝突標記後 commit), + * 再推送到遠端。 + * + * @param {object} opts + * @param {string} opts.target 目標分支 + * @param {string} opts.source 來源分支 + * @param {string} opts.resolveBranch 解衝突分支名稱 + * @returns {{ files: string[] }} 衝突檔案清單 + */ + createResolveBranch({ target, source, resolveBranch }) { + log.info(`以 ${target} 為基礎建立解衝突分支 ${resolveBranch}`); + this._git(['checkout', '-B', resolveBranch, `refs/remotes/pr/${target}`]); + + const merge = this._git([ + 'merge', + '--no-ff', + '-m', + `Merge branch '${source}' into ${resolveBranch} (待人工解衝突)`, + `refs/remotes/pr/${source}`, + ]); + + let files = []; + if (merge.status !== 0) { + // 合併產生衝突:將含有衝突標記的檔案標記為已解決後 commit, + // 讓開發者可以在 PR 中看到並解決衝突。 + const unmerged = this._git(['diff', '--name-only', '--diff-filter=U']); + files = unmerged.stdout.split('\n').map((s) => s.trim()).filter(Boolean); + + runOrThrow('git', ['add', '-A'], { cwd: this.cwd }); + runOrThrow( + 'git', + ['commit', '--no-verify', '-m', `Merge branch '${source}' into ${resolveBranch}(含衝突標記,待人工解衝突)`], + { cwd: this.cwd }, + ); + } + + log.info(`推送解衝突分支 ${resolveBranch}`); + this._authGit(['push', '--force', this.remoteUrl, `HEAD:refs/heads/${resolveBranch}`]); + + return { files }; + } +} diff --git a/app/lib/gitea.js b/app/lib/gitea.js new file mode 100644 index 0000000..28ff34e --- /dev/null +++ b/app/lib/gitea.js @@ -0,0 +1,98 @@ +import { log } from './util.js'; + +/** + * 極簡的 Gitea API client,只實作這個 action 需要的 PR 相關操作。 + */ +export class GiteaClient { + /** + * @param {object} opts + * @param {string} opts.serverUrl Gitea base URL(不含結尾斜線) + * @param {string} opts.owner + * @param {string} opts.repo + * @param {string} opts.token + */ + constructor({ serverUrl, owner, repo, token }) { + this.apiBase = `${serverUrl}/api/v1`; + this.owner = owner; + this.repo = repo; + this.token = token; + } + + async _request(method, path, body) { + const url = `${this.apiBase}${path}`; + const res = await fetch(url, { + method, + headers: { + Authorization: `token ${this.token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }); + + const text = await res.text(); + let json; + try { + json = text ? JSON.parse(text) : {}; + } catch { + json = { message: text }; + } + return { ok: res.ok, status: res.status, json }; + } + + /** + * 查詢 head -> base 是否已存在開啟中的 PR。 + * + * @param {string} head 來源分支 + * @param {string} base 目標分支 + * @returns {Promise} + */ + async findOpenPull(head, base) { + // Gitea pulls 不直接支援 head/base 過濾,這裡撈開啟中的 PR 自行比對 + const { ok, json } = await this._request( + 'GET', + `/repos/${this.owner}/${this.repo}/pulls?state=open&limit=50`, + ); + if (!ok || !Array.isArray(json)) return null; + return ( + json.find( + (pr) => pr?.head?.ref === head && pr?.base?.ref === base, + ) || null + ); + } + + /** + * 建立 Pull Request。若已存在相同 head/base 的 PR 則回傳既有 PR。 + * + * @param {object} opts + * @param {string} opts.head 來源分支 + * @param {string} opts.base 目標分支 + * @param {string} opts.title + * @param {string} opts.body + * @returns {Promise<{ pull: object, created: boolean }>} + */ + async createPull({ head, base, title, body }) { + log.info(`建立 PR: ${head} → ${base}`); + const { ok, status, json } = await this._request( + 'POST', + `/repos/${this.owner}/${this.repo}/pulls`, + { head, base, title, body }, + ); + + if (ok) { + return { pull: json, created: true }; + } + + // 422 通常代表 PR 已存在 + if (status === 422 || status === 409) { + const existing = await this.findOpenPull(head, base); + if (existing) { + log.warn(`PR 已存在: #${existing.number}`); + return { pull: existing, created: false }; + } + } + + const message = json?.message || JSON.stringify(json); + throw new Error(`建立 PR 失敗 (${status}): ${message}`); + } +} diff --git a/app/lib/inputs.js b/app/lib/inputs.js new file mode 100644 index 0000000..a221542 --- /dev/null +++ b/app/lib/inputs.js @@ -0,0 +1,81 @@ +import { log } from './util.js'; + +/** + * 從環境變數讀取並驗證所有輸入參數。 + * + * @returns {{ + * serverUrl: string, + * repository: string, + * owner: string, + * repo: string, + * token: string, + * sourceBranch: string, + * targetBranch: string, + * opencode: { baseUrl: string, model: string, provider: string }, + * language: string, + * maxDiffChars: number, + * workspace: string, + * }} + */ +export function loadInputs() { + const serverUrl = trimSlash(required('GITEA_SERVER_URL')); + const repository = required('GITEA_REPOSITORY'); // owner/repo + const token = required('GITEA_TOKEN'); + const sourceBranch = required('SOURCE_BRANCH'); + const targetBranch = required('TARGET_BRANCH'); + + const [owner, repo] = repository.split('/'); + if (!owner || !repo) { + throw new Error(`GITEA_REPOSITORY 格式應為 owner/repo,收到: ${repository}`); + } + + if (sourceBranch === targetBranch) { + throw new Error(`來源分支與目標分支不可相同: ${sourceBranch}`); + } + + const opencode = { + baseUrl: trimSlash(process.env.OPENCODE_BASE_URL || ''), + model: process.env.OPENCODE_MODEL || '', + provider: process.env.OPENCODE_PROVIDER || '', + }; + + // PR 標題/描述固定使用繁體中文,diff 截斷上限固定,皆不透過參數控制 + const language = 'Traditional Chinese (繁體中文)'; + const maxDiffChars = 60000; + const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); + + return { + serverUrl, + repository, + owner, + repo, + token, + sourceBranch, + targetBranch, + opencode, + language, + maxDiffChars, + workspace, + }; +} + +function required(name) { + const value = process.env[name]; + if (!value || !value.trim()) { + throw new Error(`缺少必要的環境變數: ${name}`); + } + return value.trim(); +} + +function trimSlash(url) { + return url.replace(/\/+$/, ''); +} + +/** 印出輸入摘要(遮蔽敏感資訊)。 */ +export function logInputs(inputs) { + log.info(`Gitea Server : ${inputs.serverUrl}`); + log.info(`Repository : ${inputs.repository}`); + log.info(`來源分支 : ${inputs.sourceBranch}`); + log.info(`目標分支 : ${inputs.targetBranch}`); + log.info(`opencode : provider=${inputs.opencode.provider || '(未設定)'} model=${inputs.opencode.model || '(未設定)'} baseUrl=${inputs.opencode.baseUrl || '(未設定)'}`); +} diff --git a/app/lib/opencode.js b/app/lib/opencode.js new file mode 100644 index 0000000..1190790 --- /dev/null +++ b/app/lib/opencode.js @@ -0,0 +1,230 @@ +import { writeFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { run, log, maskSecrets } from './util.js'; + +/** + * 透過 opencode CLI 分析 git diff,產生 PR 標題與描述。 + */ +export class OpenCode { + /** + * @param {object} opts + * @param {string} opts.baseUrl + * @param {string} opts.model + * @param {string} opts.provider + * @param {string} [opts.language] + */ + constructor({ baseUrl, model, provider, language = 'Traditional Chinese (繁體中文)' }) { + this.baseUrl = baseUrl; + this.model = model; + this.provider = provider; + this.language = language; + } + + /** 是否有足夠設定可以呼叫 opencode。 */ + isConfigured() { + return Boolean(this.baseUrl && this.model && this.provider); + } + + /** + * 在暫存目錄寫出 opencode.json,將自訂 provider 設為 OpenAI 相容端點。 + * + * @returns {string} config 檔路徑 + */ + _writeConfig() { + const dir = mkdtempSync(join(tmpdir(), 'opencode-')); + const options = { baseURL: this.baseUrl }; + + const config = { + $schema: 'https://opencode.ai/config.json', + provider: { + [this.provider]: { + npm: '@ai-sdk/openai-compatible', + name: this.provider, + options, + models: { + [this.model]: { name: this.model }, + }, + }, + }, + }; + + const path = join(dir, 'opencode.json'); + writeFileSync(path, JSON.stringify(config, null, 2)); + return path; + } + + /** + * 呼叫 opencode 產生標題與描述。 + * + * @param {object} ctx + * @param {string} ctx.sourceBranch + * @param {string} ctx.targetBranch + * @param {string} ctx.commitMessages + * @param {string} ctx.diffStat + * @param {string} ctx.diff 已截斷的 diff + * @returns {Promise<{ title: string, description: string } | null>} + */ + async summarize(ctx) { + if (!this.isConfigured()) { + log.warn('opencode 參數不完整(需要 base_url / model / provider),略過 AI 摘要'); + return null; + } + + const configPath = this._writeConfig(); + const prompt = buildPrompt({ ...ctx, language: this.language }); + + log.info(`呼叫 opencode(${this.provider}/${this.model})分析 diff...`); + const result = run( + 'opencode', + ['run', '--model', `${this.provider}/${this.model}`, prompt], + { + cwd: tmpdir(), + env: { + ...process.env, + OPENCODE_CONFIG: configPath, + // 確保 opencode 有可寫的 HOME / 設定目錄 + HOME: process.env.HOME || '/root', + }, + timeout: 5 * 60 * 1000, + }, + ); + + if (result.status !== 0) { + log.warn(`opencode 執行失敗 (${result.status}):${maskSecrets(result.stderr).slice(0, 500)}`); + return null; + } + + const parsed = extractResult(result.stdout); + if (!parsed) { + log.warn('無法從 opencode 輸出解析出標題/描述'); + return null; + } + return parsed; + } +} + +function buildPrompt({ sourceBranch, targetBranch, commitMessages, diffStat, diff, language }) { + return [ + `You are an assistant that writes high-quality Pull Request titles and descriptions.`, + `Analyze the following git changes for a PR merging branch "${sourceBranch}" into "${targetBranch}".`, + ``, + `Write the title and description in ${language}.`, + `The title should be a concise one-line summary (ideally following Conventional Commits style, e.g. "feat: ...").`, + `The description should be Markdown and include: a short summary, a bullet list of key changes, and any notable impact or risk.`, + ``, + `Respond with ONLY a single JSON object, no code fences, no extra text:`, + `{"title": "...", "description": "..."}`, + ``, + `=== Commits ===`, + commitMessages || '(no commit messages)', + ``, + `=== Changed files (stat) ===`, + diffStat || '(no stat)', + ``, + `=== Diff ===`, + diff || '(no diff)', + ].join('\n'); +} + +/** 去除 ANSI 控制碼。 */ +function stripAnsi(text) { + // eslint-disable-next-line no-control-regex + return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); +} + +/** + * 從 opencode 輸出中擷取含 title 的 JSON 物件並解析。 + * + * @param {string} stdout + * @returns {{ title: string, description: string } | null} + */ +export function extractResult(stdout) { + const text = stripAnsi(stdout || ''); + + // 掃描所有平衡的 {...} 區塊,挑出第一個能成功解析且含 title 的物件 + for (const candidate of findJsonObjects(text)) { + // LLM 常在字串值內輸出未跳脫的換行,先嘗試原始解析,失敗再嘗試修正 + for (const variant of [candidate, escapeControlCharsInStrings(candidate)]) { + try { + const obj = JSON.parse(variant); + if (obj && typeof obj === 'object' && obj.title) { + return { + title: String(obj.title).trim(), + description: String(obj.description || '').trim(), + }; + } + } catch { + // 試下一個變體 / 候選 + } + } + } + return null; +} + +/** 將字串值內未跳脫的控制字元(換行、tab 等)跳脫,修正 LLM 常見的無效 JSON。 */ +function escapeControlCharsInStrings(text) { + let out = ''; + let inString = false; + let escape = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (inString) { + if (escape) { + out += ch; + escape = false; + continue; + } + if (ch === '\\') { + out += ch; + escape = true; + continue; + } + if (ch === '"') { + out += ch; + inString = false; + continue; + } + if (ch === '\n') { out += '\\n'; continue; } + if (ch === '\r') { out += '\\r'; continue; } + if (ch === '\t') { out += '\\t'; continue; } + out += ch; + } else { + out += ch; + if (ch === '"') inString = true; + } + } + return out; +} + +/** 以括號平衡方式找出文字中所有最外層的 {...} 區塊。 */ +function findJsonObjects(text) { + const objects = []; + let depth = 0; + let start = -1; + let inString = false; + let escape = false; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (inString) { + if (escape) escape = false; + else if (ch === '\\') escape = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + inString = true; + } else if (ch === '{') { + if (depth === 0) start = i; + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0 && start !== -1) { + objects.push(text.slice(start, i + 1)); + start = -1; + } + } + } + return objects; +} diff --git a/app/lib/util.js b/app/lib/util.js new file mode 100644 index 0000000..d0463d7 --- /dev/null +++ b/app/lib/util.js @@ -0,0 +1,72 @@ +import { spawnSync } from 'node:child_process'; + +/** + * 執行外部指令並回傳結果(不會因為非零結束碼而 throw)。 + * + * @param {string} command 要執行的指令 + * @param {string[]} args 指令參數 + * @param {object} [options] spawnSync 額外設定(cwd、env、input、maxBuffer...) + * @returns {{ status: number, stdout: string, stderr: string }} + */ +export function run(command, args = [], options = {}) { + const result = spawnSync(command, args, { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, // 64MB,避免大型 diff 被截斷 + ...options, + }); + + if (result.error) { + return { status: 1, stdout: '', stderr: String(result.error.message || result.error) }; + } + + return { + status: typeof result.status === 'number' ? result.status : 1, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +/** + * 執行外部指令,若結束碼非零則 throw。 + * + * @param {string} command + * @param {string[]} args + * @param {object} [options] + * @returns {string} stdout(已 trim) + */ +export function runOrThrow(command, args = [], options = {}) { + const result = run(command, args, options); + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || '').trim(); + throw new Error(`指令失敗 (${result.status}): ${command} ${args.join(' ')}\n${detail}`); + } + return result.stdout.trim(); +} + +const ICONS = { info: 'ℹ️', warn: '⚠️', error: '❌', success: '✅', step: '▶️' }; + +/** 簡單的分級日誌輸出。 */ +export const log = { + info: (msg) => console.log(`${ICONS.info} ${msg}`), + warn: (msg) => console.log(`${ICONS.warn} ${msg}`), + error: (msg) => console.error(`${ICONS.error} ${msg}`), + success: (msg) => console.log(`${ICONS.success} ${msg}`), + step: (msg) => console.log(`\n${ICONS.step} ${msg}`), +}; + +/** + * 將敏感字串(如 token)從文字中遮蔽,避免寫入日誌。 + * + * @param {string} text + * @param {string[]} secrets + * @returns {string} + */ +export function maskSecrets(text, secrets = []) { + let masked = String(text ?? ''); + for (const secret of secrets) { + if (secret && secret.length >= 4) { + masked = masked.split(secret).join('***'); + } + } + return masked; +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..136121a --- /dev/null +++ b/app/package.json @@ -0,0 +1,14 @@ +{ + "name": "ai-pull-request", + "version": "1.0.0", + "description": "使用 opencode 分析 git diff 自動產生 PR 標題與描述,並透過 Gitea API 建立 Pull Request", + "type": "module", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT" +} diff --git a/entrypoint.sh b/entrypoint.sh index c8c429c..9a8fb15 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,11 +1,9 @@ #!/bin/bash +set -euo pipefail -echo "Gitea Server Url: $GITEA_SERVER_URL" +echo "🚀 ai-pull-request action 啟動" +echo " Repository: ${GITEA_REPOSITORY:-?}" +echo " ${SOURCE_BRANCH:-?} → ${TARGET_BRANCH:-?}" -echo "Gitea Repository: $GITEA_REPOSITORY" - -echo "Gitea Token: $GITEA_TOKEN" - -echo "Text: $TEXT" - -echo "text=$TEXT" >> "$GITHUB_OUTPUT" \ No newline at end of file +# Node.js 應用程式進入點 +exec node /app/index.js -- 2.53.0 From 77ef69de7911b7e8f257262c964e209a4d2876d6 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 11:42:12 +0800 Subject: [PATCH 02/24] =?UTF-8?q?docs(README):=20=E6=96=B0=E5=A2=9E=20acti?= =?UTF-8?q?on=20=E5=8A=9F=E8=83=BD=E3=80=81=E5=8F=83=E6=95=B8=E8=88=87?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=AF=84=E4=BE=8B=E8=AA=AA=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..ade622e --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# AI Pull Request + +Gitea Docker Action:使用 [opencode](https://opencode.ai) 分析 `git diff`,自動產生 Pull Request 的標題與描述,並透過 Gitea token 建立 PR。 + +## 功能 + +1. 抓取**來源分支**與**目標分支**,計算兩者的差異(commits / stat / diff)。 +2. 呼叫 `opencode`(指定 `base_url` / `model` / `provider`)將 diff 總結成 PR 標題與描述(固定使用繁體中文)。 + - 若 opencode 不可用或解析失敗,會自動以 commit 訊息與檔案統計產生 fallback 標題/描述。 +3. 偵測來源分支合併進目標分支是否會**衝突**: + - **無衝突**:直接建立 `來源分支 → 目標分支` 的 PR。 + - **有衝突**:從**目標分支**建立解衝突分支,合併來源分支(保留衝突標記後 commit 並推送),再建立 `解衝突分支 → 來源分支` 的 PR,讓開發者在 PR 中手動解衝突;解決後來源分支即可順利合併回目標分支。 + +## 輸入參數(inputs) + +| 參數 | 必填 | 說明 | +| --- | --- | --- | +| `source_branch` | ✅ | 來源分支 | +| `target_branch` | ✅ | 目標分支 | +| `opencode_base_url` | ✅ | opencode 使用的模型服務 base URL(OpenAI 相容端點) | +| `opencode_model` | ✅ | opencode 使用的模型名稱 | +| `opencode_provider` | ✅ | opencode provider 名稱 | + +## 使用範例 + +```yaml +name: AI PR +on: + workflow_dispatch: + inputs: + source_branch: + description: '來源分支' + required: true + target_branch: + description: '目標分支' + required: true + +jobs: + ai-pull-request: + runs-on: ubuntu + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: AI Pull Request + uses: https://gitea.jsc.idv.tw/docker-actions/ai-pull-request@v1 + with: + source_branch: ${{ inputs.source_branch }} + target_branch: ${{ inputs.target_branch }} + opencode_base_url: ${{ vars.OPENCODE_BASE_URL }} + opencode_model: ${{ vars.OPENCODE_MODEL }} + opencode_provider: ${{ vars.OPENCODE_PROVIDER }} +``` + +## 開發 + +應用程式以 Node.js 開發,位於 [`app/`](app/),進入點為 [`entrypoint.sh`](entrypoint.sh) → `node /app/index.js`。 + +``` +app/ +├── index.js # 主流程 +└── lib/ + ├── inputs.js # 讀取/驗證環境變數 + ├── git.js # git 操作(fetch / diff / 衝突偵測 / 解衝突分支) + ├── gitea.js # Gitea API(建立 PR) + ├── opencode.js # 呼叫 opencode 產生標題與描述 + └── util.js # 共用工具(執行指令、日誌、遮蔽敏感資訊) +``` + +語法檢查: + +```bash +cd app && node --check index.js +``` -- 2.53.0 From c6823fa0a71663783053ef13c84ef3c9f3351060 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 12:16:55 +0800 Subject: [PATCH 03/24] fix: ci&cd --- .gitea/workflows/cd.yaml | 12 ++++++++++++ .gitea/workflows/ci.yaml | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 .gitea/workflows/cd.yaml create mode 100644 .gitea/workflows/ci.yaml diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml new file mode 100644 index 0000000..30b1952 --- /dev/null +++ b/.gitea/workflows/cd.yaml @@ -0,0 +1,12 @@ +name: CD +on: + push: + branches: + - master +jobs: + release-tag-version: + name: Release Tag Version + runs-on: ubuntu + steps: + - name: 釋出並標註成品版本 + uses: https://gitea.jsc.idv.tw/composite-actions/release-tag-version@${{ vars.ACTION_RELEASE_TAG_VERSION }} diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..b9d4eed --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,19 @@ +name: CI +on: + pull_request: + branches-ignore: + - master + types: [opened, synchronize] +jobs: + ai-code-review: + name: AI Code Review + runs-on: ubuntu + permissions: + contents: write + pull-requests: write + issues: write + steps: + - name: AI 程式碼審查 by OpenCode + uses: https://gitea.jsc.idv.tw/composite-actions/opencode-code-review@${{ vars.ACTION_OPENCODE_CODE_REVIEW_VERSION }} + with: + comment_token: ${{ secrets.COMMENT_TOKEN }} -- 2.53.0 From 2f2695311234f1f5f9958e93deac1def38bf40b9 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 04:17:41 +0000 Subject: [PATCH 04/24] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 114 +++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 .gitea/ai-review/findings.json diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json new file mode 100644 index 0000000..09fd6f6 --- /dev/null +++ b/.gitea/ai-review/findings.json @@ -0,0 +1,114 @@ +[ + { + "level": "critical", + "role": "Assassin", + "location": "app/lib/gitea.js:28", + "problem": "Gitea API 請求在 headers 中直接放入了 `this.token`。雖然這在正常情況下是必要的,但如果 `this.token` 來源於不可信的輸入且未經嚴格驗證,這將導致 token 洩漏風險(透過請求日誌或中間人攻擊)。", + "suggestion": "在 `GiteaClient` 的所有請求方法中增加對 token 的處理,並確保在任何可能將請求細節(包含 headers)輸出到日誌的邏輯中,必須將 token 遮蔽。", + "is_new": true + }, + { + "level": "critical", + "role": "Mage", + "location": "app/lib/gitea.js:52", + "problem": "1. `findOpenPull` 方法僅撈取前 50 個 PR,若數量眾多可能導致重複建立。2. `_request` 進行 `GET` 操作時,未對 API 回傳的 `json` 內容結構進行嚴格合法性驗證,可能導致執行時錯誤。", + "suggestion": "1. 實作分頁(pagination)機制確保完整性。2. 在確保 HTTP 狀態碼為 200 後,強化對 `json` 的防禦性檢測(如檢查是否為 undefined 或預期陣列)。" + }, + { + "level": "critical", + "role": "Mage", + "location": "app/lib/git.js:154", + "problem": "在 `createResolveBranch` 中,執行 `git add -A` 與 `git commit` 會強制提交包括未追蹤檔案在內的所有變更,可能污染分支且掩蓋衝突內容,此流程亦缺乏測試。", + "suggestion": "僅針對衝突檔案(`--diff-filter=U`)執行 `git add`,並補上整合測試,驗證模擬衝突時,產生的分支確實包含正確的衝突標記與檔案。" + }, + { + "level": "critical", + "role": "Maya", + "location": "app/index.js:58", + "problem": "在偵測到衝突並建立解衝突分支後,程式雖然嘗試透過 `git.createResolveBranch` 建立並 commit 衝突檔案,但後續缺乏邏輯處理衝突檔案,亦無測試驗證「自動解衝突分支是否真的被建立」以及「提交的內容是否正確」。", + "suggestion": "應補上整合測試,模擬合併衝突,驗證 `detectConflict` 能偵測衝突,且 `createResolveBranch` 產生的分支確實包含預期的衝突檔案與 commit。" + }, + { + "level": "warning", + "role": "Assassin", + "location": "app/lib/git.js:52", + "problem": "Git 的 `--no-tags` 參數在某些舊版 git 可能無法完全防禦標籤帶來的惡意遠端物件下載。雖然 `fetchBranches` 限制了 refspec,但使用不可信的 `remoteUrl` 進行 fetch 操作時,仍存在與 git 協定漏洞相關的風險。", + "suggestion": "建議確保容器內的 git 版本為最新,並考慮在 fetch 前驗證 `remoteUrl` 是否為預期的 Gitea 網域,而非任意使用者輸入的網址。", + "is_new": true + }, + { + "level": "warning", + "role": "Assassin", + "location": "app/index.js:180", + "problem": "在 `main` 函數的 catch 區塊中,直接將 `err.stack` 輸出到標準錯誤流(log.error)。如果錯誤物件中包含了敏感資訊(如 token、API 參數),這些機密將被寫入到 CI/CD 的執行日誌中,極易洩漏。", + "suggestion": "在輸出 `err.stack` 前,必須使用類似 `maskSecrets` 的函式,過濾掉所有可能的機密資訊。", + "is_new": true + }, + { + "level": "warning", + "role": "Leo", + "location": "app/lib/opencode.js:34", + "problem": "1. `_writeConfig` 使用 mkdtempSync 建立暫存目錄後未清理,造成空間堆積。2. `summarize` 函式重複建立零散設定檔,增加 I/O 與清理負擔。", + "suggestion": "在程式執行完畢後的 finally 區塊中,統一實作檔案系統清理邏輯(如 fs.rmSync)以刪除暫存目錄與檔案。同時考慮設定檔重用性,減少頻繁的檔案操作。" + }, + { + "level": "warning", + "role": "Mage", + "location": "app/index.js:84", + "problem": "在 `buildResolveBranchName` 中,處理衝突分支名稱時,雖然使用了 `safe` 函數替換特殊字元,但如果分支名稱過長,加上 `suffix`(runId)可能導致分支名稱過長而超出 Git 對 branch 名稱長度的極限(雖然通常很大,但這是不必要的風險)。", + "suggestion": "建議對 `resolveBranch` 的總長度進行截斷,確保其不會超過 Git 的建議長度限制。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "app/lib/opencode.js:106", + "problem": "在 `summarize` 方法中將 `HOME` 環境變數硬編碼為 `/root`,若 Dockerfile 變更使用者,將導致無法寫入設定檔。", + "suggestion": "建議動態獲取當前環境的使用者家目錄(如使用 `os.homedir()`),增加相容性。" + }, + { + "level": "warning", + "role": "Rogue", + "location": "app/lib/git.js:63", + "problem": "在 `getCommitMessages` 中使用 `git log --max-count=50`,若專案歷史悠久,此數量可能不足以產生精確的 AI 摘要,且若取得數量過多則浪費處理資源。", + "suggestion": "評估實際使用場景調整 `limit`,或改用時間區間(例如 `--since`)來抓取相關變更。" + }, + { + "level": "warning", + "role": "Rogue", + "location": "app/lib/util.js:14", + "problem": "`run` 函式設定 `maxBuffer: 64 * 1024 * 1024` (64MB)。雖然避免了截斷,但如果 `git diff` 內容極大,這會一次性將大量文字讀入記憶體,極易引發記憶體不足 (OOM) 或過高的 GC 壓力。", + "suggestion": "改用 stream 方式讀取 `git` 指令輸出,而非一次性載入 buffer。", + "is_new": true + }, + { + "level": "info", + "role": "Leo", + "location": "app/lib/git.js:107", + "problem": "detectConflict 使用固定名稱的暫存分支(__conflict_check_${target}),若程式意外中斷可能導致分支殘留,下次執行可能引發命名衝突或狀態異常。", + "suggestion": "建議在分支名稱中加入隨機字串(如 uuid 或時間戳),並確保在 finally 區塊中有強制清理該分支的機制。", + "is_new": true + }, + { + "level": "info", + "role": "Maya", + "location": "app/index.js:150", + "problem": "對於 `fallbackSummary` 函數,當 opencode 產生摘要失敗時會觸發,但目前缺乏測試案例驗證在各種輸入下,fallback 的結果是否符合預期格式。", + "suggestion": "補上單元測試,驗證 `fallbackSummary` 在不同輸入下(如為空、多行訊息、stat 為空)產生的標題與描述格式是否正確。" + }, + { + "level": "info", + "role": "Maya", + "location": "app/lib/opencode.js:176", + "problem": "`extractResult` 函數處理 JSON 解析與清理邏輯,雖然複雜,但目前沒有單元測試驗證其對「LLM 容易輸出的各種非標準 JSON」的處理能力(例如字串內含未跳脫換行)。", + "suggestion": "補上單元測試,提供幾種 LLM 常見的「壞」JSON 格式,驗證 `extractResult` 能否正確解析出 `title` 與 `description`。", + "is_new": true + }, + { + "level": "info", + "role": "Rogue", + "location": "app/lib/git.js:95", + "problem": "`detectConflict` 函式透過 `git merge --no-commit --no-ff` 進行完整合併測試,極其耗時且佔用大量磁碟空間。", + "suggestion": "考慮改用 `git merge-tree` (Git 2.29+) 檢查衝突,在不觸碰工作區的情況下快速檢測。" + } +] -- 2.53.0 From 73c53e11de34fa6afb80259a167c30c469157c15 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 13:58:37 +0800 Subject: [PATCH 05/24] =?UTF-8?q?fix(ai-pull-request):=20=E9=81=AE?= =?UTF-8?q?=E8=94=BD=E9=8C=AF=E8=AA=A4=E8=A8=8A=E6=81=AF=20token=E3=80=81?= =?UTF-8?q?=E6=B8=85=E7=90=86=E6=9A=AB=E5=AD=98=E8=A8=AD=E5=AE=9A=E3=80=81?= =?UTF-8?q?=E6=88=AA=E6=96=B7=E5=88=86=E6=94=AF=E5=90=8D=E7=A8=B1=E4=B8=A6?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20homedir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/index.js | 10 +++++--- app/lib/opencode.js | 57 ++++++++++++++++++++++++--------------------- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/app/index.js b/app/index.js index 81b4c3d..d67a4f0 100644 --- a/app/index.js +++ b/app/index.js @@ -2,7 +2,7 @@ import { loadInputs, logInputs } from './lib/inputs.js'; import { Git } from './lib/git.js'; import { GiteaClient } from './lib/gitea.js'; import { OpenCode } from './lib/opencode.js'; -import { log } from './lib/util.js'; +import { log, maskSecrets } from './lib/util.js'; async function main() { const inputs = loadInputs(); @@ -143,7 +143,9 @@ function buildResolveBranchName(target, source) { const runId = process.env.GITHUB_RUN_NUMBER || process.env.GITHUB_RUN_ID || ''; const safe = (s) => s.replace(/[^a-zA-Z0-9._/-]/g, '-'); const suffix = runId ? `-${runId}` : ''; - return `resolve-conflict/${safe(target)}-into-${safe(source)}${suffix}`; + // 截斷主體長度,避免 target/source 過長使分支名稱超出 Git 限制 + const stem = `${safe(target)}-into-${safe(source)}`.slice(0, 180); + return `resolve-conflict/${stem}${suffix}`; } /** 解衝突 PR 的描述。 */ @@ -180,6 +182,8 @@ function reportPull(pull, created) { } main().catch((err) => { - log.error(err?.stack || err?.message || String(err)); + const detail = err?.stack || err?.message || String(err); + // 錯誤訊息/stack 可能夾帶 token,輸出到 CI 日誌前先遮蔽 + log.error(maskSecrets(detail, [process.env.GITEA_TOKEN])); process.exit(1); }); diff --git a/app/lib/opencode.js b/app/lib/opencode.js index 1190790..0d3870f 100644 --- a/app/lib/opencode.js +++ b/app/lib/opencode.js @@ -1,6 +1,6 @@ -import { writeFileSync, mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir, homedir } from 'node:os'; +import { join, dirname } from 'node:path'; import { run, log, maskSecrets } from './util.js'; /** @@ -74,33 +74,38 @@ export class OpenCode { const configPath = this._writeConfig(); const prompt = buildPrompt({ ...ctx, language: this.language }); - log.info(`呼叫 opencode(${this.provider}/${this.model})分析 diff...`); - const result = run( - 'opencode', - ['run', '--model', `${this.provider}/${this.model}`, prompt], - { - cwd: tmpdir(), - env: { - ...process.env, - OPENCODE_CONFIG: configPath, - // 確保 opencode 有可寫的 HOME / 設定目錄 - HOME: process.env.HOME || '/root', + try { + log.info(`呼叫 opencode(${this.provider}/${this.model})分析 diff...`); + const result = run( + 'opencode', + ['run', '--model', `${this.provider}/${this.model}`, prompt], + { + cwd: tmpdir(), + env: { + ...process.env, + OPENCODE_CONFIG: configPath, + // 確保 opencode 有可寫的 HOME / 設定目錄 + HOME: process.env.HOME || homedir(), + }, + timeout: 5 * 60 * 1000, }, - timeout: 5 * 60 * 1000, - }, - ); + ); - if (result.status !== 0) { - log.warn(`opencode 執行失敗 (${result.status}):${maskSecrets(result.stderr).slice(0, 500)}`); - return null; - } + if (result.status !== 0) { + log.warn(`opencode 執行失敗 (${result.status}):${maskSecrets(result.stderr).slice(0, 500)}`); + return null; + } - const parsed = extractResult(result.stdout); - if (!parsed) { - log.warn('無法從 opencode 輸出解析出標題/描述'); - return null; + const parsed = extractResult(result.stdout); + if (!parsed) { + log.warn('無法從 opencode 輸出解析出標題/描述'); + return null; + } + return parsed; + } finally { + // 清理 _writeConfig 建立的暫存設定目錄,避免堆積 + rmSync(dirname(configPath), { recursive: true, force: true }); } - return parsed; } } -- 2.53.0 From f09199f2cff428d969cecaf0a6523a4bc70d488b Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 13:58:41 +0800 Subject: [PATCH 06/24] =?UTF-8?q?chore(workflows):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E8=B7=AF=E5=BE=91=E5=90=AB=E7=A9=BA=E7=99=BD=E7=9A=84=E9=8C=AF?= =?UTF-8?q?=E8=AA=A4=20workflow=20=E6=AA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea / workflows/cd.yaml | 17 ----------------- .gitea / workflows/ci.yaml | 17 ----------------- 2 files changed, 34 deletions(-) delete mode 100644 .gitea / workflows/cd.yaml delete mode 100644 .gitea / workflows/ci.yaml diff --git a/ .gitea / workflows/cd.yaml b/ .gitea / workflows/cd.yaml deleted file mode 100644 index 7b9122c..0000000 --- a/ .gitea / workflows/cd.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: CD -on: - push: - branches: - - master -jobs: - release-tag: - name: Release Tag - runs-on: ubuntu - steps: - - name: Release Tag - uses: https://gitea.jsc.idv.tw/composite-actions/release-tag@${{ vars.ACTION_VERSION_CALCULATE_VERSION }} - with: - gitea_token: ${{ secrets.GITEA_TOKEN }} - gitea_release: ${{ vars.ACTION_GITEA_RELEASE_VERSION }} - version_calculate: ${{ vars.ACTION_VERSION_CALCULATE_VERSION }} - release_cleanup: ${{ vars.ACTION_RELEASE_CLEANUP_VERSION }} diff --git a/ .gitea / workflows/ci.yaml b/ .gitea / workflows/ci.yaml deleted file mode 100644 index c80bf56..0000000 --- a/ .gitea / workflows/ci.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: CI -on: - pull_request: - branches-ignore: - - master - types: [opened, synchronize] -permissions: - contents: write - pull-requests: write - issues: write -jobs: - ai-code-review: - name: Code Review - runs-on: ubuntu - steps: - - name: Code Review - uses: https://gitea.jsc.idv.tw/composite-actions/opencode-code-review@${{ vars.ACTION_AI_CODE_REVIEW_VERSION }} -- 2.53.0 From fb475758604c0ad652f9979abd44f25324f357d8 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 13:58:46 +0800 Subject: [PATCH 07/24] =?UTF-8?q?chore(ai-review):=20=E6=9B=B4=E6=96=B0=20?= =?UTF-8?q?findings=20=E8=88=87=20exclusions=20=E8=A7=A3=E6=B1=BA=E7=8B=80?= =?UTF-8?q?=E6=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/ai-review/exclusions.json | 32 ++++++++++++++ .gitea/ai-review/findings.json | 75 -------------------------------- 2 files changed, 32 insertions(+), 75 deletions(-) create mode 100644 .gitea/ai-review/exclusions.json diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json new file mode 100644 index 0000000..d5d5caf --- /dev/null +++ b/.gitea/ai-review/exclusions.json @@ -0,0 +1,32 @@ +[ + { + "location": "app/lib/gitea.js:28", + "role": "Assassin", + "original_finding": "Gitea API 請求在 headers 中直接放入了 `this.token`,若來源於不可信輸入且未經驗證,將導致 token 洩漏風險。", + "reason": "token 來自受信任的 `gitea.token`(CI 自動注入)而非使用者輸入;`_request` 從未將 headers 或 request 物件輸出到日誌,無實際洩漏路徑。錯誤訊息可能夾帶 token 的真正風險已於 index.js 頂層 catch 以 maskSecrets 遮蔽處理。" + }, + { + "location": "app/lib/gitea.js:52", + "role": "Mage", + "original_finding": "findOpenPull 僅撈取前 50 個 PR,數量眾多可能導致重複建立;且 _request GET 未對回傳 json 結構嚴格驗證。", + "reason": "重複建立由 Gitea 在 POST 時回傳 422/409 阻擋,findOpenPull 僅在收到 422/409 後用於查回既有 PR 編號(fallback),分頁與否不影響是否重複建立。JSON 結構已透過 `if (!ok || !Array.isArray(json)) return null` 與 `_request` 的 try/catch 防禦驗證。" + }, + { + "location": "app/lib/git.js:52", + "role": "Assassin", + "original_finding": "使用不可信的 remoteUrl 進行 fetch 操作存在 git 協定漏洞風險,建議驗證 remoteUrl 是否為預期 Gitea 網域。", + "reason": "remoteUrl 由 `${serverUrl}/${owner}/${repo}.git` 組成,serverUrl 來自受信任的 `gitea.server_url`(CI 環境變數),並非任意使用者輸入;且已使用 `--no-tags` 限制 refspec。容器基底為 node:20-bookworm-slim,git 版本為近期版本。" + }, + { + "location": "app/lib/git.js:63", + "role": "Rogue", + "original_finding": "getCommitMessages 使用 `git log --max-count=50`,數量可能不足或過多。", + "reason": "50 筆為 PR 摘要的合理預設上限,非缺陷;改用 `--since` 屬使用場景偏好調整,無明確需求佐證,不在本次修復範圍。" + }, + { + "location": "app/lib/util.js:14", + "role": "Rogue", + "original_finding": "run 函式 maxBuffer 設為 64MB,git diff 內容極大時易引發 OOM,建議改用 stream。", + "reason": "run() 採同步 spawnSync 為刻意設計(所有呼叫端皆同步取用 result.stdout);maxBuffer 為上限保護而非預先配置,僅在輸出達該量時才佔用;傳給 opencode 的 diff 已於 index.js 以 maxDiffChars 截斷。改為 stream 屬大規模架構重構,牽涉設計取捨。" + } +] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 09fd6f6..fdf6239 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,19 +1,4 @@ [ - { - "level": "critical", - "role": "Assassin", - "location": "app/lib/gitea.js:28", - "problem": "Gitea API 請求在 headers 中直接放入了 `this.token`。雖然這在正常情況下是必要的,但如果 `this.token` 來源於不可信的輸入且未經嚴格驗證,這將導致 token 洩漏風險(透過請求日誌或中間人攻擊)。", - "suggestion": "在 `GiteaClient` 的所有請求方法中增加對 token 的處理,並確保在任何可能將請求細節(包含 headers)輸出到日誌的邏輯中,必須將 token 遮蔽。", - "is_new": true - }, - { - "level": "critical", - "role": "Mage", - "location": "app/lib/gitea.js:52", - "problem": "1. `findOpenPull` 方法僅撈取前 50 個 PR,若數量眾多可能導致重複建立。2. `_request` 進行 `GET` 操作時,未對 API 回傳的 `json` 內容結構進行嚴格合法性驗證,可能導致執行時錯誤。", - "suggestion": "1. 實作分頁(pagination)機制確保完整性。2. 在確保 HTTP 狀態碼為 200 後,強化對 `json` 的防禦性檢測(如檢查是否為 undefined 或預期陣列)。" - }, { "level": "critical", "role": "Mage", @@ -28,59 +13,6 @@ "problem": "在偵測到衝突並建立解衝突分支後,程式雖然嘗試透過 `git.createResolveBranch` 建立並 commit 衝突檔案,但後續缺乏邏輯處理衝突檔案,亦無測試驗證「自動解衝突分支是否真的被建立」以及「提交的內容是否正確」。", "suggestion": "應補上整合測試,模擬合併衝突,驗證 `detectConflict` 能偵測衝突,且 `createResolveBranch` 產生的分支確實包含預期的衝突檔案與 commit。" }, - { - "level": "warning", - "role": "Assassin", - "location": "app/lib/git.js:52", - "problem": "Git 的 `--no-tags` 參數在某些舊版 git 可能無法完全防禦標籤帶來的惡意遠端物件下載。雖然 `fetchBranches` 限制了 refspec,但使用不可信的 `remoteUrl` 進行 fetch 操作時,仍存在與 git 協定漏洞相關的風險。", - "suggestion": "建議確保容器內的 git 版本為最新,並考慮在 fetch 前驗證 `remoteUrl` 是否為預期的 Gitea 網域,而非任意使用者輸入的網址。", - "is_new": true - }, - { - "level": "warning", - "role": "Assassin", - "location": "app/index.js:180", - "problem": "在 `main` 函數的 catch 區塊中,直接將 `err.stack` 輸出到標準錯誤流(log.error)。如果錯誤物件中包含了敏感資訊(如 token、API 參數),這些機密將被寫入到 CI/CD 的執行日誌中,極易洩漏。", - "suggestion": "在輸出 `err.stack` 前,必須使用類似 `maskSecrets` 的函式,過濾掉所有可能的機密資訊。", - "is_new": true - }, - { - "level": "warning", - "role": "Leo", - "location": "app/lib/opencode.js:34", - "problem": "1. `_writeConfig` 使用 mkdtempSync 建立暫存目錄後未清理,造成空間堆積。2. `summarize` 函式重複建立零散設定檔,增加 I/O 與清理負擔。", - "suggestion": "在程式執行完畢後的 finally 區塊中,統一實作檔案系統清理邏輯(如 fs.rmSync)以刪除暫存目錄與檔案。同時考慮設定檔重用性,減少頻繁的檔案操作。" - }, - { - "level": "warning", - "role": "Mage", - "location": "app/index.js:84", - "problem": "在 `buildResolveBranchName` 中,處理衝突分支名稱時,雖然使用了 `safe` 函數替換特殊字元,但如果分支名稱過長,加上 `suffix`(runId)可能導致分支名稱過長而超出 Git 對 branch 名稱長度的極限(雖然通常很大,但這是不必要的風險)。", - "suggestion": "建議對 `resolveBranch` 的總長度進行截斷,確保其不會超過 Git 的建議長度限制。", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "app/lib/opencode.js:106", - "problem": "在 `summarize` 方法中將 `HOME` 環境變數硬編碼為 `/root`,若 Dockerfile 變更使用者,將導致無法寫入設定檔。", - "suggestion": "建議動態獲取當前環境的使用者家目錄(如使用 `os.homedir()`),增加相容性。" - }, - { - "level": "warning", - "role": "Rogue", - "location": "app/lib/git.js:63", - "problem": "在 `getCommitMessages` 中使用 `git log --max-count=50`,若專案歷史悠久,此數量可能不足以產生精確的 AI 摘要,且若取得數量過多則浪費處理資源。", - "suggestion": "評估實際使用場景調整 `limit`,或改用時間區間(例如 `--since`)來抓取相關變更。" - }, - { - "level": "warning", - "role": "Rogue", - "location": "app/lib/util.js:14", - "problem": "`run` 函式設定 `maxBuffer: 64 * 1024 * 1024` (64MB)。雖然避免了截斷,但如果 `git diff` 內容極大,這會一次性將大量文字讀入記憶體,極易引發記憶體不足 (OOM) 或過高的 GC 壓力。", - "suggestion": "改用 stream 方式讀取 `git` 指令輸出,而非一次性載入 buffer。", - "is_new": true - }, { "level": "info", "role": "Leo", @@ -103,12 +35,5 @@ "problem": "`extractResult` 函數處理 JSON 解析與清理邏輯,雖然複雜,但目前沒有單元測試驗證其對「LLM 容易輸出的各種非標準 JSON」的處理能力(例如字串內含未跳脫換行)。", "suggestion": "補上單元測試,提供幾種 LLM 常見的「壞」JSON 格式,驗證 `extractResult` 能否正確解析出 `title` 與 `description`。", "is_new": true - }, - { - "level": "info", - "role": "Rogue", - "location": "app/lib/git.js:95", - "problem": "`detectConflict` 函式透過 `git merge --no-commit --no-ff` 進行完整合併測試,極其耗時且佔用大量磁碟空間。", - "suggestion": "考慮改用 `git merge-tree` (Git 2.29+) 檢查衝突,在不觸碰工作區的情況下快速檢測。" } ] -- 2.53.0 From 5e0dc865b2f58eb281d4eaa93d6fb2cc9b33b20d Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 05:59:38 +0000 Subject: [PATCH 08/24] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 91 ++++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 15 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fdf6239..ecdeca3 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -2,38 +2,99 @@ { "level": "critical", "role": "Mage", - "location": "app/lib/git.js:154", - "problem": "在 `createResolveBranch` 中,執行 `git add -A` 與 `git commit` 會強制提交包括未追蹤檔案在內的所有變更,可能污染分支且掩蓋衝突內容,此流程亦缺乏測試。", + "location": "app/lib/git.js:147, 154", + "problem": "在 `createResolveBranch` 中使用 `git add -A` 與 `git commit` 會強制提交包括未追蹤檔案在內的所有變更,可能污染分支且掩蓋衝突內容。且此流程缺乏測試。", "suggestion": "僅針對衝突檔案(`--diff-filter=U`)執行 `git add`,並補上整合測試,驗證模擬衝突時,產生的分支確實包含正確的衝突標記與檔案。" }, { "level": "critical", "role": "Maya", - "location": "app/index.js:58", - "problem": "在偵測到衝突並建立解衝突分支後,程式雖然嘗試透過 `git.createResolveBranch` 建立並 commit 衝突檔案,但後續缺乏邏輯處理衝突檔案,亦無測試驗證「自動解衝突分支是否真的被建立」以及「提交的內容是否正確」。", - "suggestion": "應補上整合測試,模擬合併衝突,驗證 `detectConflict` 能偵測衝突,且 `createResolveBranch` 產生的分支確實包含預期的衝突檔案與 commit。" + "location": "app/index.js:1, 58", + "problem": "核心邏輯(涉及分支操作、API 交互、AI 分析)完全缺乏自動化測試。且 `createResolveBranch` 產生的分支缺乏邏輯處理衝突檔案,亦無測試驗證分支建立及提交內容。", + "suggestion": "必須建立測試目錄,導入測試框架(如 Jest 或 Mocha),並至少為 lib/ 下的工具類與 index.js 的核心邏輯增加單元測試。應補上整合測試,驗證 detectConflict 能偵測衝突,且 createResolveBranch 產生的分支確實包含預期的衝突檔案與 commit。" }, { - "level": "info", + "level": "critical", + "role": "Maya", + "location": "app/lib/git.js:106", + "problem": "detectConflict 函式在執行 git 合併失敗後的 abort 嘗試若失敗,會導致工作區殘留錯誤狀態,且未經測試驗證。", + "suggestion": "增加測試案例來模擬 git 合併失敗與 abort 失敗的場景,確保狀態正確復原。" + }, + { + "level": "warning", + "role": "Mage", + "location": "app/lib/git.js:39, 52", + "problem": "多次使用 `git config --global` 修改全域設定,可能導致 `~/.gitconfig` 無限膨脹、污染環境或導致並行 Git 作業行為異常。", + "suggestion": "改用 `--local` 設定,或在執行 Git 指令時透過 `-c` 傳入設定,避免修改全域組態。同時限制 `safe.directory` 只針對特定的工作目錄,而非萬用字元 `*`。" + }, + { + "level": "warning", + "role": "Mage", + "location": "app/lib/opencode.js:106, 111", + "problem": "將 `HOME` 環境變數硬編碼為 `/root`;`opencode run` 設定了 5 分鐘 timeout 但未處理發生時的清理。", + "suggestion": "動態獲取當前使用者的家目錄;增加對 timeout 的特殊處理,並確保 finally 區塊正確清理所有狀態。" + }, + { + "level": "warning", "role": "Leo", - "location": "app/lib/git.js:107", - "problem": "detectConflict 使用固定名稱的暫存分支(__conflict_check_${target}),若程式意外中斷可能導致分支殘留,下次執行可能引發命名衝突或狀態異常。", - "suggestion": "建議在分支名稱中加入隨機字串(如 uuid 或時間戳),並確保在 finally 區塊中有強制清理該分支的機制。", - "is_new": true + "location": "app/lib/opencode.js:34, 46, 143", + "problem": "暫存目錄與設定檔未清理造成空間堆積;頻繁 I/O;且複雜的 JSON 解析邏輯不僅脆弱且維護成本高。", + "suggestion": "在 finally 區塊中統一實作檔案系統清理;將設定檔產生邏輯快取;優化 Prompt 嚴格要求 LLM 僅輸出標準 JSON,並使用原生 `JSON.parse`。" + }, + { + "level": "warning", + "role": "Mage", + "location": "app/index.js:84, 176", + "problem": "分支名稱長度可能超出 Git 限制,或字元替換後導致名稱無效。", + "suggestion": "確保 resolveBranch 長度不超過 Git 建議限制,並對最終產生的分支名稱進行正規化。" + }, + { + "level": "warning", + "role": "Assassin", + "location": "app/index.js:183", + "problem": "錯誤處理中的 `maskSecrets` 基於字串取代,可能無法處理所有 Token 變體導致敏感資訊洩漏。", + "suggestion": "確保 `maskSecrets` 處理所有可能的變體,並在生產環境中禁止輸出原始錯誤物件。" + }, + { + "level": "warning", + "role": "Leo", + "location": "app/index.js:7", + "problem": "函式 `main()` 承擔過多責任,違反單一職責原則。", + "suggestion": "將職責拆解,抽離衝突處理邏輯為 `ConflictManager`,並封裝 Gitea API 互動。" + }, + { + "level": "warning", + "role": "Maya", + "location": "app/index.js:114, 126", + "problem": "diff 截斷邏輯與 fallbackSummary 處理邊界情況缺乏測試。", + "suggestion": "補上針對 truncateDiff 及 commitMessages 為空/null 時的測試案例。" }, { "level": "info", "role": "Maya", "location": "app/index.js:150", - "problem": "對於 `fallbackSummary` 函數,當 opencode 產生摘要失敗時會觸發,但目前缺乏測試案例驗證在各種輸入下,fallback 的結果是否符合預期格式。", - "suggestion": "補上單元測試,驗證 `fallbackSummary` 在不同輸入下(如為空、多行訊息、stat 為空)產生的標題與描述格式是否正確。" + "problem": "缺乏測試案例驗證 fallbackSummary 的結果是否符合預期格式。", + "suggestion": "補上單元測試,驗證 fallbackSummary 在不同輸入下的產出格式。" }, { "level": "info", "role": "Maya", "location": "app/lib/opencode.js:176", - "problem": "`extractResult` 函數處理 JSON 解析與清理邏輯,雖然複雜,但目前沒有單元測試驗證其對「LLM 容易輸出的各種非標準 JSON」的處理能力(例如字串內含未跳脫換行)。", - "suggestion": "補上單元測試,提供幾種 LLM 常見的「壞」JSON 格式,驗證 `extractResult` 能否正確解析出 `title` 與 `description`。", - "is_new": true + "problem": "缺乏單元測試驗證 `extractResult` 對非標準 JSON 的解析能力。", + "suggestion": "補上單元測試,驗證 extractResult 能否正確解析壞 JSON。" + }, + { + "level": "info", + "role": "Assassin", + "location": "app/lib/opencode.js:77", + "problem": "呼叫外部指令時傳遞整個 `process.env`,導致敏感環境變數暴露。", + "suggestion": "應明確篩選並只傳遞必要環境變數。" + }, + { + "level": "info", + "role": "Rogue", + "location": "app/index.js:37", + "problem": "在 `ahead` 為 0 時,仍執行昂貴的 diff 採集與分析。", + "suggestion": "先執行 `countAheadCommits`,若 `ahead === 0` 則直接終止。" } ] -- 2.53.0 From af4bb899e649796747dab93d912869a857282a4a Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:03:48 +0800 Subject: [PATCH 09/24] =?UTF-8?q?chore(ai-review):=20=E6=9B=B4=E6=96=B0=20?= =?UTF-8?q?findings=20=E8=88=87=20exclusions=20=E8=A7=A3=E6=B1=BA=E7=8B=80?= =?UTF-8?q?=E6=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/ai-review/exclusions.json | 24 ++++++++++++++++ .gitea/ai-review/findings.json | 49 -------------------------------- 2 files changed, 24 insertions(+), 49 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index d5d5caf..b8c4d13 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -28,5 +28,29 @@ "role": "Rogue", "original_finding": "run 函式 maxBuffer 設為 64MB,git diff 內容極大時易引發 OOM,建議改用 stream。", "reason": "run() 採同步 spawnSync 為刻意設計(所有呼叫端皆同步取用 result.stdout);maxBuffer 為上限保護而非預先配置,僅在輸出達該量時才佔用;傳給 opencode 的 diff 已於 index.js 以 maxDiffChars 截斷。改為 stream 屬大規模架構重構,牽涉設計取捨。" + }, + { + "location": "app/lib/git.js:39, 52", + "role": "Mage", + "original_finding": "多次使用 `git config --global` 修改全域設定,可能導致 ~/.gitconfig 無限膨脹、污染環境;safe.directory 使用萬用字元 `*` 過於寬鬆,建議改用 --local。", + "reason": "action 於每次執行皆在全新且即拋的 Docker 容器內運行,~/.gitconfig 不跨執行保留,無「無限膨脹」問題。safe.directory 基於安全考量 git 刻意忽略 repo-local 設定,必須寫在 global/system,無法改用 `--local`;在 owner 不可預期的 CI checkout 工作區使用 `*` 是 runner 的標準做法(如 actions/checkout 亦同)。user.name/email 已使用 --local。" + }, + { + "location": "app/index.js:183", + "role": "Assassin", + "original_finding": "錯誤處理中的 maskSecrets 基於字串取代,可能無法處理所有 Token 變體導致敏感資訊洩漏;建議禁止輸出原始錯誤物件。", + "reason": "maskSecrets 以子字串比對取代,能涵蓋 token 出現於錯誤訊息的各處(含 URL 內嵌 `oauth2:@`),實際洩漏向量(http.extraheader 帶入的原始 token)已被遮蔽。URL 編碼/base64 變體不會出現在本專案的錯誤路徑;完全禁止輸出 err.stack 會嚴重損及 CI 除錯能力,取捨上以遮蔽 token 為宜。" + }, + { + "location": "app/index.js:7", + "role": "Leo", + "original_finding": "函式 main() 承擔過多責任,違反單一職責原則,建議抽離 ConflictManager 並封裝 Gitea API 互動。", + "reason": "屬主觀重構偏好而非缺陷;程式已分層為 Git/GiteaClient/OpenCode 三個職責清楚的類別,main() 僅負責編排流程,長度與複雜度可控,無立即重構必要。" + }, + { + "location": "app/index.js:37", + "role": "Rogue", + "original_finding": "在 ahead 為 0 時,仍執行昂貴的 diff 採集與分析;建議先執行 countAheadCommits,若 ahead === 0 則直接終止。", + "reason": "現有程式已於 `countAheadCommits` 後立即檢查,`if (ahead === 0) { ...; return; }`(index.js:28-32)早於 diff 採集(index.js:36 起)就終止,與建議行為一致,屬誤報。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index ecdeca3..3722013 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -20,48 +20,6 @@ "problem": "detectConflict 函式在執行 git 合併失敗後的 abort 嘗試若失敗,會導致工作區殘留錯誤狀態,且未經測試驗證。", "suggestion": "增加測試案例來模擬 git 合併失敗與 abort 失敗的場景,確保狀態正確復原。" }, - { - "level": "warning", - "role": "Mage", - "location": "app/lib/git.js:39, 52", - "problem": "多次使用 `git config --global` 修改全域設定,可能導致 `~/.gitconfig` 無限膨脹、污染環境或導致並行 Git 作業行為異常。", - "suggestion": "改用 `--local` 設定,或在執行 Git 指令時透過 `-c` 傳入設定,避免修改全域組態。同時限制 `safe.directory` 只針對特定的工作目錄,而非萬用字元 `*`。" - }, - { - "level": "warning", - "role": "Mage", - "location": "app/lib/opencode.js:106, 111", - "problem": "將 `HOME` 環境變數硬編碼為 `/root`;`opencode run` 設定了 5 分鐘 timeout 但未處理發生時的清理。", - "suggestion": "動態獲取當前使用者的家目錄;增加對 timeout 的特殊處理,並確保 finally 區塊正確清理所有狀態。" - }, - { - "level": "warning", - "role": "Leo", - "location": "app/lib/opencode.js:34, 46, 143", - "problem": "暫存目錄與設定檔未清理造成空間堆積;頻繁 I/O;且複雜的 JSON 解析邏輯不僅脆弱且維護成本高。", - "suggestion": "在 finally 區塊中統一實作檔案系統清理;將設定檔產生邏輯快取;優化 Prompt 嚴格要求 LLM 僅輸出標準 JSON,並使用原生 `JSON.parse`。" - }, - { - "level": "warning", - "role": "Mage", - "location": "app/index.js:84, 176", - "problem": "分支名稱長度可能超出 Git 限制,或字元替換後導致名稱無效。", - "suggestion": "確保 resolveBranch 長度不超過 Git 建議限制,並對最終產生的分支名稱進行正規化。" - }, - { - "level": "warning", - "role": "Assassin", - "location": "app/index.js:183", - "problem": "錯誤處理中的 `maskSecrets` 基於字串取代,可能無法處理所有 Token 變體導致敏感資訊洩漏。", - "suggestion": "確保 `maskSecrets` 處理所有可能的變體,並在生產環境中禁止輸出原始錯誤物件。" - }, - { - "level": "warning", - "role": "Leo", - "location": "app/index.js:7", - "problem": "函式 `main()` 承擔過多責任,違反單一職責原則。", - "suggestion": "將職責拆解,抽離衝突處理邏輯為 `ConflictManager`,並封裝 Gitea API 互動。" - }, { "level": "warning", "role": "Maya", @@ -89,12 +47,5 @@ "location": "app/lib/opencode.js:77", "problem": "呼叫外部指令時傳遞整個 `process.env`,導致敏感環境變數暴露。", "suggestion": "應明確篩選並只傳遞必要環境變數。" - }, - { - "level": "info", - "role": "Rogue", - "location": "app/index.js:37", - "problem": "在 `ahead` 為 0 時,仍執行昂貴的 diff 採集與分析。", - "suggestion": "先執行 `countAheadCommits`,若 `ahead === 0` 則直接終止。" } ] -- 2.53.0 From 32153b7bd5d9c2895b548f318a8984d81580b8cb Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 06:04:34 +0000 Subject: [PATCH 10/24] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 86 +++++++++++++++++++++++++++------- 1 file changed, 69 insertions(+), 17 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 3722013..dcc8b2c 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,51 +1,103 @@ [ - { - "level": "critical", - "role": "Mage", - "location": "app/lib/git.js:147, 154", - "problem": "在 `createResolveBranch` 中使用 `git add -A` 與 `git commit` 會強制提交包括未追蹤檔案在內的所有變更,可能污染分支且掩蓋衝突內容。且此流程缺乏測試。", - "suggestion": "僅針對衝突檔案(`--diff-filter=U`)執行 `git add`,並補上整合測試,驗證模擬衝突時,產生的分支確實包含正確的衝突標記與檔案。" - }, { "level": "critical", "role": "Maya", - "location": "app/index.js:1, 58", - "problem": "核心邏輯(涉及分支操作、API 交互、AI 分析)完全缺乏自動化測試。且 `createResolveBranch` 產生的分支缺乏邏輯處理衝突檔案,亦無測試驗證分支建立及提交內容。", - "suggestion": "必須建立測試目錄,導入測試框架(如 Jest 或 Mocha),並至少為 lib/ 下的工具類與 index.js 的核心邏輯增加單元測試。應補上整合測試,驗證 detectConflict 能偵測衝突,且 createResolveBranch 產生的分支確實包含預期的衝突檔案與 commit。" + "location": "app/index.js:1, 52, 58", + "problem": "核心邏輯完全缺乏自動化測試。自動解衝突流程直接 commit 但缺乏對人工解衝突後正確性、以及對 build/test 結果的驗證,且 `createPull` 錯誤處理可能因 JSON 解析問題導致行為異常。", + "suggestion": "建立完整的單元與整合測試架構。在建立解衝突分支並合併後,執行專案的建置指令或測試指令,並增強對 API 回傳錯誤的解析與處理。" }, { "level": "critical", "role": "Maya", "location": "app/lib/git.js:106", - "problem": "detectConflict 函式在執行 git 合併失敗後的 abort 嘗試若失敗,會導致工作區殘留錯誤狀態,且未經測試驗證。", - "suggestion": "增加測試案例來模擬 git 合併失敗與 abort 失敗的場景,確保狀態正確復原。" + "problem": "detectConflict 執行 git 合併失敗後的 abort 嘗試若失敗,會導致工作區殘留錯誤狀態,且未經測試。", + "suggestion": "增加測試案例模擬 git 合併失敗與 abort 失敗的場景,確保狀態正確復原。" + }, + { + "level": "critical", + "role": "Assassin", + "location": "app/lib/opencode.js:180", + "problem": "AI 模型產生的 PR 描述未經 sanitization,易遭 Prompt Injection 導致 Stored XSS 攻擊。", + "suggestion": "在 `extractResult` 中對 `obj.description` 使用成熟的 HTML Sanitizer 過濾惡意標籤。" }, { "level": "warning", "role": "Maya", "location": "app/index.js:114, 126", "problem": "diff 截斷邏輯與 fallbackSummary 處理邊界情況缺乏測試。", - "suggestion": "補上針對 truncateDiff 及 commitMessages 為空/null 時的測試案例。" + "suggestion": "補上針對 truncateDiff 及 commitMessages 為空/null 時的測試案例。", + "is_new": false + }, + { + "level": "warning", + "role": "Leo", + "location": "app/lib/opencode.js:154", + "problem": "summarize 函式使用了 5 分鐘固定 timeout,大型 diff 可能導致分析失敗。", + "suggestion": "將 timeout 設定為可配置參數或根據 diff 大小動態計算。" + }, + { + "level": "warning", + "role": "Mage", + "location": "app/lib/opencode.js:127", + "problem": "`summarize` 方法中使用 `spawnSync` 執行指令,未處理退出訊號可能導致清理競態。", + "suggestion": "明確處理 `spawnSync` 的退出訊號,並確保清理操作是原子性的。" + }, + { + "level": "warning", + "role": "Maya", + "location": "app/lib/git.js:145", + "problem": "合併衝突後未檢查是否存在殘留衝突標記。", + "suggestion": "在 `git add` 之後,使用 grep 掃描檔案中是否仍有未處理的衝突標記。" }, { "level": "info", "role": "Maya", "location": "app/index.js:150", "problem": "缺乏測試案例驗證 fallbackSummary 的結果是否符合預期格式。", - "suggestion": "補上單元測試,驗證 fallbackSummary 在不同輸入下的產出格式。" + "suggestion": "補上單元測試,驗證 fallbackSummary 在不同輸入下的產出格式。", + "is_new": false }, { "level": "info", "role": "Maya", "location": "app/lib/opencode.js:176", "problem": "缺乏單元測試驗證 `extractResult` 對非標準 JSON 的解析能力。", - "suggestion": "補上單元測試,驗證 extractResult 能否正確解析壞 JSON。" + "suggestion": "補上單元測試,驗證 extractResult 能否正確解析壞 JSON。", + "is_new": false }, { "level": "info", "role": "Assassin", "location": "app/lib/opencode.js:77", - "problem": "呼叫外部指令時傳遞整個 `process.env`,導致敏感環境變數暴露。", - "suggestion": "應明確篩選並只傳遞必要環境變數。" + "problem": "傳遞整個 `process.env` 導致敏感環境變數暴露。", + "suggestion": "明確篩選並只傳遞必要環境變數。" + }, + { + "level": "info", + "role": "Bard", + "location": "Dockerfile:16", + "problem": "在 RUN 指令中使用 cd 切換目錄,導致環境隱晦。", + "suggestion": "使用 `WORKDIR /app`。" + }, + { + "level": "info", + "role": "Leo", + "location": "app/lib/git.js:122", + "problem": "臨時分支名稱可能衝突或殘留。", + "suggestion": "產生臨時分支名稱時加入 process ID 或隨機字串,並在 finally 區塊清理。" + }, + { + "level": "info", + "role": "Maya", + "location": "app/index.js:77", + "problem": "解衝突的 PR 產出缺乏人工檢查機制。", + "suggestion": "加入「檢查清單(Checklist)」要求人工確認。" + }, + { + "level": "info", + "role": "Rogue", + "location": "app/lib/opencode.js:40", + "problem": "頻繁寫入讀取 `opencode.json` 設定檔造成無謂的 I/O。", + "suggestion": "若支援,透過參數或環境變數傳入配置。" } ] -- 2.53.0 From f7e6f1c64e90d302e83e663f054b754039dec6e3 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:09:13 +0800 Subject: [PATCH 11/24] =?UTF-8?q?fix(git):=20=E8=A1=9D=E7=AA=81=E5=81=B5?= =?UTF-8?q?=E6=B8=AC=E6=9A=AB=E5=AD=98=E5=88=86=E6=94=AF=E5=8A=A0=20PID=20?= =?UTF-8?q?=E4=B8=A6=E4=BB=A5=20finally=20=E7=A2=BA=E4=BF=9D=E6=B8=85?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/git.js | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/app/lib/git.js b/app/lib/git.js index 28b9bfd..eb6c880 100644 --- a/app/lib/git.js +++ b/app/lib/git.js @@ -97,25 +97,28 @@ export class Git { * @returns {{ hasConflict: boolean, files: string[] }} */ detectConflict(target, source) { - // 建立暫時的本地 target 分支,嘗試以 --no-commit 合併 source - const tmp = `__conflict_check_${target}`; + // 建立暫時的本地 target 分支,嘗試以 --no-commit 合併 source。 + // 名稱帶 PID,避免並行或前次殘留造成命名衝突。 + const tmp = `__conflict_check_${target}_${process.pid}`; this._git(['checkout', '-B', tmp, `refs/remotes/pr/${target}`]); - const merge = this._git(['merge', '--no-commit', '--no-ff', `refs/remotes/pr/${source}`]); - let hasConflict = merge.status !== 0; - let files = []; + try { + const merge = this._git(['merge', '--no-commit', '--no-ff', `refs/remotes/pr/${source}`]); + const hasConflict = merge.status !== 0; + let files = []; - if (hasConflict) { - const unmerged = this._git(['diff', '--name-only', '--diff-filter=U']); - files = unmerged.stdout.split('\n').map((s) => s.trim()).filter(Boolean); + if (hasConflict) { + const unmerged = this._git(['diff', '--name-only', '--diff-filter=U']); + files = unmerged.stdout.split('\n').map((s) => s.trim()).filter(Boolean); + } + + return { hasConflict, files }; + } finally { + // 無論成敗都還原工作區並清除暫存分支 + this._git(['merge', '--abort']); + this._git(['checkout', '--detach']); + this._git(['branch', '-D', tmp]); } - - // 還原工作區 - this._git(['merge', '--abort']); - this._git(['checkout', '--detach']); - this._git(['branch', '-D', tmp]); - - return { hasConflict, files }; } /** -- 2.53.0 From f39f58afb6c87edd1d2dcc7af38ad3d975ae90ce Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:09:13 +0800 Subject: [PATCH 12/24] =?UTF-8?q?feat(=E8=A7=A3=E8=A1=9D=E7=AA=81=20PR):?= =?UTF-8?q?=20PR=20=E5=85=A7=E6=96=87=E5=8A=A0=E5=85=A5=E5=90=88=E4=BD=B5?= =?UTF-8?q?=E5=89=8D=E4=BA=BA=E5=B7=A5=E6=AA=A2=E6=9F=A5=E6=B8=85=E5=96=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/index.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/index.js b/app/index.js index d67a4f0..bc05f69 100644 --- a/app/index.js +++ b/app/index.js @@ -160,6 +160,12 @@ function buildResolveBody({ source, target, resolveBranch, files, summary }) { ``, ...files.map((f) => `- \`${f}\``), ``, + `### ✅ 合併前人工檢查清單`, + ``, + `- [ ] 已移除所有檔案中的衝突標記(\`<<<<<<<\`、\`=======\`、\`>>>>>>>\`)`, + `- [ ] 已確認合併結果可正常建置/執行`, + `- [ ] 已保留雙方必要變更,無誤刪`, + ``, `解決並合併此 PR 後,\`${source}\` 即可順利合併進 \`${target}\`。`, ``, `---`, -- 2.53.0 From 21b75caac90acea2258f734f0cfa07016ad836d1 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:09:13 +0800 Subject: [PATCH 13/24] =?UTF-8?q?refactor(Dockerfile):=20=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20WORKDIR=20=E5=8F=96=E4=BB=A3=20RUN=20=E5=85=A7=20cd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index eec32a5..766dbfb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,9 +10,10 @@ RUN npm install -g opencode-ai # 複製 Node.js 應用程式 COPY app/ /app/ +WORKDIR /app # 應用程式無第三方相依套件,僅在有 package-lock 時安裝 -RUN if [ -f /app/package-lock.json ]; then cd /app && npm ci --omit=dev; fi +RUN if [ -f package-lock.json ]; then npm ci --omit=dev; fi COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -- 2.53.0 From 84a50e4987e3c8c21ac15220559acdf7de436e1a Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:09:13 +0800 Subject: [PATCH 14/24] =?UTF-8?q?chore(ai-review):=20=E6=9B=B4=E6=96=B0=20?= =?UTF-8?q?findings=20=E8=88=87=20exclusions=20=E8=A7=A3=E6=B1=BA=E7=8B=80?= =?UTF-8?q?=E6=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/ai-review/exclusions.json | 30 +++++++++++++++++ .gitea/ai-review/findings.json | 56 -------------------------------- 2 files changed, 30 insertions(+), 56 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index b8c4d13..e757b9d 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -52,5 +52,35 @@ "role": "Rogue", "original_finding": "在 ahead 為 0 時,仍執行昂貴的 diff 採集與分析;建議先執行 countAheadCommits,若 ahead === 0 則直接終止。", "reason": "現有程式已於 `countAheadCommits` 後立即檢查,`if (ahead === 0) { ...; return; }`(index.js:28-32)早於 diff 採集(index.js:36 起)就終止,與建議行為一致,屬誤報。" + }, + { + "location": "app/lib/opencode.js:180", + "role": "Assassin", + "original_finding": "AI 模型產生的 PR 描述未經 sanitization,易遭 Prompt Injection 導致 Stored XSS 攻擊;建議在 extractResult 對 obj.description 使用 HTML Sanitizer。", + "reason": "description 以 Markdown 文字經 API 寫入 Gitea PR body,HTML 的消毒由 Gitea 渲染端負責(Gitea 對使用者內容套用 HTML sanitizer policy),非本 action 職責。description 是 Markdown 而非 HTML,在 client 端套用 HTML Sanitizer 反而會破壞合法的 Markdown 內容。" + }, + { + "location": "app/lib/opencode.js:154", + "role": "Leo", + "original_finding": "summarize 函式使用 5 分鐘固定 timeout,大型 diff 可能導致分析失敗;建議改為可配置或依 diff 大小動態計算。", + "reason": "送入 opencode 的 diff 已於 index.js 以 maxDiffChars(60000 字元)截斷,prompt 大小有上限,5 分鐘對此規模輸入相當充裕;timeout 可配置屬增強而非缺陷。" + }, + { + "location": "app/lib/opencode.js:127", + "role": "Mage", + "original_finding": "summarize 方法中使用 spawnSync 執行指令,未處理退出訊號可能導致清理競態。", + "reason": "spawnSync 為同步阻塞呼叫,回傳後才執行 finally 清理,無並行清理路徑,不存在競態;timeout/訊號終止時 spawnSync 仍會回傳,finally 的 rmSync 必定執行。" + }, + { + "location": "app/lib/git.js:145", + "role": "Maya", + "original_finding": "合併衝突後未檢查是否存在殘留衝突標記;建議在 git add 後以 grep 掃描衝突標記。", + "reason": "createResolveBranch 刻意保留衝突標記並 commit,讓開發者在解衝突 PR 中看到並手動解決(PR body 亦明確要求解決 `<<<<<<<` 等標記),保留標記為設計核心;若在此偵測並失敗反而會破壞既定流程。" + }, + { + "location": "app/lib/opencode.js:40", + "role": "Rogue", + "original_finding": "頻繁寫入讀取 opencode.json 設定檔造成無謂的 I/O;建議透過參數或環境變數傳入配置。", + "reason": "summarize 每次 action 執行僅呼叫一次,並非「頻繁」;opencode 以 OPENCODE_CONFIG 指向設定檔為其官方配置介面,寫入單一小檔的 I/O 可忽略。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index dcc8b2c..38dd569 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -13,13 +13,6 @@ "problem": "detectConflict 執行 git 合併失敗後的 abort 嘗試若失敗,會導致工作區殘留錯誤狀態,且未經測試。", "suggestion": "增加測試案例模擬 git 合併失敗與 abort 失敗的場景,確保狀態正確復原。" }, - { - "level": "critical", - "role": "Assassin", - "location": "app/lib/opencode.js:180", - "problem": "AI 模型產生的 PR 描述未經 sanitization,易遭 Prompt Injection 導致 Stored XSS 攻擊。", - "suggestion": "在 `extractResult` 中對 `obj.description` 使用成熟的 HTML Sanitizer 過濾惡意標籤。" - }, { "level": "warning", "role": "Maya", @@ -28,27 +21,6 @@ "suggestion": "補上針對 truncateDiff 及 commitMessages 為空/null 時的測試案例。", "is_new": false }, - { - "level": "warning", - "role": "Leo", - "location": "app/lib/opencode.js:154", - "problem": "summarize 函式使用了 5 分鐘固定 timeout,大型 diff 可能導致分析失敗。", - "suggestion": "將 timeout 設定為可配置參數或根據 diff 大小動態計算。" - }, - { - "level": "warning", - "role": "Mage", - "location": "app/lib/opencode.js:127", - "problem": "`summarize` 方法中使用 `spawnSync` 執行指令,未處理退出訊號可能導致清理競態。", - "suggestion": "明確處理 `spawnSync` 的退出訊號,並確保清理操作是原子性的。" - }, - { - "level": "warning", - "role": "Maya", - "location": "app/lib/git.js:145", - "problem": "合併衝突後未檢查是否存在殘留衝突標記。", - "suggestion": "在 `git add` 之後,使用 grep 掃描檔案中是否仍有未處理的衝突標記。" - }, { "level": "info", "role": "Maya", @@ -71,33 +43,5 @@ "location": "app/lib/opencode.js:77", "problem": "傳遞整個 `process.env` 導致敏感環境變數暴露。", "suggestion": "明確篩選並只傳遞必要環境變數。" - }, - { - "level": "info", - "role": "Bard", - "location": "Dockerfile:16", - "problem": "在 RUN 指令中使用 cd 切換目錄,導致環境隱晦。", - "suggestion": "使用 `WORKDIR /app`。" - }, - { - "level": "info", - "role": "Leo", - "location": "app/lib/git.js:122", - "problem": "臨時分支名稱可能衝突或殘留。", - "suggestion": "產生臨時分支名稱時加入 process ID 或隨機字串,並在 finally 區塊清理。" - }, - { - "level": "info", - "role": "Maya", - "location": "app/index.js:77", - "problem": "解衝突的 PR 產出缺乏人工檢查機制。", - "suggestion": "加入「檢查清單(Checklist)」要求人工確認。" - }, - { - "level": "info", - "role": "Rogue", - "location": "app/lib/opencode.js:40", - "problem": "頻繁寫入讀取 `opencode.json` 設定檔造成無謂的 I/O。", - "suggestion": "若支援,透過參數或環境變數傳入配置。" } ] -- 2.53.0 From 74807491927287c91554d660f5737e40c9ffb8bb Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 06:10:55 +0000 Subject: [PATCH 15/24] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 80 +++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 38dd569..01f4c6a 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -2,32 +2,58 @@ { "level": "critical", "role": "Maya", - "location": "app/index.js:1, 52, 58", - "problem": "核心邏輯完全缺乏自動化測試。自動解衝突流程直接 commit 但缺乏對人工解衝突後正確性、以及對 build/test 結果的驗證,且 `createPull` 錯誤處理可能因 JSON 解析問題導致行為異常。", - "suggestion": "建立完整的單元與整合測試架構。在建立解衝突分支並合併後,執行專案的建置指令或測試指令,並增強對 API 回傳錯誤的解析與處理。" + "location": "app/index.js:1, 14, 52, 58", + "problem": "核心邏輯缺乏自動化測試。包含 main 入口點、自動解衝突流程、以及 `createPull` 錯誤處理與 fallback 機制均未受測試驗證,難以保證建置與合併的正確性。", + "suggestion": "建立完整的單元與整合測試架構。Mock `Git`、`GiteaClient` 與 `OpenCode` 類別,針對 main() 覆蓋成功路徑與合併衝突的失敗路徑,並增強對 API 回傳錯誤的解析與處理。" }, { "level": "critical", - "role": "Maya", - "location": "app/lib/git.js:106", - "problem": "detectConflict 執行 git 合併失敗後的 abort 嘗試若失敗,會導致工作區殘留錯誤狀態,且未經測試。", - "suggestion": "增加測試案例模擬 git 合併失敗與 abort 失敗的場景,確保狀態正確復原。" + "role": "Maya/Mage", + "location": "app/lib/git.js:93, 106", + "problem": "Git 衝突偵測與狀態復原邏輯缺乏測試。`detectConflict` 若行為預期外或 abort 失敗,將導致工作區殘留錯誤狀態,且未經測試驗證。", + "suggestion": "針對 `Git.detectConflict` 補寫單元測試(模擬衝突場景)。在 `finally` 區塊中,增加檢查分支是否存在再進行刪除,確保環境乾淨。" }, { "level": "warning", "role": "Maya", - "location": "app/index.js:114, 126", - "problem": "diff 截斷邏輯與 fallbackSummary 處理邊界情況缺乏測試。", - "suggestion": "補上針對 truncateDiff 及 commitMessages 為空/null 時的測試案例。", - "is_new": false + "location": "app/index.js:114, 126, 154", + "problem": "diff 截斷邏輯、fallbackSummary 邊界處理、以及分支名稱長度限制(Magic Number 180)缺乏測試或硬編碼,難以維護。", + "suggestion": "補上針對 truncateDiff 及 commitMessages 為空/null 時的測試案例。將長度限制抽離為具名常數(例如 `MAX_BRANCH_NAME_LENGTH`)。" }, { - "level": "info", + "level": "warning", + "role": "Assassin", + "location": "Dockerfile:11", + "problem": "Dockerfile 中使用全域 `npm install` 存在供應鏈風險。", + "suggestion": "使用 lockfile(如 `package-lock.json`)確保依賴版本一致性,並定期審查更新。" + }, + { + "level": "warning", + "role": "Bard", + "location": "app/lib/opencode.js:154", + "problem": "錯誤訊息處理中,對 `result.stderr` 進行截斷,可能導致切斷關鍵錯誤上下文,除錯困難。", + "suggestion": "改用 log.warn 輸出完整內容(遮蔽敏感資訊後),或將截斷訊息與「內容已截斷」提示並列。" + }, + { + "level": "warning", + "role": "Bard", + "location": "app/index.js:80", + "problem": "分支命名格式若目標分支名稱過長,可能導致總長度超過 Git 限制。", + "suggestion": "調整 `stem` 截斷長度,或加入總長度檢查機制,確保不超過 255 字元。" + }, + { + "level": "warning", + "role": "Mage", + "location": "app/index.js:52", + "problem": "opencode 失敗時,若相關資訊皆為空,fallback 機制產出的 PR 描述將空洞無效。", + "suggestion": "增加對 `fallbackSummary` 輸出內容的檢查。若資訊不足,應拋出錯誤或提供更有意義的預設說明。" + }, + { + "level": "warning", "role": "Maya", - "location": "app/index.js:150", - "problem": "缺乏測試案例驗證 fallbackSummary 的結果是否符合預期格式。", - "suggestion": "補上單元測試,驗證 fallbackSummary 在不同輸入下的產出格式。", - "is_new": false + "location": "app/lib/opencode.js:130, app/lib/gitea.js:56", + "problem": "fallback 機制與 Gitea API 狀態碼處理(422/409)缺乏測試,難以確保產出品質與正確行為。", + "suggestion": "補寫 OpenCode.summarize 與 GiteaClient.createPull 測試,Mock 不同情境回應,驗證系統邏輯。" }, { "level": "info", @@ -39,9 +65,23 @@ }, { "level": "info", - "role": "Assassin", - "location": "app/lib/opencode.js:77", - "problem": "傳遞整個 `process.env` 導致敏感環境變數暴露。", - "suggestion": "明確篩選並只傳遞必要環境變數。" + "role": "Bard", + "location": "app/lib/util.js:11, 13", + "problem": "Buffer 大小硬編碼,缺乏靈活性,且針對極端巨大輸入缺乏保護。", + "suggestion": "將 buffer 大小作為常數定義或透過環境變數傳入,並在呼叫 `run` 前增加對輸入內容的檢查。" + }, + { + "level": "info", + "role": "Bard", + "location": "app/index.js:176", + "problem": "PR 已存在時僅記錄 `log.info`,CI 流程中可能需要更明確的提示。", + "suggestion": "建議改用 `log.warn` 或新增 `log.notice` 等級,以更明確提示使用者「PR 已經存在」。" + }, + { + "level": "info", + "role": "Leo", + "location": "app/lib/git.js:32", + "problem": "Sensitive Token 處理耦合在 `Git` 類別中,且未驗證有效性。", + "suggestion": "將 Token 的遮蔽邏輯交由 `util.js` 處理,或在 `Git` 初始化時驗證其有效性。" } ] -- 2.53.0 From cde327bbe01224b7256de8b9f7fa24b451e34f44 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:14:30 +0800 Subject: [PATCH 16/24] =?UTF-8?q?fix(ai-pull-request):=20=E5=88=86?= =?UTF-8?q?=E6=94=AF=E9=95=B7=E5=BA=A6=E6=8A=BD=E5=85=B7=E5=90=8D=E5=B8=B8?= =?UTF-8?q?=E6=95=B8=E4=B8=A6=E6=A8=99=E7=A4=BA=20opencode=20stderr=20?= =?UTF-8?q?=E6=88=AA=E6=96=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/index.js | 6 +++++- app/lib/opencode.js | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/index.js b/app/index.js index bc05f69..164709d 100644 --- a/app/index.js +++ b/app/index.js @@ -138,13 +138,17 @@ function fallbackSummary({ source, target, commitMessages, diffStat }) { return { title, description }; } +// 解衝突分支主體最大長度;連同前綴 `resolve-conflict/` 與 runId 後綴, +// 總長度仍遠低於 Git 對 ref 名稱的限制(約 255)。 +const MAX_BRANCH_STEM_LENGTH = 180; + /** 解衝突分支名稱。 */ function buildResolveBranchName(target, source) { const runId = process.env.GITHUB_RUN_NUMBER || process.env.GITHUB_RUN_ID || ''; const safe = (s) => s.replace(/[^a-zA-Z0-9._/-]/g, '-'); const suffix = runId ? `-${runId}` : ''; // 截斷主體長度,避免 target/source 過長使分支名稱超出 Git 限制 - const stem = `${safe(target)}-into-${safe(source)}`.slice(0, 180); + const stem = `${safe(target)}-into-${safe(source)}`.slice(0, MAX_BRANCH_STEM_LENGTH); return `resolve-conflict/${stem}${suffix}`; } diff --git a/app/lib/opencode.js b/app/lib/opencode.js index 0d3870f..43f312e 100644 --- a/app/lib/opencode.js +++ b/app/lib/opencode.js @@ -92,7 +92,11 @@ export class OpenCode { ); if (result.status !== 0) { - log.warn(`opencode 執行失敗 (${result.status}):${maskSecrets(result.stderr).slice(0, 500)}`); + const stderr = maskSecrets(result.stderr); + const shown = stderr.length > 2000 + ? `${stderr.slice(0, 2000)}\n…(錯誤訊息過長,已截斷,僅顯示前 2000 字元)` + : stderr; + log.warn(`opencode 執行失敗 (${result.status}):${shown}`); return null; } -- 2.53.0 From 8f5c510add012a0edeb1068308ca398300fee3d9 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:14:30 +0800 Subject: [PATCH 17/24] =?UTF-8?q?chore(ai-review):=20=E6=9B=B4=E6=96=B0=20?= =?UTF-8?q?findings=20=E8=88=87=20exclusions=20=E8=A7=A3=E6=B1=BA=E7=8B=80?= =?UTF-8?q?=E6=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/ai-review/exclusions.json | 30 +++++++++++++++++++++ .gitea/ai-review/findings.json | 46 ++------------------------------ 2 files changed, 32 insertions(+), 44 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index e757b9d..18c0e17 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -82,5 +82,35 @@ "role": "Rogue", "original_finding": "頻繁寫入讀取 opencode.json 設定檔造成無謂的 I/O;建議透過參數或環境變數傳入配置。", "reason": "summarize 每次 action 執行僅呼叫一次,並非「頻繁」;opencode 以 OPENCODE_CONFIG 指向設定檔為其官方配置介面,寫入單一小檔的 I/O 可忽略。" + }, + { + "location": "app/index.js:80", + "role": "Bard", + "original_finding": "分支命名格式若目標分支名稱過長,可能導致總長度超過 Git 限制;建議確保不超過 255 字元。", + "reason": "buildResolveBranchName 已將主體截斷至 MAX_BRANCH_STEM_LENGTH(180),連同前綴 `resolve-conflict/`(17)與 runId 後綴,總長度約 207,遠低於 Git 的 255 上限,已滿足建議。" + }, + { + "location": "app/index.js:52", + "role": "Mage", + "original_finding": "opencode 失敗時,若相關資訊皆為空,fallback 機制產出的 PR 描述將空洞無效;建議檢查輸出內容或拋錯。", + "reason": "fallbackSummary 的 title 在無 commit 時退回 `Merge into `,description 恆包含固定結構標題(## 變更摘要、### Commits、### 變更檔案)與 `(無)` 佔位,不會產生空字串;PR 仍具基本可讀內容,非缺陷。" + }, + { + "location": "app/lib/util.js:11, 13", + "role": "Bard", + "original_finding": "run 函式 Buffer 大小硬編碼,缺乏靈活性,且針對極端巨大輸入缺乏保護。", + "reason": "maxBuffer(64MB)為刻意的上限保護而非預先配置;本 action 於即拋容器內執行,將其抽為環境變數只增配置面而無實益。等同已收錄的 util.js:14 排除(同一機制)。" + }, + { + "location": "app/index.js:176", + "role": "Bard", + "original_finding": "PR 已存在時僅記錄 log.info,CI 流程中可能需要更明確的提示;建議改用 log.warn 或 log.notice。", + "reason": "「PR 已存在」是冪等重跑下的正常且預期結果,log.info 語意正確;改為 warn 會在正常流程中產生誤導性警告雜訊,屬偏好而非缺陷。" + }, + { + "location": "app/lib/git.js:32", + "role": "Leo", + "original_finding": "Sensitive Token 處理耦合在 Git 類別中,且未驗證有效性;建議將遮蔽邏輯交由 util.js 或於初始化時驗證。", + "reason": "token 遮蔽邏輯已實作於 util.js 的 maskSecrets 並由 Git 類別重用(非重複實作);token 存在性已於 inputs.js 的 required('GITEA_TOKEN') 驗證。Git 持有 token 以組 http.extraheader 為必要,耦合度可接受。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 01f4c6a..1a7ff6f 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -17,8 +17,8 @@ "level": "warning", "role": "Maya", "location": "app/index.js:114, 126, 154", - "problem": "diff 截斷邏輯、fallbackSummary 邊界處理、以及分支名稱長度限制(Magic Number 180)缺乏測試或硬編碼,難以維護。", - "suggestion": "補上針對 truncateDiff 及 commitMessages 為空/null 時的測試案例。將長度限制抽離為具名常數(例如 `MAX_BRANCH_NAME_LENGTH`)。" + "problem": "diff 截斷邏輯、fallbackSummary 邊界處理缺乏測試。(分支名稱長度 Magic Number 已抽為具名常數 MAX_BRANCH_STEM_LENGTH。)", + "suggestion": "補上針對 truncateDiff 及 commitMessages 為空/null 時的測試案例。" }, { "level": "warning", @@ -27,27 +27,6 @@ "problem": "Dockerfile 中使用全域 `npm install` 存在供應鏈風險。", "suggestion": "使用 lockfile(如 `package-lock.json`)確保依賴版本一致性,並定期審查更新。" }, - { - "level": "warning", - "role": "Bard", - "location": "app/lib/opencode.js:154", - "problem": "錯誤訊息處理中,對 `result.stderr` 進行截斷,可能導致切斷關鍵錯誤上下文,除錯困難。", - "suggestion": "改用 log.warn 輸出完整內容(遮蔽敏感資訊後),或將截斷訊息與「內容已截斷」提示並列。" - }, - { - "level": "warning", - "role": "Bard", - "location": "app/index.js:80", - "problem": "分支命名格式若目標分支名稱過長,可能導致總長度超過 Git 限制。", - "suggestion": "調整 `stem` 截斷長度,或加入總長度檢查機制,確保不超過 255 字元。" - }, - { - "level": "warning", - "role": "Mage", - "location": "app/index.js:52", - "problem": "opencode 失敗時,若相關資訊皆為空,fallback 機制產出的 PR 描述將空洞無效。", - "suggestion": "增加對 `fallbackSummary` 輸出內容的檢查。若資訊不足,應拋出錯誤或提供更有意義的預設說明。" - }, { "level": "warning", "role": "Maya", @@ -62,26 +41,5 @@ "problem": "缺乏單元測試驗證 `extractResult` 對非標準 JSON 的解析能力。", "suggestion": "補上單元測試,驗證 extractResult 能否正確解析壞 JSON。", "is_new": false - }, - { - "level": "info", - "role": "Bard", - "location": "app/lib/util.js:11, 13", - "problem": "Buffer 大小硬編碼,缺乏靈活性,且針對極端巨大輸入缺乏保護。", - "suggestion": "將 buffer 大小作為常數定義或透過環境變數傳入,並在呼叫 `run` 前增加對輸入內容的檢查。" - }, - { - "level": "info", - "role": "Bard", - "location": "app/index.js:176", - "problem": "PR 已存在時僅記錄 `log.info`,CI 流程中可能需要更明確的提示。", - "suggestion": "建議改用 `log.warn` 或新增 `log.notice` 等級,以更明確提示使用者「PR 已經存在」。" - }, - { - "level": "info", - "role": "Leo", - "location": "app/lib/git.js:32", - "problem": "Sensitive Token 處理耦合在 `Git` 類別中,且未驗證有效性。", - "suggestion": "將 Token 的遮蔽邏輯交由 `util.js` 處理,或在 `Git` 初始化時驗證其有效性。" } ] -- 2.53.0 From 2f2a8095a97b35152af83c6cc3b4c2ce8f838993 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 06:15:15 +0000 Subject: [PATCH 18/24] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 47 ++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 1a7ff6f..5499c9a 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -2,37 +2,46 @@ { "level": "critical", "role": "Maya", - "location": "app/index.js:1, 14, 52, 58", - "problem": "核心邏輯缺乏自動化測試。包含 main 入口點、自動解衝突流程、以及 `createPull` 錯誤處理與 fallback 機制均未受測試驗證,難以保證建置與合併的正確性。", - "suggestion": "建立完整的單元與整合測試架構。Mock `Git`、`GiteaClient` 與 `OpenCode` 類別,針對 main() 覆蓋成功路徑與合併衝突的失敗路徑,並增強對 API 回傳錯誤的解析與處理。" + "location": "app/index.js:1, 14, 52, 58, 108, 132", + "problem": "核心功能(包括入口、解衝突流程、錯誤處理、diff 截斷、分支名稱產生)嚴重缺乏自動化測試,難以保證在極端或失敗情境下的正確性與穩定性。", + "suggestion": "建立完整的單元與整合測試架構。Mock 外部依賴(Git, Gitea, OpenCode),針對成功路徑、合併衝突、邊界條件(如極小長度)及錯誤路徑(如 API 失敗、參數異常)補全測試。" }, { "level": "critical", - "role": "Maya/Mage", - "location": "app/lib/git.js:93, 106", - "problem": "Git 衝突偵測與狀態復原邏輯缺乏測試。`detectConflict` 若行為預期外或 abort 失敗,將導致工作區殘留錯誤狀態,且未經測試驗證。", - "suggestion": "針對 `Git.detectConflict` 補寫單元測試(模擬衝突場景)。在 `finally` 區塊中,增加檢查分支是否存在再進行刪除,確保環境乾淨。" - }, - { - "level": "warning", - "role": "Maya", - "location": "app/index.js:114, 126, 154", - "problem": "diff 截斷邏輯、fallbackSummary 邊界處理缺乏測試。(分支名稱長度 Magic Number 已抽為具名常數 MAX_BRANCH_STEM_LENGTH。)", - "suggestion": "補上針對 truncateDiff 及 commitMessages 為空/null 時的測試案例。" + "role": "Mage/Assassin", + "location": "app/lib/git.js:17, 37, 93, 106", + "problem": "Git 操作存在嚴重安全性與穩定性隱患:命令列傳遞敏感 Token(竊取風險)、使用全域 Git 配置(污染環境/權限衝突)、衝突偵測邏輯缺乏測試且復原不健壯(工作區殘留狀態)。", + "suggestion": "改用 Git 憑證輔助工具或環境變數傳遞 Token;使用 --local 設定而非 --global;針對偵測與清理邏輯補寫單元測試,引入時間戳記/UUID 確保分支唯一性,並嚴謹處理資源清理(finally 區塊)。" }, { "level": "warning", "role": "Assassin", "location": "Dockerfile:11", "problem": "Dockerfile 中使用全域 `npm install` 存在供應鏈風險。", - "suggestion": "使用 lockfile(如 `package-lock.json`)確保依賴版本一致性,並定期審查更新。" + "suggestion": "使用 lockfile(如 `package-lock.json`)確保依賴版本一致性,並定期審查更新。", + "is_new": false }, { "level": "warning", - "role": "Maya", - "location": "app/lib/opencode.js:130, app/lib/gitea.js:56", - "problem": "fallback 機制與 Gitea API 狀態碼處理(422/409)缺乏測試,難以確保產出品質與正確行為。", - "suggestion": "補寫 OpenCode.summarize 與 GiteaClient.createPull 測試,Mock 不同情境回應,驗證系統邏輯。" + "role": "Maya/Mage/Leo", + "location": "app/index.js:52, 114, 126, 154, app/lib/opencode.js:141, app/lib/gitea.js:56, 84", + "problem": "fallback 機制、API 狀態碼處理(422/409)、diff 邊界處理與分支名稱生成邏輯缺乏測試,且在 fallback 資訊不足時產出的 PR 描述空洞無效。", + "suggestion": "補寫各模組的測試,Mock 不同 API 回應情境。增加對 fallback 輸出內容的檢查,確保分支名稱在極端情況下具唯一性,並優化錯誤處理邏輯。" + }, + { + "level": "warning", + "role": "Leo/Rogue", + "location": "app/lib/git.js:148, 84", + "problem": "Git 提交策略不精確(全域 add 可能帶入非預期變更)、頻繁執行完整 checkout/merge 導致高效能消耗與高 I/O 成本。", + "suggestion": "使用更精確的 add 策略或在 commit 前檢查狀態;改用 `git rev-list --count` 或 `git merge-tree` 進行衝突預檢,減少對檔案系統的寫入。" + }, + { + "level": "warning", + "role": "Bard", + "problem": "錯誤訊息處理中,對 `result.stderr` 進行截斷,可能導致切斷關鍵錯誤上下文,除錯困難。", + "suggestion": "改用 log.warn 輸出完整內容(遮蔽敏感資訊後),或將截斷訊息與「內容已截斷」提示並列。", + "location": "app/lib/opencode.js:154", + "is_new": false }, { "level": "info", -- 2.53.0 From 4045fc0073fa29618c5589e6e1b4e1db6641d7c8 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 15:16:25 +0800 Subject: [PATCH 19/24] =?UTF-8?q?chore(Dockerfile):=20=E9=87=98=E4=BD=8F?= =?UTF-8?q?=20opencode-ai=20=E7=89=88=E6=9C=AC=E8=87=B3=201.17.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 766dbfb..dfa0d9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,8 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* # 安裝 opencode CLI(用於分析 git diff 產生 PR 標題與描述) -RUN npm install -g opencode-ai +# 釘住版本以確保建置可重現並降低供應鏈風險 +RUN npm install -g opencode-ai@1.17.11 # 複製 Node.js 應用程式 COPY app/ /app/ -- 2.53.0 From ad54073690593e5cb141f079b91917c8529da89f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 15:16:25 +0800 Subject: [PATCH 20/24] =?UTF-8?q?chore(ai-review):=20=E8=87=AA=20findings?= =?UTF-8?q?=20=E7=A7=BB=E9=99=A4=E5=B7=B2=E8=A7=A3=E6=B1=BA=E7=9A=84=20Doc?= =?UTF-8?q?kerfile=20=E4=BE=9B=E6=87=89=E9=8F=88=E5=95=8F=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/ai-review/findings.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 5499c9a..f7a09dc 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -13,14 +13,6 @@ "problem": "Git 操作存在嚴重安全性與穩定性隱患:命令列傳遞敏感 Token(竊取風險)、使用全域 Git 配置(污染環境/權限衝突)、衝突偵測邏輯缺乏測試且復原不健壯(工作區殘留狀態)。", "suggestion": "改用 Git 憑證輔助工具或環境變數傳遞 Token;使用 --local 設定而非 --global;針對偵測與清理邏輯補寫單元測試,引入時間戳記/UUID 確保分支唯一性,並嚴謹處理資源清理(finally 區塊)。" }, - { - "level": "warning", - "role": "Assassin", - "location": "Dockerfile:11", - "problem": "Dockerfile 中使用全域 `npm install` 存在供應鏈風險。", - "suggestion": "使用 lockfile(如 `package-lock.json`)確保依賴版本一致性,並定期審查更新。", - "is_new": false - }, { "level": "warning", "role": "Maya/Mage/Leo", -- 2.53.0 From 20b0387b4d5c7abd61e36b35a6fb75982cfe3595 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 15:27:30 +0800 Subject: [PATCH 21/24] =?UTF-8?q?refactor(index):=20=E5=8C=AF=E5=87=BA?= =?UTF-8?q?=E7=B4=94=E5=87=BD=E5=BC=8F=E4=B8=A6=E5=83=85=E5=9C=A8=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E5=9F=B7=E8=A1=8C=E6=99=82=E8=B7=91=E4=B8=BB=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/index.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/app/index.js b/app/index.js index 164709d..227b12f 100644 --- a/app/index.js +++ b/app/index.js @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import { loadInputs, logInputs } from './lib/inputs.js'; import { Git } from './lib/git.js'; import { GiteaClient } from './lib/gitea.js'; @@ -191,9 +192,15 @@ function reportPull(pull, created) { } } -main().catch((err) => { - const detail = err?.stack || err?.message || String(err); - // 錯誤訊息/stack 可能夾帶 token,輸出到 CI 日誌前先遮蔽 - log.error(maskSecrets(detail, [process.env.GITEA_TOKEN])); - process.exit(1); -}); +// 純函式對外匯出,供測試使用(不觸發 main 流程) +export { truncateDiff, fallbackSummary, buildResolveBranchName, buildResolveBody }; + +// 僅在直接以 `node index.js` 執行時才跑主流程;被測試 import 時不執行 +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((err) => { + const detail = err?.stack || err?.message || String(err); + // 錯誤訊息/stack 可能夾帶 token,輸出到 CI 日誌前先遮蔽 + log.error(maskSecrets(detail, [process.env.GITEA_TOKEN])); + process.exit(1); + }); +} -- 2.53.0 From 264be4de307ca7b282cce27661112c87d7ebc615 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 15:27:30 +0800 Subject: [PATCH 22/24] =?UTF-8?q?test(app):=20=E6=96=B0=E5=A2=9E=20node:te?= =?UTF-8?q?st=20=E5=96=AE=E5=85=83=E8=88=87=E6=95=B4=E5=90=88=E6=B8=AC?= =?UTF-8?q?=E8=A9=A6=E4=B8=A6=E5=8A=A0=E5=85=A5=20test=20=E6=8C=87?= =?UTF-8?q?=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/index.test.js | 79 +++++++++++++++++++++++++++ app/lib/git.test.js | 114 +++++++++++++++++++++++++++++++++++++++ app/lib/gitea.test.js | 78 +++++++++++++++++++++++++++ app/lib/opencode.test.js | 41 ++++++++++++++ app/lib/util.test.js | 37 +++++++++++++ app/package.json | 3 +- 6 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 app/index.test.js create mode 100644 app/lib/git.test.js create mode 100644 app/lib/gitea.test.js create mode 100644 app/lib/opencode.test.js create mode 100644 app/lib/util.test.js diff --git a/app/index.test.js b/app/index.test.js new file mode 100644 index 0000000..e609483 --- /dev/null +++ b/app/index.test.js @@ -0,0 +1,79 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + truncateDiff, + fallbackSummary, + buildResolveBranchName, + buildResolveBody, +} from './index.js'; + +test('truncateDiff: 內容在上限內不截斷', () => { + const { diff, truncated } = truncateDiff('abc', 100); + assert.equal(diff, 'abc'); + assert.equal(truncated, false); +}); + +test('truncateDiff: null/空字串回傳空字串且不截斷', () => { + assert.deepEqual(truncateDiff(null, 100), { diff: '', truncated: false }); + assert.deepEqual(truncateDiff('', 100), { diff: '', truncated: false }); +}); + +test('truncateDiff: 超過上限時截斷並標示', () => { + const big = 'x'.repeat(50); + const { diff, truncated } = truncateDiff(big, 10); + assert.equal(truncated, true); + assert.ok(diff.startsWith('xxxxxxxxxx')); + assert.ok(diff.includes('已截斷')); +}); + +test('fallbackSummary: 取首個 commit 當標題', () => { + const s = fallbackSummary({ + source: 'feature', + target: 'develop', + commitMessages: '- feat: 新增功能\n- fix: 修正', + diffStat: ' a.js | 2 +-', + }); + assert.equal(s.title, 'feat: 新增功能'); + assert.ok(s.description.includes('## 變更摘要')); + assert.ok(s.description.includes('a.js')); +}); + +test('fallbackSummary: 無 commit 時退回 Merge 標題且描述非空', () => { + const s = fallbackSummary({ + source: 'feature', + target: 'develop', + commitMessages: '', + diffStat: '', + }); + assert.equal(s.title, 'Merge feature into develop'); + assert.ok(s.description.includes('(無)')); + assert.ok(s.description.length > 0); +}); + +test('buildResolveBranchName: 含前綴並淨化非法字元', () => { + const name = buildResolveBranchName('develop', 'feature/x y'); + assert.ok(name.startsWith('resolve-conflict/')); + assert.ok(name.includes('-into-')); + // 空白等非法字元被替換為 - + assert.ok(!/\s/.test(name)); +}); + +test('buildResolveBranchName: 過長 target/source 仍遠低於 255', () => { + const long = 'a'.repeat(500); + const name = buildResolveBranchName(long, long); + assert.ok(name.length < 255); +}); + +test('buildResolveBody: 含衝突檔案清單與人工檢查清單', () => { + const body = buildResolveBody({ + source: 'feature', + target: 'develop', + resolveBranch: 'resolve-conflict/develop-into-feature', + files: ['a.js', 'b.js'], + summary: { title: 't', description: '摘要內容' }, + }); + assert.ok(body.includes('- `a.js`')); + assert.ok(body.includes('- `b.js`')); + assert.ok(body.includes('合併前人工檢查清單')); + assert.ok(body.includes('摘要內容')); +}); diff --git a/app/lib/git.test.js b/app/lib/git.test.js new file mode 100644 index 0000000..4b5e127 --- /dev/null +++ b/app/lib/git.test.js @@ -0,0 +1,114 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { Git } from './git.js'; + +function g(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }); +} + +/** 建立 bare remote + working clone,於 target/source 製造會衝突的變更。 */ +function setupRepo() { + const root = mkdtempSync(join(tmpdir(), 'git-test-')); + const bare = join(root, 'remote.git'); + const work = join(root, 'work'); + execFileSync('git', ['init', '-q', '--bare', bare]); + execFileSync('git', ['clone', '-q', bare, work]); + g(work, ['config', 'user.email', 'test@example.com']); + g(work, ['config', 'user.name', 'tester']); + g(work, ['config', 'commit.gpgsign', 'false']); + + writeFileSync(join(work, 'file.txt'), 'base\n'); + g(work, ['add', '-A']); + g(work, ['commit', '-q', '-m', 'base']); + const def = g(work, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(); + + g(work, ['checkout', '-q', '-b', 'target']); + writeFileSync(join(work, 'file.txt'), 'target change\n'); + g(work, ['commit', '-qam', 'target change']); + + g(work, ['checkout', '-q', def]); + g(work, ['checkout', '-q', '-b', 'source']); + writeFileSync(join(work, 'file.txt'), 'source change\n'); + g(work, ['commit', '-qam', 'source change']); + + g(work, ['push', '-q', 'origin', 'target', 'source', def]); + return { root, bare, work }; +} + +test('detectConflict: 偵測到衝突並回傳衝突檔,事後還原暫存分支', () => { + const { root, bare, work } = setupRepo(); + try { + const git = new Git({ cwd: work, remoteUrl: bare, token: 'x' }); + git.configure(); + git.fetchBranches(['target', 'source']); + + assert.ok(git.countAheadCommits('target', 'source') >= 1); + + const det = git.detectConflict('target', 'source'); + assert.equal(det.hasConflict, true); + assert.ok(det.files.includes('file.txt')); + + // 暫存分支應已清除 + assert.ok(!g(work, ['branch']).includes('__conflict_check')); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('createResolveBranch: 推送含衝突標記的解衝突分支到 remote', () => { + const { root, bare, work } = setupRepo(); + try { + const git = new Git({ cwd: work, remoteUrl: bare, token: 'x' }); + git.configure(); + git.fetchBranches(['target', 'source']); + + const res = git.createResolveBranch({ target: 'target', source: 'source', resolveBranch: 'resolve-x' }); + assert.ok(res.files.includes('file.txt')); + + // remote 應有 resolve-x 分支 + assert.ok(execFileSync('git', ['--git-dir', bare, 'branch'], { encoding: 'utf8' }).includes('resolve-x')); + // 已 commit 的檔案應保留衝突標記 + assert.ok(readFileSync(join(work, 'file.txt'), 'utf8').includes('<<<<<<<')); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('detectConflict: 可順利合併時回報無衝突', () => { + const root = mkdtempSync(join(tmpdir(), 'git-test-')); + try { + const bare = join(root, 'remote.git'); + const work = join(root, 'work'); + execFileSync('git', ['init', '-q', '--bare', bare]); + execFileSync('git', ['clone', '-q', bare, work]); + g(work, ['config', 'user.email', 'test@example.com']); + g(work, ['config', 'user.name', 'tester']); + g(work, ['config', 'commit.gpgsign', 'false']); + + writeFileSync(join(work, 'a.txt'), 'base\n'); + g(work, ['add', '-A']); + g(work, ['commit', '-qm', 'base']); + const def = g(work, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(); + + g(work, ['checkout', '-q', '-b', 'target']); // target 不動 + g(work, ['checkout', '-q', def]); + g(work, ['checkout', '-q', '-b', 'source']); + writeFileSync(join(work, 'b.txt'), 'new file\n'); // 改不同檔,無衝突 + g(work, ['add', '-A']); + g(work, ['commit', '-qm', 'add b']); + g(work, ['push', '-q', 'origin', 'target', 'source', def]); + + const git = new Git({ cwd: work, remoteUrl: bare, token: 'x' }); + git.configure(); + git.fetchBranches(['target', 'source']); + const det = git.detectConflict('target', 'source'); + assert.equal(det.hasConflict, false); + assert.deepEqual(det.files, []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/app/lib/gitea.test.js b/app/lib/gitea.test.js new file mode 100644 index 0000000..9823e76 --- /dev/null +++ b/app/lib/gitea.test.js @@ -0,0 +1,78 @@ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { GiteaClient } from './gitea.js'; + +const realFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = realFetch; }); + +function makeClient() { + return new GiteaClient({ + serverUrl: 'https://gitea.example', + owner: 'o', + repo: 'r', + token: 't', + }); +} + +/** 建立假的 fetch,依序回傳給定的回應。 */ +function stubFetch(responses) { + const calls = []; + globalThis.fetch = async (url, opts) => { + calls.push({ url, opts }); + const r = responses.shift(); + return { + ok: r.status >= 200 && r.status < 300, + status: r.status, + text: async () => (r.body == null ? '' : JSON.stringify(r.body)), + }; + }; + return calls; +} + +test('createPull: 成功建立回傳 created=true', async () => { + stubFetch([{ status: 201, body: { number: 7, html_url: 'u' } }]); + const { pull, created } = await makeClient().createPull({ + head: 'feature', base: 'develop', title: 't', body: 'b', + }); + assert.equal(created, true); + assert.equal(pull.number, 7); +}); + +test('createPull: 422 已存在時回查既有 PR,created=false', async () => { + stubFetch([ + { status: 422, body: { message: 'already exists' } }, + { status: 200, body: [ + { number: 3, head: { ref: 'feature' }, base: { ref: 'develop' } }, + ] }, + ]); + const { pull, created } = await makeClient().createPull({ + head: 'feature', base: 'develop', title: 't', body: 'b', + }); + assert.equal(created, false); + assert.equal(pull.number, 3); +}); + +test('createPull: 422 但查無對應 PR 時丟出錯誤', async () => { + stubFetch([ + { status: 422, body: { message: 'bad' } }, + { status: 200, body: [] }, + ]); + await assert.rejects( + () => makeClient().createPull({ head: 'feature', base: 'develop', title: 't', body: 'b' }), + /建立 PR 失敗/, + ); +}); + +test('createPull: 其他錯誤狀態碼直接丟出', async () => { + stubFetch([{ status: 500, body: { message: '伺服器錯誤' } }]); + await assert.rejects( + () => makeClient().createPull({ head: 'feature', base: 'develop', title: 't', body: 'b' }), + /建立 PR 失敗 \(500\)/, + ); +}); + +test('findOpenPull: 非陣列回應回傳 null', async () => { + stubFetch([{ status: 200, body: { unexpected: true } }]); + const r = await makeClient().findOpenPull('feature', 'develop'); + assert.equal(r, null); +}); diff --git a/app/lib/opencode.test.js b/app/lib/opencode.test.js new file mode 100644 index 0000000..7ead244 --- /dev/null +++ b/app/lib/opencode.test.js @@ -0,0 +1,41 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { extractResult } from './opencode.js'; + +test('extractResult: 解析乾淨的 JSON', () => { + const r = extractResult('{"title":"標題","description":"描述"}'); + assert.deepEqual(r, { title: '標題', description: '描述' }); +}); + +test('extractResult: 忽略 JSON 前後的雜訊文字', () => { + const r = extractResult('以下是結果:\n{"title":"T","description":"D"}\n完成'); + assert.deepEqual(r, { title: 'T', description: 'D' }); +}); + +test('extractResult: 修正字串值內未跳脫的換行', () => { + // LLM 常輸出字串內含真實換行的無效 JSON + const r = extractResult('{"title":"T","description":"第一行\n第二行"}'); + assert.equal(r.title, 'T'); + assert.equal(r.description, '第一行\n第二行'); +}); + +test('extractResult: 去除 ANSI 控制碼後解析', () => { + const r = extractResult('\x1b[32m{"title":"T","description":"D"}\x1b[0m'); + assert.deepEqual(r, { title: 'T', description: 'D' }); +}); + +test('extractResult: 挑出第一個含 title 的物件', () => { + const r = extractResult('{"foo":1}\n{"title":"對的","description":"D"}'); + assert.equal(r.title, '對的'); +}); + +test('extractResult: 無有效物件回傳 null', () => { + assert.equal(extractResult('沒有任何 JSON'), null); + assert.equal(extractResult(''), null); + assert.equal(extractResult('{"description":"缺少 title"}'), null); +}); + +test('extractResult: description 缺漏時以空字串補上', () => { + const r = extractResult('{"title":"只有標題"}'); + assert.deepEqual(r, { title: '只有標題', description: '' }); +}); diff --git a/app/lib/util.test.js b/app/lib/util.test.js new file mode 100644 index 0000000..13d2bc2 --- /dev/null +++ b/app/lib/util.test.js @@ -0,0 +1,37 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { maskSecrets, run } from './util.js'; + +test('maskSecrets: 遮蔽出現的祕密字串', () => { + const out = maskSecrets('token is abcd1234 here', ['abcd1234']); + assert.equal(out, 'token is *** here'); +}); + +test('maskSecrets: 遮蔽 URL 內嵌的 token', () => { + const out = maskSecrets('https://oauth2:abcd1234@host/repo.git', ['abcd1234']); + assert.ok(!out.includes('abcd1234')); +}); + +test('maskSecrets: 太短(<4)的祕密不遮蔽以免誤傷', () => { + assert.equal(maskSecrets('abc here', ['abc']), 'abc here'); +}); + +test('maskSecrets: 多個祕密與空值都安全處理', () => { + const out = maskSecrets('aaaa bbbb', ['aaaa', '', undefined, 'bbbb']); + assert.equal(out, '*** ***'); +}); + +test('maskSecrets: 非字串輸入回傳空字串', () => { + assert.equal(maskSecrets(null), ''); +}); + +test('run: 非零結束碼不丟例外並回傳 status', () => { + const r = run('node', ['-e', 'process.exit(3)']); + assert.equal(r.status, 3); +}); + +test('run: 指令不存在時回傳 status=1 與錯誤訊息', () => { + const r = run('a-command-that-does-not-exist-xyz', []); + assert.equal(r.status, 1); + assert.ok(r.stderr.length > 0); +}); diff --git a/app/package.json b/app/package.json index 136121a..48c5e84 100644 --- a/app/package.json +++ b/app/package.json @@ -5,7 +5,8 @@ "type": "module", "main": "index.js", "scripts": { - "start": "node index.js" + "start": "node index.js", + "test": "node --test" }, "engines": { "node": ">=18" -- 2.53.0 From 6c0ed33ac5aede7de5ac32f5cff6bf1a998230ce Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 15:27:30 +0800 Subject: [PATCH 23/24] =?UTF-8?q?chore(ci):=20=E6=96=B0=E5=A2=9E=E5=96=AE?= =?UTF-8?q?=E5=85=83=E6=B8=AC=E8=A9=A6=20job=20=E4=B8=A6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=20AI=20review=20token=20=E5=8F=83=E6=95=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b9d4eed..4177dfd 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -5,6 +5,17 @@ on: - master types: [opened, synchronize] jobs: + test: + name: Unit Test + runs-on: ubuntu + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: 執行單元測試 + run: node --test + working-directory: app ai-code-review: name: AI Code Review runs-on: ubuntu @@ -16,4 +27,4 @@ jobs: - name: AI 程式碼審查 by OpenCode uses: https://gitea.jsc.idv.tw/composite-actions/opencode-code-review@${{ vars.ACTION_OPENCODE_CODE_REVIEW_VERSION }} with: - comment_token: ${{ secrets.COMMENT_TOKEN }} + token: ${{ secrets.TOKEN }} -- 2.53.0 From 1cd47171b43392a708a86bafa3ee519262163515 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 15:27:30 +0800 Subject: [PATCH 24/24] =?UTF-8?q?chore(ai-review):=20=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=E5=B7=B2=E8=A7=A3=E6=B1=BA=20findings=20=E4=B8=A6=E8=A3=9C?= =?UTF-8?q?=E7=99=BB=E8=AA=A4=E5=A0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/ai-review/exclusions.json | 12 ++++++++ .gitea/ai-review/findings.json | 47 +------------------------------- 2 files changed, 13 insertions(+), 46 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 18c0e17..bc5fc6a 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -112,5 +112,17 @@ "role": "Leo", "original_finding": "Sensitive Token 處理耦合在 Git 類別中,且未驗證有效性;建議將遮蔽邏輯交由 util.js 或於初始化時驗證。", "reason": "token 遮蔽邏輯已實作於 util.js 的 maskSecrets 並由 Git 類別重用(非重複實作);token 存在性已於 inputs.js 的 required('GITEA_TOKEN') 驗證。Git 持有 token 以組 http.extraheader 為必要,耦合度可接受。" + }, + { + "location": "app/lib/git.js:17, 37", + "role": "Mage/Assassin", + "original_finding": "Git 命令列以 -c http.extraheader 傳遞敏感 Token(竊取風險),並使用全域 git config(污染環境/權限衝突)。", + "reason": "token 經 `-c http.extraheader` 帶入雖會出現在 git 的 argv,但本 action 於單租戶、即拋的 CI 容器內執行,無其他使用者可讀 /proc,與已收錄的 gitea.js:28 同一信任模型。safe.directory 基於安全考量 git 刻意忽略 repo-local 設定,必須寫在 global(見已收錄的 git.js:39,52 排除),容器即拋無污染。detectConflict 的測試已於 app/lib/git.test.js 補上(整合測試)。" + }, + { + "location": "app/lib/git.js:148, 84", + "role": "Leo/Rogue", + "original_finding": "Git 提交策略不精確(git add -A 可能帶入非預期變更),且頻繁執行完整 checkout/merge 造成高 I/O;建議改用 merge-tree。", + "reason": "createResolveBranch 的 `git add -A` 為刻意保留完整合併結果(含衝突標記)供人工於 PR 解決,已有整合測試(app/lib/git.test.js)驗證行為;於即拋容器、單次執行下,checkout/merge 的 I/O 成本可忽略,改用 merge-tree 屬選用最佳化而非缺陷,現行 detectConflict 行為正確且已測。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index f7a09dc..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,46 +1 @@ -[ - { - "level": "critical", - "role": "Maya", - "location": "app/index.js:1, 14, 52, 58, 108, 132", - "problem": "核心功能(包括入口、解衝突流程、錯誤處理、diff 截斷、分支名稱產生)嚴重缺乏自動化測試,難以保證在極端或失敗情境下的正確性與穩定性。", - "suggestion": "建立完整的單元與整合測試架構。Mock 外部依賴(Git, Gitea, OpenCode),針對成功路徑、合併衝突、邊界條件(如極小長度)及錯誤路徑(如 API 失敗、參數異常)補全測試。" - }, - { - "level": "critical", - "role": "Mage/Assassin", - "location": "app/lib/git.js:17, 37, 93, 106", - "problem": "Git 操作存在嚴重安全性與穩定性隱患:命令列傳遞敏感 Token(竊取風險)、使用全域 Git 配置(污染環境/權限衝突)、衝突偵測邏輯缺乏測試且復原不健壯(工作區殘留狀態)。", - "suggestion": "改用 Git 憑證輔助工具或環境變數傳遞 Token;使用 --local 設定而非 --global;針對偵測與清理邏輯補寫單元測試,引入時間戳記/UUID 確保分支唯一性,並嚴謹處理資源清理(finally 區塊)。" - }, - { - "level": "warning", - "role": "Maya/Mage/Leo", - "location": "app/index.js:52, 114, 126, 154, app/lib/opencode.js:141, app/lib/gitea.js:56, 84", - "problem": "fallback 機制、API 狀態碼處理(422/409)、diff 邊界處理與分支名稱生成邏輯缺乏測試,且在 fallback 資訊不足時產出的 PR 描述空洞無效。", - "suggestion": "補寫各模組的測試,Mock 不同 API 回應情境。增加對 fallback 輸出內容的檢查,確保分支名稱在極端情況下具唯一性,並優化錯誤處理邏輯。" - }, - { - "level": "warning", - "role": "Leo/Rogue", - "location": "app/lib/git.js:148, 84", - "problem": "Git 提交策略不精確(全域 add 可能帶入非預期變更)、頻繁執行完整 checkout/merge 導致高效能消耗與高 I/O 成本。", - "suggestion": "使用更精確的 add 策略或在 commit 前檢查狀態;改用 `git rev-list --count` 或 `git merge-tree` 進行衝突預檢,減少對檔案系統的寫入。" - }, - { - "level": "warning", - "role": "Bard", - "problem": "錯誤訊息處理中,對 `result.stderr` 進行截斷,可能導致切斷關鍵錯誤上下文,除錯困難。", - "suggestion": "改用 log.warn 輸出完整內容(遮蔽敏感資訊後),或將截斷訊息與「內容已截斷」提示並列。", - "location": "app/lib/opencode.js:154", - "is_new": false - }, - { - "level": "info", - "role": "Maya", - "location": "app/lib/opencode.js:176", - "problem": "缺乏單元測試驗證 `extractResult` 對非標準 JSON 的解析能力。", - "suggestion": "補上單元測試,驗證 extractResult 能否正確解析壞 JSON。", - "is_new": false - } -] +[] -- 2.53.0