feat: 階段一 - 改用 Node.js 實作基本流程骨架

- 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 每個主要階段
This commit is contained in:
2026-05-11 07:24:47 +00:00
parent 1324f1575d
commit ec1f6c96e7
14 changed files with 179 additions and 210 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim FROM node:20-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
git \ git \
@@ -9,7 +9,7 @@ WORKDIR /action
COPY app/ /action/app/ COPY app/ /action/app/
COPY entrypoint.sh /entrypoint.sh 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 chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
+27
View File
@@ -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 };
}
-31
View File
@@ -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
+15
View File
@@ -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;
}
-26
View File
@@ -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()
+33
View File
@@ -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 [];
}
}
-50
View File
@@ -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 []
+71
View File
@@ -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);
});
-75
View File
@@ -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)
+10
View File
@@ -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"
}
}
-3
View File
@@ -1,3 +0,0 @@
requests==2.31.0
pyyaml==6.0.1
openai==1.12.0
+20
View File
@@ -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');
}
-22
View File
@@ -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)
+1 -1
View File
@@ -5,4 +5,4 @@ echo "🚀 AI Code Review Action 啟動"
echo "Repository: $GITEA_REPOSITORY" echo "Repository: $GITEA_REPOSITORY"
echo "PR: #$PR_NUMBER ($PR_HEAD_BRANCH -> $PR_BASE_BRANCH)" echo "PR: #$PR_NUMBER ($PR_HEAD_BRANCH -> $PR_BASE_BRANCH)"
exec python /action/app/main.py exec node /action/app/main.js