feat: AI Pull Request action — 以 opencode 自動產生並建立 PR #1
@@ -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 }}
|
|
||||||
@@ -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 }}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"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 屬大規模架構重構,牽涉設計取捨。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "屬主觀重構偏好而非缺陷;程式已分層為 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 起)就終止,與建議行為一致,屬誤報。"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"level": "critical",
|
||||||
|
"role": "Maya",
|
||||||
|
"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 失敗的場景,確保狀態正確復原。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 時的測試案例。",
|
||||||
|
"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 在不同輸入下的產出格式。",
|
||||||
|
"is_new": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "info",
|
||||||
|
"role": "Maya",
|
||||||
|
"location": "app/lib/opencode.js:176",
|
||||||
|
"problem": "缺乏單元測試驗證 `extractResult` 對非標準 JSON 的解析能力。",
|
||||||
|
"suggestion": "補上單元測試,驗證 extractResult 能否正確解析壞 JSON。",
|
||||||
|
"is_new": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "info",
|
||||||
|
"role": "Assassin",
|
||||||
|
"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": "若支援,透過參數或環境變數傳入配置。"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -1,22 +1,31 @@
|
|||||||
name: 'Docker Action Template'
|
name: 'AI Pull Request'
|
||||||
description: 'Docker Action 範本'
|
description: '使用 opencode 分析 git diff 產生 PR 標題與描述,並透過 Gitea token 建立 Pull Request;遇衝突時自動建立解衝突分支'
|
||||||
author: 'Jeffery'
|
author: 'Jeffery'
|
||||||
inputs:
|
inputs:
|
||||||
gitea_token:
|
source_branch:
|
||||||
description: 'Gitea Token'
|
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
|
required: true
|
||||||
text:
|
|
||||||
description: '輸入的文字'
|
|
||||||
required: false
|
|
||||||
default: 'Hello, World!'
|
|
||||||
outputs:
|
|
||||||
text:
|
|
||||||
description: '輸出的文字'
|
|
||||||
runs:
|
runs:
|
||||||
using: 'docker'
|
using: 'docker'
|
||||||
image: 'Dockerfile'
|
image: 'Dockerfile'
|
||||||
env:
|
env:
|
||||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || inputs.gitea_token }}
|
GITEA_TOKEN: ${{ gitea.token }}
|
||||||
TEXT: ${{ inputs.text }}
|
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 }}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
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() {
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Leo
**問題**:函式 `main()` 承擔過多責任,違反單一職責原則。
**建議**:將職責拆解,抽離衝突處理邏輯為 `ConflictManager`,並封裝 Gitea API 互動。
|
|||||||
|
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);
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Rogue
**問題**:在 `ahead` 為 0 時,仍執行昂貴的 diff 採集與分析。
**建議**:先執行 `countAheadCommits`,若 `ahead === 0` 則直接終止。
|
|||||||
|
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,
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Maya
**問題**:在偵測到衝突並建立解衝突分支後,程式雖然嘗試透過 `git.createResolveBranch` 建立並 commit 衝突檔案,但後續缺乏邏輯處理衝突檔案,亦無測試驗證「自動解衝突分支是否真的被建立」以及「提交的內容是否正確」。
**建議**:應補上整合測試,模擬合併衝突,驗證 `detectConflict` 能偵測衝突,且 `createResolveBranch` 產生的分支確實包含預期的衝突檔案與 commit。
|
|||||||
|
});
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Maya
**問題**:解衝突的 PR 產出缺乏人工檢查機制。
**建議**:加入「檢查清單(Checklist)」要求人工確認。
|
|||||||
|
}
|
||||||
|
|
||||||
|
// 5b. 有衝突:從目標分支建立解衝突分支,合併來源分支後 PR 回來源分支
|
||||||
|
log.warn(`偵測到衝突檔案 (${files.length}): ${files.join(', ')}`);
|
||||||
|
const resolveBranch = buildResolveBranchName(inputs.targetBranch, inputs.sourceBranch);
|
||||||
|
|
||||||
|
log.step('建立解衝突分支並合併來源分支');
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Mage
**問題**:在 `buildResolveBranchName` 中,處理衝突分支名稱時,雖然使用了 `safe` 函數替換特殊字元,但如果分支名稱過長,加上 `suffix`(runId)可能導致分支名稱過長而超出 Git 對 branch 名稱長度的極限(雖然通常很大,但這是不必要的風險)。
**建議**:建議對 `resolveBranch` 的總長度進行截斷,確保其不會超過 Git 的建議長度限制。
|
|||||||
|
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}` : '';
|
||||||
|
// 截斷主體長度,避免 target/source 過長使分支名稱超出 Git 限制
|
||||||
|
const stem = `${safe(target)}-into-${safe(source)}`.slice(0, 180);
|
||||||
|
return `resolve-conflict/${stem}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Maya
**問題**:對於 `fallbackSummary` 函數,當 opencode 產生摘要失敗時會觸發,但目前缺乏測試案例驗證在各種輸入下,fallback 的結果是否符合預期格式。
**建議**:補上單元測試,驗證 `fallbackSummary` 在不同輸入下(如為空、多行訊息、stat 為空)產生的標題與描述格式是否正確。
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Maya
**問題**:缺乏測試案例驗證 fallbackSummary 的結果是否符合預期格式。
**建議**:補上單元測試,驗證 fallbackSummary 在不同輸入下的產出格式。
|
|||||||
|
/** 解衝突 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}`);
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Assassin
**問題**:在 `main` 函數的 catch 區塊中,直接將 `err.stack` 輸出到標準錯誤流(log.error)。如果錯誤物件中包含了敏感資訊(如 token、API 參數),這些機密將被寫入到 CI/CD 的執行日誌中,極易洩漏。
**建議**:在輸出 `err.stack` 前,必須使用類似 `maskSecrets` 的函式,過濾掉所有可能的機密資訊。
|
|||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Assassin
**問題**:錯誤處理中的 `maskSecrets` 基於字串取代,可能無法處理所有 Token 變體導致敏感資訊洩漏。
**建議**:確保 `maskSecrets` 處理所有可能的變體,並在生產環境中禁止輸出原始錯誤物件。
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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 <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) {
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Assassin
**問題**:Git 的 `--no-tags` 參數在某些舊版 git 可能無法完全防禦標籤帶來的惡意遠端物件下載。雖然 `fetchBranches` 限制了 refspec,但使用不可信的 `remoteUrl` 進行 fetch 操作時,仍存在與 git 協定漏洞相關的風險。
**建議**:建議確保容器內的 git 版本為最新,並考慮在 fetch 前驗證 `remoteUrl` 是否為預期的 Gitea 網域,而非任意使用者輸入的網址。
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Rogue
**問題**:在 `getCommitMessages` 中使用 `git log --max-count=50`,若專案歷史悠久,此數量可能不足以產生精確的 AI 摘要,且若取得數量過多則浪費處理資源。
**建議**:評估實際使用場景調整 `limit`,或改用時間區間(例如 `--since`)來抓取相關變更。
|
|||||||
|
/** 取得 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 是否會衝突(不會留下任何變更)。
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Rogue
**問題**:`detectConflict` 函式透過 `git merge --no-commit --no-ff` 進行完整合併測試,極其耗時且佔用大量磁碟空間。
**建議**:考慮改用 `git merge-tree` (Git 2.29+) 檢查衝突,在不觸碰工作區的情況下快速檢測。
|
|||||||
|
*
|
||||||
|
* @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 = [];
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Maya
**問題**:detectConflict 函式在執行 git 合併失敗後的 abort 嘗試若失敗,會導致工作區殘留錯誤狀態,且未經測試驗證。
**建議**:增加測試案例來模擬 git 合併失敗與 abort 失敗的場景,確保狀態正確復原。
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Maya
**問題**:detectConflict 執行 git 合併失敗後的 abort 嘗試若失敗,會導致工作區殘留錯誤狀態,且未經測試。
**建議**:增加測試案例模擬 git 合併失敗與 abort 失敗的場景,確保狀態正確復原。
|
|||||||
|
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Leo
**問題**:detectConflict 使用固定名稱的暫存分支(__conflict_check_${target}),若程式意外中斷可能導致分支殘留,下次執行可能引發命名衝突或狀態異常。
**建議**:建議在分支名稱中加入隨機字串(如 uuid 或時間戳),並確保在 finally 區塊中有強制清理該分支的機制。
|
|||||||
|
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),
|
||||||
|
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Leo
**問題**:臨時分支名稱可能衝突或殘留。
**建議**:產生臨時分支名稱時加入 process ID 或隨機字串,並在 finally 區塊清理。
|
|||||||
|
* 再推送到遠端。
|
||||||
|
*
|
||||||
|
* @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,
|
||||||
|
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Maya
**問題**:合併衝突後未檢查是否存在殘留衝突標記。
**建議**:在 `git add` 之後,使用 grep 掃描檔案中是否仍有未處理的衝突標記。
|
|||||||
|
// 讓開發者可以在 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 },
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Mage
**問題**:在 `createResolveBranch` 中,執行 `git add -A` 與 `git commit` 會強制提交包括未追蹤檔案在內的所有變更,可能污染分支且掩蓋衝突內容,此流程亦缺乏測試。
**建議**:僅針對衝突檔案(`--diff-filter=U`)執行 `git add`,並補上整合測試,驗證模擬衝突時,產生的分支確實包含正確的衝突標記與檔案。
|
|||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`推送解衝突分支 ${resolveBranch}`);
|
||||||
|
this._authGit(['push', '--force', this.remoteUrl, `HEAD:refs/heads/${resolveBranch}`]);
|
||||||
|
|
||||||
|
return { files };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Assassin
**問題**:Gitea API 請求在 headers 中直接放入了 `this.token`。雖然這在正常情況下是必要的,但如果 `this.token` 來源於不可信的輸入且未經嚴格驗證,這將導致 token 洩漏風險(透過請求日誌或中間人攻擊)。
**建議**:在 `GiteaClient` 的所有請求方法中增加對 token 的處理,並確保在任何可能將請求細節(包含 headers)輸出到日誌的邏輯中,必須將 token 遮蔽。
|
|||||||
|
},
|
||||||
|
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(
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Mage
**問題**:1. `findOpenPull` 方法僅撈取前 50 個 PR,若數量眾多可能導致重複建立。2. `_request` 進行 `GET` 操作時,未對 API 回傳的 `json` 內容結構進行嚴格合法性驗證,可能導致執行時錯誤。
**建議**:1. 實作分頁(pagination)機制確保完整性。2. 在確保 HTTP 狀態碼為 200 後,強化對 `json` 的防禦性檢測(如檢查是否為 undefined 或預期陣列)。
|
|||||||
|
'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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 || '(未設定)'}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
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() {
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Leo
**問題**:1. `_writeConfig` 使用 mkdtempSync 建立暫存目錄後未清理,造成空間堆積。2. `summarize` 函式重複建立零散設定檔,增加 I/O 與清理負擔。
**建議**:在程式執行完畢後的 finally 區塊中,統一實作檔案系統清理邏輯(如 fs.rmSync)以刪除暫存目錄與檔案。同時考慮設定檔重用性,減少頻繁的檔案操作。
|
|||||||
|
const dir = mkdtempSync(join(tmpdir(), 'opencode-'));
|
||||||
|
const options = { baseURL: this.baseUrl };
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
$schema: 'https://opencode.ai/config.json',
|
||||||
|
provider: {
|
||||||
|
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Rogue
**問題**:頻繁寫入讀取 `opencode.json` 設定檔造成無謂的 I/O。
**建議**:若支援,透過參數或環境變數傳入配置。
|
|||||||
|
[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 {
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Assassin
**問題**:呼叫外部指令時傳遞整個 `process.env`,導致敏感環境變數暴露。
**建議**:應明確篩選並只傳遞必要環境變數。
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Assassin
**問題**:傳遞整個 `process.env` 導致敏感環境變數暴露。
**建議**:明確篩選並只傳遞必要環境變數。
|
|||||||
|
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) {
|
||||||
|
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;
|
||||||
|
} finally {
|
||||||
|
// 清理 _writeConfig 建立的暫存設定目錄,避免堆積
|
||||||
|
Ghost marked this conversation as resolved
Outdated
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Mage
**問題**:在 `summarize` 方法中將 `HOME` 環境變數硬編碼為 `/root`,若 Dockerfile 變更使用者,將導致無法寫入設定檔。
**建議**:建議動態獲取當前環境的使用者家目錄(如使用 `os.homedir()`),增加相容性。
|
|||||||
|
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) ===`,
|
||||||
|
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Mage
**問題**:`summarize` 方法中使用 `spawnSync` 執行指令,未處理退出訊號可能導致清理競態。
**建議**:明確處理 `spawnSync` 的退出訊號,並確保清理操作是原子性的。
|
|||||||
|
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 {
|
||||||
|
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Leo
**問題**:summarize 函式使用了 5 分鐘固定 timeout,大型 diff 可能導致分析失敗。
**建議**:將 timeout 設定為可配置參數或根據 diff 大小動態計算。
|
|||||||
|
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];
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Maya
**問題**:`extractResult` 函數處理 JSON 解析與清理邏輯,雖然複雜,但目前沒有單元測試驗證其對「LLM 容易輸出的各種非標準 JSON」的處理能力(例如字串內含未跳脫換行)。
**建議**:補上單元測試,提供幾種 LLM 常見的「壞」JSON 格式,驗證 `extractResult` 能否正確解析出 `title` 與 `description`。
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Maya
**問題**:缺乏單元測試驗證 `extractResult` 對非標準 JSON 的解析能力。
**建議**:補上單元測試,驗證 extractResult 能否正確解析壞 JSON。
|
|||||||
|
if (inString) {
|
||||||
|
if (escape) {
|
||||||
|
out += ch;
|
||||||
|
escape = false;
|
||||||
|
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Assassin
**問題**:AI 模型產生的 PR 描述未經 sanitization,易遭 Prompt Injection 導致 Stored XSS 攻擊。
**建議**:在 `extractResult` 中對 `obj.description` 使用成熟的 HTML Sanitizer 過濾惡意標籤。
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 被截斷
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Rogue
**問題**:`run` 函式設定 `maxBuffer: 64 * 1024 * 1024` (64MB)。雖然避免了截斷,但如果 `git diff` 內容極大,這會一次性將大量文字讀入記憶體,極易引發記憶體不足 (OOM) 或過高的 GC 壓力。
**建議**:改用 stream 方式讀取 `git` 指令輸出,而非一次性載入 buffer。
|
|||||||
|
...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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
#!/bin/bash
|
#!/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"
|
# Node.js 應用程式進入點
|
||||||
|
exec node /app/index.js
|
||||||
echo "Gitea Token: $GITEA_TOKEN"
|
|
||||||
|
|
||||||
echo "Text: $TEXT"
|
|
||||||
|
|
||||||
echo "text=$TEXT" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|||||||
嚴重等級:🔵 建議
審查員:Bard
問題:在 RUN 指令中使用 cd 切換目錄,導致環境隱晦。
建議:使用
WORKDIR /app。