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