Author SHA1 Message Date
Jeffery 1cd47171b4 chore(ai-review): 清空已解決 findings 並補登誤報
CI / Unit Test (pull_request) Failing after 14m6s
CI / AI Code Review (pull_request) Failing after 30m59s
2026-06-26 15:27:30 +08:00
Jeffery 6c0ed33ac5 chore(ci): 新增單元測試 job 並更新 AI review token 參數 2026-06-26 15:27:30 +08:00
Jeffery 264be4de30 test(app): 新增 node:test 單元與整合測試並加入 test 指令 2026-06-26 15:27:30 +08:00
Jeffery 20b0387b4d refactor(index): 匯出純函式並僅在直接執行時跑主流程 2026-06-26 15:27:30 +08:00
Jeffery ad54073690 chore(ai-review): 自 findings 移除已解決的 Dockerfile 供應鏈問題
CI / AI Code Review (pull_request) Failing after 2s
2026-06-26 15:17:46 +08:00
Jeffery 4045fc0073 chore(Dockerfile): 釘住 opencode-ai 版本至 1.17.11 2026-06-26 15:16:54 +08:00
AI Review Bot 2f2a8095a9 chore: update ai-review findings [ai-review-bot][failure] 2026-06-26 06:15:15 +00:00
Jeffery 8f5c510add chore(ai-review): 更新 findings 與 exclusions 解決狀態
CI / AI Code Review (pull_request) Failing after 39s
2026-06-26 14:14:30 +08:00
Jeffery cde327bbe0 fix(ai-pull-request): 分支長度抽具名常數並標示 opencode stderr 截斷 2026-06-26 14:14:30 +08:00
AI Review Bot 7480749192 chore: update ai-review findings [ai-review-bot][failure] 2026-06-26 06:10:55 +00:00
Jeffery 84a50e4987 chore(ai-review): 更新 findings 與 exclusions 解決狀態
CI / AI Code Review (pull_request) Failing after 48s
2026-06-26 14:09:13 +08:00
Jeffery 21b75caac9 refactor(Dockerfile): 改用 WORKDIR 取代 RUN 內 cd 2026-06-26 14:09:13 +08:00
Jeffery f39f58afb6 feat(解衝突 PR): PR 內文加入合併前人工檢查清單 2026-06-26 14:09:13 +08:00
Jeffery f7e6f1c64e fix(git): 衝突偵測暫存分支加 PID 並以 finally 確保清理 2026-06-26 14:09:13 +08:00
AI Review Bot 32153b7bd5 chore: update ai-review findings [ai-review-bot][failure] 2026-06-26 06:04:34 +00:00
Jeffery af4bb899e6 chore(ai-review): 更新 findings 與 exclusions 解決狀態
CI / AI Code Review (pull_request) Failing after 40s
2026-06-26 14:03:48 +08:00
AI Review Bot 5e0dc865b2 chore: update ai-review findings [ai-review-bot][failure] 2026-06-26 05:59:38 +00:00
Jeffery fb47575860 chore(ai-review): 更新 findings 與 exclusions 解決狀態
CI / AI Code Review (pull_request) Failing after 43s
2026-06-26 13:58:46 +08:00
Jeffery f09199f2cf chore(workflows): 移除路徑含空白的錯誤 workflow 檔 2026-06-26 13:58:41 +08:00
Jeffery 73c53e11de fix(ai-pull-request): 遮蔽錯誤訊息 token、清理暫存設定、截斷分支名稱並改用 homedir 2026-06-26 13:58:37 +08:00
AI Review Bot 2f26953112 chore: update ai-review findings [ai-review-bot][failure] 2026-06-26 04:17:41 +00:00
Jeffery c6823fa0a7 fix: ci&cd
CI / AI Code Review (pull_request) Failing after 41s
2026-06-26 12:16:55 +08:00
Jeffery 77ef69de79 docs(README): 新增 action 功能、參數與使用範例說明 2026-06-26 11:42:12 +08:00
Jeffery d42ccd8b08 feat(ai-pull-request): 以 opencode 分析 diff 自動產生並建立 Pull Request 2026-06-26 11:42:05 +08:00
22 changed files with 1517 additions and 61 deletions
-17
View File
@@ -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 }}
-17
View File
@@ -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 }}
+128
View File
@@ -0,0 +1,128 @@
[
{
"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-slimgit 版本為近期版本。"
},
{
"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 設為 64MBgit 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:<token>@`),實際洩漏向量(http.extraheader 帶入的原始 token)已被遮蔽。URL 編碼/base64 變體不會出現在本專案的錯誤路徑;完全禁止輸出 err.stack 會嚴重損及 CI 除錯能力,取捨上以遮蔽 token 為宜。"
},
{
"location": "app/index.js:7",
"role": "Leo",
"original_finding": "函式 main() 承擔過多責任,違反單一職責原則,建議抽離 ConflictManager 並封裝 Gitea API 互動。",
"reason": "屬主觀重構偏好而非缺陷;程式已分層為 GitGiteaClientOpenCode 三個職責清楚的類別,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 起)就終止,與建議行為一致,屬誤報。"
},
{
"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 bodyHTML 的消毒由 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 以 maxDiffChars60000 字元)截斷,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 可忽略。"
},
{
"location": "app/index.js:80",
"role": "Bard",
"original_finding": "分支命名格式若目標分支名稱過長,可能導致總長度超過 Git 限制;建議確保不超過 255 字元。",
"reason": "buildResolveBranchName 已將主體截斷至 MAX_BRANCH_STEM_LENGTH180),連同前綴 `resolve-conflict/`17)與 runId 後綴,總長度約 207,遠低於 Git 的 255 上限,已滿足建議。"
},
{
"location": "app/index.js:52",
"role": "Mage",
"original_finding": "opencode 失敗時,若相關資訊皆為空,fallback 機制產出的 PR 描述將空洞無效;建議檢查輸出內容或拋錯。",
"reason": "fallbackSummary 的 title 在無 commit 時退回 `Merge <source> into <target>`description 恆包含固定結構標題(## 變更摘要、### Commits、### 變更檔案)與 `(無)` 佔位,不會產生空字串;PR 仍具基本可讀內容,非缺陷。"
},
{
"location": "app/lib/util.js:11, 13",
"role": "Bard",
"original_finding": "run 函式 Buffer 大小硬編碼,缺乏靈活性,且針對極端巨大輸入缺乏保護。",
"reason": "maxBuffer64MB)為刻意的上限保護而非預先配置;本 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 為必要,耦合度可接受。"
},
{
"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 行為正確且已測。"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+12
View File
@@ -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 }}
+30
View File
@@ -0,0 +1,30 @@
name: CI
on:
pull_request:
branches-ignore:
- 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
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:
token: ${{ secrets.TOKEN }}
+18 -6
View File
@@ -1,10 +1,22 @@
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@1.17.11
# 複製 Node.js 應用程式
COPY app/ /app/
WORKDIR /app
# 應用程式無第三方相依套件,僅在有 package-lock 時安裝
RUN if [ -f package-lock.json ]; then 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"]
ENTRYPOINT ["/entrypoint.sh"]
+74
View File
@@ -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 URLOpenAI 相容端點) |
| `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
```
+22 -13
View File
@@ -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 URLOpenAI 相容端點)'
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 }}
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 }}
+206
View File
@@ -0,0 +1,206 @@
import { fileURLToPath } from 'node:url';
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, maskSecrets } 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 };
}
// 解衝突分支主體最大長度;連同前綴 `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, MAX_BRANCH_STEM_LENGTH);
return `resolve-conflict/${stem}${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 流程)
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);
});
}
+79
View File
@@ -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('摘要內容'));
});
+166
View File
@@ -0,0 +1,166 @@
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 <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/<branch>。
*
* @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() : '';
}
/** 取得完整 diffthree-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。
// 名稱帶 PID,避免並行或前次殘留造成命名衝突。
const tmp = `__conflict_check_${target}_${process.pid}`;
this._git(['checkout', '-B', tmp, `refs/remotes/pr/${target}`]);
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);
}
return { hasConflict, files };
} finally {
// 無論成敗都還原工作區並清除暫存分支
this._git(['merge', '--abort']);
this._git(['checkout', '--detach']);
this._git(['branch', '-D', tmp]);
}
}
/**
* 建立解衝突分支:以 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 };
}
}
+114
View File
@@ -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 });
}
});
+98
View File
@@ -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<object|null>}
*/
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}`);
}
}
+78
View File
@@ -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 已存在時回查既有 PRcreated=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);
});
+81
View File
@@ -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 || '(未設定)'}`);
}
+239
View File
@@ -0,0 +1,239 @@
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';
/**
* 透過 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 });
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,
},
);
if (result.status !== 0) {
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;
}
const parsed = extractResult(result.stdout);
if (!parsed) {
log.warn('無法從 opencode 輸出解析出標題/描述');
return null;
}
return parsed;
} finally {
// 清理 _writeConfig 建立的暫存設定目錄,避免堆積
rmSync(dirname(configPath), { recursive: true, force: true });
}
}
}
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;
}
+41
View File
@@ -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: '' });
});
+72
View File
@@ -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;
}
+37
View File
@@ -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);
});
+15
View File
@@ -0,0 +1,15 @@
{
"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",
"test": "node --test"
},
"engines": {
"node": ">=18"
},
"license": "MIT"
}
+6 -8
View File
@@ -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"
# Node.js 應用程式進入點
exec node /app/index.js