From 1324f1575d2bad2b02134996a84788def04b6991 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 11 May 2026 07:23:06 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E9=9A=8E=E6=AE=B5=E4=B8=80=20-=20?= =?UTF-8?q?=E5=9F=BA=E6=9C=AC=E6=B5=81=E7=A8=8B=E4=B8=B2=E6=8E=A5=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重寫 action.yaml:支援所有 LLM providers 的 inputs - 重寫 Dockerfile:python:3.11-slim + git - 重寫 entrypoint.sh:啟動 app/main.py - app/config.py:環境變數與 LLM 自動偵測 - app/llm.py:OpenAI-compatible 統一介面 - app/gitea.py:PR diff 取得與 comment 發布 - app/roles.py:從 prompts/roles/*.yaml 載入角色 - app/main.py:pipeline 骨架,log 每個主要階段 - app/prompts/roles/:五個角色定義(Aria/Rex/Zara/Leo/Maya) --- Dockerfile | 17 ++- action.yaml | 166 ++++++++++++++++++++++--- app/config.py | 31 +++++ app/gitea.py | 26 ++++ app/llm.py | 50 ++++++++ app/main.py | 75 +++++++++++ app/prompts/roles/maintainability.yaml | 23 ++++ app/prompts/roles/performance.yaml | 23 ++++ app/prompts/roles/security.yaml | 23 ++++ app/prompts/roles/style.yaml | 23 ++++ app/prompts/roles/testing.yaml | 23 ++++ app/requirements.txt | 3 + app/roles.py | 22 ++++ entrypoint.sh | 13 +- 14 files changed, 490 insertions(+), 28 deletions(-) create mode 100644 app/config.py create mode 100644 app/gitea.py create mode 100644 app/llm.py create mode 100644 app/main.py create mode 100644 app/prompts/roles/maintainability.yaml create mode 100644 app/prompts/roles/performance.yaml create mode 100644 app/prompts/roles/security.yaml create mode 100644 app/prompts/roles/style.yaml create mode 100644 app/prompts/roles/testing.yaml create mode 100644 app/requirements.txt create mode 100644 app/roles.py diff --git a/Dockerfile b/Dockerfile index af3dacb..1ef0bb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,15 @@ -FROM alpine:latest +FROM python:3.11-slim -# 安裝必要的工具 -RUN apk add --no-cache --no-check-certificate bash - +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /action + +COPY app/ /action/app/ COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +RUN pip install --no-cache-dir -r /action/app/requirements.txt && \ + chmod +x /entrypoint.sh -ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["/entrypoint.sh"] diff --git a/action.yaml b/action.yaml index f8849d0..4e28a5c 100644 --- a/action.yaml +++ b/action.yaml @@ -1,20 +1,158 @@ -name: 'Docker Action Template' -description: 'Docker Action 範本' +name: 'AI Code Review' +description: 'AI 多角色 Code Review Action,自動分析 PR 並發布問題 Comment' author: 'Jeffery' inputs: - runner_token: - description: 'Gitea Runner Token' - required: true - text: - description: '輸入的文字' - default: "Hello, World!" -outputs: - text: - description: '輸出的文字' + # Gitea 相關(可從 gitea context 自動取得) + GITEA_TOKEN: + description: 'Gitea API Token' + required: false + GITEA_SERVER_URL: + description: 'Gitea Server URL' + required: false + GITEA_REPOSITORY: + description: 'Gitea Repository (owner/repo)' + required: false + PR_NUMBER: + description: 'Pull Request Number' + required: false + PR_HEAD_BRANCH: + description: 'PR 來源分支' + required: false + PR_BASE_BRANCH: + description: 'PR 目標分支' + required: false + + # OpenAI-compatible + OPENAI_API_KEY: + description: 'OpenAI / OpenRouter API Key' + required: false + OPENAI_BASE_URL: + description: 'OpenAI-compatible Base URL' + required: false + OPENAI_MODEL: + description: 'OpenAI-compatible Model Name' + required: false + + # Anthropic Claude + CLAUDE_API_KEY: + description: 'Anthropic Claude API Key' + required: false + CLAUDE_BASE_URL: + description: 'Claude Base URL' + required: false + CLAUDE_MODEL: + description: 'Claude Model Name' + required: false + + # Google Gemini + GEMINI_API_KEY: + description: 'Google Gemini API Key' + required: false + GEMINI_BASE_URL: + description: 'Gemini Base URL' + required: false + GEMINI_MODEL: + description: 'Gemini Model Name' + required: false + + # Ollama + OLLAMA_BASE_URL: + description: 'Ollama Base URL' + required: false + OLLAMA_MODEL: + description: 'Ollama Model Name' + required: false + + # Amazon Q + AMAZONQ_API_KEY: + description: 'Amazon Q API Key' + required: false + AMAZONQ_BASE_URL: + description: 'Amazon Q Base URL' + required: false + + # SonarQube + SONARQUBE_TOKEN: + description: 'SonarQube Token' + required: false + SONARQUBE_URL: + description: 'SonarQube URL' + required: false + + # Kilo Code + KILO_API_KEY: + description: 'Kilo Code API Key' + required: false + KILO_BASE_URL: + description: 'Kilo Code Base URL' + required: false + + # Roo Code + ROO_API_KEY: + description: 'Roo Code API Key' + required: false + ROO_BASE_URL: + description: 'Roo Code Base URL' + required: false + + # Cline + CLINE_API_KEY: + description: 'Cline API Key' + required: false + CLINE_BASE_URL: + description: 'Cline Base URL' + required: false + + # Continue + CONTINUE_API_KEY: + description: 'Continue API Key' + required: false + CONTINUE_BASE_URL: + description: 'Continue Base URL' + required: false + + # Kade + KADE_API_KEY: + description: 'Kade API Key' + required: false + KADE_BASE_URL: + description: 'Kade Base URL' + required: false + runs: using: 'docker' image: 'Dockerfile' env: - GITEA_SERVER_URL: ${{ gitea.server_url }} - GITEA_REPOSITORY: ${{ gitea.repository }} - RUNNER_TOKEN: ${{ inputs.runner_token || secrets.GITEA_TOKEN || secrets.RUNNER_TOKEN }} \ No newline at end of file + # Gitea context(優先用 inputs,否則從 gitea context 取) + GITEA_TOKEN: ${{ inputs.GITEA_TOKEN || secrets.GITEA_TOKEN }} + GITEA_SERVER_URL: ${{ inputs.GITEA_SERVER_URL || gitea.server_url }} + GITEA_REPOSITORY: ${{ inputs.GITEA_REPOSITORY || gitea.repository }} + PR_NUMBER: ${{ inputs.PR_NUMBER || gitea.event.pull_request.number }} + PR_HEAD_BRANCH: ${{ inputs.PR_HEAD_BRANCH || gitea.event.pull_request.head.ref }} + PR_BASE_BRANCH: ${{ inputs.PR_BASE_BRANCH || gitea.event.pull_request.base.ref }} + # LLM + OPENAI_API_KEY: ${{ inputs.OPENAI_API_KEY }} + OPENAI_BASE_URL: ${{ inputs.OPENAI_BASE_URL }} + OPENAI_MODEL: ${{ inputs.OPENAI_MODEL }} + CLAUDE_API_KEY: ${{ inputs.CLAUDE_API_KEY }} + CLAUDE_BASE_URL: ${{ inputs.CLAUDE_BASE_URL }} + CLAUDE_MODEL: ${{ inputs.CLAUDE_MODEL }} + GEMINI_API_KEY: ${{ inputs.GEMINI_API_KEY }} + GEMINI_BASE_URL: ${{ inputs.GEMINI_BASE_URL }} + GEMINI_MODEL: ${{ inputs.GEMINI_MODEL }} + OLLAMA_BASE_URL: ${{ inputs.OLLAMA_BASE_URL }} + OLLAMA_MODEL: ${{ inputs.OLLAMA_MODEL }} + AMAZONQ_API_KEY: ${{ inputs.AMAZONQ_API_KEY }} + AMAZONQ_BASE_URL: ${{ inputs.AMAZONQ_BASE_URL }} + SONARQUBE_TOKEN: ${{ inputs.SONARQUBE_TOKEN }} + SONARQUBE_URL: ${{ inputs.SONARQUBE_URL }} + KILO_API_KEY: ${{ inputs.KILO_API_KEY }} + KILO_BASE_URL: ${{ inputs.KILO_BASE_URL }} + ROO_API_KEY: ${{ inputs.ROO_API_KEY }} + ROO_BASE_URL: ${{ inputs.ROO_BASE_URL }} + CLINE_API_KEY: ${{ inputs.CLINE_API_KEY }} + CLINE_BASE_URL: ${{ inputs.CLINE_BASE_URL }} + CONTINUE_API_KEY: ${{ inputs.CONTINUE_API_KEY }} + CONTINUE_BASE_URL: ${{ inputs.CONTINUE_BASE_URL }} + KADE_API_KEY: ${{ inputs.KADE_API_KEY }} + KADE_BASE_URL: ${{ inputs.KADE_BASE_URL }} diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..d0f6c04 --- /dev/null +++ b/app/config.py @@ -0,0 +1,31 @@ +import os + +# Gitea +GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") +GITEA_SERVER_URL = os.environ.get("GITEA_SERVER_URL", "https://gitea.com") +GITEA_REPOSITORY = os.environ.get("GITEA_REPOSITORY", "") +PR_NUMBER = os.environ.get("PR_NUMBER", "") +PR_HEAD_BRANCH = os.environ.get("PR_HEAD_BRANCH", "") +PR_BASE_BRANCH = os.environ.get("PR_BASE_BRANCH", "") + +FINDINGS_PATH = ".gitea/ai-review/findings.json" + + +def get_llm_config(): + """依優先順序偵測可用的 LLM,回傳 (provider, api_key, base_url, model)""" + checks = [ + ("openai", os.environ.get("OPENAI_API_KEY"), os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"), os.environ.get("OPENAI_MODEL", "gpt-4o-mini")), + ("claude", os.environ.get("CLAUDE_API_KEY"), os.environ.get("CLAUDE_BASE_URL", "https://api.anthropic.com/v1"), os.environ.get("CLAUDE_MODEL", "claude-3-haiku-20240307")), + ("gemini", os.environ.get("GEMINI_API_KEY"), os.environ.get("GEMINI_BASE_URL", "https://generativelanguage.googleapis.com/v1beta"), os.environ.get("GEMINI_MODEL", "gemini-1.5-flash")), + ("ollama", "ollama", os.environ.get("OLLAMA_BASE_URL", ""), os.environ.get("OLLAMA_MODEL", "")), + ("amazonq", os.environ.get("AMAZONQ_API_KEY"), os.environ.get("AMAZONQ_BASE_URL", "https://q.api.aws"), os.environ.get("OPENAI_MODEL", "amazon-q")), + ("kilo", os.environ.get("KILO_API_KEY"), os.environ.get("KILO_BASE_URL", "https://api.kilocode.com/v1"), os.environ.get("OPENAI_MODEL", "kilo-default")), + ("roo", os.environ.get("ROO_API_KEY"), os.environ.get("ROO_BASE_URL", "https://api.roocode.com/v1"), os.environ.get("OPENAI_MODEL", "roo-default")), + ("cline", os.environ.get("CLINE_API_KEY"), os.environ.get("CLINE_BASE_URL", "https://api.cline.dev/v1"), os.environ.get("OPENAI_MODEL", "cline-default")), + ("continue", os.environ.get("CONTINUE_API_KEY"), os.environ.get("CONTINUE_BASE_URL", "https://api.continue.dev/v1"), os.environ.get("OPENAI_MODEL", "continue-default")), + ("kade", os.environ.get("KADE_API_KEY"), os.environ.get("KADE_BASE_URL", "https://api.kade.dev/v1"), os.environ.get("OPENAI_MODEL", "kade-default")), + ] + for provider, key, base_url, model in checks: + if key and base_url: + return provider, key, base_url, model + return None, None, None, None diff --git a/app/gitea.py b/app/gitea.py new file mode 100644 index 0000000..4a1cd4c --- /dev/null +++ b/app/gitea.py @@ -0,0 +1,26 @@ +import requests +from config import GITEA_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, PR_NUMBER + + +def _headers(): + return {"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json"} + + +def _api(path: str) -> str: + return f"{GITEA_SERVER_URL.rstrip('/')}/api/v1{path}" + + +def get_pr_diff() -> str: + """取得 PR 的 git diff 內容""" + url = _api(f"/repos/{GITEA_REPOSITORY}/pulls/{PR_NUMBER}.diff") + resp = requests.get(url, headers=_headers(), timeout=60) + resp.raise_for_status() + return resp.text + + +def post_comment(body: str) -> dict: + """在 PR 發布 comment""" + url = _api(f"/repos/{GITEA_REPOSITORY}/issues/{PR_NUMBER}/comments") + resp = requests.post(url, headers=_headers(), json={"body": body}, timeout=30) + resp.raise_for_status() + return resp.json() diff --git a/app/llm.py b/app/llm.py new file mode 100644 index 0000000..b15a8ae --- /dev/null +++ b/app/llm.py @@ -0,0 +1,50 @@ +import json +import requests +from config import get_llm_config + + +def chat(system_prompt: str, user_content: str) -> str: + """呼叫 LLM,回傳回應文字。失敗時拋出例外。""" + provider, api_key, base_url, model = get_llm_config() + if not provider: + raise RuntimeError("未設定任何 LLM API Key") + + print(f" [LLM] provider={provider} model={model}") + + # 所有服務統一用 OpenAI-compatible chat completions 介面 + url = f"{base_url.rstrip('/')}/chat/completions" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + # Claude 額外 header + if provider == "claude": + headers["anthropic-version"] = "2023-06-01" + + payload = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content}, + ], + "temperature": 0.2, + } + + resp = requests.post(url, headers=headers, json=payload, timeout=120) + resp.raise_for_status() + return resp.json()["choices"][0]["message"]["content"] + + +def chat_json(system_prompt: str, user_content: str) -> list: + """呼叫 LLM 並解析 JSON 陣列回應。失敗時回傳空陣列。""" + try: + text = chat(system_prompt, user_content) + # 去除可能的 markdown code block + text = text.strip() + if text.startswith("```"): + text = text.split("\n", 1)[1] + text = text.rsplit("```", 1)[0] + return json.loads(text) + except Exception as e: + print(f" [LLM] 解析失敗: {e}") + return [] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..2c7c725 --- /dev/null +++ b/app/main.py @@ -0,0 +1,75 @@ +import sys +import traceback +from config import GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, get_llm_config +from roles import load_roles, get_role_intro +from gitea import get_pr_diff, post_comment + + +def main(): + print("=" * 60) + print("🚀 Step1: Pipeline 啟動") + print(f" repo={GITEA_REPOSITORY} PR=#{PR_NUMBER}") + print(f" {PR_HEAD_BRANCH} -> {PR_BASE_BRANCH}") + + # 偵測 LLM + provider, _, base_url, model = get_llm_config() + if not provider: + print("❌ 未設定任何 LLM API Key,請檢查 action inputs") + sys.exit(1) + print(f" LLM: provider={provider} model={model} base_url={base_url}") + + # 載入角色 + roles = load_roles() + print(f" 已載入 {len(roles)} 個角色: {[r['name'] for r in roles]}") + + # 取得 PR diff + print("\n📋 Step1: 取得 PR Diff") + try: + diff = get_pr_diff() + print(f" diff 長度: {len(diff)} 字元") + except Exception as e: + print(f" ❌ 取得 diff 失敗: {e}") + sys.exit(1) + + if not diff.strip(): + print(" ⚠️ diff 為空,無需審查") + sys.exit(0) + + # 發布角色介紹 comment + print("\n💬 Step1: 發布角色介紹 Comment") + try: + intro = get_role_intro(roles) + intro += f"\n\n> 🔍 服務:{provider} 模型:{model}" + post_comment(intro) + print(" ✅ 角色介紹 comment 發布成功") + except Exception as e: + print(f" ⚠️ comment 發布失敗(繼續執行): {e}") + + print("\n📊 Step2: Findings 產生(待實作)") + print(" [stub] 各角色分析 diff...") + + print("\n🔀 Step3: Findings 合併與去重(待實作)") + print(" [stub] 合併新舊 findings...") + + print("\n📝 Step4: Findings 寫入與 Comment 發布(待實作)") + print(" [stub] 寫入 findings.json,發布 comment...") + + print("\n💾 Step5: 記憶區 Commit/Push(待實作)") + print(" [stub] commit & push findings.json...") + + print("\n🚦 Step6: 嚴重問題檢查(待實作)") + print(" [stub] 檢查 critical findings...") + + print("\n✅ Pipeline 完成") + print("=" * 60) + + +if __name__ == "__main__": + try: + main() + except SystemExit: + raise + except Exception: + print("❌ Runner failed:") + traceback.print_exc() + sys.exit(1) diff --git a/app/prompts/roles/maintainability.yaml b/app/prompts/roles/maintainability.yaml new file mode 100644 index 0000000..23f22d3 --- /dev/null +++ b/app/prompts/roles/maintainability.yaml @@ -0,0 +1,23 @@ +name: "Leo" +role: "可維護性審查員" +personality: "有遠見、重視長期維護成本,常常思考「六個月後的自己能看懂嗎?」" +focus: "程式碼複雜度、模組化、重複程式碼、文件完整性、錯誤處理、可測試性" +system_prompt: | + 你是 Leo,一位重視長期維護成本的審查員。你的工作是審查程式碼的可維護性,包含複雜度、模組化、重複程式碼、文件完整性、錯誤處理。 + + 請分析以下 Git Diff,找出所有可維護性相關問題。 + + 回傳 JSON 陣列,每個問題格式如下: + { + "level": "critical|warning|info", + "role": "Leo", + "location": "檔案路徑:行號 或 檔案路徑", + "suggestion": "繁體中文的具體修改建議" + } + + 等級定義: + - critical:嚴重影響可維護性,會造成技術債(如超長函式、完全無文件的公開 API) + - warning:建議改善的可維護性問題 + - info:可選的改善建議 + + 只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。 diff --git a/app/prompts/roles/performance.yaml b/app/prompts/roles/performance.yaml new file mode 100644 index 0000000..51f6249 --- /dev/null +++ b/app/prompts/roles/performance.yaml @@ -0,0 +1,23 @@ +name: "Zara" +role: "效能優化專家" +personality: "追求極致效能,對任何不必要的資源消耗都感到不舒服,喜歡用數據說話" +focus: "時間複雜度、空間複雜度、資料庫查詢效率、快取策略、不必要的重複運算" +system_prompt: | + 你是 Zara,一位追求極致效能的優化專家。你的工作是審查程式碼的效能問題,包含時間複雜度、空間複雜度、資料庫查詢效率、快取策略。 + + 請分析以下 Git Diff,找出所有效能相關問題。 + + 回傳 JSON 陣列,每個問題格式如下: + { + "level": "critical|warning|info", + "role": "Zara", + "location": "檔案路徑:行號 或 檔案路徑", + "suggestion": "繁體中文的具體修改建議" + } + + 等級定義: + - critical:會造成明顯效能瓶頸或系統崩潰的問題(如 N+1 query、無限迴圈風險) + - warning:值得優化的效能問題 + - info:效能最佳實踐建議 + + 只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。 diff --git a/app/prompts/roles/security.yaml b/app/prompts/roles/security.yaml new file mode 100644 index 0000000..3bc5d31 --- /dev/null +++ b/app/prompts/roles/security.yaml @@ -0,0 +1,23 @@ +name: "Rex" +role: "資安審查員" +personality: "謹慎、多疑、對任何潛在風險都保持高度警覺,寧可誤報也不放過漏洞" +focus: "安全漏洞、注入攻擊、敏感資料洩漏、認證授權問題、依賴套件風險" +system_prompt: | + 你是 Rex,一位謹慎的資安審查員。你的工作是審查程式碼中的安全漏洞、注入攻擊風險、敏感資料洩漏、認證授權問題。 + + 請分析以下 Git Diff,找出所有安全相關問題。 + + 回傳 JSON 陣列,每個問題格式如下: + { + "level": "critical|warning|info", + "role": "Rex", + "location": "檔案路徑:行號 或 檔案路徑", + "suggestion": "繁體中文的具體修改建議" + } + + 等級定義: + - critical:可被直接利用的安全漏洞(如 SQL injection、hardcoded secret、RCE) + - warning:潛在安全風險,需要關注 + - info:安全最佳實踐建議 + + 只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。 diff --git a/app/prompts/roles/style.yaml b/app/prompts/roles/style.yaml new file mode 100644 index 0000000..75955a4 --- /dev/null +++ b/app/prompts/roles/style.yaml @@ -0,0 +1,23 @@ +name: "Aria" +role: "程式碼風格審查員" +personality: "嚴謹、注重細節、對程式碼整潔度有高度要求,說話直接但不失禮貌" +focus: "程式碼風格、命名規範、格式一致性、可讀性" +system_prompt: | + 你是 Aria,一位嚴謹的程式碼風格審查員。你的工作是審查程式碼的風格、命名規範、格式一致性與可讀性。 + + 請分析以下 Git Diff,找出所有風格相關問題。 + + 回傳 JSON 陣列,每個問題格式如下: + { + "level": "critical|warning|info", + "role": "Aria", + "location": "檔案路徑:行號 或 檔案路徑", + "suggestion": "繁體中文的具體修改建議" + } + + 等級定義: + - critical:嚴重違反規範,會影響團隊協作或工具運作 + - warning:建議修正的風格問題 + - info:可選的改善建議 + + 只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。 diff --git a/app/prompts/roles/testing.yaml b/app/prompts/roles/testing.yaml new file mode 100644 index 0000000..e83cf05 --- /dev/null +++ b/app/prompts/roles/testing.yaml @@ -0,0 +1,23 @@ +name: "Maya" +role: "測試品質審查員" +personality: "對測試覆蓋率有執念,相信沒有測試的程式碼等於沒有完成,溫和但堅持" +focus: "測試覆蓋率、測試品質、邊界條件、錯誤情境測試、測試可讀性" +system_prompt: | + 你是 Maya,一位對測試品質有高度要求的審查員。你的工作是審查程式碼的測試覆蓋率、測試品質、邊界條件處理。 + + 請分析以下 Git Diff,找出所有測試相關問題。 + + 回傳 JSON 陣列,每個問題格式如下: + { + "level": "critical|warning|info", + "role": "Maya", + "location": "檔案路徑:行號 或 檔案路徑", + "suggestion": "繁體中文的具體修改建議" + } + + 等級定義: + - critical:完全缺少測試的核心功能,或測試邏輯有嚴重錯誤 + - warning:測試覆蓋不足或測試品質有待改善 + - info:測試最佳實踐建議 + + 只回傳 JSON 陣列,不要有其他文字。如果沒有問題,回傳空陣列 []。 diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..1ebdae8 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,3 @@ +requests==2.31.0 +pyyaml==6.0.1 +openai==1.12.0 diff --git a/app/roles.py b/app/roles.py new file mode 100644 index 0000000..07d123c --- /dev/null +++ b/app/roles.py @@ -0,0 +1,22 @@ +import os +import yaml + +ROLES_DIR = "/action/app/prompts/roles" + + +def load_roles() -> list[dict]: + """載入所有角色定義""" + roles = [] + for fname in sorted(os.listdir(ROLES_DIR)): + if fname.endswith(".yaml"): + with open(os.path.join(ROLES_DIR, fname), "r", encoding="utf-8") as f: + roles.append(yaml.safe_load(f)) + return roles + + +def get_role_intro(roles: list[dict]) -> str: + """產生角色介紹文字(用於 comment)""" + lines = ["## 🤖 AI Code Review 團隊", ""] + for r in roles: + lines.append(f"- **{r['name']}** ({r['role']}): {r['personality']}") + return "\n".join(lines) diff --git a/entrypoint.sh b/entrypoint.sh index 378b552..ec0b7b7 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,11 +1,8 @@ #!/bin/bash +set -e -echo "Gitea Server Url: $GITEA_SERVER_URL" +echo "🚀 AI Code Review Action 啟動" +echo "Repository: $GITEA_REPOSITORY" +echo "PR: #$PR_NUMBER ($PR_HEAD_BRANCH -> $PR_BASE_BRANCH)" -echo "Gitea Repository: $GITEA_REPOSITORY" - -echo "Gitea Runner Token: $RUNNER_TOKEN" - -echo "Input Text: $INPUT_TEXT" - -echo "text=$INPUT_TEXT" >> "$GITHUB_OUTPUT" \ No newline at end of file +exec python /action/app/main.py From ec1f6c96e7650c68517088259f4599dfd20fa21a Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 11 May 2026 07:24:47 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E9=9A=8E=E6=AE=B5=E4=B8=80=20-=20?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20Node.js=20=E5=AF=A6=E4=BD=9C=E5=9F=BA?= =?UTF-8?q?=E6=9C=AC=E6=B5=81=E7=A8=8B=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile: 改用 node:20-slim - entrypoint.sh: 執行 app/main.js - app/package.json: axios + js-yaml + openai - app/config.js: 環境變數與 LLM 自動偵測(10 種服務) - app/llm.js: OpenAI-compatible 統一介面 - app/gitea.js: PR diff 取得與 comment 發布 - app/roles.js: 從 prompts/roles/*.yaml 載入角色 - app/main.js: pipeline 骨架,log 每個主要階段 --- Dockerfile | 4 +-- app/config.js | 27 ++++++++++++++++ app/config.py | 31 ------------------ app/gitea.js | 15 +++++++++ app/gitea.py | 26 --------------- app/llm.js | 33 +++++++++++++++++++ app/llm.py | 50 ----------------------------- app/main.js | 71 +++++++++++++++++++++++++++++++++++++++++ app/main.py | 75 -------------------------------------------- app/package.json | 10 ++++++ app/requirements.txt | 3 -- app/roles.js | 20 ++++++++++++ app/roles.py | 22 ------------- entrypoint.sh | 2 +- 14 files changed, 179 insertions(+), 210 deletions(-) create mode 100644 app/config.js delete mode 100644 app/config.py create mode 100644 app/gitea.js delete mode 100644 app/gitea.py create mode 100644 app/llm.js delete mode 100644 app/llm.py create mode 100644 app/main.js delete mode 100644 app/main.py create mode 100644 app/package.json delete mode 100644 app/requirements.txt create mode 100644 app/roles.js delete mode 100644 app/roles.py diff --git a/Dockerfile b/Dockerfile index 1ef0bb9..484bf92 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim +FROM node:20-slim RUN apt-get update && apt-get install -y --no-install-recommends \ git \ @@ -9,7 +9,7 @@ WORKDIR /action COPY app/ /action/app/ COPY entrypoint.sh /entrypoint.sh -RUN pip install --no-cache-dir -r /action/app/requirements.txt && \ +RUN cd /action/app && npm install && \ chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] diff --git a/app/config.js b/app/config.js new file mode 100644 index 0000000..ea20e2c --- /dev/null +++ b/app/config.js @@ -0,0 +1,27 @@ +export const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; +export const GITEA_SERVER_URL = process.env.GITEA_SERVER_URL || 'https://gitea.com'; +export const GITEA_REPOSITORY = process.env.GITEA_REPOSITORY || ''; +export const PR_NUMBER = process.env.PR_NUMBER || ''; +export const PR_HEAD_BRANCH = process.env.PR_HEAD_BRANCH || ''; +export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || ''; + +export const FINDINGS_PATH = '.gitea/ai-review/findings.json'; + +export function getLLMConfig() { + const checks = [ + ['openai', process.env.OPENAI_API_KEY, process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', process.env.OPENAI_MODEL || 'gpt-4o-mini'], + ['claude', process.env.CLAUDE_API_KEY, process.env.CLAUDE_BASE_URL || 'https://api.anthropic.com/v1', process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307'], + ['gemini', process.env.GEMINI_API_KEY, process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta', process.env.GEMINI_MODEL || 'gemini-1.5-flash'], + ['ollama', 'ollama', process.env.OLLAMA_BASE_URL, process.env.OLLAMA_MODEL], + ['amazonq', process.env.AMAZONQ_API_KEY, process.env.AMAZONQ_BASE_URL || 'https://q.api.aws', process.env.OPENAI_MODEL || 'amazon-q'], + ['kilo', process.env.KILO_API_KEY, process.env.KILO_BASE_URL || 'https://api.kilocode.com/v1', process.env.OPENAI_MODEL || 'kilo-default'], + ['roo', process.env.ROO_API_KEY, process.env.ROO_BASE_URL || 'https://api.roocode.com/v1', process.env.OPENAI_MODEL || 'roo-default'], + ['cline', process.env.CLINE_API_KEY, process.env.CLINE_BASE_URL || 'https://api.cline.dev/v1', process.env.OPENAI_MODEL || 'cline-default'], + ['continue', process.env.CONTINUE_API_KEY, process.env.CONTINUE_BASE_URL || 'https://api.continue.dev/v1', process.env.OPENAI_MODEL || 'continue-default'], + ['kade', process.env.KADE_API_KEY, process.env.KADE_BASE_URL || 'https://api.kade.dev/v1', process.env.OPENAI_MODEL || 'kade-default'], + ]; + for (const [provider, key, baseURL, model] of checks) { + if (key && baseURL) return { provider, apiKey: key, baseURL, model }; + } + return { provider: null, apiKey: null, baseURL: null, model: null }; +} diff --git a/app/config.py b/app/config.py deleted file mode 100644 index d0f6c04..0000000 --- a/app/config.py +++ /dev/null @@ -1,31 +0,0 @@ -import os - -# Gitea -GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") -GITEA_SERVER_URL = os.environ.get("GITEA_SERVER_URL", "https://gitea.com") -GITEA_REPOSITORY = os.environ.get("GITEA_REPOSITORY", "") -PR_NUMBER = os.environ.get("PR_NUMBER", "") -PR_HEAD_BRANCH = os.environ.get("PR_HEAD_BRANCH", "") -PR_BASE_BRANCH = os.environ.get("PR_BASE_BRANCH", "") - -FINDINGS_PATH = ".gitea/ai-review/findings.json" - - -def get_llm_config(): - """依優先順序偵測可用的 LLM,回傳 (provider, api_key, base_url, model)""" - checks = [ - ("openai", os.environ.get("OPENAI_API_KEY"), os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"), os.environ.get("OPENAI_MODEL", "gpt-4o-mini")), - ("claude", os.environ.get("CLAUDE_API_KEY"), os.environ.get("CLAUDE_BASE_URL", "https://api.anthropic.com/v1"), os.environ.get("CLAUDE_MODEL", "claude-3-haiku-20240307")), - ("gemini", os.environ.get("GEMINI_API_KEY"), os.environ.get("GEMINI_BASE_URL", "https://generativelanguage.googleapis.com/v1beta"), os.environ.get("GEMINI_MODEL", "gemini-1.5-flash")), - ("ollama", "ollama", os.environ.get("OLLAMA_BASE_URL", ""), os.environ.get("OLLAMA_MODEL", "")), - ("amazonq", os.environ.get("AMAZONQ_API_KEY"), os.environ.get("AMAZONQ_BASE_URL", "https://q.api.aws"), os.environ.get("OPENAI_MODEL", "amazon-q")), - ("kilo", os.environ.get("KILO_API_KEY"), os.environ.get("KILO_BASE_URL", "https://api.kilocode.com/v1"), os.environ.get("OPENAI_MODEL", "kilo-default")), - ("roo", os.environ.get("ROO_API_KEY"), os.environ.get("ROO_BASE_URL", "https://api.roocode.com/v1"), os.environ.get("OPENAI_MODEL", "roo-default")), - ("cline", os.environ.get("CLINE_API_KEY"), os.environ.get("CLINE_BASE_URL", "https://api.cline.dev/v1"), os.environ.get("OPENAI_MODEL", "cline-default")), - ("continue", os.environ.get("CONTINUE_API_KEY"), os.environ.get("CONTINUE_BASE_URL", "https://api.continue.dev/v1"), os.environ.get("OPENAI_MODEL", "continue-default")), - ("kade", os.environ.get("KADE_API_KEY"), os.environ.get("KADE_BASE_URL", "https://api.kade.dev/v1"), os.environ.get("OPENAI_MODEL", "kade-default")), - ] - for provider, key, base_url, model in checks: - if key and base_url: - return provider, key, base_url, model - return None, None, None, None diff --git a/app/gitea.js b/app/gitea.js new file mode 100644 index 0000000..787f801 --- /dev/null +++ b/app/gitea.js @@ -0,0 +1,15 @@ +import axios from 'axios'; +import { GITEA_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, PR_NUMBER } from './config.js'; + +const headers = () => ({ Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' }); +const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`; + +export async function getPRDiff() { + const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000 }); + return resp.data; +} + +export async function postComment(body) { + const resp = await axios.post(api(`/repos/${GITEA_REPOSITORY}/issues/${PR_NUMBER}/comments`), { body }, { headers: headers(), timeout: 30000 }); + return resp.data; +} diff --git a/app/gitea.py b/app/gitea.py deleted file mode 100644 index 4a1cd4c..0000000 --- a/app/gitea.py +++ /dev/null @@ -1,26 +0,0 @@ -import requests -from config import GITEA_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, PR_NUMBER - - -def _headers(): - return {"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json"} - - -def _api(path: str) -> str: - return f"{GITEA_SERVER_URL.rstrip('/')}/api/v1{path}" - - -def get_pr_diff() -> str: - """取得 PR 的 git diff 內容""" - url = _api(f"/repos/{GITEA_REPOSITORY}/pulls/{PR_NUMBER}.diff") - resp = requests.get(url, headers=_headers(), timeout=60) - resp.raise_for_status() - return resp.text - - -def post_comment(body: str) -> dict: - """在 PR 發布 comment""" - url = _api(f"/repos/{GITEA_REPOSITORY}/issues/{PR_NUMBER}/comments") - resp = requests.post(url, headers=_headers(), json={"body": body}, timeout=30) - resp.raise_for_status() - return resp.json() diff --git a/app/llm.js b/app/llm.js new file mode 100644 index 0000000..f2521d3 --- /dev/null +++ b/app/llm.js @@ -0,0 +1,33 @@ +import axios from 'axios'; +import { getLLMConfig } from './config.js'; + +export async function chat(systemPrompt, userContent) { + const { provider, apiKey, baseURL, model } = getLLMConfig(); + if (!provider) throw new Error('未設定任何 LLM API Key'); + + console.log(` [LLM] provider=${provider} model=${model}`); + + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }; + if (provider === 'claude') headers['anthropic-version'] = '2023-06-01'; + + const resp = await axios.post( + `${baseURL.replace(/\/$/, '')}/chat/completions`, + { model, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], temperature: 0.2 }, + { headers, timeout: 120000 } + ); + return resp.data.choices[0].message.content; +} + +export async function chatJSON(systemPrompt, userContent) { + try { + let text = await chat(systemPrompt, userContent); + text = text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim(); + return JSON.parse(text); + } catch (e) { + console.log(` [LLM] 解析失敗: ${e.message}`); + return []; + } +} diff --git a/app/llm.py b/app/llm.py deleted file mode 100644 index b15a8ae..0000000 --- a/app/llm.py +++ /dev/null @@ -1,50 +0,0 @@ -import json -import requests -from config import get_llm_config - - -def chat(system_prompt: str, user_content: str) -> str: - """呼叫 LLM,回傳回應文字。失敗時拋出例外。""" - provider, api_key, base_url, model = get_llm_config() - if not provider: - raise RuntimeError("未設定任何 LLM API Key") - - print(f" [LLM] provider={provider} model={model}") - - # 所有服務統一用 OpenAI-compatible chat completions 介面 - url = f"{base_url.rstrip('/')}/chat/completions" - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - } - # Claude 額外 header - if provider == "claude": - headers["anthropic-version"] = "2023-06-01" - - payload = { - "model": model, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_content}, - ], - "temperature": 0.2, - } - - resp = requests.post(url, headers=headers, json=payload, timeout=120) - resp.raise_for_status() - return resp.json()["choices"][0]["message"]["content"] - - -def chat_json(system_prompt: str, user_content: str) -> list: - """呼叫 LLM 並解析 JSON 陣列回應。失敗時回傳空陣列。""" - try: - text = chat(system_prompt, user_content) - # 去除可能的 markdown code block - text = text.strip() - if text.startswith("```"): - text = text.split("\n", 1)[1] - text = text.rsplit("```", 1)[0] - return json.loads(text) - except Exception as e: - print(f" [LLM] 解析失敗: {e}") - return [] diff --git a/app/main.js b/app/main.js new file mode 100644 index 0000000..16981ba --- /dev/null +++ b/app/main.js @@ -0,0 +1,71 @@ +import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig } from './config.js'; +import { loadRoles, getRoleIntro } from './roles.js'; +import { getPRDiff, postComment } from './gitea.js'; + +async function main() { + console.log('='.repeat(60)); + console.log('🚀 Step1: Pipeline 啟動'); + console.log(` repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`); + console.log(` ${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`); + + // 偵測 LLM + const { provider, baseURL, model } = getLLMConfig(); + if (!provider) { + console.error('❌ 未設定任何 LLM API Key,請檢查 action inputs'); + process.exit(1); + } + console.log(` LLM: provider=${provider} model=${model} base_url=${baseURL}`); + + // 載入角色 + const roles = loadRoles(); + console.log(` 已載入 ${roles.length} 個角色: [${roles.map(r => r.name).join(', ')}]`); + + // 取得 PR diff + console.log('\n📋 Step1: 取得 PR Diff'); + let diff; + try { + diff = await getPRDiff(); + console.log(` diff 長度: ${diff.length} 字元`); + } catch (e) { + console.error(` ❌ 取得 diff 失敗: ${e.message}`); + process.exit(1); + } + + if (!diff.trim()) { + console.log(' ⚠️ diff 為空,無需審查'); + process.exit(0); + } + + // 發布角色介紹 comment + console.log('\n💬 Step1: 發布角色介紹 Comment'); + try { + const intro = getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`; + await postComment(intro); + console.log(' ✅ 角色介紹 comment 發布成功'); + } catch (e) { + console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`); + } + + console.log('\n📊 Step2: Findings 產生(待實作)'); + console.log(' [stub] 各角色分析 diff...'); + + console.log('\n🔀 Step3: Findings 合併與去重(待實作)'); + console.log(' [stub] 合併新舊 findings...'); + + console.log('\n📝 Step4: Findings 寫入與 Comment 發布(待實作)'); + console.log(' [stub] 寫入 findings.json,發布 comment...'); + + console.log('\n💾 Step5: 記憶區 Commit/Push(待實作)'); + console.log(' [stub] commit & push findings.json...'); + + console.log('\n🚦 Step6: 嚴重問題檢查(待實作)'); + console.log(' [stub] 檢查 critical findings...'); + + console.log('\n✅ Pipeline 完成'); + console.log('='.repeat(60)); +} + +main().catch(e => { + console.error('❌ Runner failed:', e.message); + process.exit(1); +}); diff --git a/app/main.py b/app/main.py deleted file mode 100644 index 2c7c725..0000000 --- a/app/main.py +++ /dev/null @@ -1,75 +0,0 @@ -import sys -import traceback -from config import GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, get_llm_config -from roles import load_roles, get_role_intro -from gitea import get_pr_diff, post_comment - - -def main(): - print("=" * 60) - print("🚀 Step1: Pipeline 啟動") - print(f" repo={GITEA_REPOSITORY} PR=#{PR_NUMBER}") - print(f" {PR_HEAD_BRANCH} -> {PR_BASE_BRANCH}") - - # 偵測 LLM - provider, _, base_url, model = get_llm_config() - if not provider: - print("❌ 未設定任何 LLM API Key,請檢查 action inputs") - sys.exit(1) - print(f" LLM: provider={provider} model={model} base_url={base_url}") - - # 載入角色 - roles = load_roles() - print(f" 已載入 {len(roles)} 個角色: {[r['name'] for r in roles]}") - - # 取得 PR diff - print("\n📋 Step1: 取得 PR Diff") - try: - diff = get_pr_diff() - print(f" diff 長度: {len(diff)} 字元") - except Exception as e: - print(f" ❌ 取得 diff 失敗: {e}") - sys.exit(1) - - if not diff.strip(): - print(" ⚠️ diff 為空,無需審查") - sys.exit(0) - - # 發布角色介紹 comment - print("\n💬 Step1: 發布角色介紹 Comment") - try: - intro = get_role_intro(roles) - intro += f"\n\n> 🔍 服務:{provider} 模型:{model}" - post_comment(intro) - print(" ✅ 角色介紹 comment 發布成功") - except Exception as e: - print(f" ⚠️ comment 發布失敗(繼續執行): {e}") - - print("\n📊 Step2: Findings 產生(待實作)") - print(" [stub] 各角色分析 diff...") - - print("\n🔀 Step3: Findings 合併與去重(待實作)") - print(" [stub] 合併新舊 findings...") - - print("\n📝 Step4: Findings 寫入與 Comment 發布(待實作)") - print(" [stub] 寫入 findings.json,發布 comment...") - - print("\n💾 Step5: 記憶區 Commit/Push(待實作)") - print(" [stub] commit & push findings.json...") - - print("\n🚦 Step6: 嚴重問題檢查(待實作)") - print(" [stub] 檢查 critical findings...") - - print("\n✅ Pipeline 完成") - print("=" * 60) - - -if __name__ == "__main__": - try: - main() - except SystemExit: - raise - except Exception: - print("❌ Runner failed:") - traceback.print_exc() - sys.exit(1) diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..b5e3877 --- /dev/null +++ b/app/package.json @@ -0,0 +1,10 @@ +{ + "name": "ai-code-review", + "version": "1.0.0", + "type": "module", + "dependencies": { + "axios": "^1.6.7", + "js-yaml": "^4.1.0", + "openai": "^4.28.0" + } +} diff --git a/app/requirements.txt b/app/requirements.txt deleted file mode 100644 index 1ebdae8..0000000 --- a/app/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -requests==2.31.0 -pyyaml==6.0.1 -openai==1.12.0 diff --git a/app/roles.js b/app/roles.js new file mode 100644 index 0000000..f36e2d4 --- /dev/null +++ b/app/roles.js @@ -0,0 +1,20 @@ +import fs from 'fs'; +import path from 'path'; +import yaml from 'js-yaml'; + +const ROLES_DIR = '/action/app/prompts/roles'; + +export function loadRoles() { + return fs.readdirSync(ROLES_DIR) + .filter(f => f.endsWith('.yaml')) + .sort() + .map(f => yaml.load(fs.readFileSync(path.join(ROLES_DIR, f), 'utf8'))); +} + +export function getRoleIntro(roles) { + const lines = ['## 🤖 AI Code Review 團隊', '']; + for (const r of roles) { + lines.push(`- **${r.name}** (${r.role}):${r.personality}`); + } + return lines.join('\n'); +} diff --git a/app/roles.py b/app/roles.py deleted file mode 100644 index 07d123c..0000000 --- a/app/roles.py +++ /dev/null @@ -1,22 +0,0 @@ -import os -import yaml - -ROLES_DIR = "/action/app/prompts/roles" - - -def load_roles() -> list[dict]: - """載入所有角色定義""" - roles = [] - for fname in sorted(os.listdir(ROLES_DIR)): - if fname.endswith(".yaml"): - with open(os.path.join(ROLES_DIR, fname), "r", encoding="utf-8") as f: - roles.append(yaml.safe_load(f)) - return roles - - -def get_role_intro(roles: list[dict]) -> str: - """產生角色介紹文字(用於 comment)""" - lines = ["## 🤖 AI Code Review 團隊", ""] - for r in roles: - lines.append(f"- **{r['name']}** ({r['role']}): {r['personality']}") - return "\n".join(lines) diff --git a/entrypoint.sh b/entrypoint.sh index ec0b7b7..64c4ccb 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -5,4 +5,4 @@ echo "🚀 AI Code Review Action 啟動" echo "Repository: $GITEA_REPOSITORY" echo "PR: #$PR_NUMBER ($PR_HEAD_BRANCH -> $PR_BASE_BRANCH)" -exec python /action/app/main.py +exec node /action/app/main.js From bccf0e5b0b7e74b134f3d0f656c31601e81b1452 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 11 May 2026 07:29:52 +0000 Subject: [PATCH 3/7] feat: add AI code review workflow configuration Co-authored-by: Copilot --- .gitea/workflows/review.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .gitea/workflows/review.yaml diff --git a/.gitea/workflows/review.yaml b/.gitea/workflows/review.yaml new file mode 100644 index 0000000..6612336 --- /dev/null +++ b/.gitea/workflows/review.yaml @@ -0,0 +1,18 @@ +name: AI +on: + pull_request: + types: [opened, synchronize] +jobs: + code-review: + name: 'Code Review' + runs-on: ubuntu + steps: + - name: AI Code Review + uses: ../../action.yaml + with: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_BASE_URL: https://api.openai.com/v1 + permissions: + contents: write + pull-requests: write + issues: write \ No newline at end of file From 43e21d07cdb231f1116cbd18870b152262fdef2f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 11 May 2026 07:37:04 +0000 Subject: [PATCH 4/7] feat: update AI code review workflow to use specific action URL and add tagging step Co-authored-by: Copilot --- .gitea/workflows/review.yaml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/review.yaml b/.gitea/workflows/review.yaml index 6612336..0d8f2c1 100644 --- a/.gitea/workflows/review.yaml +++ b/.gitea/workflows/review.yaml @@ -7,8 +7,15 @@ jobs: name: 'Code Review' runs-on: ubuntu steps: + - name: Tag develop on source branch + uses: akkuman/gitea-release-action@${{ vars.ACTION_RELEASE_VERSION }} + with: + tag: develop + target: ${{ github.head_ref }} + generate_release_notes: false + prerelease: false - name: AI Code Review - uses: ../../action.yaml + uses: https://gitea.jsc.idv.tw/jiantw83/code-review@develop with: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_BASE_URL: https://api.openai.com/v1 From fa5a734166a33238519676eddf289d5e7e76e48f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 11 May 2026 07:39:29 +0000 Subject: [PATCH 5/7] fix: ensure 'name' parameter is included in the tag step of the code review workflow Co-authored-by: Copilot --- .gitea/workflows/review.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/review.yaml b/.gitea/workflows/review.yaml index 0d8f2c1..e07f83c 100644 --- a/.gitea/workflows/review.yaml +++ b/.gitea/workflows/review.yaml @@ -10,6 +10,7 @@ jobs: - name: Tag develop on source branch uses: akkuman/gitea-release-action@${{ vars.ACTION_RELEASE_VERSION }} with: + name: develop tag: develop target: ${{ github.head_ref }} generate_release_notes: false From cdac64e22400f7c783bf218de859ad0ef164063a Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 11 May 2026 07:46:59 +0000 Subject: [PATCH 6/7] feat: refactor code review workflow to remove tagging step and use dynamic branch reference --- .gitea/workflows/review.yaml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.gitea/workflows/review.yaml b/.gitea/workflows/review.yaml index e07f83c..18e356e 100644 --- a/.gitea/workflows/review.yaml +++ b/.gitea/workflows/review.yaml @@ -7,16 +7,8 @@ jobs: name: 'Code Review' runs-on: ubuntu steps: - - name: Tag develop on source branch - uses: akkuman/gitea-release-action@${{ vars.ACTION_RELEASE_VERSION }} - with: - name: develop - tag: develop - target: ${{ github.head_ref }} - generate_release_notes: false - prerelease: false - name: AI Code Review - uses: https://gitea.jsc.idv.tw/jiantw83/code-review@develop + uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ github.head_ref }} with: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_BASE_URL: https://api.openai.com/v1 From 46dd8320d143524309d8bdc284463bd5739cc991 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 11 May 2026 07:52:21 +0000 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=E9=9A=8E=E6=AE=B5=E4=BA=8C=20-=20F?= =?UTF-8?q?indings=20=E7=94=A2=E7=94=9F=E8=88=87=E5=90=88=E4=BD=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/findings.js: 各角色分析 diff、讀取舊 findings、合併去重、等級排序 - app/main.js: 實作 Step2/Step3,log findings 統計 --- app/findings.js | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ app/main.js | 25 ++++++++++++++++---- 2 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 app/findings.js diff --git a/app/findings.js b/app/findings.js new file mode 100644 index 0000000..3975a80 --- /dev/null +++ b/app/findings.js @@ -0,0 +1,63 @@ +import fs from 'fs'; +import path from 'path'; +import { chatJSON } from './llm.js'; +import { FINDINGS_PATH } from './config.js'; + +const LEVELS = ['critical', 'warning', 'info']; + +/** + * 用單一角色分析 diff,回傳 findings 陣列 + */ +export async function analyzeWithRole(role, diff) { + console.log(` [${role.name}] 開始分析...`); + const findings = await chatJSON(role.system_prompt, `以下是 Git Diff 內容:\n\n${diff}`); + // 確保每筆都有必要欄位,並標記為新問題 + const valid = findings.filter(f => f.level && f.role && f.location && f.suggestion) + .map(f => ({ ...f, is_new: true })); + console.log(` [${role.name}] 找到 ${valid.length} 個問題`); + return valid; +} + +/** + * 讀取舊 findings(從 workspace 的 FINDINGS_PATH) + */ +export function loadOldFindings(workspace) { + const fullPath = path.join(workspace, FINDINGS_PATH); + if (!fs.existsSync(fullPath)) { + console.log(' 舊 findings 檔案不存在,視為空'); + return []; + } + try { + const data = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + const old = (Array.isArray(data) ? data : []).map(f => ({ ...f, is_new: false })); + console.log(` 讀取舊 findings: ${old.length} 筆`); + return old; + } catch (e) { + console.log(` ⚠️ 讀取舊 findings 失敗: ${e.message},視為空`); + return []; + } +} + +/** + * 合併新舊 findings,以 (role + location + suggestion前50字) 為 key 去除重複 + * 舊問題保留,新問題若與舊問題重複則捨棄 + */ +export function mergeFindings(oldFindings, newFindings) { + const key = f => `${f.role}|${f.location}|${String(f.suggestion).slice(0, 50)}`; + const seen = new Set(oldFindings.map(key)); + const deduped = newFindings.filter(f => { + if (seen.has(key(f))) return false; + seen.add(key(f)); + return true; + }); + const merged = [...oldFindings, ...deduped]; + console.log(` 合併結果: 舊=${oldFindings.length} 新(去重後)=${deduped.length} 總計=${merged.length}`); + return merged; +} + +/** + * 依等級排序(critical > warning > info) + */ +export function sortByLevel(findings) { + return [...findings].sort((a, b) => LEVELS.indexOf(a.level) - LEVELS.indexOf(b.level)); +} diff --git a/app/main.js b/app/main.js index 16981ba..e317d61 100644 --- a/app/main.js +++ b/app/main.js @@ -1,6 +1,9 @@ import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig } from './config.js'; import { loadRoles, getRoleIntro } from './roles.js'; import { getPRDiff, postComment } from './gitea.js'; +import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel } from './findings.js'; + +const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; async function main() { console.log('='.repeat(60)); @@ -46,11 +49,25 @@ async function main() { console.log(` ⚠️ comment 發布失敗(繼續執行): ${e.message}`); } - console.log('\n📊 Step2: Findings 產生(待實作)'); - console.log(' [stub] 各角色分析 diff...'); + // Step2: 各角色分析 diff 產生新 findings + console.log('\n📊 Step2: Findings 產生'); + const newFindings = []; + for (const role of roles) { + try { + const found = await analyzeWithRole(role, diff); + newFindings.push(...found); + } catch (e) { + console.log(` ⚠️ [${role.name}] 分析失敗(跳過): ${e.message}`); + } + } + console.log(` Step2 完成: 新 findings 總計 ${newFindings.length} 筆`); - console.log('\n🔀 Step3: Findings 合併與去重(待實作)'); - console.log(' [stub] 合併新舊 findings...'); + // Step3: 讀取舊 findings,合併去重 + console.log('\n🔀 Step3: Findings 合併'); + const oldFindings = loadOldFindings(WORKSPACE); + const mergedFindings = mergeFindings(oldFindings, newFindings); + const sorted = sortByLevel(mergedFindings); + console.log(` Step3 merged findings total=${sorted.length} (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`); console.log('\n📝 Step4: Findings 寫入與 Comment 發布(待實作)'); console.log(' [stub] 寫入 findings.json,發布 comment...');