ai-git-tools 2.1.1 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -0
- package/README.md +120 -1
- package/bin/cli.js +18 -0
- package/package.json +4 -3
- package/src/commands/redmine-update.js +125 -0
- package/src/core/ai-client.js +26 -0
- package/src/pr-modules/ai/code-analyzer.js +2 -0
- package/src/redmine/config.js +27 -0
- package/src/redmine/git-evidence.js +186 -0
- package/src/redmine/issue-analyzer.js +110 -0
- package/src/redmine/issue-sync.js +151 -0
- package/src/redmine/redmine-client.js +147 -0
- package/src/redmine/redmine-formatters.js +151 -0
- package/src/utils/cli-helpers.js +8 -0
package/.env.example
ADDED
package/README.md
CHANGED
|
@@ -245,6 +245,125 @@ npx ai-git-tools model-info --json
|
|
|
245
245
|
|
|
246
246
|
> 第一次執行會連線到 Copilot CLI 取得模型清單,可能需要數秒鐘;後續預設使用 24 小時快取。
|
|
247
247
|
|
|
248
|
+
### `ai-git-tools redmine-update`
|
|
249
|
+
|
|
250
|
+
分析目前 Git branch 的程式變更,依照指定的 Redmine Issue 個別產生開發更新內容,並在確認後更新 Issue 的狀態與筆記。
|
|
251
|
+
|
|
252
|
+
> 注意:npm 套件不會也不應該保存你的 Redmine API key。npm 上發布的是讀取環境變數的程式碼;每位使用者都要在自己的本機或 CI 執行環境設定變數。
|
|
253
|
+
|
|
254
|
+
#### Redmine 設定
|
|
255
|
+
|
|
256
|
+
請先在 Redmine 管理介面啟用 REST API,並從個人帳號頁面建立具備 Issue 讀寫權限的 API key。API key 只放在環境變數,不要放在 CLI 參數、設定檔或 Git。
|
|
257
|
+
|
|
258
|
+
不同 shell 的設定語法如下。請只選擇符合你目前終端機的區塊執行:
|
|
259
|
+
|
|
260
|
+
**bash / zsh(macOS、Linux 常見)**
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
export REDMINE_URL="https://redmine.example.com"
|
|
264
|
+
export REDMINE_API_KEY="你的 Redmine API key"
|
|
265
|
+
|
|
266
|
+
# 只確認是否已設定,不顯示 key 內容
|
|
267
|
+
test -n "$REDMINE_URL" && echo "REDMINE_URL 已設定"
|
|
268
|
+
test -n "$REDMINE_API_KEY" && echo "REDMINE_API_KEY 已設定"
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**fish**
|
|
272
|
+
|
|
273
|
+
```fish
|
|
274
|
+
set -x REDMINE_URL "https://redmine.example.com"
|
|
275
|
+
set -x REDMINE_API_KEY "你的 Redmine API key"
|
|
276
|
+
|
|
277
|
+
test -n "$REDMINE_URL"; and echo "REDMINE_URL 已設定"
|
|
278
|
+
test -n "$REDMINE_API_KEY"; and echo "REDMINE_API_KEY 已設定"
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
**PowerShell**
|
|
282
|
+
|
|
283
|
+
```powershell
|
|
284
|
+
$env:REDMINE_URL = "https://redmine.example.com"
|
|
285
|
+
$env:REDMINE_API_KEY = "你的 Redmine API key"
|
|
286
|
+
|
|
287
|
+
if ($env:REDMINE_URL) { Write-Output "REDMINE_URL 已設定" }
|
|
288
|
+
if ($env:REDMINE_API_KEY) { Write-Output "REDMINE_API_KEY 已設定" }
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
**Windows Command Prompt(cmd)**
|
|
292
|
+
|
|
293
|
+
```bat
|
|
294
|
+
set REDMINE_URL=https://redmine.example.com
|
|
295
|
+
set REDMINE_API_KEY=你的 Redmine API key
|
|
296
|
+
|
|
297
|
+
if defined REDMINE_URL echo REDMINE_URL 已設定
|
|
298
|
+
if defined REDMINE_API_KEY echo REDMINE_API_KEY 已設定
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
完成環境變數設定後,`npx` 的使用方式完全相同:
|
|
302
|
+
|
|
303
|
+
```text
|
|
304
|
+
npx ai-git-tools redmine-update --issue 124 --preview --output redmine-update.json
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
環境變數只對目前的 shell 工作階段有效。若重新開啟終端機後不想重新設定,請使用各 shell 的啟動設定檔或作業系統的 Secret Manager;不要把真實 API key 提交到 Git。
|
|
308
|
+
|
|
309
|
+
也可以將專案內的 `.env.example` 複製成 `.env` 作為設定範本;目前 CLI 直接讀取 process environment,因此 shell 必須先載入該檔案:
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
cp .env.example .env
|
|
313
|
+
# 編輯 .env 填入實際 URL 與 API key
|
|
314
|
+
set -a
|
|
315
|
+
source .env
|
|
316
|
+
set +a
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
全域安裝或使用 `npx` 時,環境變數仍然設定在「執行命令的 shell」中:
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
npm install -g ai-git-tools
|
|
323
|
+
export REDMINE_URL="https://redmine.example.com"
|
|
324
|
+
export REDMINE_API_KEY="你的 Redmine API key"
|
|
325
|
+
ai-git-tools redmine-update --issue 124 --preview --output redmine-update.json
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
發布前可用以下指令確認 `.env.example` 會被包含在 npm 套件中,但不會包含 `.env`:
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
npm pack --dry-run
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
#### Preview 與 Apply
|
|
335
|
+
|
|
336
|
+
沒有 `--pr` 時,工具會比較目前 branch 與 `origin` 上依 branch 名稱版本號排序後最新的 `release-*` branch;`--base` 可指定其他比較基準。每個 `--issue` 都會先獨立讀取 Redmine 的 subject、description、status、tracker、custom fields 與 allowed statuses,再與共用 Git evidence 分開分析。
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
ai-git-tools redmine-update \
|
|
340
|
+
--issue 124 \
|
|
341
|
+
--issue 125 \
|
|
342
|
+
--issue 126 \
|
|
343
|
+
--issue 127 \
|
|
344
|
+
--preview \
|
|
345
|
+
--output redmine-update.json
|
|
346
|
+
|
|
347
|
+
# 套用已檢查的草稿,不重新呼叫 AI
|
|
348
|
+
ai-git-tools redmine-update --apply --from redmine-update.json
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
`--pr <number>` 是可選的補充 evidence,會加入 PR title、body、URL、merge state、commits 與 changed files;沒有 PR 仍可完整分析本機 Git diff。沒有 PR 時,工具不會自動推論已 merge、已部署或必須關閉 Issue。
|
|
352
|
+
|
|
353
|
+
Preview 會以色彩分開顯示每個 Issue 的開發內容、程式修改重點、API 變更、重要技術細節、修改檔案與實際 note。狀態由 Redmine API 提供選單,只有使用者選擇的 status 才會寫入。
|
|
354
|
+
|
|
355
|
+
原始 Issue description 會保留不覆寫,更新內容會寫入 notes。只有找到明確測試或驗證證據時才會加入驗證段落;流程圖則使用 Redmine Mermaid macro:
|
|
356
|
+
|
|
357
|
+
```text
|
|
358
|
+
{{mermaid
|
|
359
|
+
flowchart TD
|
|
360
|
+
A[開始] --> B[處理]
|
|
361
|
+
B --> C[完成]
|
|
362
|
+
}}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Issue note 的基本責任分工如下:`description` 保存原始需求,`status` 保存目前狀態,`notes` 保存 Git 開發與同步紀錄。
|
|
366
|
+
|
|
248
367
|
## ⚙️ 配置
|
|
249
368
|
|
|
250
369
|
配置檔案範例(\`.ai-git-config.js\`):
|
|
@@ -321,7 +440,7 @@ github: {
|
|
|
321
440
|
|
|
322
441
|
## 🔧 環境需求
|
|
323
442
|
|
|
324
|
-
- **Node.js** >=
|
|
443
|
+
- **Node.js** >= 20.19.0(或 >= 22.12.0)
|
|
325
444
|
- **Git** 已安裝並設定
|
|
326
445
|
- **GitHub CLI** (用於 PR 功能)
|
|
327
446
|
\`\`\`bash
|
package/bin/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ import { prCommand } from '../src/commands/pr.js';
|
|
|
16
16
|
import { initCommand } from '../src/commands/init.js';
|
|
17
17
|
import { usageCommand } from '../src/commands/usage.js';
|
|
18
18
|
import { modelInfoCommand } from '../src/commands/model-info.js';
|
|
19
|
+
import { redmineUpdateCommand } from '../src/commands/redmine-update.js';
|
|
19
20
|
import { registerCommand } from '../src/utils/cli-helpers.js';
|
|
20
21
|
|
|
21
22
|
// 讀取 package.json 獲取版本號
|
|
@@ -84,4 +85,21 @@ registerCommand(program, 'model-info', '查看目前可用的 AI 模型資訊',
|
|
|
84
85
|
{ flags: '--no-cache', description: '忽略快取,強制重新連線 Copilot 取得最新模型清單' },
|
|
85
86
|
], modelInfoCommand);
|
|
86
87
|
|
|
88
|
+
// Redmine Issue 更新命令
|
|
89
|
+
registerCommand(program, 'redmine-update', '分析並更新 Redmine Issue 的開發內容', [
|
|
90
|
+
{
|
|
91
|
+
flags: '--issue <id>',
|
|
92
|
+
description: '指定 Redmine Issue ID,可重複使用',
|
|
93
|
+
argParser: (value, previous = []) => [...previous, value],
|
|
94
|
+
defaultValue: [],
|
|
95
|
+
},
|
|
96
|
+
{ flags: '--pr <number>', description: '可選的 GitHub Pull Request 編號' },
|
|
97
|
+
{ flags: '--base <branch>', description: '指定 Git 比較基準 branch' },
|
|
98
|
+
{ flags: '--preview', description: '產生並顯示更新預覽,不修改 Redmine' },
|
|
99
|
+
{ flags: '--output <file>', description: '保存 preview 草稿 JSON' },
|
|
100
|
+
{ flags: '--apply', description: '套用已審核的草稿' },
|
|
101
|
+
{ flags: '--from <file>', description: '指定要套用的 preview 草稿 JSON' },
|
|
102
|
+
{ flags: '--force', description: '強制略過衝突與重複同步檢查' },
|
|
103
|
+
], redmineUpdateCommand);
|
|
104
|
+
|
|
87
105
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-git-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.3",
|
|
4
4
|
"description": "AI-powered Git automation tools for commit messages and PR generation",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"ai-git-tools": "
|
|
8
|
+
"ai-git-tools": "bin/cli.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"test": "node --test test/*.test.js",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"author": "Yiso Tsao <yiso05255@gmail.com>",
|
|
28
28
|
"license": "MIT",
|
|
29
29
|
"engines": {
|
|
30
|
-
"node": "
|
|
30
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"bin/",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"templates/",
|
|
36
36
|
"README.md",
|
|
37
37
|
"README.zh-TW.md",
|
|
38
|
+
".env.example",
|
|
38
39
|
"LICENSE",
|
|
39
40
|
"CHANGELOG.md"
|
|
40
41
|
],
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redmine Issue 更新命令
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
6
|
+
import { loadConfig } from '../core/config-loader.js';
|
|
7
|
+
import { collectGitEvidence, collectPullRequestEvidence } from '../redmine/git-evidence.js';
|
|
8
|
+
import { buildRedmineConfig } from '../redmine/config.js';
|
|
9
|
+
import { RedmineClient } from '../redmine/redmine-client.js';
|
|
10
|
+
import {
|
|
11
|
+
applyRedmineDraft,
|
|
12
|
+
generateRedmineDraft,
|
|
13
|
+
parseDraft,
|
|
14
|
+
serializeDraft,
|
|
15
|
+
} from '../redmine/issue-sync.js';
|
|
16
|
+
import { formatRedminePreview as renderPreview } from '../redmine/redmine-formatters.js';
|
|
17
|
+
|
|
18
|
+
function printApplyResults(results) {
|
|
19
|
+
for (const result of results) {
|
|
20
|
+
if (result.applied) {
|
|
21
|
+
console.log(`✅ Issue #${result.issueId} 已更新`);
|
|
22
|
+
} else if (result.skipped) {
|
|
23
|
+
const reason = result.reason === 'duplicate' ? '已同步,略過重複更新' : '資料已變更,略過更新';
|
|
24
|
+
console.log(`⚠️ Issue #${result.issueId} ${reason}`);
|
|
25
|
+
} else {
|
|
26
|
+
console.log(`❌ Issue #${result.issueId} 更新失敗:${result.error}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 驗證 Redmine update CLI 參數
|
|
33
|
+
* @param {object} options
|
|
34
|
+
*/
|
|
35
|
+
export function validateRedmineOptions(options = {}) {
|
|
36
|
+
if (options.apply && options.preview) {
|
|
37
|
+
throw new Error('--apply 與 --preview 不可同時使用');
|
|
38
|
+
}
|
|
39
|
+
if (options.apply && !options.from) {
|
|
40
|
+
throw new Error('--apply 必須搭配 --from <file> 使用');
|
|
41
|
+
}
|
|
42
|
+
if (options.from && !options.apply) {
|
|
43
|
+
throw new Error('--from 只能搭配 --apply 使用');
|
|
44
|
+
}
|
|
45
|
+
if (!options.apply && !options.preview) {
|
|
46
|
+
throw new Error('請使用 --preview 產生草稿,確認後再使用 --apply --from <file>');
|
|
47
|
+
}
|
|
48
|
+
if (!options.apply && (!options.issue || options.issue.length === 0)) {
|
|
49
|
+
throw new Error('preview 模式至少需要一個 --issue <id>');
|
|
50
|
+
}
|
|
51
|
+
const invalidIssueIds = (options.issue || []).filter(id => !/^[1-9]\d*$/.test(String(id)));
|
|
52
|
+
if (invalidIssueIds.length > 0) {
|
|
53
|
+
throw new Error(`Issue ID 必須是正整數:${invalidIssueIds.join('、')}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 判斷 apply 是否有實際失敗的 Issue
|
|
59
|
+
* @param {Array<object>} results
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
export function hasApplyFailures(results = []) {
|
|
63
|
+
return results.some(result => result.applied === false && !result.skipped);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 確認 preview 至少產生一筆可套用草稿
|
|
68
|
+
* @param {object} draft
|
|
69
|
+
*/
|
|
70
|
+
export function validateGeneratedDraft(draft = {}) {
|
|
71
|
+
if (!Array.isArray(draft.drafts) || draft.drafts.length === 0) {
|
|
72
|
+
const details = draft.failures?.map(failure => `#${failure.issueId}: ${failure.error}`).join(';');
|
|
73
|
+
throw new Error(
|
|
74
|
+
`沒有成功產生任何 Issue 更新草稿${details ? `。失敗原因:${details}` : ''}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 執行 Redmine Issue preview 或 apply
|
|
81
|
+
* @param {object} options
|
|
82
|
+
*/
|
|
83
|
+
export async function redmineUpdateCommand(options = {}) {
|
|
84
|
+
validateRedmineOptions(options);
|
|
85
|
+
const config = await loadConfig();
|
|
86
|
+
const redmineConfig = buildRedmineConfig(config, process.env);
|
|
87
|
+
const client = new RedmineClient(redmineConfig);
|
|
88
|
+
|
|
89
|
+
if (options.apply) {
|
|
90
|
+
const draft = parseDraft(readFileSync(options.from, 'utf-8'));
|
|
91
|
+
const results = await applyRedmineDraft({ draft, client, force: options.force });
|
|
92
|
+
printApplyResults(results);
|
|
93
|
+
if (hasApplyFailures(results)) {
|
|
94
|
+
throw new Error('部分 Redmine Issue 更新失敗,請查看上方結果並重新處理失敗項目');
|
|
95
|
+
}
|
|
96
|
+
return results;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const evidence = collectGitEvidence({ baseBranch: options.base });
|
|
100
|
+
const pullRequest = options.pr ? collectPullRequestEvidence(options.pr) : null;
|
|
101
|
+
const draft = await generateRedmineDraft({
|
|
102
|
+
issueIds: options.issue,
|
|
103
|
+
client,
|
|
104
|
+
evidence,
|
|
105
|
+
pullRequest,
|
|
106
|
+
model: config.ai.model,
|
|
107
|
+
maxRetries: config.ai.maxRetries,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
validateGeneratedDraft(draft);
|
|
111
|
+
console.log(renderPreview(draft.drafts));
|
|
112
|
+
for (const failure of draft.failures) {
|
|
113
|
+
console.log(`❌ Issue #${failure.issueId} 無法產生更新:${failure.error}`);
|
|
114
|
+
}
|
|
115
|
+
console.log('\n尚未修改 Redmine。');
|
|
116
|
+
if (options.output) {
|
|
117
|
+
writeFileSync(options.output, serializeDraft(draft), 'utf-8');
|
|
118
|
+
console.log(`預覽草稿已保存:${options.output}`);
|
|
119
|
+
console.log(`請使用 --apply --from ${options.output} 套用。`);
|
|
120
|
+
} else {
|
|
121
|
+
console.log('請重新執行並加入 --output redmine-update.json 保存草稿。');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return draft;
|
|
125
|
+
}
|
package/src/core/ai-client.js
CHANGED
|
@@ -6,6 +6,31 @@
|
|
|
6
6
|
import { CopilotClient, approveAll } from '@github/copilot-sdk';
|
|
7
7
|
|
|
8
8
|
export class AIClient {
|
|
9
|
+
/**
|
|
10
|
+
* 判斷 Node.js 是否符合 Copilot SDK 的版本需求
|
|
11
|
+
* @param {string} version
|
|
12
|
+
* @returns {boolean}
|
|
13
|
+
*/
|
|
14
|
+
static isSupportedNodeVersion(version = process.versions.node) {
|
|
15
|
+
const match = String(version).match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
16
|
+
if (!match) return false;
|
|
17
|
+
|
|
18
|
+
const major = Number(match[1]);
|
|
19
|
+
const minor = Number(match[2]);
|
|
20
|
+
return (major === 20 && minor >= 19) || (major >= 22 && (major > 22 || minor >= 12));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 確認 Node.js 符合 Copilot SDK 的版本需求
|
|
25
|
+
*/
|
|
26
|
+
static assertSupportedNodeVersion() {
|
|
27
|
+
if (!AIClient.isSupportedNodeVersion()) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`目前 Node.js ${process.versions.node} 不符合 Copilot SDK 需求。請升級至 Node.js 20.19+ 或 22.12+。`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
9
34
|
/**
|
|
10
35
|
* 檢測是否為 Copilot 授權相關的錯誤
|
|
11
36
|
*/
|
|
@@ -30,6 +55,7 @@ export class AIClient {
|
|
|
30
55
|
* 發送 prompt 並等待回應(帶重試機制和超時保護)
|
|
31
56
|
*/
|
|
32
57
|
static async sendAndWait(prompt, model = 'claude-haiku-4.5', maxRetries = 3, timeout = 150000) {
|
|
58
|
+
AIClient.assertSupportedNodeVersion();
|
|
33
59
|
let lastError = null;
|
|
34
60
|
|
|
35
61
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CopilotClient, approveAll } from '@github/copilot-sdk';
|
|
2
|
+
import { AIClient } from '../../core/ai-client.js';
|
|
2
3
|
import { CONSTANTS } from '../../utils/constants.js';
|
|
3
4
|
import { log } from '../../utils/logger.js';
|
|
4
5
|
import { isCopilotAuthError } from '../../utils/helpers.js';
|
|
@@ -26,6 +27,7 @@ export class AIAnalyzer {
|
|
|
26
27
|
* 取得(或建立)共用的 CopilotClient
|
|
27
28
|
*/
|
|
28
29
|
async _getOrCreateClient() {
|
|
30
|
+
AIClient.assertSupportedNodeVersion();
|
|
29
31
|
if (!this._client) {
|
|
30
32
|
this._client = new CopilotClient();
|
|
31
33
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redmine 連線設定
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 建立 Redmine API 設定
|
|
7
|
+
* @param {object} projectConfig
|
|
8
|
+
* @param {NodeJS.ProcessEnv} env
|
|
9
|
+
* @returns {{url: string, apiKey: string}}
|
|
10
|
+
*/
|
|
11
|
+
export function buildRedmineConfig(projectConfig = {}, env = process.env) {
|
|
12
|
+
const url = env.REDMINE_URL || projectConfig.redmine?.url;
|
|
13
|
+
const apiKey = env.REDMINE_API_KEY;
|
|
14
|
+
|
|
15
|
+
if (!url || !apiKey) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
'缺少 Redmine 設定。請先設定 REDMINE_URL 與 REDMINE_API_KEY 環境變數。\n' +
|
|
18
|
+
'請依照目前使用的 shell 參考 README 中的 Redmine 設定說明。\n' +
|
|
19
|
+
'API key 不要放在 CLI 參數或提交到 Git。'
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
url: url.replace(/\/+$/, ''),
|
|
25
|
+
apiKey,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { GitOperations } from '../core/git-operations.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 解析 release branch 名稱中的版本 token
|
|
6
|
+
* @param {string} branchName
|
|
7
|
+
* @returns {{year: number, cycle: number, patch: number, cadence: string} | null}
|
|
8
|
+
*/
|
|
9
|
+
export function parseReleaseVersion(branchName) {
|
|
10
|
+
const normalized = branchName.replace(/^origin\//, '');
|
|
11
|
+
const semverMatch = normalized.match(/^release-(\d+)\.(\d+)\.(\d+)$/i);
|
|
12
|
+
if (semverMatch) {
|
|
13
|
+
return {
|
|
14
|
+
major: Number(semverMatch[1]),
|
|
15
|
+
minor: Number(semverMatch[2]),
|
|
16
|
+
patch: Number(semverMatch[3]),
|
|
17
|
+
cadence: 'semver',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const match = normalized.match(/^release-(\d{4})-([mw])(\d+)(?:\.(\d+))?$/i);
|
|
22
|
+
if (!match) return null;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
year: Number(match[1]),
|
|
26
|
+
cycle: Number(match[3]),
|
|
27
|
+
patch: Number(match[4] || 0),
|
|
28
|
+
cadence: match[2].toLowerCase(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 依版本號選擇最新 release branch
|
|
34
|
+
* @param {string[]} branches
|
|
35
|
+
* @returns {string | null}
|
|
36
|
+
*/
|
|
37
|
+
export function selectLatestReleaseBranch(branches = []) {
|
|
38
|
+
const normalized = branches
|
|
39
|
+
.map(branch => branch.replace(/^origin\//, ''))
|
|
40
|
+
.filter(branch => branch.startsWith('release-'));
|
|
41
|
+
const monthly = normalized.filter(branch => parseReleaseVersion(branch)?.cadence === 'm');
|
|
42
|
+
const weekly = normalized.filter(branch => parseReleaseVersion(branch)?.cadence === 'w');
|
|
43
|
+
const semver = normalized.filter(branch => parseReleaseVersion(branch)?.cadence === 'semver');
|
|
44
|
+
const candidates = monthly.length > 0 ? monthly : weekly.length > 0 ? weekly : semver;
|
|
45
|
+
|
|
46
|
+
return candidates
|
|
47
|
+
.filter(branch => parseReleaseVersion(branch))
|
|
48
|
+
.sort((left, right) => {
|
|
49
|
+
const leftVersion = parseReleaseVersion(left);
|
|
50
|
+
const rightVersion = parseReleaseVersion(right);
|
|
51
|
+
if (leftVersion.cadence === 'semver' && rightVersion.cadence === 'semver') {
|
|
52
|
+
return (
|
|
53
|
+
rightVersion.major - leftVersion.major ||
|
|
54
|
+
rightVersion.minor - leftVersion.minor ||
|
|
55
|
+
rightVersion.patch - leftVersion.patch
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
rightVersion.year - leftVersion.year ||
|
|
60
|
+
rightVersion.cycle - leftVersion.cycle ||
|
|
61
|
+
rightVersion.patch - leftVersion.patch
|
|
62
|
+
);
|
|
63
|
+
})[0] || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 從 GitHub remote 解析 repository
|
|
68
|
+
* @param {string} remoteUrl
|
|
69
|
+
* @returns {{owner: string, repo: string} | null}
|
|
70
|
+
*/
|
|
71
|
+
export function parseGitHubRemote(remoteUrl = '') {
|
|
72
|
+
const match = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
73
|
+
if (!match) return null;
|
|
74
|
+
return { owner: match[1], repo: match[2] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function run(command, args, execImpl = execFileSync) {
|
|
78
|
+
return execImpl(command, args, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getRemoteBranches(execImpl) {
|
|
82
|
+
const remoteUrl = run('git', ['remote', 'get-url', 'origin'], execImpl);
|
|
83
|
+
const repository = parseGitHubRemote(remoteUrl);
|
|
84
|
+
if (!repository) throw new Error('無法從 origin remote 解析 GitHub repository');
|
|
85
|
+
|
|
86
|
+
const output = run(
|
|
87
|
+
'gh',
|
|
88
|
+
['api', `repos/${repository.owner}/${repository.repo}/branches`, '--paginate', '--jq', '.[].name'],
|
|
89
|
+
execImpl
|
|
90
|
+
);
|
|
91
|
+
return output.split('\n').filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getLocalRemoteBranches(execImpl) {
|
|
95
|
+
try {
|
|
96
|
+
execImpl('git', ['fetch', '--prune', 'origin'], { stdio: 'ignore' });
|
|
97
|
+
} catch {
|
|
98
|
+
// 使用本機已快取的 remote branches
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return run('git', ['branch', '-r'], execImpl)
|
|
102
|
+
.split('\n')
|
|
103
|
+
.map(branch => branch.trim().replace(/^origin\//, ''))
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 解析要比較的 base branch
|
|
109
|
+
* @param {{baseBranch?: string, execImpl?: Function}} options
|
|
110
|
+
* @returns {{branch: string, source: string}}
|
|
111
|
+
*/
|
|
112
|
+
export function resolveBaseBranch({ baseBranch, execImpl = execFileSync } = {}) {
|
|
113
|
+
if (baseBranch) return { branch: baseBranch.replace(/^origin\//, ''), source: 'explicit' };
|
|
114
|
+
|
|
115
|
+
let branches;
|
|
116
|
+
try {
|
|
117
|
+
branches = getRemoteBranches(execImpl);
|
|
118
|
+
} catch {
|
|
119
|
+
branches = getLocalRemoteBranches(execImpl);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const branch = selectLatestReleaseBranch(branches);
|
|
123
|
+
if (!branch) {
|
|
124
|
+
throw new Error('找不到可用的 release branch,請使用 --base <branch> 指定比較基準');
|
|
125
|
+
}
|
|
126
|
+
return { branch, source: 'latest-versioned-release' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 收集目前 branch 相對於 base 的 Git 變更
|
|
131
|
+
* @param {{baseBranch?: string, headBranch?: string, git?: typeof GitOperations}} options
|
|
132
|
+
* @returns {object}
|
|
133
|
+
*/
|
|
134
|
+
export function collectGitEvidence({ baseBranch, headBranch, git = GitOperations, execImpl = execFileSync } = {}) {
|
|
135
|
+
const resolvedBase = resolveBaseBranch({ baseBranch, execImpl });
|
|
136
|
+
const head = headBranch || git.getCurrentBranch();
|
|
137
|
+
let repository = null;
|
|
138
|
+
try {
|
|
139
|
+
repository = parseGitHubRemote(run('git', ['remote', 'get-url', 'origin'], execImpl));
|
|
140
|
+
} catch {
|
|
141
|
+
// 本地分析仍可使用 Git evidence,不要求 repository metadata
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
currentBranch: head,
|
|
146
|
+
baseBranch: resolvedBase.branch,
|
|
147
|
+
baseSource: resolvedBase.source,
|
|
148
|
+
repository: repository ? `${repository.owner}/${repository.repo}` : null,
|
|
149
|
+
commits: git.getCommits(resolvedBase.branch, head, { useRemoteHead: false }),
|
|
150
|
+
changedFiles: git.getChangedFiles(resolvedBase.branch, head, false),
|
|
151
|
+
diff: git.truncateDiff(git.getDiff(resolvedBase.branch, head, false)),
|
|
152
|
+
stats: git.getChangeStats(resolvedBase.branch, head, false),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 取得可選的 GitHub PR evidence
|
|
158
|
+
* @param {number|string} pullRequestNumber
|
|
159
|
+
* @param {Function} execImpl
|
|
160
|
+
* @returns {object}
|
|
161
|
+
*/
|
|
162
|
+
export function collectPullRequestEvidence(pullRequestNumber, execImpl = execFileSync) {
|
|
163
|
+
const raw = run(
|
|
164
|
+
'gh',
|
|
165
|
+
[
|
|
166
|
+
'pr',
|
|
167
|
+
'view',
|
|
168
|
+
String(pullRequestNumber),
|
|
169
|
+
'--json',
|
|
170
|
+
'title,body,url,state,mergedAt,commits,files',
|
|
171
|
+
],
|
|
172
|
+
execImpl
|
|
173
|
+
);
|
|
174
|
+
const pullRequest = JSON.parse(raw);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
number: Number(pullRequestNumber),
|
|
178
|
+
title: pullRequest.title || '',
|
|
179
|
+
body: pullRequest.body || '',
|
|
180
|
+
url: pullRequest.url || '',
|
|
181
|
+
state: pullRequest.state || '',
|
|
182
|
+
mergedAt: pullRequest.mergedAt || null,
|
|
183
|
+
commits: pullRequest.commits || [],
|
|
184
|
+
changedFiles: pullRequest.files || [],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { AIClient } from '../core/ai-client.js';
|
|
2
|
+
|
|
3
|
+
const ANALYSIS_FIELDS = [
|
|
4
|
+
'developmentSummary',
|
|
5
|
+
'codeChanges',
|
|
6
|
+
'apiChanges',
|
|
7
|
+
'technicalDetails',
|
|
8
|
+
'modifiedFiles',
|
|
9
|
+
'verification',
|
|
10
|
+
'unresolvedItems',
|
|
11
|
+
'evidence',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function asStringArray(value) {
|
|
15
|
+
return Array.isArray(value) ? value.filter(item => typeof item === 'string') : [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function filterEvidenceBackedClaims(result, evidence = {}) {
|
|
19
|
+
const source = JSON.stringify(evidence);
|
|
20
|
+
const evidenceRefs = result.evidence.filter(
|
|
21
|
+
reference => reference.trim() && source.includes(reference)
|
|
22
|
+
);
|
|
23
|
+
const changedFiles = new Set(evidence.changedFiles || []);
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
...result,
|
|
27
|
+
evidence: evidenceRefs,
|
|
28
|
+
apiChanges: evidenceRefs.length > 0 ? result.apiChanges : [],
|
|
29
|
+
verification: evidenceRefs.length > 0 ? result.verification : [],
|
|
30
|
+
modifiedFiles: result.modifiedFiles.filter(file => changedFiles.has(file)),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 建立單一 Redmine Issue 的 AI 分析 prompt
|
|
36
|
+
* @param {{issue: object, evidence: object}} input
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function buildIssueAnalysisPrompt({ issue, evidence }) {
|
|
40
|
+
return `你是資深軟體工程師,請針對指定的 Redmine Issue 分析目前的實際程式變更。
|
|
41
|
+
|
|
42
|
+
重要規則:
|
|
43
|
+
- 這次只分析指定的 Issue #${issue.id},不得把結果指派給其他 Issue。
|
|
44
|
+
- 只能描述下方 Issue、Git commits、changed files、diff 或 PR evidence 中有明確證據的內容。
|
|
45
|
+
- 不得捏造 API endpoint、參數、檔案、function、class、測試或部署結果。
|
|
46
|
+
- 沒有明確測試或驗證證據時,verification 必須是空陣列。
|
|
47
|
+
- 沒有可靠 API 變更證據時,apiChanges 必須是空陣列。
|
|
48
|
+
- 若能從實際流程推導出可靠的複製流程,才產生 flowchart;否則為 null。
|
|
49
|
+
- 不要產生 suggestedStatus、coverage 或 confidence,也不要決定 Redmine status。
|
|
50
|
+
|
|
51
|
+
## 指定 Redmine Issue
|
|
52
|
+
${JSON.stringify(issue, null, 2)}
|
|
53
|
+
|
|
54
|
+
## 共用 Git / PR Evidence
|
|
55
|
+
${JSON.stringify(evidence, null, 2)}
|
|
56
|
+
|
|
57
|
+
請只回傳 JSON,不要使用 Markdown code fence,格式如下:
|
|
58
|
+
{
|
|
59
|
+
"issueId": ${issue.id},
|
|
60
|
+
"developmentSummary": [],
|
|
61
|
+
"codeChanges": [],
|
|
62
|
+
"apiChanges": [],
|
|
63
|
+
"technicalDetails": [],
|
|
64
|
+
"modifiedFiles": [],
|
|
65
|
+
"verification": [],
|
|
66
|
+
"flowchart": null,
|
|
67
|
+
"unresolvedItems": [],
|
|
68
|
+
"evidence": []
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
各欄位都必須是字串陣列,flowchart 必須是合法 Mermaid source 字串或 null。`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 解析並正規化 AI 分析結果
|
|
76
|
+
* @param {string} content
|
|
77
|
+
* @param {number|string} issueId
|
|
78
|
+
* @returns {object}
|
|
79
|
+
*/
|
|
80
|
+
export function parseIssueAnalysis(content, issueId) {
|
|
81
|
+
const parsed = AIClient.parseJSON(content);
|
|
82
|
+
const result = { issueId };
|
|
83
|
+
|
|
84
|
+
for (const field of ANALYSIS_FIELDS) {
|
|
85
|
+
result[field] = asStringArray(parsed[field]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
result.flowchart = typeof parsed.flowchart === 'string' && parsed.flowchart.trim()
|
|
89
|
+
? parsed.flowchart.trim()
|
|
90
|
+
: null;
|
|
91
|
+
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 針對單一 Issue 呼叫 AI
|
|
97
|
+
* @param {{issue: object, evidence: object, aiClient?: typeof AIClient, model?: string, maxRetries?: number}} input
|
|
98
|
+
* @returns {Promise<object>}
|
|
99
|
+
*/
|
|
100
|
+
export async function analyzeIssue({
|
|
101
|
+
issue,
|
|
102
|
+
evidence,
|
|
103
|
+
aiClient = AIClient,
|
|
104
|
+
model = 'claude-haiku-4.5',
|
|
105
|
+
maxRetries = 3,
|
|
106
|
+
}) {
|
|
107
|
+
const prompt = buildIssueAnalysisPrompt({ issue, evidence });
|
|
108
|
+
const content = await aiClient.sendAndWait(prompt, model, maxRetries);
|
|
109
|
+
return filterEvidenceBackedClaims(parseIssueAnalysis(content, issue.id), evidence);
|
|
110
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { analyzeIssue as defaultAnalyzeIssue } from './issue-analyzer.js';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import {
|
|
4
|
+
formatRedmineNote,
|
|
5
|
+
formatRedminePreview,
|
|
6
|
+
selectIssueStatus,
|
|
7
|
+
} from './redmine-formatters.js';
|
|
8
|
+
|
|
9
|
+
function uniqueIssueIds(issueIds = []) {
|
|
10
|
+
return [...new Set(issueIds.map(id => Number(id)).filter(Number.isInteger))];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 建立單一 Issue 的同步識別
|
|
15
|
+
* @param {{issueId: number|string, evidence: object, pullRequest?: object|null}}
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function createSyncId({ issueId, evidence = {}, pullRequest = null }) {
|
|
19
|
+
const identity = pullRequest?.number
|
|
20
|
+
? `pr-${pullRequest.url || pullRequest.number}`
|
|
21
|
+
: `${evidence.repository || 'local'}-${evidence.currentBranch || 'branch'}-${createHash('sha256')
|
|
22
|
+
.update(`${evidence.commits || ''}\n${evidence.diff || ''}`)
|
|
23
|
+
.digest('hex')
|
|
24
|
+
.slice(0, 12)}`;
|
|
25
|
+
return `redmine-sync:${issueId}:${identity}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 產生一份待審核的 Redmine 更新草稿
|
|
30
|
+
* @param {object} options
|
|
31
|
+
* @returns {Promise<object>}
|
|
32
|
+
*/
|
|
33
|
+
export async function generateRedmineDraft({
|
|
34
|
+
issueIds,
|
|
35
|
+
client,
|
|
36
|
+
evidence,
|
|
37
|
+
pullRequest = null,
|
|
38
|
+
analyzeIssueFn = defaultAnalyzeIssue,
|
|
39
|
+
selectStatusFn = selectIssueStatus,
|
|
40
|
+
model,
|
|
41
|
+
maxRetries,
|
|
42
|
+
}) {
|
|
43
|
+
const drafts = [];
|
|
44
|
+
const failures = [];
|
|
45
|
+
for (const issueId of uniqueIssueIds(issueIds)) {
|
|
46
|
+
try {
|
|
47
|
+
const issue = await client.getIssue(issueId);
|
|
48
|
+
const analysis = await analyzeIssueFn({ issue, evidence: { ...evidence, pullRequest }, model, maxRetries });
|
|
49
|
+
const selectedStatus = await selectStatusFn(issue, { client });
|
|
50
|
+
const syncId = createSyncId({ issueId, evidence, pullRequest });
|
|
51
|
+
const note = formatRedmineNote({
|
|
52
|
+
issueId,
|
|
53
|
+
analysis,
|
|
54
|
+
evidence,
|
|
55
|
+
pullRequest,
|
|
56
|
+
syncId,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
drafts.push({
|
|
60
|
+
issueId,
|
|
61
|
+
subject: issue.subject,
|
|
62
|
+
originalStatusId: issue.status?.id ?? null,
|
|
63
|
+
originalStatusName: issue.status?.name || '',
|
|
64
|
+
originalDescription: issue.description,
|
|
65
|
+
statusId: selectedStatus.statusId,
|
|
66
|
+
statusName: selectedStatus.statusName,
|
|
67
|
+
analysis,
|
|
68
|
+
note,
|
|
69
|
+
syncId,
|
|
70
|
+
});
|
|
71
|
+
} catch (error) {
|
|
72
|
+
failures.push({ issueId, error: error.message });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
version: 1,
|
|
78
|
+
generatedAt: new Date().toISOString(),
|
|
79
|
+
evidence,
|
|
80
|
+
pullRequest,
|
|
81
|
+
drafts,
|
|
82
|
+
failures,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hasSyncId(issue, syncId) {
|
|
87
|
+
const journals = issue.raw?.journals || [];
|
|
88
|
+
return journals.some(journal => journal.notes?.includes(syncId));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 套用已審核的 Redmine 更新草稿
|
|
93
|
+
* @param {{draft: object, client: object, force?: boolean}} options
|
|
94
|
+
* @returns {Promise<Array<object>>}
|
|
95
|
+
*/
|
|
96
|
+
export async function applyRedmineDraft({ draft, client, force = false }) {
|
|
97
|
+
if (!draft || !Array.isArray(draft.drafts)) {
|
|
98
|
+
throw new Error('無效的 Redmine draft');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const results = [];
|
|
102
|
+
for (const item of draft.drafts) {
|
|
103
|
+
try {
|
|
104
|
+
const current = await client.getIssue(item.issueId, { include: 'allowed_statuses,journals' });
|
|
105
|
+
if (!force && hasSyncId(current, item.syncId)) {
|
|
106
|
+
results.push({ issueId: item.issueId, skipped: true, reason: 'duplicate' });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const statusChanged = item.originalStatusId !== null && current.status?.id !== item.originalStatusId;
|
|
111
|
+
const descriptionChanged = current.description !== item.originalDescription;
|
|
112
|
+
if (!force && (statusChanged || descriptionChanged)) {
|
|
113
|
+
results.push({ issueId: item.issueId, skipped: true, reason: 'conflict' });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await client.updateIssue(item.issueId, {
|
|
118
|
+
statusId: item.statusId,
|
|
119
|
+
notes: item.note,
|
|
120
|
+
});
|
|
121
|
+
results.push({ issueId: item.issueId, applied: true });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
results.push({ issueId: item.issueId, applied: false, error: error.message });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return results;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 將 draft 序列化為可保存的 JSON
|
|
131
|
+
* @param {object} draft
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
export function serializeDraft(draft) {
|
|
135
|
+
return `${JSON.stringify(draft, null, 2)}\n`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 解析 draft JSON
|
|
140
|
+
* @param {string} content
|
|
141
|
+
* @returns {object}
|
|
142
|
+
*/
|
|
143
|
+
export function parseDraft(content) {
|
|
144
|
+
const draft = JSON.parse(content);
|
|
145
|
+
if (draft.version !== 1 || !Array.isArray(draft.drafts)) {
|
|
146
|
+
throw new Error('Redmine draft 格式不受支援');
|
|
147
|
+
}
|
|
148
|
+
return draft;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export { formatRedminePreview };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redmine REST API 客戶端
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {{id: number, name: string}} RedmineStatus
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class RedmineAPIError extends Error {
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} message
|
|
12
|
+
* @param {number} status
|
|
13
|
+
* @param {string} method
|
|
14
|
+
* @param {string} path
|
|
15
|
+
*/
|
|
16
|
+
constructor(message, status, method, path) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'RedmineAPIError';
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.method = method;
|
|
21
|
+
this.path = path;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 將 Redmine Issue response 轉成命令內部使用的資料
|
|
27
|
+
* @param {object} rawIssue
|
|
28
|
+
* @returns {object}
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeIssue(rawIssue = {}) {
|
|
31
|
+
return {
|
|
32
|
+
id: rawIssue.id,
|
|
33
|
+
subject: rawIssue.subject || '',
|
|
34
|
+
description: rawIssue.description || '',
|
|
35
|
+
status: rawIssue.status || null,
|
|
36
|
+
tracker: rawIssue.tracker || null,
|
|
37
|
+
parentId: rawIssue.parent_issue_id || rawIssue.parent?.id || null,
|
|
38
|
+
customFields: rawIssue.custom_fields || rawIssue.customFields || [],
|
|
39
|
+
allowedStatuses: rawIssue.allowed_statuses || rawIssue.allowedStatuses || [],
|
|
40
|
+
project: rawIssue.project || null,
|
|
41
|
+
assignedTo: rawIssue.assigned_to || rawIssue.assignedTo || null,
|
|
42
|
+
raw: rawIssue,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class RedmineClient {
|
|
47
|
+
/**
|
|
48
|
+
* @param {{url: string, apiKey: string, fetchImpl?: typeof fetch}} config
|
|
49
|
+
*/
|
|
50
|
+
constructor({ url, apiKey, fetchImpl = globalThis.fetch }) {
|
|
51
|
+
if (!url || !apiKey) {
|
|
52
|
+
throw new Error('RedmineClient 需要 url 與 apiKey');
|
|
53
|
+
}
|
|
54
|
+
if (typeof fetchImpl !== 'function') {
|
|
55
|
+
throw new Error('目前 Node.js 環境沒有可用的 fetch');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.baseUrl = url.replace(/\/+$/, '');
|
|
59
|
+
this.apiKey = apiKey;
|
|
60
|
+
this.fetchImpl = fetchImpl;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} path
|
|
65
|
+
* @param {RequestInit & {json?: object}} options
|
|
66
|
+
* @returns {Promise<object>}
|
|
67
|
+
*/
|
|
68
|
+
async request(path, options = {}) {
|
|
69
|
+
const { json, headers = {}, ...requestOptions } = options;
|
|
70
|
+
const requestHeaders = {
|
|
71
|
+
Accept: 'application/json',
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
'X-Redmine-API-Key': this.apiKey,
|
|
74
|
+
...headers,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (json !== undefined) {
|
|
78
|
+
requestHeaders['Content-Type'] = 'application/json';
|
|
79
|
+
requestOptions.body = JSON.stringify(json);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
83
|
+
...requestOptions,
|
|
84
|
+
headers: requestHeaders,
|
|
85
|
+
});
|
|
86
|
+
const responseText = await response.text();
|
|
87
|
+
let responseData = null;
|
|
88
|
+
|
|
89
|
+
if (responseText) {
|
|
90
|
+
try {
|
|
91
|
+
responseData = JSON.parse(responseText);
|
|
92
|
+
} catch {
|
|
93
|
+
responseData = null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
const apiErrors = Array.isArray(responseData?.errors)
|
|
99
|
+
? `:${responseData.errors.join('、').replaceAll(this.apiKey, '[REDACTED]')}`
|
|
100
|
+
: '';
|
|
101
|
+
throw new RedmineAPIError(
|
|
102
|
+
`Redmine API 請求失敗 (${response.status})${apiErrors}`,
|
|
103
|
+
response.status,
|
|
104
|
+
requestOptions.method || 'GET',
|
|
105
|
+
path
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return responseData || {};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {number|string} issueId
|
|
114
|
+
* @returns {Promise<object>}
|
|
115
|
+
*/
|
|
116
|
+
async getIssue(issueId, { include = 'allowed_statuses' } = {}) {
|
|
117
|
+
const query = include ? `?include=${encodeURIComponent(include)}` : '';
|
|
118
|
+
const data = await this.request(
|
|
119
|
+
`/issues/${encodeURIComponent(issueId)}.json${query}`
|
|
120
|
+
);
|
|
121
|
+
return normalizeIssue(data.issue || data);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @returns {Promise<RedmineStatus[]>}
|
|
126
|
+
*/
|
|
127
|
+
async getIssueStatuses() {
|
|
128
|
+
const data = await this.request('/issue_statuses.json');
|
|
129
|
+
return data.issue_statuses || [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {number|string} issueId
|
|
134
|
+
* @param {{statusId?: number, notes?: string}} update
|
|
135
|
+
* @returns {Promise<object>}
|
|
136
|
+
*/
|
|
137
|
+
async updateIssue(issueId, { statusId, notes }) {
|
|
138
|
+
const issue = {};
|
|
139
|
+
if (statusId !== undefined && statusId !== null) issue.status_id = statusId;
|
|
140
|
+
if (notes) issue.notes = notes;
|
|
141
|
+
|
|
142
|
+
return this.request(`/issues/${encodeURIComponent(issueId)}.json`, {
|
|
143
|
+
method: 'PUT',
|
|
144
|
+
json: { issue },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
function bulletSection(title, items) {
|
|
5
|
+
if (!Array.isArray(items) || items.length === 0) return '';
|
|
6
|
+
return `${title}:\n${items.map(item => `- ${item}`).join('\n')}\n`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 將 Mermaid source 包成 Redmine macro
|
|
11
|
+
* @param {string|null} source
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function formatMermaid(source) {
|
|
15
|
+
if (typeof source !== 'string' || !source.trim()) return '';
|
|
16
|
+
|
|
17
|
+
let cleanSource = source.trim()
|
|
18
|
+
.replace(/^```mermaid\s*/i, '')
|
|
19
|
+
.replace(/^```\s*/, '')
|
|
20
|
+
.replace(/\s*```$/, '')
|
|
21
|
+
.trim();
|
|
22
|
+
|
|
23
|
+
if (cleanSource.startsWith('{{mermaid')) {
|
|
24
|
+
cleanSource = cleanSource
|
|
25
|
+
.replace(/^\{\{mermaid\s*/, '')
|
|
26
|
+
.replace(/\s*\}\}$/, '')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (
|
|
31
|
+
cleanSource.includes('{{mermaid') ||
|
|
32
|
+
cleanSource.includes('}}') ||
|
|
33
|
+
!/^(flowchart|graph|sequenceDiagram|stateDiagram|classDiagram|erDiagram|journey)\b/m.test(cleanSource)
|
|
34
|
+
) {
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return `{{mermaid\n${cleanSource}\n}}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 格式化 Redmine Issue note
|
|
43
|
+
* @param {{issueId: number|string, analysis: object, evidence: object, pullRequest?: object|null, syncId?: string}} input
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function formatRedmineNote({ issueId, analysis = {}, evidence = {}, pullRequest = null, syncId }) {
|
|
47
|
+
const sections = ['Git 開發更新', ''];
|
|
48
|
+
const development = bulletSection('開發內容', analysis.developmentSummary);
|
|
49
|
+
const codeChanges = bulletSection('程式修改重點', analysis.codeChanges);
|
|
50
|
+
const apiChanges = bulletSection('API 變更', analysis.apiChanges);
|
|
51
|
+
const technicalDetails = bulletSection('重要技術細節', analysis.technicalDetails);
|
|
52
|
+
const modifiedFiles = bulletSection('修改檔案', analysis.modifiedFiles || evidence.changedFiles);
|
|
53
|
+
const verification = bulletSection('驗證結果', analysis.verification);
|
|
54
|
+
const unresolved = bulletSection('尚未完成或待確認', analysis.unresolvedItems);
|
|
55
|
+
|
|
56
|
+
sections.push(development, codeChanges, apiChanges, technicalDetails, modifiedFiles, verification);
|
|
57
|
+
const mermaid = formatMermaid(analysis.flowchart);
|
|
58
|
+
if (mermaid) sections.push('流程圖:\n', mermaid, '\n');
|
|
59
|
+
sections.push(unresolved);
|
|
60
|
+
|
|
61
|
+
if (evidence.currentBranch || evidence.baseBranch) {
|
|
62
|
+
sections.push(
|
|
63
|
+
`Git branch:${evidence.currentBranch || '—'}`,
|
|
64
|
+
`比較基準:${evidence.baseBranch || '—'}`,
|
|
65
|
+
''
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (pullRequest) {
|
|
70
|
+
sections.push(
|
|
71
|
+
`GitHub PR:${pullRequest.url || `#${pullRequest.number}`}`,
|
|
72
|
+
`PR 狀態:${pullRequest.state || '—'}${pullRequest.mergedAt ? `(合併於 ${pullRequest.mergedAt})` : ''}`,
|
|
73
|
+
''
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (analysis.evidence?.length) {
|
|
78
|
+
sections.push(`分析依據:${analysis.evidence.join('、')}`, '');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (syncId) sections.push(`<!-- ${syncId} issue:${issueId} -->`);
|
|
82
|
+
return sections.filter((section, index) => section || index === 1).join('\n').trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 選取 Issue 可用的狀態清單
|
|
87
|
+
* @param {object} issue
|
|
88
|
+
* @param {Array<object>} fallbackStatuses
|
|
89
|
+
* @returns {Array<object>}
|
|
90
|
+
*/
|
|
91
|
+
export function getAvailableStatuses(issue, fallbackStatuses = []) {
|
|
92
|
+
return issue.allowedStatuses?.length ? issue.allowedStatuses : fallbackStatuses;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 建立 inquirer 狀態選項
|
|
97
|
+
* @param {Array<object>} statuses
|
|
98
|
+
* @returns {Array<{name: string, value: number}>}
|
|
99
|
+
*/
|
|
100
|
+
export function createStatusChoices(statuses = []) {
|
|
101
|
+
return statuses
|
|
102
|
+
.filter(status => status?.id !== undefined && status?.name)
|
|
103
|
+
.map(status => ({ name: status.name, value: status.id }));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 互動選擇 Issue 更新後狀態
|
|
108
|
+
* @param {object} issue
|
|
109
|
+
* @param {{client: object, prompt?: Function}} options
|
|
110
|
+
* @returns {Promise<{statusId: number, statusName: string}>}
|
|
111
|
+
*/
|
|
112
|
+
export async function selectIssueStatus(issue, { client, prompt = inquirer.prompt } = {}) {
|
|
113
|
+
const fallbackStatuses = issue.allowedStatuses?.length ? [] : await client.getIssueStatuses();
|
|
114
|
+
const statuses = getAvailableStatuses(issue, fallbackStatuses);
|
|
115
|
+
const choices = createStatusChoices(statuses);
|
|
116
|
+
if (choices.length === 0) throw new Error(`Issue #${issue.id} 沒有可用的 Redmine 狀態`);
|
|
117
|
+
|
|
118
|
+
const answer = await prompt([
|
|
119
|
+
{
|
|
120
|
+
type: 'list',
|
|
121
|
+
name: 'statusId',
|
|
122
|
+
message: `Issue #${issue.id}:選擇更新後狀態`,
|
|
123
|
+
choices,
|
|
124
|
+
},
|
|
125
|
+
]);
|
|
126
|
+
const selected = statuses.find(status => status.id === answer.statusId);
|
|
127
|
+
return { statusId: answer.statusId, statusName: selected?.name || String(answer.statusId) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 產生有色 terminal preview
|
|
132
|
+
* @param {Array<object>} drafts
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
export function formatRedminePreview(drafts = []) {
|
|
136
|
+
const lines = [chalk.bold.cyan('═'.repeat(72)), chalk.bold('Redmine 更新預覽'), chalk.bold.cyan('═'.repeat(72)), ''];
|
|
137
|
+
|
|
138
|
+
for (const draft of drafts) {
|
|
139
|
+
lines.push(
|
|
140
|
+
chalk.bold.cyan(`Issue #${draft.issueId}`),
|
|
141
|
+
`${chalk.dim('目前狀態:')} ${draft.currentStatusName || draft.originalStatusName || '—'}`,
|
|
142
|
+
`${chalk.green('更新狀態:')} ${draft.statusName || '未選擇'}`,
|
|
143
|
+
'',
|
|
144
|
+
draft.note,
|
|
145
|
+
chalk.dim('─'.repeat(72)),
|
|
146
|
+
''
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return lines.join('\n').trim();
|
|
151
|
+
}
|
package/src/utils/cli-helpers.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { handleError } from './helpers.js';
|
|
7
|
+
import { Option } from 'commander';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* 註冊一個 CLI 命令,統一包裝 try/catch 與退出碼
|
|
@@ -17,6 +18,13 @@ export function registerCommand(program, name, description, options = [], action
|
|
|
17
18
|
const command = program.command(name).description(description);
|
|
18
19
|
|
|
19
20
|
for (const option of options) {
|
|
21
|
+
if (option.argParser) {
|
|
22
|
+
const commandOption = new Option(option.flags, option.description).argParser(option.argParser);
|
|
23
|
+
if (option.defaultValue !== undefined) commandOption.default(option.defaultValue);
|
|
24
|
+
command.addOption(commandOption);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
if (option.defaultValue !== undefined) {
|
|
21
29
|
command.option(option.flags, option.description, option.defaultValue);
|
|
22
30
|
} else {
|