ai-git-tools 2.1.3 → 2.1.5

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 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 會保留不覆寫,更新內容會寫入 notes。只有找到明確測試或驗證證據時才會加入驗證段落;流程圖則使用 Redmine Mermaid macro
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,10 @@ 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
+
369
+ 同一個 Issue 若已經有先前的 notes,後續再次執行更新仍會新增一筆新的 Redmine journal,不會因為已有 notes 而略過;只有 Issue 的需求內容或狀態在產生草稿後被其他人修改時,才會停下來要求重新確認。
370
+
367
371
  ## ⚙️ 配置
368
372
 
369
373
  配置檔案範例(\`.ai-git-config.js\`):
@@ -440,7 +444,7 @@ github: {
440
444
 
441
445
  ## 🔧 環境需求
442
446
 
443
- - **Node.js** >= 20.19.0(或 >= 22.12.0
447
+ - **Node.js** >= 22.12.0
444
448
  - **Git** 已安裝並設定
445
449
  - **GitHub CLI** (用於 PR 功能)
446
450
  \`\`\`bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-git-tools",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
4
4
  "description": "AI-powered Git automation tools for commit messages and PR generation",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -112,6 +112,11 @@ export async function redmineUpdateCommand(options = {}) {
112
112
  for (const failure of draft.failures) {
113
113
  console.log(`❌ Issue #${failure.issueId} 無法產生更新:${failure.error}`);
114
114
  }
115
+ for (const item of draft.drafts) {
116
+ if (item.customFieldWarning) {
117
+ console.log(`⚠️ Issue #${item.issueId}:${item.customFieldWarning}`);
118
+ }
119
+ }
115
120
  console.log('\n尚未修改 Redmine。');
116
121
  if (options.output) {
117
122
  writeFileSync(options.output, serializeDraft(draft), 'utf-8');
@@ -1,6 +1,5 @@
1
1
  import { CopilotClient, approveAll } from '@github/copilot-sdk';
2
2
  import { AIClient } from '../../core/ai-client.js';
3
- import { CONSTANTS } from '../../utils/constants.js';
4
3
  import { log } from '../../utils/logger.js';
5
4
  import { isCopilotAuthError } from '../../utils/helpers.js';
6
5
  import {
@@ -2,6 +2,7 @@ import { AIClient } from '../core/ai-client.js';
2
2
 
3
3
  const ANALYSIS_FIELDS = [
4
4
  'developmentSummary',
5
+ 'requirementDescription',
5
6
  'codeChanges',
6
7
  'apiChanges',
7
8
  'technicalDetails',
@@ -15,6 +16,12 @@ function asStringArray(value) {
15
16
  return Array.isArray(value) ? value.filter(item => typeof item === 'string') : [];
16
17
  }
17
18
 
19
+ function asBehaviorRules(value) {
20
+ return Array.isArray(value)
21
+ ? value.filter(rule => rule && typeof rule.scenario === 'string' && typeof rule.behavior === 'string')
22
+ : [];
23
+ }
24
+
18
25
  function filterEvidenceBackedClaims(result, evidence = {}) {
19
26
  const source = JSON.stringify(evidence);
20
27
  const evidenceRefs = result.evidence.filter(
@@ -45,7 +52,8 @@ export function buildIssueAnalysisPrompt({ issue, evidence }) {
45
52
  - 不得捏造 API endpoint、參數、檔案、function、class、測試或部署結果。
46
53
  - 沒有明確測試或驗證證據時,verification 必須是空陣列。
47
54
  - 沒有可靠 API 變更證據時,apiChanges 必須是空陣列。
48
- - 若能從實際流程推導出可靠的複製流程,才產生 flowchart;否則為 null。
55
+ - 若能從實際流程推導出可靠的複製流程,才產生 flowchart;否則為 null。flowchart 必須以 graph TD 開始,不要加入 Mermaid wrapper 或 code fence。
56
+ - developmentSummary、codeChanges、apiChanges、technicalDetails 中的程式路徑、API path、function 與 class 使用雙反引號標示;需要多行程式片段時使用 code block。
49
57
  - 不要產生 suggestedStatus、coverage 或 confidence,也不要決定 Redmine status。
50
58
 
51
59
  ## 指定 Redmine Issue
@@ -58,6 +66,8 @@ ${JSON.stringify(evidence, null, 2)}
58
66
  {
59
67
  "issueId": ${issue.id},
60
68
  "developmentSummary": [],
69
+ "requirementDescription": "",
70
+ "behaviorRules": [],
61
71
  "codeChanges": [],
62
72
  "apiChanges": [],
63
73
  "technicalDetails": [],
@@ -68,7 +78,7 @@ ${JSON.stringify(evidence, null, 2)}
68
78
  "evidence": []
69
79
  }
70
80
 
71
- 各欄位都必須是字串陣列,flowchart 必須是合法 Mermaid source 字串或 null。`;
81
+ developmentSummary、codeChanges、apiChanges、technicalDetails、modifiedFiles、verification、unresolvedItems 與 evidence 必須是字串陣列;requirementDescription 必須是字串;behaviorRules 必須是包含 scenario 與 behavior 字串的物件陣列;flowchart 必須是合法 Mermaid source 字串或 null。`;
72
82
  }
73
83
 
74
84
  /**
@@ -82,8 +92,11 @@ export function parseIssueAnalysis(content, issueId) {
82
92
  const result = { issueId };
83
93
 
84
94
  for (const field of ANALYSIS_FIELDS) {
85
- result[field] = asStringArray(parsed[field]);
95
+ result[field] = field === 'requirementDescription'
96
+ ? (typeof parsed[field] === 'string' ? parsed[field].trim() : '')
97
+ : asStringArray(parsed[field]);
86
98
  }
99
+ result.behaviorRules = asBehaviorRules(parsed.behaviorRules);
87
100
 
88
101
  result.flowchart = typeof parsed.flowchart === 'string' && parsed.flowchart.trim()
89
102
  ? parsed.flowchart.trim()
@@ -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,9 +83,12 @@ 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,
91
+ issue,
53
92
  analysis,
54
93
  evidence,
55
94
  pullRequest,
@@ -64,6 +103,10 @@ export async function generateRedmineDraft({
64
103
  originalDescription: issue.description,
65
104
  statusId: selectedStatus.statusId,
66
105
  statusName: selectedStatus.statusName,
106
+ doneRatio: completion.doneRatio,
107
+ customFields: completion.customFields,
108
+ customFieldWarning: completion.warning || null,
109
+ description: description !== issue.description ? description : undefined,
67
110
  analysis,
68
111
  note,
69
112
  syncId,
@@ -83,11 +126,6 @@ export async function generateRedmineDraft({
83
126
  };
84
127
  }
85
128
 
86
- function hasSyncId(issue, syncId) {
87
- const journals = issue.raw?.journals || [];
88
- return journals.some(journal => journal.notes?.includes(syncId));
89
- }
90
-
91
129
  /**
92
130
  * 套用已審核的 Redmine 更新草稿
93
131
  * @param {{draft: object, client: object, force?: boolean}} options
@@ -102,10 +140,6 @@ export async function applyRedmineDraft({ draft, client, force = false }) {
102
140
  for (const item of draft.drafts) {
103
141
  try {
104
142
  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
143
 
110
144
  const statusChanged = item.originalStatusId !== null && current.status?.id !== item.originalStatusId;
111
145
  const descriptionChanged = current.description !== item.originalDescription;
@@ -114,10 +148,12 @@ export async function applyRedmineDraft({ draft, client, force = false }) {
114
148
  continue;
115
149
  }
116
150
 
117
- await client.updateIssue(item.issueId, {
118
- statusId: item.statusId,
119
- notes: item.note,
120
- });
151
+ const update = { statusId: item.statusId, notes: item.note };
152
+ if (item.doneRatio !== undefined) update.doneRatio = item.doneRatio;
153
+ if (item.description !== undefined) update.description = item.description;
154
+ if (item.customFields?.length) update.customFields = item.customFields;
155
+
156
+ await client.updateIssue(item.issueId, update);
121
157
  results.push({ issueId: item.issueId, applied: true });
122
158
  } catch (error) {
123
159
  results.push({ issueId: item.issueId, applied: false, error: error.message });
@@ -130,14 +130,19 @@ export class RedmineClient {
130
130
  }
131
131
 
132
132
  /**
133
- * @param {number|string} issueId
134
- * @param {{statusId?: number, notes?: string}} update
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,26 @@ import chalk from 'chalk';
3
3
 
4
4
  function bulletSection(title, items) {
5
5
  if (!Array.isArray(items) || items.length === 0) return '';
6
- return `${title}:\n${items.map(item => `- ${item}`).join('\n')}\n`;
6
+ return `### ${title}\n${items.map(item => `- ${item}`).join('\n')}\n`;
7
7
  }
8
8
 
9
+ function paragraphSection(title, content) {
10
+ if (!content) return '';
11
+ return `### ${title}\n\n${content}\n`;
12
+ }
13
+
14
+ function formatBehaviorRules(rules = []) {
15
+ if (!Array.isArray(rules) || rules.length === 0) return '';
16
+ const rows = rules
17
+ .filter(rule => rule?.scenario && rule?.behavior)
18
+ .map(rule => `| ${String(rule.scenario).replaceAll('|', '\\|')} | ${String(rule.behavior).replaceAll('|', '\\|')} |`);
19
+ if (rows.length === 0) return '';
20
+ return `### 行為規則\n\n| 情境 | 行為 |\n|---|---|\n${rows.join('\n')}\n`;
21
+ }
22
+
23
+ const MERMAID_START = '<!-- ai-git-tools:mermaid:start -->';
24
+ const MERMAID_END = '<!-- ai-git-tools:mermaid:end -->';
25
+
9
26
  /**
10
27
  * 將 Mermaid source 包成 Redmine macro
11
28
  * @param {string|null} source
@@ -27,6 +44,8 @@ export function formatMermaid(source) {
27
44
  .trim();
28
45
  }
29
46
 
47
+ cleanSource = cleanSource.replace(/^flowchart\s+TD\b/i, 'graph TD');
48
+
30
49
  if (
31
50
  cleanSource.includes('{{mermaid') ||
32
51
  cleanSource.includes('}}') ||
@@ -38,30 +57,70 @@ export function formatMermaid(source) {
38
57
  return `{{mermaid\n${cleanSource}\n}}`;
39
58
  }
40
59
 
60
+ /**
61
+ * 將 Mermaid 放入 Redmine description 的受管理區塊
62
+ * @param {string} description
63
+ * @param {string|null} source
64
+ * @returns {string}
65
+ */
66
+ export function formatRedmineDescription(description = '', source = null) {
67
+ const mermaid = formatMermaid(source);
68
+ if (!mermaid) return description;
69
+
70
+ const block = `${MERMAID_START}\n\n### 開發流程\n\n${mermaid}\n\n${MERMAID_END}`;
71
+ const markerPattern = new RegExp(
72
+ `${MERMAID_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${MERMAID_END.replace(/[.*+?^${}()|[\]\\\\]/g, '\\$&')}`
73
+ );
74
+
75
+ if (markerPattern.test(description)) {
76
+ return description.replace(markerPattern, block);
77
+ }
78
+
79
+ return `${description.trim()}\n\n${block}`.trim();
80
+ }
81
+
41
82
  /**
42
83
  * 格式化 Redmine Issue note
43
84
  * @param {{issueId: number|string, analysis: object, evidence: object, pullRequest?: object|null, syncId?: string}} input
44
85
  * @returns {string}
45
86
  */
46
- export function formatRedmineNote({ issueId, analysis = {}, evidence = {}, pullRequest = null, syncId }) {
47
- const sections = ['Git 開發更新', ''];
48
- const development = bulletSection('開發內容', analysis.developmentSummary);
87
+ export function formatRedmineNote({ issueId, issue = {}, analysis = {}, evidence = {}, pullRequest = null, syncId }) {
88
+ const sections = ['## 開發內容', ''];
89
+ const development = Array.isArray(analysis.developmentSummary)
90
+ ? analysis.developmentSummary.join('\n\n')
91
+ : analysis.developmentSummary || '';
92
+ const requirementDescription = analysis.requirementDescription || issue.description || '';
93
+ const behaviorRules = formatBehaviorRules(analysis.behaviorRules);
49
94
  const codeChanges = bulletSection('程式修改重點', analysis.codeChanges);
50
95
  const apiChanges = bulletSection('API 變更', analysis.apiChanges);
51
96
  const technicalDetails = bulletSection('重要技術細節', analysis.technicalDetails);
52
- const modifiedFiles = bulletSection('修改檔案', analysis.modifiedFiles || evidence.changedFiles);
97
+ const modifiedFiles = bulletSection(
98
+ '修改檔案',
99
+ analysis.modifiedFiles?.length
100
+ ? analysis.modifiedFiles.map(file => `\`\`${file}\`\``)
101
+ : evidence.changedFiles?.map(file => `\`\`${file}\`\``)
102
+ );
53
103
  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');
104
+ const unresolved = analysis.unresolvedItems?.length
105
+ ? `### 待確認\n${analysis.unresolvedItems.map(item => `- [ ] ${item}`).join('\n')}\n`
106
+ : '';
107
+
108
+ sections.push(
109
+ development,
110
+ paragraphSection('需求說明', requirementDescription),
111
+ behaviorRules,
112
+ modifiedFiles,
113
+ codeChanges,
114
+ apiChanges,
115
+ technicalDetails,
116
+ verification
117
+ );
59
118
  sections.push(unresolved);
60
119
 
61
120
  if (evidence.currentBranch || evidence.baseBranch) {
62
121
  sections.push(
63
- `Git branch:${evidence.currentBranch || '—'}`,
64
- `比較基準:${evidence.baseBranch || '—'}`,
122
+ `### Git\n\n- Branch:\`\`${evidence.currentBranch || '—'}\`\``,
123
+ `- Base:\`\`${evidence.baseBranch || '—'}\`\``,
65
124
  ''
66
125
  );
67
126
  }
@@ -114,6 +173,7 @@ export async function selectIssueStatus(issue, { client, prompt = inquirer.promp
114
173
  const statuses = getAvailableStatuses(issue, fallbackStatuses);
115
174
  const choices = createStatusChoices(statuses);
116
175
  if (choices.length === 0) throw new Error(`Issue #${issue.id} 沒有可用的 Redmine 狀態`);
176
+ const resolvedIndex = choices.findIndex(choice => /已解決|resolved/i.test(choice.name));
117
177
 
118
178
  const answer = await prompt([
119
179
  {
@@ -121,6 +181,7 @@ export async function selectIssueStatus(issue, { client, prompt = inquirer.promp
121
181
  name: 'statusId',
122
182
  message: `Issue #${issue.id}:選擇更新後狀態`,
123
183
  choices,
184
+ default: resolvedIndex >= 0 ? resolvedIndex : 0,
124
185
  },
125
186
  ]);
126
187
  const selected = statuses.find(status => status.id === answer.statusId);
@@ -140,8 +201,15 @@ export function formatRedminePreview(drafts = []) {
140
201
  chalk.bold.cyan(`Issue #${draft.issueId}`),
141
202
  `${chalk.dim('目前狀態:')} ${draft.currentStatusName || draft.originalStatusName || '—'}`,
142
203
  `${chalk.green('更新狀態:')} ${draft.statusName || '未選擇'}`,
204
+ draft.doneRatio !== undefined ? `${chalk.green('完成百分比:')} ${draft.doneRatio}%` : '',
205
+ draft.customFields?.length
206
+ ? `${chalk.green('程式碼更版進度:')} ${draft.customFields.flatMap(field => field.value || []).join('、')}`
207
+ : '',
143
208
  '',
144
209
  draft.note,
210
+ draft.description
211
+ ? `${chalk.bold('description Mermaid:')}\n${draft.description}`
212
+ : '',
145
213
  chalk.dim('─'.repeat(72)),
146
214
  ''
147
215
  );