Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0aefa66224 | |||
| 66d93abe24 | |||
| a4b87f9108 | |||
| 09533ff741 | |||
| e217b18c62 | |||
| cd0ced1b7f | |||
| 65cf45c558 | |||
| 09c78835e7 | |||
| ec05ce7869 | |||
| 0063f3282f | |||
| 8c3d0d9a6d | |||
| 3849bb2168 | |||
| 379938d6dc | |||
| 5bf39966d0 | |||
| 3509a882e1 | |||
| 1d2e8236de | |||
| d8423c74b1 | |||
| 94e974b5dc | |||
| a9a0b43ea5 | |||
| aa8234b5c7 | |||
| b0f2d45c11 | |||
| 3fd9a7e13d | |||
| 39cc5c932c | |||
| 255adbabe4 | |||
| a10fc8f176 | |||
| 9b39908394 |
@@ -1,7 +1,7 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"level": "critical",
|
"level": "critical",
|
||||||
"role": "Rex",
|
"role": "Leo",
|
||||||
"location": "app/git.js:11",
|
"location": "app/git.js:11",
|
||||||
"suggestion": "GITEA_TOKEN 直接嵌入 URL 中,可能導致憑證洩漏。建議使用環境變數或安全的憑證管理方式來處理敏感資訊。",
|
"suggestion": "GITEA_TOKEN 直接嵌入 URL 中,可能導致憑證洩漏。建議使用環境變數或安全的憑證管理方式來處理敏感資訊。",
|
||||||
"is_new": true
|
"is_new": true
|
||||||
@@ -16,41 +16,27 @@
|
|||||||
{
|
{
|
||||||
"level": "warning",
|
"level": "warning",
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "app/git.js:11",
|
|
||||||
"suggestion": "建議將 git 函式的實作細節封裝到一個獨立的模組中,以提高模組化和可重用性。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Leo",
|
|
||||||
"location": "app/git.js:41",
|
|
||||||
"suggestion": "建議在 try-catch 區塊中增加更詳細的錯誤處理,以便於未來的除錯和維護。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Zara",
|
|
||||||
"location": "app/git.js:25",
|
"location": "app/git.js:25",
|
||||||
"suggestion": "在使用 fs.existsSync 檢查目錄是否存在時,應考慮使用非同步方法以避免阻塞事件循環。",
|
"suggestion": "在使用 fs.existsSync 檢查目錄是否存在時,應考慮使用非同步方法以避免阻塞事件循環。",
|
||||||
"is_new": true
|
"is_new": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": "warning",
|
"level": "warning",
|
||||||
"role": "Zara",
|
"role": "Leo",
|
||||||
"location": "app/git.js:29",
|
"location": "app/git.js:29",
|
||||||
"suggestion": "在 git clone 時使用 --depth=1 可能會導致未來需要完整歷史紀錄時的性能問題,建議根據實際需求調整。",
|
"suggestion": "在 git clone 時使用 --depth=1 可能會導致未來需要完整歷史紀錄時的性能問題,建議根據實際需求調整。",
|
||||||
"is_new": true
|
"is_new": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": "warning",
|
"level": "warning",
|
||||||
"role": "Rex",
|
"role": "Leo",
|
||||||
"location": "app/git.js:11",
|
"location": "app/git.js:11",
|
||||||
"suggestion": "在使用 fs.copyFileSync 時,未檢查目標檔案是否存在,可能會覆蓋重要資料。建議在複製之前檢查檔案是否存在。",
|
"suggestion": "在使用 fs.copyFileSync 時,未檢查目標檔案是否存在,可能會覆蓋重要資料。建議在複製之前檢查檔案是否存在。",
|
||||||
"is_new": true
|
"is_new": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": "warning",
|
"level": "warning",
|
||||||
"role": "Maya",
|
"role": "Leo",
|
||||||
"location": "app/git.js:11",
|
"location": "app/git.js:11",
|
||||||
"suggestion": "在 commitAndPush 函數中,對於 git 操作的錯誤處理不夠完善,應該添加更多的測試來驗證不同情況下的行為。",
|
"suggestion": "在 commitAndPush 函數中,對於 git 操作的錯誤處理不夠完善,應該添加更多的測試來驗證不同情況下的行為。",
|
||||||
"is_new": true
|
"is_new": true
|
||||||
@@ -58,57 +44,15 @@
|
|||||||
{
|
{
|
||||||
"level": "info",
|
"level": "info",
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "app/git.js:5",
|
"location": ".gitea/workflows/review.yaml:5",
|
||||||
"suggestion": "建議為函式添加 JSDoc 註解,以提高文件完整性和可讀性。",
|
"suggestion": "建議在 'branches-ignore' 前加上空行,以提高可讀性。",
|
||||||
"is_new": true
|
"is_new": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": "info",
|
"level": "info",
|
||||||
"role": "Leo",
|
"role": "Leo",
|
||||||
"location": "app/git.js:10",
|
|
||||||
"suggestion": "建議將常數如 'repo' 提取為變數,以提高可讀性和可維護性。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"role": "Zara",
|
|
||||||
"location": "app/git.js:45",
|
"location": "app/git.js:45",
|
||||||
"suggestion": "考慮使用 async/await 來處理 fs.copyFileSync,以提高可讀性和錯誤處理能力。",
|
"suggestion": "考慮使用 async/await 來處理 fs.copyFileSync,以提高可讀性和錯誤處理能力。",
|
||||||
"is_new": true
|
"is_new": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"role": "Aria",
|
|
||||||
"location": "app/git.js:12",
|
|
||||||
"suggestion": "考慮將常量 GITEA_SERVER_URL、GITEA_REPOSITORY、GITEA_TOKEN、PR_HEAD_BRANCH 和 FINDINGS_PATH 的引入放在一起,以保持一致性。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"role": "Aria",
|
|
||||||
"location": "app/git.js:41",
|
|
||||||
"suggestion": "在 console.log 的訊息中,考慮使用更具描述性的文字來提高可讀性。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"role": "Aria",
|
|
||||||
"location": "app/git.js:43",
|
|
||||||
"suggestion": "考慮將錯誤處理的 console.log 訊息翻譯成英文,以保持一致性。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"role": "Maya",
|
|
||||||
"location": "app/git.js:11",
|
|
||||||
"suggestion": "建議為 git 函數添加測試,以確保其在不同參數下的行為正確。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "info",
|
|
||||||
"role": "Maya",
|
|
||||||
"location": "app/git.js:11",
|
|
||||||
"suggestion": "建議對 repoDir 的存在性檢查進行單元測試,以確保在不存在時能正確執行 clone 操作。",
|
|
||||||
"is_new": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
name: AI
|
name: AI
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.head_ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize]
|
|
||||||
branches-ignore:
|
branches-ignore:
|
||||||
- master
|
- master
|
||||||
|
types: [opened, synchronize]
|
||||||
jobs:
|
jobs:
|
||||||
version:
|
version:
|
||||||
name: 計算版本號
|
name: 計算版本號
|
||||||
|
|||||||
+30
-18
@@ -3,29 +3,39 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
|
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
|
||||||
|
|
||||||
function git(args, cwd) {
|
function makeRunner(spawn) {
|
||||||
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
return function run(args, cwd, env) {
|
||||||
if (result.error) throw result.error;
|
const opts = { cwd, encoding: 'utf8' };
|
||||||
if (result.status !== 0) throw new Error((result.stderr || result.stdout || '').trim());
|
if (env) opts.env = env;
|
||||||
return (result.stdout || '').trim();
|
const result = spawn('git', args, opts);
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) throw new Error((result.stderr || result.stdout || '').trim());
|
||||||
|
return (result.stdout || '').trim();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function commitAndPush(workspace) {
|
export async function commitAndPush(workspace, _spawnSync = spawnSync) {
|
||||||
const remoteUrl = GITEA_SERVER_URL.replace(/\/$/, '')
|
const run = makeRunner(_spawnSync);
|
||||||
.replace('https://', `https://${GITEA_TOKEN}@`)
|
|
||||||
.replace('http://', `http://${GITEA_TOKEN}@`) + `/${GITEA_REPOSITORY}.git`;
|
|
||||||
|
|
||||||
|
const baseUrl = GITEA_SERVER_URL.replace(/\/$/, '');
|
||||||
|
const remoteUrl = `${baseUrl}/${GITEA_REPOSITORY}.git`;
|
||||||
const repoDir = path.join(workspace, 'repo');
|
const repoDir = path.join(workspace, 'repo');
|
||||||
|
|
||||||
|
// Write a temporary askpass script so the token never appears in the URL or process list
|
||||||
|
const askpassScript = path.join(workspace, '.git-askpass.sh');
|
||||||
|
fs.writeFileSync(askpassScript, `#!/bin/sh\necho "${GITEA_TOKEN}"\n`, { mode: 0o700 });
|
||||||
|
|
||||||
|
const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, GIT_USERNAME: 'x-token' };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(repoDir)) {
|
if (!fs.existsSync(repoDir)) {
|
||||||
git(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace);
|
run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv);
|
||||||
}
|
}
|
||||||
|
|
||||||
git(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
|
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
|
||||||
git(['config', 'user.name', 'AI Review Bot'], repoDir);
|
run(['config', 'user.name', 'AI Review Bot'], repoDir);
|
||||||
git(['fetch', 'origin', PR_HEAD_BRANCH], repoDir);
|
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
|
||||||
git(['checkout', PR_HEAD_BRANCH], repoDir);
|
run(['checkout', PR_HEAD_BRANCH], repoDir);
|
||||||
|
|
||||||
// 將 findings.json 從 workspace 複製到 clone 的 repo
|
// 將 findings.json 從 workspace 複製到 clone 的 repo
|
||||||
const srcFindings = path.join(workspace, FINDINGS_PATH);
|
const srcFindings = path.join(workspace, FINDINGS_PATH);
|
||||||
@@ -33,19 +43,21 @@ export async function commitAndPush(workspace) {
|
|||||||
fs.mkdirSync(path.dirname(destFindings), { recursive: true });
|
fs.mkdirSync(path.dirname(destFindings), { recursive: true });
|
||||||
fs.copyFileSync(srcFindings, destFindings);
|
fs.copyFileSync(srcFindings, destFindings);
|
||||||
|
|
||||||
git(['add', FINDINGS_PATH], repoDir);
|
run(['add', FINDINGS_PATH], repoDir);
|
||||||
|
|
||||||
const status = git(['status', '--porcelain'], repoDir);
|
const status = run(['status', '--porcelain'], repoDir);
|
||||||
if (!status) {
|
if (!status) {
|
||||||
console.log(' findings.json 無變更,跳過 commit');
|
console.log(' findings.json 無變更,跳過 commit');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const out = git(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir);
|
const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir);
|
||||||
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
|
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
|
||||||
git(['push', remoteUrl, PR_HEAD_BRANCH], repoDir);
|
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
|
||||||
console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`);
|
console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`);
|
console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
try { fs.unlinkSync(askpassScript); } catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, before, after, beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import { commitAndPush } from './git.js';
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
function makeTmpWorkspace() {
|
||||||
|
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'git-test-'));
|
||||||
|
// Pre-create repo dir so clone branch is skipped
|
||||||
|
fs.mkdirSync(path.join(ws, 'repo'), { recursive: true });
|
||||||
|
// Create a findings.json to copy
|
||||||
|
const findingsDir = path.join(ws, '.gitea/ai-review');
|
||||||
|
fs.mkdirSync(findingsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(findingsDir, 'findings.json'), '[]');
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default stub: all commands succeed, status returns changes
|
||||||
|
function makeSpawn(overrides = {}) {
|
||||||
|
const calls = [];
|
||||||
|
const spawn = (cmd, args, opts) => {
|
||||||
|
const key = args[0];
|
||||||
|
calls.push({ cmd, args, opts });
|
||||||
|
if (overrides[key]) return overrides[key](args, opts);
|
||||||
|
if (key === 'status') return { status: 0, stdout: 'M .gitea/ai-review/findings.json', stderr: '', error: null };
|
||||||
|
if (key === 'commit') return { status: 0, stdout: '[feature-branch abc1234] chore', stderr: '', error: null };
|
||||||
|
return { status: 0, stdout: '', stderr: '', error: null };
|
||||||
|
};
|
||||||
|
spawn.calls = calls;
|
||||||
|
return spawn;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('commitAndPush', () => {
|
||||||
|
let workspace;
|
||||||
|
|
||||||
|
before(() => { workspace = makeTmpWorkspace(); });
|
||||||
|
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
|
||||||
|
beforeEach(() => {
|
||||||
|
// Remove leftover askpass scripts between tests
|
||||||
|
for (const f of fs.readdirSync(workspace)) {
|
||||||
|
if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not embed token in any git command argument', async () => {
|
||||||
|
const spawn = makeSpawn();
|
||||||
|
await commitAndPush(workspace, spawn);
|
||||||
|
|
||||||
|
for (const { args } of spawn.calls) {
|
||||||
|
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
|
||||||
|
const spawn = makeSpawn();
|
||||||
|
await commitAndPush(workspace, spawn);
|
||||||
|
|
||||||
|
const networkOps = ['fetch', 'push', 'clone'];
|
||||||
|
const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0]));
|
||||||
|
assert.ok(networkCalls.length > 0, 'expected at least one network git call');
|
||||||
|
|
||||||
|
for (const { args, opts } of networkCalls) {
|
||||||
|
assert.ok(opts?.env?.GIT_ASKPASS, `GIT_ASKPASS missing for git ${args[0]}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up askpass script after successful run', async () => {
|
||||||
|
await commitAndPush(workspace, makeSpawn());
|
||||||
|
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
||||||
|
assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up askpass script even when git fails', async () => {
|
||||||
|
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
||||||
|
await commitAndPush(workspace, failSpawn);
|
||||||
|
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
||||||
|
assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips commit when status shows no changes', async () => {
|
||||||
|
const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) });
|
||||||
|
await commitAndPush(workspace, spawn);
|
||||||
|
const commitCalled = spawn.calls.some(c => c.args[0] === 'commit');
|
||||||
|
assert.equal(commitCalled, false, 'commit should not run when there are no changes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when git command fails', async () => {
|
||||||
|
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
|
||||||
|
await assert.doesNotReject(() => commitAndPush(workspace, failSpawn));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
"name": "ai-code-review",
|
"name": "ai-code-review",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test app/git.test.js"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user