ai-git-tools 2.1.2 → 2.1.4
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/README.md +4 -2
- package/package.json +2 -2
- package/src/commands/redmine-update.js +19 -0
- package/src/core/ai-client.js +26 -0
- package/src/pr-modules/ai/code-analyzer.js +2 -1
- package/src/redmine/issue-analyzer.js +2 -1
- package/src/redmine/issue-sync.js +48 -4
- package/src/redmine/redmine-client.js +8 -3
- package/src/redmine/redmine-formatters.js +46 -7
package/README.md
CHANGED
|
@@ -352,7 +352,7 @@ ai-git-tools redmine-update --apply --from redmine-update.json
|
|
|
352
352
|
|
|
353
353
|
Preview 會以色彩分開顯示每個 Issue 的開發內容、程式修改重點、API 變更、重要技術細節、修改檔案與實際 note。狀態由 Redmine API 提供選單,只有使用者選擇的 status 才會寫入。
|
|
354
354
|
|
|
355
|
-
原始 Issue description
|
|
355
|
+
原始 Issue description 會保留原始內容;若 AI 產生可靠流程,工具只替換 description 中由 `ai-git-tools` 管理的 Mermaid 區塊,不會重複追加。更新摘要會寫入 notes,並使用 Markdown 標題、項目符號與雙反引號標示程式路徑、API path、function 與 class。只有找到明確測試或驗證證據時才會加入驗證段落;流程圖會使用 Redmine Mermaid macro 並放在 description(公司 Redmine 的 notes 不支援 Mermaid):
|
|
356
356
|
|
|
357
357
|
```text
|
|
358
358
|
{{mermaid
|
|
@@ -364,6 +364,8 @@ flowchart TD
|
|
|
364
364
|
|
|
365
365
|
Issue note 的基本責任分工如下:`description` 保存原始需求,`status` 保存目前狀態,`notes` 保存 Git 開發與同步紀錄。
|
|
366
366
|
|
|
367
|
+
當 preview 選擇「已解決」後套用,工具會同步將完成百分比設為 `100%`,並依欄位名稱找到 `程式碼更版進度` custom field,勾選測試機選項 `測`。custom field ID 會從 Issue API 動態取得,不會寫死公司 Redmine 的 ID。
|
|
368
|
+
|
|
367
369
|
## ⚙️ 配置
|
|
368
370
|
|
|
369
371
|
配置檔案範例(\`.ai-git-config.js\`):
|
|
@@ -440,7 +442,7 @@ github: {
|
|
|
440
442
|
|
|
441
443
|
## 🔧 環境需求
|
|
442
444
|
|
|
443
|
-
- **Node.js** >=
|
|
445
|
+
- **Node.js** >= 22.12.0
|
|
444
446
|
- **Git** 已安裝並設定
|
|
445
447
|
- **GitHub CLI** (用於 PR 功能)
|
|
446
448
|
\`\`\`bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-git-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.4",
|
|
4
4
|
"description": "AI-powered Git automation tools for commit messages and PR generation",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -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/",
|
|
@@ -63,6 +63,19 @@ export function hasApplyFailures(results = []) {
|
|
|
63
63
|
return results.some(result => result.applied === false && !result.skipped);
|
|
64
64
|
}
|
|
65
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
|
+
|
|
66
79
|
/**
|
|
67
80
|
* 執行 Redmine Issue preview 或 apply
|
|
68
81
|
* @param {object} options
|
|
@@ -94,10 +107,16 @@ export async function redmineUpdateCommand(options = {}) {
|
|
|
94
107
|
maxRetries: config.ai.maxRetries,
|
|
95
108
|
});
|
|
96
109
|
|
|
110
|
+
validateGeneratedDraft(draft);
|
|
97
111
|
console.log(renderPreview(draft.drafts));
|
|
98
112
|
for (const failure of draft.failures) {
|
|
99
113
|
console.log(`❌ Issue #${failure.issueId} 無法產生更新:${failure.error}`);
|
|
100
114
|
}
|
|
115
|
+
for (const item of draft.drafts) {
|
|
116
|
+
if (item.customFieldWarning) {
|
|
117
|
+
console.log(`⚠️ Issue #${item.issueId}:${item.customFieldWarning}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
101
120
|
console.log('\n尚未修改 Redmine。');
|
|
102
121
|
if (options.output) {
|
|
103
122
|
writeFileSync(options.output, serializeDraft(draft), 'utf-8');
|
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,5 +1,5 @@
|
|
|
1
1
|
import { CopilotClient, approveAll } from '@github/copilot-sdk';
|
|
2
|
-
import {
|
|
2
|
+
import { AIClient } from '../../core/ai-client.js';
|
|
3
3
|
import { log } from '../../utils/logger.js';
|
|
4
4
|
import { isCopilotAuthError } from '../../utils/helpers.js';
|
|
5
5
|
import {
|
|
@@ -26,6 +26,7 @@ export class AIAnalyzer {
|
|
|
26
26
|
* 取得(或建立)共用的 CopilotClient
|
|
27
27
|
*/
|
|
28
28
|
async _getOrCreateClient() {
|
|
29
|
+
AIClient.assertSupportedNodeVersion();
|
|
29
30
|
if (!this._client) {
|
|
30
31
|
this._client = new CopilotClient();
|
|
31
32
|
}
|
|
@@ -45,7 +45,8 @@ export function buildIssueAnalysisPrompt({ issue, evidence }) {
|
|
|
45
45
|
- 不得捏造 API endpoint、參數、檔案、function、class、測試或部署結果。
|
|
46
46
|
- 沒有明確測試或驗證證據時,verification 必須是空陣列。
|
|
47
47
|
- 沒有可靠 API 變更證據時,apiChanges 必須是空陣列。
|
|
48
|
-
- 若能從實際流程推導出可靠的複製流程,才產生 flowchart;否則為 null。
|
|
48
|
+
- 若能從實際流程推導出可靠的複製流程,才產生 flowchart;否則為 null。flowchart 必須以 graph TD 開始,不要加入 Mermaid wrapper 或 code fence。
|
|
49
|
+
- developmentSummary、codeChanges、apiChanges、technicalDetails 中的程式路徑、API path、function 與 class 使用雙反引號標示;需要多行程式片段時使用 code block。
|
|
49
50
|
- 不要產生 suggestedStatus、coverage 或 confidence,也不要決定 Redmine status。
|
|
50
51
|
|
|
51
52
|
## 指定 Redmine Issue
|
|
@@ -2,6 +2,7 @@ import { analyzeIssue as defaultAnalyzeIssue } from './issue-analyzer.js';
|
|
|
2
2
|
import { createHash } from 'crypto';
|
|
3
3
|
import {
|
|
4
4
|
formatRedmineNote,
|
|
5
|
+
formatRedmineDescription,
|
|
5
6
|
formatRedminePreview,
|
|
6
7
|
selectIssueStatus,
|
|
7
8
|
} from './redmine-formatters.js';
|
|
@@ -10,6 +11,41 @@ function uniqueIssueIds(issueIds = []) {
|
|
|
10
11
|
return [...new Set(issueIds.map(id => Number(id)).filter(Number.isInteger))];
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* 判斷 Redmine 狀態是否代表已解決
|
|
16
|
+
* @param {string} statusName
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
export function isResolvedStatus(statusName = '') {
|
|
20
|
+
return /已解決|resolved/i.test(statusName);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 建立測試機完成欄位更新
|
|
25
|
+
* @param {object} issue
|
|
26
|
+
* @param {{statusId: number, statusName: string}} selectedStatus
|
|
27
|
+
* @returns {{doneRatio?: number, customFields: Array<object>, warning?: string}}
|
|
28
|
+
*/
|
|
29
|
+
export function buildCompletionUpdate(issue, selectedStatus) {
|
|
30
|
+
if (!isResolvedStatus(selectedStatus.statusName)) return { customFields: [] };
|
|
31
|
+
|
|
32
|
+
const progressField = issue.customFields?.find(
|
|
33
|
+
field => field.name?.trim() === '程式碼更版進度'
|
|
34
|
+
);
|
|
35
|
+
if (!progressField) {
|
|
36
|
+
return {
|
|
37
|
+
doneRatio: 100,
|
|
38
|
+
customFields: [],
|
|
39
|
+
warning: '找不到 custom field「程式碼更版進度」,已略過 checkbox 更新',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
doneRatio: 100,
|
|
45
|
+
customFields: [{ id: progressField.id, value: ['測'] }],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
13
49
|
/**
|
|
14
50
|
* 建立單一 Issue 的同步識別
|
|
15
51
|
* @param {{issueId: number|string, evidence: object, pullRequest?: object|null}}
|
|
@@ -47,6 +83,8 @@ export async function generateRedmineDraft({
|
|
|
47
83
|
const issue = await client.getIssue(issueId);
|
|
48
84
|
const analysis = await analyzeIssueFn({ issue, evidence: { ...evidence, pullRequest }, model, maxRetries });
|
|
49
85
|
const selectedStatus = await selectStatusFn(issue, { client });
|
|
86
|
+
const completion = buildCompletionUpdate(issue, selectedStatus);
|
|
87
|
+
const description = formatRedmineDescription(issue.description, analysis.flowchart);
|
|
50
88
|
const syncId = createSyncId({ issueId, evidence, pullRequest });
|
|
51
89
|
const note = formatRedmineNote({
|
|
52
90
|
issueId,
|
|
@@ -64,6 +102,10 @@ export async function generateRedmineDraft({
|
|
|
64
102
|
originalDescription: issue.description,
|
|
65
103
|
statusId: selectedStatus.statusId,
|
|
66
104
|
statusName: selectedStatus.statusName,
|
|
105
|
+
doneRatio: completion.doneRatio,
|
|
106
|
+
customFields: completion.customFields,
|
|
107
|
+
customFieldWarning: completion.warning || null,
|
|
108
|
+
description: description !== issue.description ? description : undefined,
|
|
67
109
|
analysis,
|
|
68
110
|
note,
|
|
69
111
|
syncId,
|
|
@@ -114,10 +156,12 @@ export async function applyRedmineDraft({ draft, client, force = false }) {
|
|
|
114
156
|
continue;
|
|
115
157
|
}
|
|
116
158
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
159
|
+
const update = { statusId: item.statusId, notes: item.note };
|
|
160
|
+
if (item.doneRatio !== undefined) update.doneRatio = item.doneRatio;
|
|
161
|
+
if (item.description !== undefined) update.description = item.description;
|
|
162
|
+
if (item.customFields?.length) update.customFields = item.customFields;
|
|
163
|
+
|
|
164
|
+
await client.updateIssue(item.issueId, update);
|
|
121
165
|
results.push({ issueId: item.issueId, applied: true });
|
|
122
166
|
} catch (error) {
|
|
123
167
|
results.push({ issueId: item.issueId, applied: false, error: error.message });
|
|
@@ -130,14 +130,19 @@ export class RedmineClient {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
* @param {number|string} issueId
|
|
134
|
+
* @param {{statusId?: number, doneRatio?: number, description?: string, notes?: string, customFields?: Array<object>}} update
|
|
135
135
|
* @returns {Promise<object>}
|
|
136
136
|
*/
|
|
137
|
-
async updateIssue(issueId, { statusId, notes }) {
|
|
137
|
+
async updateIssue(issueId, { statusId, doneRatio, description, notes, customFields }) {
|
|
138
138
|
const issue = {};
|
|
139
139
|
if (statusId !== undefined && statusId !== null) issue.status_id = statusId;
|
|
140
|
+
if (doneRatio !== undefined && doneRatio !== null) issue.done_ratio = doneRatio;
|
|
141
|
+
if (description !== undefined) issue.description = description;
|
|
140
142
|
if (notes) issue.notes = notes;
|
|
143
|
+
if (Array.isArray(customFields) && customFields.length > 0) {
|
|
144
|
+
issue.custom_fields = customFields;
|
|
145
|
+
}
|
|
141
146
|
|
|
142
147
|
return this.request(`/issues/${encodeURIComponent(issueId)}.json`, {
|
|
143
148
|
method: 'PUT',
|
|
@@ -3,9 +3,12 @@ import chalk from 'chalk';
|
|
|
3
3
|
|
|
4
4
|
function bulletSection(title, items) {
|
|
5
5
|
if (!Array.isArray(items) || items.length === 0) return '';
|
|
6
|
-
return
|
|
6
|
+
return `### ${title}\n${items.map(item => `- ${item}`).join('\n')}\n`;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
const MERMAID_START = '<!-- ai-git-tools:mermaid:start -->';
|
|
10
|
+
const MERMAID_END = '<!-- ai-git-tools:mermaid:end -->';
|
|
11
|
+
|
|
9
12
|
/**
|
|
10
13
|
* 將 Mermaid source 包成 Redmine macro
|
|
11
14
|
* @param {string|null} source
|
|
@@ -27,6 +30,8 @@ export function formatMermaid(source) {
|
|
|
27
30
|
.trim();
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
cleanSource = cleanSource.replace(/^flowchart\s+TD\b/i, 'graph TD');
|
|
34
|
+
|
|
30
35
|
if (
|
|
31
36
|
cleanSource.includes('{{mermaid') ||
|
|
32
37
|
cleanSource.includes('}}') ||
|
|
@@ -38,30 +43,55 @@ export function formatMermaid(source) {
|
|
|
38
43
|
return `{{mermaid\n${cleanSource}\n}}`;
|
|
39
44
|
}
|
|
40
45
|
|
|
46
|
+
/**
|
|
47
|
+
* 將 Mermaid 放入 Redmine description 的受管理區塊
|
|
48
|
+
* @param {string} description
|
|
49
|
+
* @param {string|null} source
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function formatRedmineDescription(description = '', source = null) {
|
|
53
|
+
const mermaid = formatMermaid(source);
|
|
54
|
+
if (!mermaid) return description;
|
|
55
|
+
|
|
56
|
+
const block = `${MERMAID_START}\n\n### 開發流程\n\n${mermaid}\n\n${MERMAID_END}`;
|
|
57
|
+
const markerPattern = new RegExp(
|
|
58
|
+
`${MERMAID_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${MERMAID_END.replace(/[.*+?^${}()|[\]\\\\]/g, '\\$&')}`
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (markerPattern.test(description)) {
|
|
62
|
+
return description.replace(markerPattern, block);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return `${description.trim()}\n\n${block}`.trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
41
68
|
/**
|
|
42
69
|
* 格式化 Redmine Issue note
|
|
43
70
|
* @param {{issueId: number|string, analysis: object, evidence: object, pullRequest?: object|null, syncId?: string}} input
|
|
44
71
|
* @returns {string}
|
|
45
72
|
*/
|
|
46
73
|
export function formatRedmineNote({ issueId, analysis = {}, evidence = {}, pullRequest = null, syncId }) {
|
|
47
|
-
const sections = [
|
|
74
|
+
const sections = [`## Git 開發更新(Issue #${issueId})`, ''];
|
|
48
75
|
const development = bulletSection('開發內容', analysis.developmentSummary);
|
|
49
76
|
const codeChanges = bulletSection('程式修改重點', analysis.codeChanges);
|
|
50
77
|
const apiChanges = bulletSection('API 變更', analysis.apiChanges);
|
|
51
78
|
const technicalDetails = bulletSection('重要技術細節', analysis.technicalDetails);
|
|
52
|
-
const modifiedFiles = bulletSection(
|
|
79
|
+
const modifiedFiles = bulletSection(
|
|
80
|
+
'修改檔案',
|
|
81
|
+
analysis.modifiedFiles?.length
|
|
82
|
+
? analysis.modifiedFiles.map(file => `\`\`${file}\`\``)
|
|
83
|
+
: evidence.changedFiles?.map(file => `\`\`${file}\`\``)
|
|
84
|
+
);
|
|
53
85
|
const verification = bulletSection('驗證結果', analysis.verification);
|
|
54
86
|
const unresolved = bulletSection('尚未完成或待確認', analysis.unresolvedItems);
|
|
55
87
|
|
|
56
88
|
sections.push(development, codeChanges, apiChanges, technicalDetails, modifiedFiles, verification);
|
|
57
|
-
const mermaid = formatMermaid(analysis.flowchart);
|
|
58
|
-
if (mermaid) sections.push('流程圖:\n', mermaid, '\n');
|
|
59
89
|
sections.push(unresolved);
|
|
60
90
|
|
|
61
91
|
if (evidence.currentBranch || evidence.baseBranch) {
|
|
62
92
|
sections.push(
|
|
63
|
-
|
|
64
|
-
|
|
93
|
+
`### Git 關聯\n\n- Branch:\`\`${evidence.currentBranch || '—'}\`\``,
|
|
94
|
+
`- 比較基準:\`\`${evidence.baseBranch || '—'}\`\``,
|
|
65
95
|
''
|
|
66
96
|
);
|
|
67
97
|
}
|
|
@@ -114,6 +144,7 @@ export async function selectIssueStatus(issue, { client, prompt = inquirer.promp
|
|
|
114
144
|
const statuses = getAvailableStatuses(issue, fallbackStatuses);
|
|
115
145
|
const choices = createStatusChoices(statuses);
|
|
116
146
|
if (choices.length === 0) throw new Error(`Issue #${issue.id} 沒有可用的 Redmine 狀態`);
|
|
147
|
+
const resolvedIndex = choices.findIndex(choice => /已解決|resolved/i.test(choice.name));
|
|
117
148
|
|
|
118
149
|
const answer = await prompt([
|
|
119
150
|
{
|
|
@@ -121,6 +152,7 @@ export async function selectIssueStatus(issue, { client, prompt = inquirer.promp
|
|
|
121
152
|
name: 'statusId',
|
|
122
153
|
message: `Issue #${issue.id}:選擇更新後狀態`,
|
|
123
154
|
choices,
|
|
155
|
+
default: resolvedIndex >= 0 ? resolvedIndex : 0,
|
|
124
156
|
},
|
|
125
157
|
]);
|
|
126
158
|
const selected = statuses.find(status => status.id === answer.statusId);
|
|
@@ -140,8 +172,15 @@ export function formatRedminePreview(drafts = []) {
|
|
|
140
172
|
chalk.bold.cyan(`Issue #${draft.issueId}`),
|
|
141
173
|
`${chalk.dim('目前狀態:')} ${draft.currentStatusName || draft.originalStatusName || '—'}`,
|
|
142
174
|
`${chalk.green('更新狀態:')} ${draft.statusName || '未選擇'}`,
|
|
175
|
+
draft.doneRatio !== undefined ? `${chalk.green('完成百分比:')} ${draft.doneRatio}%` : '',
|
|
176
|
+
draft.customFields?.length
|
|
177
|
+
? `${chalk.green('程式碼更版進度:')} ${draft.customFields.flatMap(field => field.value || []).join('、')}`
|
|
178
|
+
: '',
|
|
143
179
|
'',
|
|
144
180
|
draft.note,
|
|
181
|
+
draft.description
|
|
182
|
+
? `${chalk.bold('description Mermaid:')}\n${draft.description}`
|
|
183
|
+
: '',
|
|
145
184
|
chalk.dim('─'.repeat(72)),
|
|
146
185
|
''
|
|
147
186
|
);
|