ai-git-tools 2.1.13 → 2.1.15
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 +54 -3
- package/bin/cli.js +17 -0
- package/package.json +1 -1
- package/src/commands/init.js +1 -1
- package/src/commands/redmine-subtasks.js +170 -0
- package/src/core/ai-client.js +2 -2
- package/src/core/config-loader.js +2 -2
- package/src/pr-modules/ai/code-analyzer.js +1 -1
- package/src/redmine/issue-analyzer.js +4 -3
- package/src/redmine/redmine-client.js +46 -2
- package/src/redmine/redmine-formatters.js +2 -1
- package/src/redmine/subtask-analyzer.js +872 -0
- package/src/redmine/subtask-formatters.js +499 -0
- package/src/redmine/subtask-sync.js +349 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
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;
|
|
5
|
+
|
|
6
|
+
function asString(value) {
|
|
7
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function asStringArray(value) {
|
|
11
|
+
return Array.isArray(value)
|
|
12
|
+
? value.filter(item => typeof item === 'string').map(item => item.trim()).filter(Boolean)
|
|
13
|
+
: [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function bulletList(items) {
|
|
17
|
+
return asStringArray(items).map(item => `- ${item}`).join('\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function addSection(sections, title, body) {
|
|
21
|
+
const content = asString(body);
|
|
22
|
+
if (content) sections.push(`## ${title}\n\n${content}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatScope(subtask) {
|
|
26
|
+
const parts = [];
|
|
27
|
+
if (subtask.inScope?.length) parts.push(`### 包含\n${bulletList(subtask.inScope)}`);
|
|
28
|
+
if (subtask.outOfScope?.length) parts.push(`### 不包含\n${bulletList(subtask.outOfScope)}`);
|
|
29
|
+
return parts.join('\n\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatBehaviorRules(rules) {
|
|
33
|
+
const rows = Array.isArray(rules)
|
|
34
|
+
? rules
|
|
35
|
+
.filter(rule => rule?.scenario && rule?.behavior)
|
|
36
|
+
.map(rule => `| ${String(rule.scenario).replaceAll('|', '\\|')} | ${String(rule.behavior).replaceAll('|', '\\|')} |`)
|
|
37
|
+
: [];
|
|
38
|
+
return rows.length > 0 ? `| 情境 | 行為 |\n|---|---|\n${rows.join('\n')}` : '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatMermaid(source) {
|
|
42
|
+
let cleanSource = asString(source)
|
|
43
|
+
.replace(/^```mermaid\s*/i, '')
|
|
44
|
+
.replace(/^```\s*/, '')
|
|
45
|
+
.replace(/\s*```$/, '')
|
|
46
|
+
.replace(/^\{\{\s*mermaid\s*/i, '')
|
|
47
|
+
.replace(/\s*\}\}\s*$/, '')
|
|
48
|
+
.trim();
|
|
49
|
+
if (!cleanSource || cleanSource.includes('{{mermaid') || cleanSource.includes('}}')) return '';
|
|
50
|
+
if (!/^(flowchart|graph|sequenceDiagram|stateDiagram(?:-v2)?|classDiagram|erDiagram|journey)\b/m.test(cleanSource)) return '';
|
|
51
|
+
return `{{mermaid\n${cleanSource}\n}}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatFlowcharts(flowcharts) {
|
|
55
|
+
if (!Array.isArray(flowcharts)) return '';
|
|
56
|
+
return flowcharts
|
|
57
|
+
.map(flowchart => {
|
|
58
|
+
const mermaid = formatMermaid(flowchart?.source);
|
|
59
|
+
if (!mermaid) return '';
|
|
60
|
+
const title = asString(flowchart.title) || '流程';
|
|
61
|
+
const id = asString(flowchart.id) || 'flowchart-1';
|
|
62
|
+
return `### ${title}\n\n<!-- flowchart-id: ${id} -->\n${mermaid}`;
|
|
63
|
+
})
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.join('\n\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function formatWireframe(wireframe) {
|
|
69
|
+
const value = asString(wireframe);
|
|
70
|
+
return value ? `\`\`\`text\n${value}\n\`\`\`` : '';
|
|
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
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 建立受控的子任務識別字串
|
|
368
|
+
* @param {number|string} parentIssueId
|
|
369
|
+
* @param {string} key
|
|
370
|
+
* @returns {string}
|
|
371
|
+
*/
|
|
372
|
+
export function buildManagedSubtaskKey(parentIssueId, key) {
|
|
373
|
+
return `${MANAGED_KEY_PREFIX} parent=${parentIssueId} key=${asString(key)}`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 從子任務 description 讀取受控識別字串
|
|
378
|
+
* @param {string} content
|
|
379
|
+
* @returns {string|null}
|
|
380
|
+
*/
|
|
381
|
+
export function getManagedSubtaskKey(content = '') {
|
|
382
|
+
const match = String(content).match(/<!--\s*(ai-git-tools:redmine-subtask parent=\S+ key=\S+)\s*-->/);
|
|
383
|
+
return match?.[1] || null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* 將單一候選子任務格式化為 Redmine description
|
|
388
|
+
* @param {{subtask: object, parentIssueId: number|string}} input
|
|
389
|
+
* @returns {string}
|
|
390
|
+
*/
|
|
391
|
+
export function formatSubtaskContent({ subtask = {}, parentIssueId, scopeDecision = {}, evidence = [] }) {
|
|
392
|
+
if (isFrontendSubtask(subtask)) {
|
|
393
|
+
return formatFrontendContent({ subtask, parentIssueId, scopeDecision, evidence });
|
|
394
|
+
}
|
|
395
|
+
const sections = [];
|
|
396
|
+
addSection(sections, '目的', subtask.purpose);
|
|
397
|
+
addSection(sections, '工作範圍', formatScope(subtask));
|
|
398
|
+
addSection(sections, '實作重點', bulletList(subtask.implementationDetails));
|
|
399
|
+
addSection(sections, '行為規則', formatBehaviorRules(subtask.behaviorRules));
|
|
400
|
+
addSection(sections, '驗收條件', bulletList(subtask.acceptanceCriteria));
|
|
401
|
+
|
|
402
|
+
const dependencies = bulletList(subtask.dependsOnKeys);
|
|
403
|
+
const unresolvedItems = asStringArray(subtask.unresolvedItems);
|
|
404
|
+
const dependencyParts = [];
|
|
405
|
+
if (dependencies) dependencyParts.push(`### 依賴\n${dependencies}`);
|
|
406
|
+
if (unresolvedItems.length) {
|
|
407
|
+
dependencyParts.push(`### 待確認\n${unresolvedItems.map(item => `- [ ] ${item}`).join('\n')}`);
|
|
408
|
+
}
|
|
409
|
+
addSection(sections, '依賴與待確認', dependencyParts.join('\n\n'));
|
|
410
|
+
addSection(sections, '流程圖', formatFlowcharts(subtask.flowcharts));
|
|
411
|
+
addSection(sections, 'Wireframe', formatWireframe(subtask.wireframe));
|
|
412
|
+
addSection(sections, '證據', bulletList(subtask.evidence));
|
|
413
|
+
|
|
414
|
+
sections.push(`<!-- ${buildManagedSubtaskKey(parentIssueId, subtask.key)} -->`);
|
|
415
|
+
return sections.join('\n\n').trim();
|
|
416
|
+
}
|
|
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
|
+
|
|
461
|
+
/**
|
|
462
|
+
* 產生主 Issue 子任務 terminal preview
|
|
463
|
+
* @param {{parent: object, candidates: Array<object>}} input
|
|
464
|
+
* @returns {string}
|
|
465
|
+
*/
|
|
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
|
+
}
|
|
470
|
+
const lines = [
|
|
471
|
+
chalk.bold.cyan('═'.repeat(72)),
|
|
472
|
+
chalk.bold('Redmine 子任務拆分預覽'),
|
|
473
|
+
chalk.bold.cyan('═'.repeat(72)),
|
|
474
|
+
'',
|
|
475
|
+
chalk.bold.cyan(`Issue #${parent.id || '—'}:${parent.subject || '無標題'}`),
|
|
476
|
+
'既有子任務:',
|
|
477
|
+
];
|
|
478
|
+
|
|
479
|
+
if (parent.children?.length) {
|
|
480
|
+
for (const child of parent.children) lines.push(`- #${child.id} ${child.subject || '無標題'}`);
|
|
481
|
+
} else {
|
|
482
|
+
lines.push('- 無');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
486
|
+
lines.push(
|
|
487
|
+
'',
|
|
488
|
+
chalk.bold.green(`[${index + 1}/${candidates.length}] ${candidate.title || '無標題'}`),
|
|
489
|
+
`分類:${candidate.category || '—'}`,
|
|
490
|
+
candidate.dependsOnKeys?.length ? `依賴:${candidate.dependsOnKeys.join('、')}` : '依賴:無',
|
|
491
|
+
candidate.evidence?.length ? `依據:${candidate.evidence.join('、')}` : '依據:未提供',
|
|
492
|
+
candidate.content || '',
|
|
493
|
+
chalk.dim('─'.repeat(72))
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
lines.push('', chalk.yellow('尚未修改 Redmine。請審核 title 與 content 後再使用 --apply。'));
|
|
498
|
+
return lines.join('\n').trim();
|
|
499
|
+
}
|