ai-git-tools 2.1.1 → 2.1.2

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 ADDED
@@ -0,0 +1,5 @@
1
+ # Redmine REST API base URL
2
+ REDMINE_URL=https://redmine.example.com
3
+
4
+ # 建議使用環境變數或 Secret Manager 保存,不要提交真實 API key
5
+ REDMINE_API_KEY=your-redmine-api-key
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\`):
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.1",
3
+ "version": "2.1.2",
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": "./bin/cli.js"
8
+ "ai-git-tools": "bin/cli.js"
9
9
  },
10
10
  "scripts": {
11
11
  "test": "node --test test/*.test.js",
@@ -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,111 @@
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
+ * 執行 Redmine Issue preview 或 apply
68
+ * @param {object} options
69
+ */
70
+ export async function redmineUpdateCommand(options = {}) {
71
+ validateRedmineOptions(options);
72
+ const config = await loadConfig();
73
+ const redmineConfig = buildRedmineConfig(config, process.env);
74
+ const client = new RedmineClient(redmineConfig);
75
+
76
+ if (options.apply) {
77
+ const draft = parseDraft(readFileSync(options.from, 'utf-8'));
78
+ const results = await applyRedmineDraft({ draft, client, force: options.force });
79
+ printApplyResults(results);
80
+ if (hasApplyFailures(results)) {
81
+ throw new Error('部分 Redmine Issue 更新失敗,請查看上方結果並重新處理失敗項目');
82
+ }
83
+ return results;
84
+ }
85
+
86
+ const evidence = collectGitEvidence({ baseBranch: options.base });
87
+ const pullRequest = options.pr ? collectPullRequestEvidence(options.pr) : null;
88
+ const draft = await generateRedmineDraft({
89
+ issueIds: options.issue,
90
+ client,
91
+ evidence,
92
+ pullRequest,
93
+ model: config.ai.model,
94
+ maxRetries: config.ai.maxRetries,
95
+ });
96
+
97
+ console.log(renderPreview(draft.drafts));
98
+ for (const failure of draft.failures) {
99
+ console.log(`❌ Issue #${failure.issueId} 無法產生更新:${failure.error}`);
100
+ }
101
+ console.log('\n尚未修改 Redmine。');
102
+ if (options.output) {
103
+ writeFileSync(options.output, serializeDraft(draft), 'utf-8');
104
+ console.log(`預覽草稿已保存:${options.output}`);
105
+ console.log(`請使用 --apply --from ${options.output} 套用。`);
106
+ } else {
107
+ console.log('請重新執行並加入 --output redmine-update.json 保存草稿。');
108
+ }
109
+
110
+ return draft;
111
+ }
@@ -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
+ }
@@ -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 {