musubi-sdd 6.1.2 → 6.2.1
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.ja.md +60 -1
- package/README.md +60 -1
- package/bin/musubi-dashboard.js +340 -0
- package/package.json +3 -2
- package/src/cli/dashboard-cli.js +536 -0
- package/src/constitutional/checker.js +633 -0
- package/src/constitutional/ci-reporter.js +336 -0
- package/src/constitutional/index.js +22 -0
- package/src/constitutional/phase-minus-one.js +404 -0
- package/src/constitutional/steering-sync.js +473 -0
- package/src/dashboard/index.js +20 -0
- package/src/dashboard/sprint-planner.js +361 -0
- package/src/dashboard/sprint-reporter.js +378 -0
- package/src/dashboard/transition-recorder.js +209 -0
- package/src/dashboard/workflow-dashboard.js +434 -0
- package/src/enterprise/error-recovery.js +524 -0
- package/src/enterprise/experiment-report.js +573 -0
- package/src/enterprise/index.js +57 -4
- package/src/enterprise/rollback-manager.js +584 -0
- package/src/enterprise/tech-article.js +509 -0
- package/src/orchestration/builtin-skills.js +425 -0
- package/src/templates/agents/claude-code/skills/design-reviewer/SKILL.md +1135 -0
- package/src/templates/agents/claude-code/skills/requirements-reviewer/SKILL.md +997 -0
- package/src/traceability/extractor.js +294 -0
- package/src/traceability/gap-detector.js +230 -0
- package/src/traceability/index.js +15 -0
- package/src/traceability/matrix-storage.js +368 -0
- package/src/validators/design-reviewer.js +1300 -0
- package/src/validators/requirements-reviewer.js +1019 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SprintReporter Implementation
|
|
3
|
+
*
|
|
4
|
+
* Generates sprint completion reports.
|
|
5
|
+
*
|
|
6
|
+
* Requirement: IMP-6.2-003-04
|
|
7
|
+
* Design: Section 4.4
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs').promises;
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default configuration
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_CONFIG = {
|
|
17
|
+
storageDir: 'storage/reports'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* SprintReporter
|
|
22
|
+
*
|
|
23
|
+
* Generates and manages sprint reports.
|
|
24
|
+
*/
|
|
25
|
+
class SprintReporter {
|
|
26
|
+
/**
|
|
27
|
+
* @param {Object} config - Configuration options
|
|
28
|
+
*/
|
|
29
|
+
constructor(config = {}) {
|
|
30
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generate sprint completion report
|
|
35
|
+
* @param {Object} sprint - Sprint data
|
|
36
|
+
* @returns {Promise<Object>} Generated report
|
|
37
|
+
*/
|
|
38
|
+
async generateReport(sprint) {
|
|
39
|
+
const report = {
|
|
40
|
+
id: `RPT-${sprint.id}-${Date.now()}`,
|
|
41
|
+
sprintId: sprint.id,
|
|
42
|
+
sprintName: sprint.name,
|
|
43
|
+
featureId: sprint.featureId,
|
|
44
|
+
generatedAt: new Date().toISOString(),
|
|
45
|
+
period: {
|
|
46
|
+
start: sprint.startDate,
|
|
47
|
+
end: sprint.endDate,
|
|
48
|
+
startedAt: sprint.startedAt,
|
|
49
|
+
completedAt: sprint.completedAt
|
|
50
|
+
},
|
|
51
|
+
metrics: this.calculateMetrics(sprint),
|
|
52
|
+
taskSummary: this.summarizeTasks(sprint),
|
|
53
|
+
velocityAnalysis: this.analyzeVelocity(sprint),
|
|
54
|
+
recommendations: this.generateRecommendations(sprint)
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
await this.saveReport(report);
|
|
58
|
+
|
|
59
|
+
return report;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Calculate sprint metrics
|
|
64
|
+
* @param {Object} sprint - Sprint data
|
|
65
|
+
* @returns {Object} Metrics
|
|
66
|
+
*/
|
|
67
|
+
calculateMetrics(sprint) {
|
|
68
|
+
const tasks = sprint.tasks || [];
|
|
69
|
+
const totalPoints = tasks.reduce((sum, t) => sum + (t.storyPoints || 0), 0);
|
|
70
|
+
const completedTasks = tasks.filter(t => t.status === 'done');
|
|
71
|
+
const completedPoints = completedTasks.reduce((sum, t) => sum + (t.storyPoints || 0), 0);
|
|
72
|
+
|
|
73
|
+
const plannedVelocity = sprint.velocity || 0;
|
|
74
|
+
const actualVelocity = completedPoints;
|
|
75
|
+
const velocityDiff = actualVelocity - plannedVelocity;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
totalTasks: tasks.length,
|
|
79
|
+
completedTasks: completedTasks.length,
|
|
80
|
+
incompleteTasks: tasks.length - completedTasks.length,
|
|
81
|
+
totalPoints,
|
|
82
|
+
completedPoints,
|
|
83
|
+
remainingPoints: totalPoints - completedPoints,
|
|
84
|
+
completionRate: tasks.length > 0
|
|
85
|
+
? Math.round((completedTasks.length / tasks.length) * 100)
|
|
86
|
+
: 0,
|
|
87
|
+
pointsCompletionRate: totalPoints > 0
|
|
88
|
+
? Math.round((completedPoints / totalPoints) * 100)
|
|
89
|
+
: 0,
|
|
90
|
+
plannedVelocity,
|
|
91
|
+
actualVelocity,
|
|
92
|
+
velocityDiff,
|
|
93
|
+
velocityAccuracy: plannedVelocity > 0
|
|
94
|
+
? Math.round((actualVelocity / plannedVelocity) * 100)
|
|
95
|
+
: 0
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Summarize tasks by status and priority
|
|
101
|
+
* @param {Object} sprint - Sprint data
|
|
102
|
+
* @returns {Object} Task summary
|
|
103
|
+
*/
|
|
104
|
+
summarizeTasks(sprint) {
|
|
105
|
+
const tasks = sprint.tasks || [];
|
|
106
|
+
|
|
107
|
+
const byStatus = {
|
|
108
|
+
todo: tasks.filter(t => t.status === 'todo'),
|
|
109
|
+
inProgress: tasks.filter(t => t.status === 'in-progress'),
|
|
110
|
+
done: tasks.filter(t => t.status === 'done')
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const byPriority = {
|
|
114
|
+
critical: tasks.filter(t => t.priority === 'critical'),
|
|
115
|
+
high: tasks.filter(t => t.priority === 'high'),
|
|
116
|
+
medium: tasks.filter(t => t.priority === 'medium'),
|
|
117
|
+
low: tasks.filter(t => t.priority === 'low')
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const completedByPriority = {
|
|
121
|
+
critical: byPriority.critical.filter(t => t.status === 'done').length,
|
|
122
|
+
high: byPriority.high.filter(t => t.status === 'done').length,
|
|
123
|
+
medium: byPriority.medium.filter(t => t.status === 'done').length,
|
|
124
|
+
low: byPriority.low.filter(t => t.status === 'done').length
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
byStatus: {
|
|
129
|
+
todo: byStatus.todo.length,
|
|
130
|
+
inProgress: byStatus.inProgress.length,
|
|
131
|
+
done: byStatus.done.length
|
|
132
|
+
},
|
|
133
|
+
byPriority: {
|
|
134
|
+
critical: byPriority.critical.length,
|
|
135
|
+
high: byPriority.high.length,
|
|
136
|
+
medium: byPriority.medium.length,
|
|
137
|
+
low: byPriority.low.length
|
|
138
|
+
},
|
|
139
|
+
completedByPriority,
|
|
140
|
+
incompleteTasks: [...byStatus.todo, ...byStatus.inProgress].map(t => ({
|
|
141
|
+
id: t.id,
|
|
142
|
+
title: t.title,
|
|
143
|
+
priority: t.priority,
|
|
144
|
+
storyPoints: t.storyPoints,
|
|
145
|
+
status: t.status
|
|
146
|
+
}))
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Analyze velocity trends
|
|
152
|
+
* @param {Object} sprint - Sprint data
|
|
153
|
+
* @returns {Object} Velocity analysis
|
|
154
|
+
*/
|
|
155
|
+
analyzeVelocity(sprint) {
|
|
156
|
+
const metrics = this.calculateMetrics(sprint);
|
|
157
|
+
|
|
158
|
+
let status;
|
|
159
|
+
if (metrics.velocityAccuracy >= 90 && metrics.velocityAccuracy <= 110) {
|
|
160
|
+
status = 'on-target';
|
|
161
|
+
} else if (metrics.velocityAccuracy > 110) {
|
|
162
|
+
status = 'over-performing';
|
|
163
|
+
} else if (metrics.velocityAccuracy >= 70) {
|
|
164
|
+
status = 'slightly-under';
|
|
165
|
+
} else {
|
|
166
|
+
status = 'under-performing';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
planned: metrics.plannedVelocity,
|
|
171
|
+
actual: metrics.actualVelocity,
|
|
172
|
+
difference: metrics.velocityDiff,
|
|
173
|
+
accuracy: metrics.velocityAccuracy,
|
|
174
|
+
status
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Generate recommendations based on sprint results
|
|
180
|
+
* @param {Object} sprint - Sprint data
|
|
181
|
+
* @returns {Array} Recommendations
|
|
182
|
+
*/
|
|
183
|
+
generateRecommendations(sprint) {
|
|
184
|
+
const recommendations = [];
|
|
185
|
+
const metrics = this.calculateMetrics(sprint);
|
|
186
|
+
const taskSummary = this.summarizeTasks(sprint);
|
|
187
|
+
|
|
188
|
+
// Velocity recommendations
|
|
189
|
+
if (metrics.velocityAccuracy < 70) {
|
|
190
|
+
recommendations.push({
|
|
191
|
+
type: 'velocity',
|
|
192
|
+
severity: 'high',
|
|
193
|
+
message: 'スプリントの実績ベロシティが計画の70%未満でした。次のスプリントでは計画ベロシティを下げることを検討してください。'
|
|
194
|
+
});
|
|
195
|
+
} else if (metrics.velocityAccuracy > 130) {
|
|
196
|
+
recommendations.push({
|
|
197
|
+
type: 'velocity',
|
|
198
|
+
severity: 'medium',
|
|
199
|
+
message: '計画以上のベロシティを達成しました。次のスプリントでは計画ベロシティを上げることを検討してください。'
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Incomplete critical tasks
|
|
204
|
+
const incompleteCritical = taskSummary.byPriority.critical - taskSummary.completedByPriority.critical;
|
|
205
|
+
if (incompleteCritical > 0) {
|
|
206
|
+
recommendations.push({
|
|
207
|
+
type: 'priority',
|
|
208
|
+
severity: 'critical',
|
|
209
|
+
message: `${incompleteCritical}件のクリティカルタスクが未完了です。次のスプリントで優先的に対応してください。`
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// High number of incomplete tasks
|
|
214
|
+
if (metrics.completionRate < 50) {
|
|
215
|
+
recommendations.push({
|
|
216
|
+
type: 'planning',
|
|
217
|
+
severity: 'high',
|
|
218
|
+
message: 'タスク完了率が50%未満です。タスクの見積もりや優先順位付けの改善を検討してください。'
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Many in-progress tasks
|
|
223
|
+
if (taskSummary.byStatus.inProgress > 3) {
|
|
224
|
+
recommendations.push({
|
|
225
|
+
type: 'wip',
|
|
226
|
+
severity: 'medium',
|
|
227
|
+
message: '進行中のタスクが多すぎます。WIP制限を設けてフォーカスを高めることを検討してください。'
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return recommendations;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Generate markdown report
|
|
236
|
+
* @param {Object} sprint - Sprint data
|
|
237
|
+
* @returns {Promise<string>} Markdown report
|
|
238
|
+
*/
|
|
239
|
+
async generateMarkdownReport(sprint) {
|
|
240
|
+
const report = await this.generateReport(sprint);
|
|
241
|
+
const lines = [];
|
|
242
|
+
|
|
243
|
+
lines.push(`# Sprint Report: ${report.sprintName}`);
|
|
244
|
+
lines.push('');
|
|
245
|
+
lines.push(`**Generated:** ${report.generatedAt}`);
|
|
246
|
+
lines.push(`**Feature:** ${report.featureId || 'N/A'}`);
|
|
247
|
+
lines.push(`**Period:** ${report.period.start} - ${report.period.end}`);
|
|
248
|
+
lines.push('');
|
|
249
|
+
|
|
250
|
+
// Metrics
|
|
251
|
+
lines.push('## Metrics');
|
|
252
|
+
lines.push('');
|
|
253
|
+
lines.push('| Metric | Value |');
|
|
254
|
+
lines.push('|--------|-------|');
|
|
255
|
+
lines.push(`| Total Tasks | ${report.metrics.totalTasks} |`);
|
|
256
|
+
lines.push(`| Completed Tasks | ${report.metrics.completedTasks} |`);
|
|
257
|
+
lines.push(`| Completion Rate | ${report.metrics.completionRate}% |`);
|
|
258
|
+
lines.push(`| Total Points | ${report.metrics.totalPoints} |`);
|
|
259
|
+
lines.push(`| Completed Points | ${report.metrics.completedPoints} |`);
|
|
260
|
+
lines.push(`| Points Completion | ${report.metrics.pointsCompletionRate}% |`);
|
|
261
|
+
lines.push(`| Planned Velocity | ${report.metrics.plannedVelocity} |`);
|
|
262
|
+
lines.push(`| Actual Velocity | ${report.metrics.actualVelocity} |`);
|
|
263
|
+
lines.push(`| Velocity Accuracy | ${report.metrics.velocityAccuracy}% |`);
|
|
264
|
+
lines.push('');
|
|
265
|
+
|
|
266
|
+
// Velocity Analysis
|
|
267
|
+
lines.push('## Velocity Analysis');
|
|
268
|
+
lines.push('');
|
|
269
|
+
const va = report.velocityAnalysis;
|
|
270
|
+
const statusEmoji = {
|
|
271
|
+
'on-target': '✅',
|
|
272
|
+
'over-performing': '🚀',
|
|
273
|
+
'slightly-under': '⚠️',
|
|
274
|
+
'under-performing': '❌'
|
|
275
|
+
};
|
|
276
|
+
lines.push(`Status: ${statusEmoji[va.status] || '❓'} **${va.status}**`);
|
|
277
|
+
lines.push('');
|
|
278
|
+
|
|
279
|
+
// Task Summary
|
|
280
|
+
lines.push('## Task Summary');
|
|
281
|
+
lines.push('');
|
|
282
|
+
lines.push('### By Status');
|
|
283
|
+
lines.push(`- ⬜ Todo: ${report.taskSummary.byStatus.todo}`);
|
|
284
|
+
lines.push(`- 🔄 In Progress: ${report.taskSummary.byStatus.inProgress}`);
|
|
285
|
+
lines.push(`- ✅ Done: ${report.taskSummary.byStatus.done}`);
|
|
286
|
+
lines.push('');
|
|
287
|
+
|
|
288
|
+
lines.push('### By Priority');
|
|
289
|
+
lines.push(`- 🔴 Critical: ${report.taskSummary.completedByPriority.critical}/${report.taskSummary.byPriority.critical}`);
|
|
290
|
+
lines.push(`- 🟠 High: ${report.taskSummary.completedByPriority.high}/${report.taskSummary.byPriority.high}`);
|
|
291
|
+
lines.push(`- 🟡 Medium: ${report.taskSummary.completedByPriority.medium}/${report.taskSummary.byPriority.medium}`);
|
|
292
|
+
lines.push(`- 🟢 Low: ${report.taskSummary.completedByPriority.low}/${report.taskSummary.byPriority.low}`);
|
|
293
|
+
lines.push('');
|
|
294
|
+
|
|
295
|
+
// Incomplete Tasks
|
|
296
|
+
if (report.taskSummary.incompleteTasks.length > 0) {
|
|
297
|
+
lines.push('### Incomplete Tasks');
|
|
298
|
+
lines.push('');
|
|
299
|
+
for (const task of report.taskSummary.incompleteTasks) {
|
|
300
|
+
lines.push(`- **${task.id}**: ${task.title} (${task.priority}, ${task.storyPoints}pt)`);
|
|
301
|
+
}
|
|
302
|
+
lines.push('');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Recommendations
|
|
306
|
+
if (report.recommendations.length > 0) {
|
|
307
|
+
lines.push('## Recommendations');
|
|
308
|
+
lines.push('');
|
|
309
|
+
const severityEmoji = {
|
|
310
|
+
critical: '🔴',
|
|
311
|
+
high: '🟠',
|
|
312
|
+
medium: '🟡',
|
|
313
|
+
low: '🟢'
|
|
314
|
+
};
|
|
315
|
+
for (const rec of report.recommendations) {
|
|
316
|
+
lines.push(`${severityEmoji[rec.severity] || '❓'} **${rec.type}**: ${rec.message}`);
|
|
317
|
+
lines.push('');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return lines.join('\n');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Save report to storage
|
|
326
|
+
* @param {Object} report - Report to save
|
|
327
|
+
*/
|
|
328
|
+
async saveReport(report) {
|
|
329
|
+
await this.ensureStorageDir();
|
|
330
|
+
|
|
331
|
+
const filePath = path.join(this.config.storageDir, `${report.id}.json`);
|
|
332
|
+
await fs.writeFile(filePath, JSON.stringify(report, null, 2), 'utf-8');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Load report from storage
|
|
337
|
+
* @param {string} reportId - Report ID
|
|
338
|
+
* @returns {Promise<Object|null>} Report
|
|
339
|
+
*/
|
|
340
|
+
async loadReport(reportId) {
|
|
341
|
+
try {
|
|
342
|
+
const filePath = path.join(this.config.storageDir, `${reportId}.json`);
|
|
343
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
344
|
+
return JSON.parse(content);
|
|
345
|
+
} catch {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* List reports for sprint
|
|
352
|
+
* @param {string} sprintId - Sprint ID
|
|
353
|
+
* @returns {Promise<Array>} Report list
|
|
354
|
+
*/
|
|
355
|
+
async listReports(sprintId) {
|
|
356
|
+
try {
|
|
357
|
+
const files = await fs.readdir(this.config.storageDir);
|
|
358
|
+
return files
|
|
359
|
+
.filter(f => f.includes(sprintId) && f.endsWith('.json'))
|
|
360
|
+
.map(f => f.replace('.json', ''));
|
|
361
|
+
} catch {
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Ensure storage directory exists
|
|
368
|
+
*/
|
|
369
|
+
async ensureStorageDir() {
|
|
370
|
+
try {
|
|
371
|
+
await fs.access(this.config.storageDir);
|
|
372
|
+
} catch {
|
|
373
|
+
await fs.mkdir(this.config.storageDir, { recursive: true });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
module.exports = { SprintReporter };
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TransitionRecorder Implementation
|
|
3
|
+
*
|
|
4
|
+
* Records stage transitions with timestamps and reviewers.
|
|
5
|
+
*
|
|
6
|
+
* Requirement: IMP-6.2-003-02
|
|
7
|
+
* Design: Section 4.2
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs').promises;
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default configuration
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_CONFIG = {
|
|
17
|
+
storageDir: 'storage/transitions'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* TransitionRecorder
|
|
22
|
+
*
|
|
23
|
+
* Records and manages workflow stage transitions.
|
|
24
|
+
*/
|
|
25
|
+
class TransitionRecorder {
|
|
26
|
+
/**
|
|
27
|
+
* @param {Object} config - Configuration options
|
|
28
|
+
*/
|
|
29
|
+
constructor(config = {}) {
|
|
30
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Record a stage transition
|
|
35
|
+
* @param {string} featureId - Feature ID
|
|
36
|
+
* @param {Object} transition - Transition data
|
|
37
|
+
* @returns {Promise<Object>} Created transition record
|
|
38
|
+
*/
|
|
39
|
+
async recordTransition(featureId, transition) {
|
|
40
|
+
const history = await this.getHistory(featureId) || {
|
|
41
|
+
featureId,
|
|
42
|
+
transitions: [],
|
|
43
|
+
createdAt: new Date().toISOString()
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const record = {
|
|
47
|
+
id: `TR-${Date.now()}`,
|
|
48
|
+
fromStage: transition.fromStage,
|
|
49
|
+
toStage: transition.toStage,
|
|
50
|
+
status: transition.status || 'completed',
|
|
51
|
+
reviewer: transition.reviewer || null,
|
|
52
|
+
reviewResult: transition.reviewResult || null,
|
|
53
|
+
artifacts: transition.artifacts || [],
|
|
54
|
+
notes: transition.notes || null,
|
|
55
|
+
timestamp: new Date().toISOString()
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
history.transitions.push(record);
|
|
59
|
+
history.updatedAt = new Date().toISOString();
|
|
60
|
+
|
|
61
|
+
await this.saveHistory(featureId, history);
|
|
62
|
+
|
|
63
|
+
return record;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get transition history for feature
|
|
68
|
+
* @param {string} featureId - Feature ID
|
|
69
|
+
* @returns {Promise<Object|null>} Transition history
|
|
70
|
+
*/
|
|
71
|
+
async getHistory(featureId) {
|
|
72
|
+
try {
|
|
73
|
+
const filePath = path.join(
|
|
74
|
+
this.config.storageDir,
|
|
75
|
+
`${featureId}-transitions.json`
|
|
76
|
+
);
|
|
77
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
78
|
+
return JSON.parse(content);
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get last transition for feature
|
|
86
|
+
* @param {string} featureId - Feature ID
|
|
87
|
+
* @returns {Promise<Object|null>} Last transition record
|
|
88
|
+
*/
|
|
89
|
+
async getLastTransition(featureId) {
|
|
90
|
+
const history = await this.getHistory(featureId);
|
|
91
|
+
if (!history || history.transitions.length === 0) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return history.transitions[history.transitions.length - 1];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get transitions to a specific stage
|
|
99
|
+
* @param {string} featureId - Feature ID
|
|
100
|
+
* @param {string} targetStage - Target stage
|
|
101
|
+
* @returns {Promise<Array>} Transitions to stage
|
|
102
|
+
*/
|
|
103
|
+
async getTransitionsByStage(featureId, targetStage) {
|
|
104
|
+
const history = await this.getHistory(featureId);
|
|
105
|
+
if (!history) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
return history.transitions.filter(t => t.toStage === targetStage);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Calculate average transition time between stages
|
|
113
|
+
* @param {string} featureId - Feature ID
|
|
114
|
+
* @returns {Promise<number>} Average time in milliseconds
|
|
115
|
+
*/
|
|
116
|
+
async calculateAverageTransitionTime(featureId) {
|
|
117
|
+
const history = await this.getHistory(featureId);
|
|
118
|
+
if (!history || history.transitions.length < 2) {
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const transitions = history.transitions;
|
|
123
|
+
let totalTime = 0;
|
|
124
|
+
let count = 0;
|
|
125
|
+
|
|
126
|
+
for (let i = 1; i < transitions.length; i++) {
|
|
127
|
+
const prev = new Date(transitions[i - 1].timestamp).getTime();
|
|
128
|
+
const curr = new Date(transitions[i].timestamp).getTime();
|
|
129
|
+
totalTime += curr - prev;
|
|
130
|
+
count++;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return count > 0 ? Math.round(totalTime / count) : 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get transition statistics
|
|
138
|
+
* @param {string} featureId - Feature ID
|
|
139
|
+
* @returns {Promise<Object>} Transition statistics
|
|
140
|
+
*/
|
|
141
|
+
async getTransitionStats(featureId) {
|
|
142
|
+
const history = await this.getHistory(featureId);
|
|
143
|
+
if (!history) {
|
|
144
|
+
return {
|
|
145
|
+
totalTransitions: 0,
|
|
146
|
+
successfulTransitions: 0,
|
|
147
|
+
failedTransitions: 0,
|
|
148
|
+
averageTime: 0,
|
|
149
|
+
stageTransitions: {}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const transitions = history.transitions;
|
|
154
|
+
const stageTransitions = {};
|
|
155
|
+
|
|
156
|
+
let successful = 0;
|
|
157
|
+
let failed = 0;
|
|
158
|
+
|
|
159
|
+
for (const t of transitions) {
|
|
160
|
+
if (t.status === 'completed') {
|
|
161
|
+
successful++;
|
|
162
|
+
} else if (t.status === 'failed' || t.status === 'rejected') {
|
|
163
|
+
failed++;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const key = `${t.fromStage}->${t.toStage}`;
|
|
167
|
+
stageTransitions[key] = (stageTransitions[key] || 0) + 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const averageTime = await this.calculateAverageTransitionTime(featureId);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
totalTransitions: transitions.length,
|
|
174
|
+
successfulTransitions: successful,
|
|
175
|
+
failedTransitions: failed,
|
|
176
|
+
averageTime,
|
|
177
|
+
stageTransitions
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Save transition history
|
|
183
|
+
* @param {string} featureId - Feature ID
|
|
184
|
+
* @param {Object} history - History to save
|
|
185
|
+
*/
|
|
186
|
+
async saveHistory(featureId, history) {
|
|
187
|
+
await this.ensureStorageDir();
|
|
188
|
+
|
|
189
|
+
const filePath = path.join(
|
|
190
|
+
this.config.storageDir,
|
|
191
|
+
`${featureId}-transitions.json`
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
await fs.writeFile(filePath, JSON.stringify(history, null, 2), 'utf-8');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Ensure storage directory exists
|
|
199
|
+
*/
|
|
200
|
+
async ensureStorageDir() {
|
|
201
|
+
try {
|
|
202
|
+
await fs.access(this.config.storageDir);
|
|
203
|
+
} catch {
|
|
204
|
+
await fs.mkdir(this.config.storageDir, { recursive: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = { TransitionRecorder };
|