ai-git-tools 2.1.4 → 2.1.6

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 會保留原始內容;若 AI 產生可靠流程,工具只替換 description 中由 `ai-git-tools` 管理的 Mermaid 區塊,不會重複追加。更新摘要會寫入 notes,並使用 Markdown 標題、項目符號與雙反引號標示程式路徑、API path、function 與 class。只有找到明確測試或驗證證據時才會加入驗證段落;流程圖會使用 Redmine Mermaid macro 並放在 description(公司 Redmine 的 notes 不支援 Mermaid):
355
+ 原始 Issue description 會保留原始內容;AI 可產生多張流程圖,每張流程圖都有穩定的用途 ID。相同 ID 會更新原流程圖,不同 ID 會追加新流程圖;既有沒有 ID 的 Mermaid 會保留,內容相同時不重複追加。更新摘要會寫入 notes,並使用 Markdown 標題、項目符號與雙反引號標示程式路徑、API path、function 與 class。只有找到明確測試或驗證證據時才會加入驗證段落;流程圖會使用 Redmine Mermaid macro 並放在 description(公司 Redmine 的 notes 不支援 Mermaid):
356
356
 
357
357
  ```text
358
358
  {{mermaid
@@ -366,6 +366,8 @@ Issue note 的基本責任分工如下:`description` 保存原始需求,`sta
366
366
 
367
367
  當 preview 選擇「已解決」後套用,工具會同步將完成百分比設為 `100%`,並依欄位名稱找到 `程式碼更版進度` custom field,勾選測試機選項 `測`。custom field ID 會從 Issue API 動態取得,不會寫死公司 Redmine 的 ID。
368
368
 
369
+ 同一個 Issue 若已經有先前的 notes,後續再次執行更新仍會新增一筆新的 Redmine journal,不會因為已有 notes 而略過;只有 Issue 的需求內容或狀態在產生草稿後被其他人修改時,才會停下來要求重新確認。
370
+
369
371
  ## ⚙️ 配置
370
372
 
371
373
  配置檔案範例(\`.ai-git-config.js\`):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-git-tools",
3
- "version": "2.1.4",
3
+ "version": "2.1.6",
4
4
  "description": "AI-powered Git automation tools for commit messages and PR generation",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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',
@@ -11,10 +12,48 @@ const ANALYSIS_FIELDS = [
11
12
  'evidence',
12
13
  ];
13
14
 
15
+ function normalizeFlowcharts(value) {
16
+ const flowcharts = Array.isArray(value)
17
+ ? value
18
+ : value
19
+ ? [{ source: value }]
20
+ : [];
21
+
22
+ const usedIds = new Set();
23
+ return flowcharts
24
+ .filter(flowchart => flowchart && typeof flowchart === 'object')
25
+ .map((flowchart, index) => {
26
+ const title = typeof flowchart.title === 'string' ? flowchart.title.trim() : '';
27
+ const requestedId = typeof flowchart.id === 'string' ? flowchart.id.trim() : '';
28
+ const stableId = (requestedId || title || `flowchart-${index + 1}`)
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9_-]+/g, '-')
31
+ .replace(/^-+|-+$/g, '') || `flowchart-${index + 1}`;
32
+
33
+ let uniqueId = stableId;
34
+ if (usedIds.has(uniqueId)) uniqueId = `${stableId}-${index + 1}`;
35
+ usedIds.add(uniqueId);
36
+
37
+ return {
38
+ id: uniqueId,
39
+ title,
40
+ source: typeof flowchart.source === 'string' ? flowchart.source.trim() : '',
41
+ evidence: asStringArray(flowchart.evidence),
42
+ };
43
+ })
44
+ .filter(flowchart => flowchart.source);
45
+ }
46
+
14
47
  function asStringArray(value) {
15
48
  return Array.isArray(value) ? value.filter(item => typeof item === 'string') : [];
16
49
  }
17
50
 
51
+ function asBehaviorRules(value) {
52
+ return Array.isArray(value)
53
+ ? value.filter(rule => rule && typeof rule.scenario === 'string' && typeof rule.behavior === 'string')
54
+ : [];
55
+ }
56
+
18
57
  function filterEvidenceBackedClaims(result, evidence = {}) {
19
58
  const source = JSON.stringify(evidence);
20
59
  const evidenceRefs = result.evidence.filter(
@@ -45,7 +84,8 @@ export function buildIssueAnalysisPrompt({ issue, evidence }) {
45
84
  - 不得捏造 API endpoint、參數、檔案、function、class、測試或部署結果。
46
85
  - 沒有明確測試或驗證證據時,verification 必須是空陣列。
47
86
  - 沒有可靠 API 變更證據時,apiChanges 必須是空陣列。
48
- - 若能從實際流程推導出可靠的複製流程,才產生 flowchart;否則為 null。flowchart 必須以 graph TD 開始,不要加入 Mermaid wrapper 或 code fence。
87
+ - requirementDescription 應整理指定 Issue 的需求重點;behaviorRules 使用 scenario/behavior 物件陣列。
88
+ - 若能從實際流程推導出可靠的複製流程,才產生 flowcharts;否則為空陣列。每張流程圖必須有穩定、描述用途的 id,source 必須以 graph TD 開始,不要加入 Mermaid wrapper 或 code fence。
49
89
  - developmentSummary、codeChanges、apiChanges、technicalDetails 中的程式路徑、API path、function 與 class 使用雙反引號標示;需要多行程式片段時使用 code block。
50
90
  - 不要產生 suggestedStatus、coverage 或 confidence,也不要決定 Redmine status。
51
91
 
@@ -59,17 +99,19 @@ ${JSON.stringify(evidence, null, 2)}
59
99
  {
60
100
  "issueId": ${issue.id},
61
101
  "developmentSummary": [],
102
+ "requirementDescription": "",
103
+ "behaviorRules": [],
62
104
  "codeChanges": [],
63
105
  "apiChanges": [],
64
106
  "technicalDetails": [],
65
107
  "modifiedFiles": [],
66
108
  "verification": [],
67
- "flowchart": null,
109
+ "flowcharts": [],
68
110
  "unresolvedItems": [],
69
111
  "evidence": []
70
112
  }
71
113
 
72
- 各欄位都必須是字串陣列,flowchart 必須是合法 Mermaid source 字串或 null。`;
114
+ developmentSummary、codeChanges、apiChanges、technicalDetails、modifiedFiles、verification、unresolvedItems evidence 必須是字串陣列;requirementDescription 必須是字串;behaviorRules 必須是包含 scenario 與 behavior 字串的物件陣列;flowcharts 必須是包含 id、title、source evidence 的物件陣列。`;
73
115
  }
74
116
 
75
117
  /**
@@ -83,12 +125,14 @@ export function parseIssueAnalysis(content, issueId) {
83
125
  const result = { issueId };
84
126
 
85
127
  for (const field of ANALYSIS_FIELDS) {
86
- result[field] = asStringArray(parsed[field]);
128
+ result[field] = field === 'requirementDescription'
129
+ ? (typeof parsed[field] === 'string' ? parsed[field].trim() : '')
130
+ : asStringArray(parsed[field]);
87
131
  }
132
+ result.behaviorRules = asBehaviorRules(parsed.behaviorRules);
133
+ result.flowcharts = normalizeFlowcharts(parsed.flowcharts || parsed.flowchart);
88
134
 
89
- result.flowchart = typeof parsed.flowchart === 'string' && parsed.flowchart.trim()
90
- ? parsed.flowchart.trim()
91
- : null;
135
+ result.flowchart = result.flowcharts[0]?.source || null;
92
136
 
93
137
  return result;
94
138
  }
@@ -84,10 +84,11 @@ export async function generateRedmineDraft({
84
84
  const analysis = await analyzeIssueFn({ issue, evidence: { ...evidence, pullRequest }, model, maxRetries });
85
85
  const selectedStatus = await selectStatusFn(issue, { client });
86
86
  const completion = buildCompletionUpdate(issue, selectedStatus);
87
- const description = formatRedmineDescription(issue.description, analysis.flowchart);
87
+ const description = formatRedmineDescription(issue.description, analysis.flowcharts);
88
88
  const syncId = createSyncId({ issueId, evidence, pullRequest });
89
89
  const note = formatRedmineNote({
90
90
  issueId,
91
+ issue,
91
92
  analysis,
92
93
  evidence,
93
94
  pullRequest,
@@ -125,11 +126,6 @@ export async function generateRedmineDraft({
125
126
  };
126
127
  }
127
128
 
128
- function hasSyncId(issue, syncId) {
129
- const journals = issue.raw?.journals || [];
130
- return journals.some(journal => journal.notes?.includes(syncId));
131
- }
132
-
133
129
  /**
134
130
  * 套用已審核的 Redmine 更新草稿
135
131
  * @param {{draft: object, client: object, force?: boolean}} options
@@ -144,10 +140,6 @@ export async function applyRedmineDraft({ draft, client, force = false }) {
144
140
  for (const item of draft.drafts) {
145
141
  try {
146
142
  const current = await client.getIssue(item.issueId, { include: 'allowed_statuses,journals' });
147
- if (!force && hasSyncId(current, item.syncId)) {
148
- results.push({ issueId: item.issueId, skipped: true, reason: 'duplicate' });
149
- continue;
150
- }
151
143
 
152
144
  const statusChanged = item.originalStatusId !== null && current.status?.id !== item.originalStatusId;
153
145
  const descriptionChanged = current.description !== item.originalDescription;
@@ -6,8 +6,66 @@ function bulletSection(title, items) {
6
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 -->';
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
+
26
+ function escapeRegExp(value) {
27
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
28
+ }
29
+
30
+ function normalizeFlowchartId(id, index) {
31
+ const source = typeof id === 'string' && id.trim() ? id : `flowchart-${index + 1}`;
32
+ return source
33
+ .trim()
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9_-]+/g, '-')
36
+ .replace(/^-+|-+$/g, '') || `flowchart-${index + 1}`;
37
+ }
38
+
39
+ function normalizeFlowchartInput(flowcharts) {
40
+ if (Array.isArray(flowcharts)) return flowcharts;
41
+ if (typeof flowcharts === 'string' && flowcharts.trim()) {
42
+ return [{ id: 'flowchart-1', title: '開發流程', source: flowcharts }];
43
+ }
44
+ return [];
45
+ }
46
+
47
+ function hasSameMermaid(description, mermaid) {
48
+ const existing = description.match(/\{\{\s*mermaid\b[\s\S]*?\}\}/gi) || [];
49
+ return existing.some(block => formatMermaid(block) === mermaid);
50
+ }
51
+
52
+ function managedFlowchartPattern(id) {
53
+ const escapedId = escapeRegExp(id);
54
+ return new RegExp(
55
+ `${escapeRegExp(MERMAID_START)} id="${escapedId}" -->[\\s\\S]*?${escapeRegExp(MERMAID_END)} id="${escapedId}" -->`
56
+ );
57
+ }
58
+
59
+ function formatManagedFlowchart(flowchart, index) {
60
+ const mermaid = formatMermaid(flowchart.source);
61
+ if (!mermaid) return '';
62
+
63
+ const id = normalizeFlowchartId(flowchart.id, index);
64
+ const title = typeof flowchart.title === 'string' && flowchart.title.trim()
65
+ ? flowchart.title.trim()
66
+ : '開發流程';
67
+ return `${MERMAID_START} id="${id}" -->\n\n### 流程圖:${title}\n\n${mermaid}\n\n${MERMAID_END} id="${id}" -->`;
68
+ }
11
69
 
12
70
  /**
13
71
  * 將 Mermaid source 包成 Redmine macro
@@ -23,9 +81,9 @@ export function formatMermaid(source) {
23
81
  .replace(/\s*```$/, '')
24
82
  .trim();
25
83
 
26
- if (cleanSource.startsWith('{{mermaid')) {
84
+ if (/^\{\{\s*mermaid\b/i.test(cleanSource)) {
27
85
  cleanSource = cleanSource
28
- .replace(/^\{\{mermaid\s*/, '')
86
+ .replace(/^\{\{\s*mermaid\s*/i, '')
29
87
  .replace(/\s*\}\}$/, '')
30
88
  .trim();
31
89
  }
@@ -50,19 +108,27 @@ export function formatMermaid(source) {
50
108
  * @returns {string}
51
109
  */
52
110
  export function formatRedmineDescription(description = '', source = null) {
53
- const mermaid = formatMermaid(source);
54
- if (!mermaid) return description;
111
+ let result = description;
112
+ const flowcharts = normalizeFlowchartInput(source);
55
113
 
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
- );
114
+ flowcharts.forEach((flowchart, index) => {
115
+ const mermaid = formatMermaid(flowchart?.source);
116
+ if (!mermaid) return;
60
117
 
61
- if (markerPattern.test(description)) {
62
- return description.replace(markerPattern, block);
63
- }
118
+ const block = formatManagedFlowchart(flowchart, index);
119
+ const id = normalizeFlowchartId(flowchart?.id, index);
120
+ const markerPattern = managedFlowchartPattern(id);
121
+ if (markerPattern.test(result)) {
122
+ result = result.replace(markerPattern, block);
123
+ return;
124
+ }
64
125
 
65
- return `${description.trim()}\n\n${block}`.trim();
126
+ if (!hasSameMermaid(result, mermaid)) {
127
+ result = `${result.trim()}\n\n${block}`.trim();
128
+ }
129
+ });
130
+
131
+ return result;
66
132
  }
67
133
 
68
134
  /**
@@ -70,9 +136,13 @@ export function formatRedmineDescription(description = '', source = null) {
70
136
  * @param {{issueId: number|string, analysis: object, evidence: object, pullRequest?: object|null, syncId?: string}} input
71
137
  * @returns {string}
72
138
  */
73
- export function formatRedmineNote({ issueId, analysis = {}, evidence = {}, pullRequest = null, syncId }) {
74
- const sections = [`## Git 開發更新(Issue #${issueId})`, ''];
75
- const development = bulletSection('開發內容', analysis.developmentSummary);
139
+ export function formatRedmineNote({ issueId, issue = {}, analysis = {}, evidence = {}, pullRequest = null, syncId }) {
140
+ const sections = ['## 開發內容', ''];
141
+ const development = Array.isArray(analysis.developmentSummary)
142
+ ? analysis.developmentSummary.join('\n\n')
143
+ : analysis.developmentSummary || '';
144
+ const requirementDescription = analysis.requirementDescription || issue.description || '';
145
+ const behaviorRules = formatBehaviorRules(analysis.behaviorRules);
76
146
  const codeChanges = bulletSection('程式修改重點', analysis.codeChanges);
77
147
  const apiChanges = bulletSection('API 變更', analysis.apiChanges);
78
148
  const technicalDetails = bulletSection('重要技術細節', analysis.technicalDetails);
@@ -83,15 +153,26 @@ export function formatRedmineNote({ issueId, analysis = {}, evidence = {}, pullR
83
153
  : evidence.changedFiles?.map(file => `\`\`${file}\`\``)
84
154
  );
85
155
  const verification = bulletSection('驗證結果', analysis.verification);
86
- const unresolved = bulletSection('尚未完成或待確認', analysis.unresolvedItems);
156
+ const unresolved = analysis.unresolvedItems?.length
157
+ ? `### 待確認\n${analysis.unresolvedItems.map(item => `- [ ] ${item}`).join('\n')}\n`
158
+ : '';
87
159
 
88
- sections.push(development, codeChanges, apiChanges, technicalDetails, modifiedFiles, verification);
160
+ sections.push(
161
+ development,
162
+ paragraphSection('需求說明', requirementDescription),
163
+ behaviorRules,
164
+ modifiedFiles,
165
+ codeChanges,
166
+ apiChanges,
167
+ technicalDetails,
168
+ verification
169
+ );
89
170
  sections.push(unresolved);
90
171
 
91
172
  if (evidence.currentBranch || evidence.baseBranch) {
92
173
  sections.push(
93
- `### Git 關聯\n\n- Branch:\`\`${evidence.currentBranch || '—'}\`\``,
94
- `- 比較基準:\`\`${evidence.baseBranch || '—'}\`\``,
174
+ `### Git\n\n- Branch:\`\`${evidence.currentBranch || '—'}\`\``,
175
+ `- Base:\`\`${evidence.baseBranch || '—'}\`\``,
95
176
  ''
96
177
  );
97
178
  }