ai-git-tools 2.1.14 → 2.1.16

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.
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
 
3
3
  const MANAGED_KEY_PREFIX = 'ai-git-tools:redmine-subtask';
4
+ const FRONTEND_MERMAID_PATTERN = /^(flowchart|graph|sequenceDiagram|stateDiagram(?:-v2)?|classDiagram|erDiagram|journey)\b/m;
4
5
 
5
6
  function asString(value) {
6
7
  return typeof value === 'string' ? value.trim() : '';
@@ -46,7 +47,7 @@ function formatMermaid(source) {
46
47
  .replace(/\s*\}\}\s*$/, '')
47
48
  .trim();
48
49
  if (!cleanSource || cleanSource.includes('{{mermaid') || cleanSource.includes('}}')) return '';
49
- if (!/^(flowchart|graph|sequenceDiagram|stateDiagram|classDiagram|erDiagram|journey)\b/m.test(cleanSource)) return '';
50
+ if (!/^(flowchart|graph|sequenceDiagram|stateDiagram(?:-v2)?|classDiagram|erDiagram|journey)\b/m.test(cleanSource)) return '';
50
51
  return `{{mermaid\n${cleanSource}\n}}`;
51
52
  }
52
53
 
@@ -69,6 +70,299 @@ function formatWireframe(wireframe) {
69
70
  return value ? `\`\`\`text\n${value}\n\`\`\`` : '';
70
71
  }
71
72
 
73
+ function normalizeApiPathPlaceholders(value) {
74
+ return value.replace(/(\/[^{}\s()[\]]*?)\{([A-Za-z][A-Za-z0-9_-]*)\}/g, '$1:$2');
75
+ }
76
+
77
+ function isFrontendSubtask(subtask = {}) {
78
+ return Boolean(
79
+ subtask.type === 'page'
80
+ || subtask.type === 'feature'
81
+ || subtask.type === 'shared-infrastructure'
82
+ || subtask.requirementDescription
83
+ || subtask.uiWireframe?.screens
84
+ || subtask.apiContract
85
+ );
86
+ }
87
+
88
+ function formatFrontendItem(item, fallback = '') {
89
+ if (typeof item === 'string') return item;
90
+ if (!item || typeof item !== 'object') return fallback;
91
+ const text = asString(item.text || item.value || item.description || item.quote || item.expected || item.behavior);
92
+ if (!text) return fallback;
93
+ const source = asString(item.source);
94
+ const evidenceIds = Array.isArray(item.evidenceIds) && item.evidenceIds.length
95
+ ? ` [${item.evidenceIds.join(', ')}]`
96
+ : '';
97
+ return `${text}${source ? `(${source})` : ''}${evidenceIds}`;
98
+ }
99
+
100
+ function formatFrontendItems(items = []) {
101
+ return (Array.isArray(items) ? items : [])
102
+ .map(item => formatFrontendItem(item))
103
+ .filter(Boolean)
104
+ .map(item => `- ${item}`)
105
+ .join('\n');
106
+ }
107
+
108
+ function formatFrontendObject(value) {
109
+ if (value === null || value === undefined || value === '') return '未提供';
110
+ if (typeof value === 'string') return value;
111
+ return JSON.stringify(value, null, 2);
112
+ }
113
+
114
+ function formatFrontendScope(scopeDecision = {}) {
115
+ const lines = [`## Frontend 範圍`, '', `- 分析狀態:${asString(scopeDecision.status) || '未判定'}`];
116
+ const included = formatFrontendItems(scopeDecision.includedRequirements);
117
+ const excluded = formatFrontendItems(scopeDecision.excludedRequirements);
118
+ if (included) lines.push('', '### 納入 Frontend', included);
119
+ if (excluded) lines.push('', '### 排除項目', excluded);
120
+ return lines.join('\n');
121
+ }
122
+
123
+ function formatFrontendRoutes(routes = []) {
124
+ if (!Array.isArray(routes) || routes.length === 0) return '';
125
+ const rows = routes.map(route => [
126
+ route.id || '—',
127
+ route.path || '未提供',
128
+ route.purpose || '未提供',
129
+ route.accessRole || '未指定',
130
+ route.source || 'derived',
131
+ ].map(value => String(value).replaceAll('|', '\\|')));
132
+ return [
133
+ '### Routes',
134
+ '',
135
+ '| ID | Path | 用途 | 存取角色 | 來源 |',
136
+ '|---|---|---|---|---|',
137
+ ...rows.map(row => `| ${row.join(' | ')} |`),
138
+ ].join('\n');
139
+ }
140
+
141
+ function formatFrontendScreens(wireframe = {}) {
142
+ if (!Array.isArray(wireframe.screens) || wireframe.screens.length === 0) return '';
143
+ const screens = wireframe.screens.map(screen => [
144
+ `#### Screen:${screen.title || screen.id || '未命名'}(${screen.screenType || 'custom'})`,
145
+ `- Screen ID:${screen.id || '—'}`,
146
+ `- Route ID:${screen.routeId || '未指定'}`,
147
+ `- 用途:${screen.purpose || '未提供'}`,
148
+ screen.regions?.length ? `- 區塊:${screen.regions.join('、')}` : '',
149
+ screen.controls?.length ? `- 操作:${screen.controls.join('、')}` : '',
150
+ screen.states?.length ? `- 狀態:${screen.states.join('、')}` : '',
151
+ screen.wireframe ? `- 來源:${screen.source || 'derived'}\n\n\`\`\`text\n${screen.wireframe}\n\`\`\`` : '- Wireframe:未提供',
152
+ screen.unresolvedItems?.length
153
+ ? `- 畫面待確認:${screen.unresolvedItems.join('、')}`
154
+ : ''
155
+ ].filter(Boolean).join('\n'));
156
+ return ['### Screens', ...screens].join('\n\n');
157
+ }
158
+
159
+ function formatFrontendUserFlow(userFlow = []) {
160
+ if (!Array.isArray(userFlow) || userFlow.length === 0) return '';
161
+ const rows = userFlow.map(flow => [
162
+ flow.step || '—',
163
+ flow.actor || '未指定',
164
+ flow.action || '未提供',
165
+ flow.systemResponse || '未提供',
166
+ flow.branch || '—',
167
+ flow.source || 'derived',
168
+ ].map(value => String(value).replaceAll('|', '\\|')));
169
+ return [
170
+ '## 使用者流程',
171
+ '',
172
+ '| 步驟 | 角色 | 操作 | 系統回應 | 分支 | 來源 |',
173
+ '|---|---|---|---|---|---|',
174
+ ...rows.map(row => `| ${row.join(' | ')} |`),
175
+ ].join('\n');
176
+ }
177
+
178
+ function formatFrontendMermaid(mermaid = {}) {
179
+ if (!mermaid || typeof mermaid !== 'object') return '';
180
+ let diagram = asString(mermaid.diagram);
181
+ if (diagram) {
182
+ diagram = diagram
183
+ .replace(/^```mermaid\s*/i, '')
184
+ .replace(/\s*```$/, '')
185
+ .replace(/^\{\{\s*mermaid\s*/i, '')
186
+ .replace(/\s*\}\}\s*$/, '')
187
+ .replace(/\r\n/g, '\n');
188
+ diagram = normalizeApiPathPlaceholders(diagram)
189
+ .trim();
190
+ }
191
+ const hasUnsupportedType = Boolean(diagram) && !FRONTEND_MERMAID_PATTERN.test(diagram);
192
+ if (hasUnsupportedType) diagram = '';
193
+ const status = hasUnsupportedType
194
+ ? 'unknown'
195
+ : asString(mermaid.status) || (diagram ? 'ready' : 'not-supported');
196
+ const title = asString(mermaid.title) || 'Frontend 流程';
197
+ const evidence = mermaid.evidenceIds?.length ? `\n依據:${mermaid.evidenceIds.join('、')}` : '';
198
+ const reason = mermaid.reason
199
+ ? `\n原因:${mermaid.reason}`
200
+ : hasUnsupportedType ? '\n原因:不支援的 Mermaid diagram type' : '';
201
+ return [
202
+ '## Mermaid 流程圖',
203
+ '',
204
+ `### ${title}`,
205
+ '',
206
+ `- Diagram ID:${mermaid.id || 'flowchart-1'}`,
207
+ `- Status:${status}`,
208
+ diagram ? `\n{{mermaid\n${diagram}\n}}` : '',
209
+ evidence,
210
+ reason,
211
+ ].filter(Boolean).join('\n');
212
+ }
213
+
214
+ function formatFrontendApiContracts(apiContracts = [], requestResponses = []) {
215
+ if (!Array.isArray(apiContracts) || apiContracts.length === 0) return '';
216
+ const sections = ['## API Contracts'];
217
+ for (const api of apiContracts) {
218
+ const requestResponse = requestResponses.find(item => item.apiId === api.id);
219
+ const requestExample = requestResponse?.requestExample || '';
220
+ const responseExample = requestResponse?.responseExample || '';
221
+ sections.push(
222
+ '',
223
+ `### ${api.method || '未提供'} ${api.baseUrl || ''}${api.path || '未提供'}`.trim(),
224
+ `- API ID:${api.id || '—'}`,
225
+ `- 用途:${api.purpose || '未提供'}`,
226
+ `- Auth:${api.auth || '未提供'}`,
227
+ `- Completeness:${api.completeness || 'partial'}`,
228
+ '',
229
+ '#### Request',
230
+ `- Path params:${formatFrontendObject(api.request?.pathParams)}`,
231
+ `- Query:${formatFrontendObject(api.request?.query)}`,
232
+ `- Headers:${formatFrontendObject(api.request?.headers)}`,
233
+ `- Body:${formatFrontendObject(api.request?.body)}`,
234
+ `- requestExample:${formatFrontendObject(requestExample)}`,
235
+ '',
236
+ '#### Response',
237
+ `- Status codes:${formatFrontendObject(api.statusCodes)}`,
238
+ `- Headers:${formatFrontendObject(api.response?.headers)}`,
239
+ `- Body:${formatFrontendObject(api.response?.body)}`,
240
+ `- responseExample:${formatFrontendObject(responseExample)}`,
241
+ `- Errors:${formatFrontendObject(api.errors)}`,
242
+ `- pagination:${formatFrontendObject(api.pagination)}`,
243
+ `- filtering:${formatFrontendObject(api.filtering)}`,
244
+ `- sorting:${formatFrontendObject(api.sorting)}`,
245
+ `- Evidence:${api.evidenceIds?.length ? api.evidenceIds.join('、') : '未提供'}`
246
+ );
247
+ }
248
+ return sections.join('\n');
249
+ }
250
+
251
+ function formatFrontendBehavior(subtask) {
252
+ const sections = [];
253
+ const state = formatFrontendItems(subtask.state);
254
+ const validation = formatFrontendItems(subtask.validation);
255
+ const errors = (subtask.errorHandling || [])
256
+ .map(item => `- ${item.case || item.scenario || '錯誤情境'}:${item.behavior || item.expected || '未提供'}`)
257
+ .join('\n');
258
+ if (state || validation || errors) {
259
+ sections.push(
260
+ [
261
+ '## 狀態與互動',
262
+ state ? `### State\n${state}` : '',
263
+ validation ? `### Validation\n${validation}` : '',
264
+ errors ? `### Error handling\n${errors}` : '',
265
+ ].filter(Boolean).join('\n\n')
266
+ );
267
+ }
268
+ const optionalSections = [
269
+ ['Permission/Auth', [subtask.permission, subtask.authentication, subtask.authorization].filter(Boolean).join('\n')],
270
+ ['Responsive', subtask.responsiveRequirement],
271
+ ['Accessibility', formatFrontendItems(subtask.accessibilityRequirement)],
272
+ ['i18n', formatFrontendItems(subtask.i18nRequirement)],
273
+ ['Performance', formatFrontendItems(subtask.performanceRequirement)],
274
+ ['Security', formatFrontendItems(subtask.securityRequirement)],
275
+ ['Analytics', formatFrontendItems(subtask.analyticsRequirement)],
276
+ ['Browser compatibility', formatFrontendItems(subtask.browserCompatibility)],
277
+ ['Cache', subtask.cache],
278
+ ['Polling', subtask.polling],
279
+ ['Feature flag', subtask.featureFlag],
280
+ ];
281
+ for (const [title, body] of optionalSections) {
282
+ if (body) sections.push(`### ${title}\n${body}`);
283
+ }
284
+ return sections.join('\n\n');
285
+ }
286
+
287
+ function formatFrontendAcceptance(items = []) {
288
+ const content = formatFrontendItems(items);
289
+ return content ? `## 驗收條件\n\n${content}` : '';
290
+ }
291
+
292
+ function formatFrontendTestPlan(items = []) {
293
+ if (!Array.isArray(items) || items.length === 0) return '';
294
+ const content = items.map(item => {
295
+ const scenario = item.scenario || item.case || '未命名情境';
296
+ const expected = item.expected || item.behavior || '未提供';
297
+ return `- ${scenario}(${item.layer || '未指定'}):${expected}${item.source ? `(${item.source})` : ''}`;
298
+ }).join('\n');
299
+ return `## Test Plan\n\n${content}`;
300
+ }
301
+
302
+ function formatFrontendEvidence(ids = [], evidence = []) {
303
+ const byId = new Map((Array.isArray(evidence) ? evidence : []).map(item => [item.id, item]));
304
+ return (Array.isArray(ids) ? ids : [])
305
+ .map(id => {
306
+ const item = byId.get(id);
307
+ if (!item) return `- ${id}`;
308
+ const location = item.location ? `(${item.location})` : '';
309
+ return `- [${id}] ${item.source || 'unknown'}:${item.quote || '未提供'}${location}`;
310
+ })
311
+ .join('\n');
312
+ }
313
+
314
+ function collectFrontendEvidenceIds(value, result = new Set()) {
315
+ if (Array.isArray(value)) {
316
+ value.forEach(item => collectFrontendEvidenceIds(item, result));
317
+ return result;
318
+ }
319
+ if (!value || typeof value !== 'object') return result;
320
+ if (Array.isArray(value.evidenceIds)) value.evidenceIds.forEach(id => result.add(id));
321
+ Object.entries(value)
322
+ .filter(([key]) => key !== 'evidenceIds')
323
+ .forEach(([, item]) => collectFrontendEvidenceIds(item, result));
324
+ return result;
325
+ }
326
+
327
+ function formatFrontendContent({ subtask, parentIssueId, scopeDecision = {}, evidence = [] }) {
328
+ const routes = formatFrontendRoutes(subtask.route);
329
+ const screens = formatFrontendScreens(subtask.uiWireframe);
330
+ const evidenceIdSet = collectFrontendEvidenceIds({ scopeDecision, subtask });
331
+ for (const item of [
332
+ ...(scopeDecision.includedRequirements || []),
333
+ ...(scopeDecision.excludedRequirements || []),
334
+ ]) {
335
+ if (item?.evidenceIds?.length) item.evidenceIds.forEach(id => evidenceIdSet.add(id));
336
+ else if (item?.id) evidenceIdSet.add(item.id);
337
+ }
338
+ const evidenceIds = [...evidenceIdSet];
339
+ const sections = [
340
+ formatFrontendScope(scopeDecision),
341
+ `## 需求說明\n\n${subtask.requirementDescription}`,
342
+ `## 功能需求\n\n${formatFrontendItems(subtask.functionalRequirements)}`,
343
+ `## 工作範圍\n\n${formatFrontendItems(subtask.scope?.inScope) || '- 未提供'}${subtask.scope?.outOfScope?.length ? `\n\n### 不包含\n${formatFrontendItems(subtask.scope.outOfScope)}` : ''}`,
344
+ routes || screens ? `## Screens 與 Routes\n\n${[routes, screens].filter(Boolean).join('\n\n')}` : '',
345
+ formatFrontendUserFlow(subtask.userFlow),
346
+ formatFrontendMermaid(subtask.mermaid),
347
+ formatFrontendApiContracts(subtask.apiContract, subtask.requestResponse),
348
+ formatFrontendBehavior(subtask),
349
+ subtask.frontendTechnicalConstraint?.length
350
+ ? `## Frontend 技術限制\n\n${formatFrontendItems(subtask.frontendTechnicalConstraint)}`
351
+ : '',
352
+ `## 依賴與開發順序\n\n- Development order:${subtask.developmentOrder || '未提供'}\n- Dependencies:${subtask.dependsOnIds?.length ? subtask.dependsOnIds.join('、') : '無'}`,
353
+ formatFrontendAcceptance(subtask.acceptanceCriteria),
354
+ formatFrontendTestPlan(subtask.testPlan),
355
+ subtask.unresolvedItems?.length
356
+ ? `## 待確認事項\n\n${subtask.unresolvedItems.map(item => `- [ ] ${item}`).join('\n')}`
357
+ : '',
358
+ evidenceIds.length
359
+ ? `## Evidence\n\n${formatFrontendEvidence(evidenceIds, evidence)}`
360
+ : '',
361
+ `<!-- ${buildManagedSubtaskKey(parentIssueId, subtask.id)} -->`,
362
+ ];
363
+ return sections.filter(Boolean).join('\n\n').trim();
364
+ }
365
+
72
366
  /**
73
367
  * 建立受控的子任務識別字串
74
368
  * @param {number|string} parentIssueId
@@ -94,7 +388,10 @@ export function getManagedSubtaskKey(content = '') {
94
388
  * @param {{subtask: object, parentIssueId: number|string}} input
95
389
  * @returns {string}
96
390
  */
97
- export function formatSubtaskContent({ subtask = {}, parentIssueId }) {
391
+ export function formatSubtaskContent({ subtask = {}, parentIssueId, scopeDecision = {}, evidence = [] }) {
392
+ if (isFrontendSubtask(subtask)) {
393
+ return formatFrontendContent({ subtask, parentIssueId, scopeDecision, evidence });
394
+ }
98
395
  const sections = [];
99
396
  addSection(sections, '目的', subtask.purpose);
100
397
  addSection(sections, '工作範圍', formatScope(subtask));
@@ -118,12 +415,58 @@ export function formatSubtaskContent({ subtask = {}, parentIssueId }) {
118
415
  return sections.join('\n\n').trim();
119
416
  }
120
417
 
418
+ function formatFrontendPreview({ parent = {}, candidates = [], scopeDecision = {}, analysisScope = 'frontend' }) {
419
+ const lines = [
420
+ chalk.bold.cyan('═'.repeat(72)),
421
+ chalk.bold('Redmine Frontend 子任務拆分預覽'),
422
+ chalk.bold.cyan('═'.repeat(72)),
423
+ '',
424
+ chalk.bold.cyan(`Issue #${parent.id || '—'}:${parent.subject || '無標題'}`),
425
+ `Analysis scope:${analysisScope}`,
426
+ `Scope decision:${scopeDecision.status || '未判定'}`,
427
+ scopeDecision.excludedRequirements?.length
428
+ ? `排除項目:${scopeDecision.excludedRequirements.map(item => item.quote || item.text).filter(Boolean).join('、')}`
429
+ : '排除項目:無',
430
+ '既有子任務:',
431
+ ];
432
+
433
+ if (parent.children?.length) {
434
+ for (const child of parent.children) lines.push(`- #${child.id} ${child.subject || '無標題'}`);
435
+ } else {
436
+ lines.push('- 無');
437
+ }
438
+
439
+ for (const [index, candidate] of candidates.entries()) {
440
+ const screens = candidate.uiWireframe?.screens || [];
441
+ const apiStatuses = (candidate.apiContract || []).map(api => api.completeness || 'partial');
442
+ const evidenceIds = candidate.evidenceIds?.length ? candidate.evidenceIds : candidate.evidence || [];
443
+ lines.push(
444
+ '',
445
+ chalk.bold.green(`[${index + 1}/${candidates.length}] ${candidate.title || '無標題'}`),
446
+ `類型:${candidate.type || '—'}`,
447
+ `Screens:${screens.length ? screens.map(screen => `${screen.id}(${screen.screenType})`).join('、') : '無'}`,
448
+ `API completeness:${apiStatuses.length ? apiStatuses.join('、') : '無 API'}`,
449
+ `Mermaid:${candidate.mermaid?.status || '未提供'}`,
450
+ evidenceIds.length ? `依據:${evidenceIds.join('、')}` : '依據:未提供',
451
+ candidate.unresolvedItems?.length ? `待確認:${candidate.unresolvedItems.join('、')}` : '待確認:無',
452
+ candidate.content || '',
453
+ chalk.dim('─'.repeat(72))
454
+ );
455
+ }
456
+
457
+ lines.push('', chalk.yellow('尚未修改 Redmine。請審核 Frontend scope、screens、API 與 content 後再使用 --apply。'));
458
+ return lines.join('\n').trim();
459
+ }
460
+
121
461
  /**
122
462
  * 產生主 Issue 子任務 terminal preview
123
463
  * @param {{parent: object, candidates: Array<object>}} input
124
464
  * @returns {string}
125
465
  */
126
- export function formatSubtaskPreview({ parent = {}, candidates = [] }) {
466
+ export function formatSubtaskPreview({ parent = {}, candidates = [], scopeDecision = {}, analysisScope }) {
467
+ if (analysisScope || candidates.some(candidate => isFrontendSubtask(candidate))) {
468
+ return formatFrontendPreview({ parent, candidates, scopeDecision, analysisScope: analysisScope || 'frontend' });
469
+ }
127
470
  const lines = [
128
471
  chalk.bold.cyan('═'.repeat(72)),
129
472
  chalk.bold('Redmine 子任務拆分預覽'),