@reqord/web 0.1.0
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/LICENSE +661 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +7 -0
- package/package.json +59 -0
- package/postcss.config.mjs +7 -0
- package/src/__tests__/components/dashboard/critical-path-display.test.tsx +129 -0
- package/src/__tests__/components/dashboard/progress-bar.test.tsx +87 -0
- package/src/__tests__/components/dashboard/project-health.test.tsx +57 -0
- package/src/__tests__/components/dashboard/warning-alert.test.tsx +75 -0
- package/src/__tests__/components/feedback/feedback-client-view.test.tsx +84 -0
- package/src/__tests__/components/feedback/feedback-filters.test.tsx +51 -0
- package/src/__tests__/components/feedback/feedback-linked-items.test.tsx +131 -0
- package/src/__tests__/components/feedback/feedback-list.test.tsx +49 -0
- package/src/__tests__/components/feedback/feedback-table.test.tsx +165 -0
- package/src/__tests__/components/flags/flag-badge.test.tsx +41 -0
- package/src/__tests__/components/flags/flag-list.test.tsx +51 -0
- package/src/__tests__/components/gantt/gantt-bar.test.tsx +190 -0
- package/src/__tests__/components/gantt/gantt-chart.test.tsx +141 -0
- package/src/__tests__/components/gantt/gantt-header.test.tsx +84 -0
- package/src/__tests__/components/gantt/gantt-legend.test.tsx +52 -0
- package/src/__tests__/components/graph/dag-layout.test.ts +129 -0
- package/src/__tests__/components/graph/dependency-graph.test.tsx +94 -0
- package/src/__tests__/components/graph/drilldown-breadcrumb.test.tsx +70 -0
- package/src/__tests__/components/graph/drilldown-graph.test.tsx +108 -0
- package/src/__tests__/components/graph/edge-styles.test.ts +27 -0
- package/src/__tests__/components/graph/graph-page-client.test.tsx +124 -0
- package/src/__tests__/components/graph/issue-node.test.tsx +173 -0
- package/src/__tests__/components/graph/requirement-node.test.tsx +151 -0
- package/src/__tests__/components/graph/specification-node.test.tsx +140 -0
- package/src/__tests__/components/specification/spec-tabs.test.tsx +153 -0
- package/src/__tests__/components/specification/tab-coverage.test.tsx +70 -0
- package/src/__tests__/components/specification/tab-design.test.tsx +42 -0
- package/src/__tests__/components/specification/tab-history.test.tsx +118 -0
- package/src/__tests__/components/specification/tab-issues.test.tsx +126 -0
- package/src/__tests__/components/specification/tab-research.test.tsx +42 -0
- package/src/__tests__/lib/dashboard-data.test.ts +334 -0
- package/src/__tests__/lib/drilldown-graph-data.test.ts +267 -0
- package/src/__tests__/lib/gantt-data.test.ts +299 -0
- package/src/__tests__/lib/graph-data.test.ts +309 -0
- package/src/__tests__/lib/local-feedback-repository.test.ts +74 -0
- package/src/__tests__/lib/local-specification-repository.test.ts +194 -0
- package/src/__tests__/lib/reqord-root.test.ts +31 -0
- package/src/__tests__/lib/specification-file.test.ts +63 -0
- package/src/__tests__/lib/tasks-data.test.ts +104 -0
- package/src/app/dashboard/loading.tsx +21 -0
- package/src/app/dashboard/page.tsx +50 -0
- package/src/app/error.tsx +22 -0
- package/src/app/feedback/loading.tsx +13 -0
- package/src/app/feedback/page.tsx +48 -0
- package/src/app/globals.css +2 -0
- package/src/app/graph/page.tsx +32 -0
- package/src/app/layout.tsx +25 -0
- package/src/app/page.tsx +5 -0
- package/src/app/requirements/[id]/edit/page.tsx +40 -0
- package/src/app/requirements/[id]/loading.tsx +14 -0
- package/src/app/requirements/[id]/not-found.tsx +18 -0
- package/src/app/requirements/[id]/page.tsx +43 -0
- package/src/app/requirements/loading.tsx +13 -0
- package/src/app/requirements/new/page.tsx +14 -0
- package/src/app/requirements/page.tsx +35 -0
- package/src/app/specifications/[id]/loading.tsx +14 -0
- package/src/app/specifications/[id]/not-found.tsx +18 -0
- package/src/app/specifications/[id]/page.tsx +52 -0
- package/src/app/specifications/loading.tsx +13 -0
- package/src/app/specifications/page.tsx +42 -0
- package/src/components/dashboard/critical-path-display.tsx +76 -0
- package/src/components/dashboard/progress-bar.tsx +45 -0
- package/src/components/dashboard/progress-section.tsx +57 -0
- package/src/components/dashboard/project-health.tsx +35 -0
- package/src/components/dashboard/status-card.tsx +27 -0
- package/src/components/dashboard/status-cards.tsx +28 -0
- package/src/components/dashboard/warning-alert.tsx +33 -0
- package/src/components/dashboard/warning-alerts.tsx +24 -0
- package/src/components/feedback/feedback-badge.tsx +48 -0
- package/src/components/feedback/feedback-client-view.tsx +38 -0
- package/src/components/feedback/feedback-filters.tsx +86 -0
- package/src/components/feedback/feedback-linked-items.tsx +93 -0
- package/src/components/feedback/feedback-list.tsx +40 -0
- package/src/components/feedback/feedback-table.tsx +115 -0
- package/src/components/gantt/gantt-bar.tsx +65 -0
- package/src/components/gantt/gantt-chart.tsx +88 -0
- package/src/components/gantt/gantt-constants.ts +15 -0
- package/src/components/gantt/gantt-critical-path.tsx +38 -0
- package/src/components/gantt/gantt-group.tsx +25 -0
- package/src/components/gantt/gantt-header.tsx +47 -0
- package/src/components/gantt/gantt-legend.tsx +26 -0
- package/src/components/graph/dag-layout.ts +131 -0
- package/src/components/graph/dependency-graph.tsx +88 -0
- package/src/components/graph/drilldown-breadcrumb.tsx +35 -0
- package/src/components/graph/drilldown-graph.tsx +45 -0
- package/src/components/graph/edge-styles.ts +16 -0
- package/src/components/graph/graph-loader.tsx +25 -0
- package/src/components/graph/graph-page-client.tsx +98 -0
- package/src/components/graph/issue-node.tsx +46 -0
- package/src/components/graph/multi-level-graph.tsx +91 -0
- package/src/components/graph/requirement-node.tsx +69 -0
- package/src/components/graph/specification-node.tsx +39 -0
- package/src/components/requirement/delete-button.tsx +46 -0
- package/src/components/requirement/dependency-editor.tsx +79 -0
- package/src/components/requirement/markdown-editor.tsx +47 -0
- package/src/components/requirement/markdown-renderer.tsx +12 -0
- package/src/components/requirement/requirement-detail.tsx +228 -0
- package/src/components/requirement/requirement-form.tsx +390 -0
- package/src/components/requirement/requirement-table.tsx +203 -0
- package/src/components/requirement/requirement-tabs.tsx +65 -0
- package/src/components/requirement/success-criteria-editor.tsx +53 -0
- package/src/components/specification/spec-detail.tsx +103 -0
- package/src/components/specification/spec-tabs.tsx +66 -0
- package/src/components/specification/specification-table.tsx +193 -0
- package/src/components/specification/tab-coverage.tsx +52 -0
- package/src/components/specification/tab-design.tsx +16 -0
- package/src/components/specification/tab-history.tsx +61 -0
- package/src/components/specification/tab-issues.tsx +111 -0
- package/src/components/specification/tab-research.tsx +16 -0
- package/src/components/ui/badge.tsx +64 -0
- package/src/components/ui/nav.tsx +49 -0
- package/src/components/ui/tabs.tsx +39 -0
- package/src/lib/actions.ts +222 -0
- package/src/lib/dashboard-data.ts +224 -0
- package/src/lib/data.ts +21 -0
- package/src/lib/drilldown-graph-data.ts +98 -0
- package/src/lib/feedback-data.ts +33 -0
- package/src/lib/feedback-repository.ts +6 -0
- package/src/lib/file-system.ts +167 -0
- package/src/lib/gantt-data.ts +168 -0
- package/src/lib/get-repository.ts +43 -0
- package/src/lib/graph-data.ts +161 -0
- package/src/lib/id-generator.ts +23 -0
- package/src/lib/local-feedback-repository.ts +36 -0
- package/src/lib/local-repository.ts +78 -0
- package/src/lib/local-specification-repository.ts +61 -0
- package/src/lib/repository.ts +11 -0
- package/src/lib/reqord-root.ts +33 -0
- package/src/lib/specification-data.ts +28 -0
- package/src/lib/specification-file.ts +12 -0
- package/src/lib/specification-repository.ts +8 -0
- package/src/lib/tasks-data.ts +32 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { TaskEntry } from "@reqord/shared";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_HOURS = 4;
|
|
4
|
+
|
|
5
|
+
export type GanttTask = {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
issueNumber: number;
|
|
9
|
+
issueUrl: string;
|
|
10
|
+
priority: string;
|
|
11
|
+
state: string;
|
|
12
|
+
estimatedHours: number;
|
|
13
|
+
startOffset: number;
|
|
14
|
+
dependencies: number[];
|
|
15
|
+
isCriticalPath: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type GanttGroup = {
|
|
19
|
+
priority: string;
|
|
20
|
+
label: string;
|
|
21
|
+
tasks: GanttTask[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type GanttData = {
|
|
25
|
+
specId: string;
|
|
26
|
+
groups: GanttGroup[];
|
|
27
|
+
totalEstimatedHours: number;
|
|
28
|
+
timelineStart: number;
|
|
29
|
+
timelineEnd: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type PriorityConfig = {
|
|
33
|
+
priority: string;
|
|
34
|
+
label: string;
|
|
35
|
+
isSerial: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const PRIORITY_CONFIGS: Record<string, PriorityConfig> = {
|
|
39
|
+
P0: { priority: "P0", label: "P0: Sequential", isSerial: true },
|
|
40
|
+
P1: { priority: "P1", label: "P1: Parallel", isSerial: false },
|
|
41
|
+
P2: { priority: "P2", label: "P2: Parallel", isSerial: false },
|
|
42
|
+
P3: { priority: "P3", label: "P3: Parallel", isSerial: false },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function getEstimatedHours(task: TaskEntry): number {
|
|
46
|
+
return task.estimatedHours ?? DEFAULT_HOURS;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createGanttTask(
|
|
50
|
+
task: TaskEntry,
|
|
51
|
+
startOffset: number,
|
|
52
|
+
isCriticalPath: boolean,
|
|
53
|
+
): GanttTask {
|
|
54
|
+
return {
|
|
55
|
+
id: String(task.number),
|
|
56
|
+
title: task.title,
|
|
57
|
+
issueNumber: task.number,
|
|
58
|
+
issueUrl: task.url,
|
|
59
|
+
priority: task.priority ?? "",
|
|
60
|
+
state: task.status,
|
|
61
|
+
estimatedHours: getEstimatedHours(task),
|
|
62
|
+
startOffset,
|
|
63
|
+
dependencies: [],
|
|
64
|
+
isCriticalPath,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function groupTasksByPriority(
|
|
69
|
+
tasks: TaskEntry[],
|
|
70
|
+
): Map<string, TaskEntry[]> {
|
|
71
|
+
const groups = new Map<string, TaskEntry[]>();
|
|
72
|
+
|
|
73
|
+
for (const task of tasks) {
|
|
74
|
+
if (!task.priority) continue;
|
|
75
|
+
const existing = groups.get(task.priority) || [];
|
|
76
|
+
groups.set(task.priority, [...existing, task]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return groups;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function calculateStartOffsets(taskGroups: Map<string, TaskEntry[]>): Map<string, number> {
|
|
83
|
+
const startOffsets = new Map<string, number>();
|
|
84
|
+
|
|
85
|
+
const p0Tasks = taskGroups.get("P0") || [];
|
|
86
|
+
const p1Tasks = taskGroups.get("P1") || [];
|
|
87
|
+
const p2Tasks = taskGroups.get("P2") || [];
|
|
88
|
+
|
|
89
|
+
const p0TotalTime = p0Tasks.reduce((sum, t) => sum + getEstimatedHours(t), 0);
|
|
90
|
+
const p1StartTime = p0TotalTime;
|
|
91
|
+
const p1MaxHours = p1Tasks.length > 0
|
|
92
|
+
? Math.max(...p1Tasks.map(getEstimatedHours))
|
|
93
|
+
: 0;
|
|
94
|
+
const p2StartTime = p1Tasks.length > 0 ? p1StartTime + p1MaxHours : p1StartTime;
|
|
95
|
+
const p2MaxHours = p2Tasks.length > 0
|
|
96
|
+
? Math.max(...p2Tasks.map(getEstimatedHours))
|
|
97
|
+
: 0;
|
|
98
|
+
const p3StartTime = p2Tasks.length > 0 ? p2StartTime + p2MaxHours : p2StartTime;
|
|
99
|
+
|
|
100
|
+
startOffsets.set("P0", 0);
|
|
101
|
+
startOffsets.set("P1", p1StartTime);
|
|
102
|
+
startOffsets.set("P2", p2StartTime);
|
|
103
|
+
startOffsets.set("P3", p3StartTime);
|
|
104
|
+
|
|
105
|
+
return startOffsets;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function createPriorityGroup(
|
|
109
|
+
priority: string,
|
|
110
|
+
tasks: TaskEntry[],
|
|
111
|
+
startOffset: number,
|
|
112
|
+
config: PriorityConfig,
|
|
113
|
+
): GanttGroup {
|
|
114
|
+
let currentOffset = startOffset;
|
|
115
|
+
const ganttTasks = config.isSerial
|
|
116
|
+
? tasks.map((task) => {
|
|
117
|
+
const ganttTask = createGanttTask(task, currentOffset, true);
|
|
118
|
+
currentOffset += getEstimatedHours(task);
|
|
119
|
+
return ganttTask;
|
|
120
|
+
})
|
|
121
|
+
: tasks.map((task) => createGanttTask(task, startOffset, false));
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
priority,
|
|
125
|
+
label: config.label,
|
|
126
|
+
tasks: ganttTasks,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function calculateTimelineEnd(groups: GanttGroup[]): number {
|
|
131
|
+
const allTasks = groups.flatMap((g) => g.tasks);
|
|
132
|
+
|
|
133
|
+
if (allTasks.length === 0) {
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return Math.max(...allTasks.map((task) => task.startOffset + task.estimatedHours));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function transformToGanttData(
|
|
141
|
+
specId: string,
|
|
142
|
+
tasks: TaskEntry[],
|
|
143
|
+
): GanttData {
|
|
144
|
+
const taskGroups = groupTasksByPriority(tasks);
|
|
145
|
+
const startOffsets = calculateStartOffsets(taskGroups);
|
|
146
|
+
|
|
147
|
+
const groups: GanttGroup[] = [];
|
|
148
|
+
|
|
149
|
+
for (const [priority, config] of Object.entries(PRIORITY_CONFIGS)) {
|
|
150
|
+
const priorityTasks = taskGroups.get(priority);
|
|
151
|
+
if (priorityTasks && priorityTasks.length > 0) {
|
|
152
|
+
const startOffset = startOffsets.get(priority) || 0;
|
|
153
|
+
groups.push(createPriorityGroup(priority, priorityTasks, startOffset, config));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const totalEstimatedHours = groups
|
|
158
|
+
.flatMap((g) => g.tasks)
|
|
159
|
+
.reduce((sum, t) => sum + t.estimatedHours, 0);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
specId,
|
|
163
|
+
groups,
|
|
164
|
+
totalEstimatedHours,
|
|
165
|
+
timelineStart: 0,
|
|
166
|
+
timelineEnd: calculateTimelineEnd(groups),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RequirementRepository } from "./repository";
|
|
2
|
+
import { LocalRequirementRepository } from "./local-repository";
|
|
3
|
+
import type { SpecificationRepository } from "./specification-repository";
|
|
4
|
+
import { LocalSpecificationRepository } from "./local-specification-repository";
|
|
5
|
+
|
|
6
|
+
let instance: RequirementRepository | null = null;
|
|
7
|
+
let specInstance: SpecificationRepository | null = null;
|
|
8
|
+
|
|
9
|
+
export function getRepository(): RequirementRepository {
|
|
10
|
+
if (instance) {
|
|
11
|
+
return instance;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const dataSource = process.env.REQORD_DATA_SOURCE ?? "local";
|
|
15
|
+
|
|
16
|
+
switch (dataSource) {
|
|
17
|
+
case "local":
|
|
18
|
+
instance = new LocalRequirementRepository();
|
|
19
|
+
break;
|
|
20
|
+
default:
|
|
21
|
+
throw new Error(`Unknown REQORD_DATA_SOURCE: ${dataSource}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return instance;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getSpecificationRepository(): SpecificationRepository {
|
|
28
|
+
if (specInstance) {
|
|
29
|
+
return specInstance;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dataSource = process.env.REQORD_DATA_SOURCE ?? "local";
|
|
33
|
+
|
|
34
|
+
switch (dataSource) {
|
|
35
|
+
case "local":
|
|
36
|
+
specInstance = new LocalSpecificationRepository();
|
|
37
|
+
break;
|
|
38
|
+
default:
|
|
39
|
+
throw new Error(`Unknown REQORD_DATA_SOURCE: ${dataSource}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return specInstance;
|
|
43
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { Requirement, Specification, TaskEntry } from "@reqord/shared";
|
|
2
|
+
|
|
3
|
+
export type NodeType = "requirement" | "specification" | "issue";
|
|
4
|
+
export type EdgeType = "dependency" | "implements" | "tracks";
|
|
5
|
+
|
|
6
|
+
export type MultiLevelNode = {
|
|
7
|
+
id: string;
|
|
8
|
+
type: NodeType;
|
|
9
|
+
data: {
|
|
10
|
+
label: string;
|
|
11
|
+
status: string;
|
|
12
|
+
priority?: string;
|
|
13
|
+
issueNumber?: number;
|
|
14
|
+
issueUrl?: string;
|
|
15
|
+
};
|
|
16
|
+
position: { x: number; y: number };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type MultiLevelEdge = {
|
|
20
|
+
id: string;
|
|
21
|
+
source: string;
|
|
22
|
+
target: string;
|
|
23
|
+
type: EdgeType;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type MultiLevelGraphData = {
|
|
27
|
+
nodes: MultiLevelNode[];
|
|
28
|
+
edges: MultiLevelEdge[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const COLUMN_X = {
|
|
32
|
+
requirement: 0,
|
|
33
|
+
specification: 400,
|
|
34
|
+
issue: 800,
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
const NODE_SPACING = {
|
|
38
|
+
requirement: 120,
|
|
39
|
+
specification: 120,
|
|
40
|
+
issue: 80,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
function createRequirementNode(
|
|
44
|
+
req: Requirement,
|
|
45
|
+
index: number,
|
|
46
|
+
): MultiLevelNode {
|
|
47
|
+
return {
|
|
48
|
+
id: req.id,
|
|
49
|
+
type: "requirement",
|
|
50
|
+
data: {
|
|
51
|
+
label: req.title,
|
|
52
|
+
status: req.status,
|
|
53
|
+
priority: req.priority,
|
|
54
|
+
},
|
|
55
|
+
position: { x: COLUMN_X.requirement, y: index * NODE_SPACING.requirement },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createDependencyEdges(req: Requirement): MultiLevelEdge[] {
|
|
60
|
+
return req.dependencies.blockedBy.map((blockerId) => ({
|
|
61
|
+
id: `dep-${blockerId}-${req.id}`,
|
|
62
|
+
source: blockerId,
|
|
63
|
+
target: req.id,
|
|
64
|
+
type: "dependency" as const,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createSpecificationNode(
|
|
69
|
+
spec: Specification,
|
|
70
|
+
specIndex: number,
|
|
71
|
+
): MultiLevelNode {
|
|
72
|
+
return {
|
|
73
|
+
id: spec.id,
|
|
74
|
+
type: "specification",
|
|
75
|
+
data: {
|
|
76
|
+
label: spec.id,
|
|
77
|
+
status: spec.status,
|
|
78
|
+
},
|
|
79
|
+
position: {
|
|
80
|
+
x: COLUMN_X.specification,
|
|
81
|
+
y: specIndex * NODE_SPACING.specification,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function createImplementsEdge(spec: Specification): MultiLevelEdge {
|
|
87
|
+
return {
|
|
88
|
+
id: `impl-${spec.id}-${spec.requirementId}`,
|
|
89
|
+
source: spec.id,
|
|
90
|
+
target: spec.requirementId,
|
|
91
|
+
type: "implements",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createIssueNode(
|
|
96
|
+
task: TaskEntry,
|
|
97
|
+
specIndex: number,
|
|
98
|
+
issueIndex: number,
|
|
99
|
+
): MultiLevelNode {
|
|
100
|
+
const issueId = `issue-${task.number}`;
|
|
101
|
+
return {
|
|
102
|
+
id: issueId,
|
|
103
|
+
type: "issue",
|
|
104
|
+
data: {
|
|
105
|
+
label: `Issue #${task.number}`,
|
|
106
|
+
status: task.status,
|
|
107
|
+
priority: task.priority,
|
|
108
|
+
issueNumber: task.number,
|
|
109
|
+
issueUrl: task.url,
|
|
110
|
+
},
|
|
111
|
+
position: {
|
|
112
|
+
x: COLUMN_X.issue,
|
|
113
|
+
y:
|
|
114
|
+
specIndex * NODE_SPACING.specification +
|
|
115
|
+
issueIndex * NODE_SPACING.issue,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function createTracksEdge(issueId: string, specId: string): MultiLevelEdge {
|
|
121
|
+
return {
|
|
122
|
+
id: `track-${issueId}-${specId}`,
|
|
123
|
+
source: issueId,
|
|
124
|
+
target: specId,
|
|
125
|
+
type: "tracks",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function buildMultiLevelGraphData(
|
|
130
|
+
requirements: Requirement[],
|
|
131
|
+
specifications: Specification[],
|
|
132
|
+
tasks: TaskEntry[],
|
|
133
|
+
): MultiLevelGraphData {
|
|
134
|
+
const nodes: MultiLevelNode[] = [];
|
|
135
|
+
const edges: MultiLevelEdge[] = [];
|
|
136
|
+
const addedIssueIds = new Set<string>();
|
|
137
|
+
|
|
138
|
+
requirements.forEach((req, index) => {
|
|
139
|
+
nodes.push(createRequirementNode(req, index));
|
|
140
|
+
edges.push(...createDependencyEdges(req));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
specifications.forEach((spec, specIndex) => {
|
|
144
|
+
nodes.push(createSpecificationNode(spec, specIndex));
|
|
145
|
+
edges.push(createImplementsEdge(spec));
|
|
146
|
+
|
|
147
|
+
const specTasks = tasks.filter((t) =>
|
|
148
|
+
t.linkedTo.specifications.includes(spec.id),
|
|
149
|
+
);
|
|
150
|
+
specTasks.forEach((task, issueIndex) => {
|
|
151
|
+
const issueId = `issue-${task.number}`;
|
|
152
|
+
if (!addedIssueIds.has(issueId)) {
|
|
153
|
+
nodes.push(createIssueNode(task, specIndex, issueIndex));
|
|
154
|
+
addedIssueIds.add(issueId);
|
|
155
|
+
}
|
|
156
|
+
edges.push(createTracksEdge(issueId, spec.id));
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return { nodes, edges };
|
|
161
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { REQORD_DIR, REQUIREMENTS_DIR } from "@reqord/shared";
|
|
2
|
+
import * as fs from "./file-system";
|
|
3
|
+
|
|
4
|
+
export async function generateNextId(cwd: string): Promise<string> {
|
|
5
|
+
const reqDir = fs.joinPath(cwd, REQORD_DIR, REQUIREMENTS_DIR);
|
|
6
|
+
const files = await fs.readdirFiles(reqDir, (name) =>
|
|
7
|
+
/^req-\d{6}\.yaml$/.test(name),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
let maxNum = 0;
|
|
11
|
+
for (const file of files) {
|
|
12
|
+
const match = file.match(/^req-(\d{6})\.yaml$/);
|
|
13
|
+
if (match) {
|
|
14
|
+
const num = parseInt(match[1], 10);
|
|
15
|
+
if (num > maxNum) {
|
|
16
|
+
maxNum = num;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const nextNum = maxNum + 1;
|
|
22
|
+
return `req-${String(nextNum).padStart(6, "0")}`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { FeedbackIndexSchema, type FeedbackEntry } from "@reqord/shared";
|
|
2
|
+
import type { FeedbackRepository } from "./feedback-repository";
|
|
3
|
+
import { readYAML, joinPath } from "./file-system";
|
|
4
|
+
import { getIssuesDir } from "./reqord-root";
|
|
5
|
+
|
|
6
|
+
export class LocalFeedbackRepository implements FeedbackRepository {
|
|
7
|
+
async findAll(): Promise<FeedbackEntry[]> {
|
|
8
|
+
const indexPath = joinPath(getIssuesDir(), "feedbacks.yaml");
|
|
9
|
+
try {
|
|
10
|
+
const raw = await readYAML<unknown>(indexPath);
|
|
11
|
+
const parsed = FeedbackIndexSchema.safeParse(raw);
|
|
12
|
+
if (!parsed.success) return [];
|
|
13
|
+
return parsed.data.feedbacks;
|
|
14
|
+
} catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async findUnresolvedByArtifactId(artifactId: string): Promise<FeedbackEntry[]> {
|
|
20
|
+
const feedbacks = await this.findAll();
|
|
21
|
+
return feedbacks.filter((f) => {
|
|
22
|
+
const linked = [
|
|
23
|
+
...f.linkedTo.requirements,
|
|
24
|
+
...(f.linkedTo.createdRequirements ?? []),
|
|
25
|
+
...f.linkedTo.specifications,
|
|
26
|
+
...(f.linkedTo.createdSpecifications ?? []),
|
|
27
|
+
];
|
|
28
|
+
if (!linked.includes(artifactId)) return false;
|
|
29
|
+
const resolved = [
|
|
30
|
+
...(f.linkedTo.resolved?.requirements ?? []),
|
|
31
|
+
...(f.linkedTo.resolved?.specifications ?? []),
|
|
32
|
+
];
|
|
33
|
+
return !resolved.includes(artifactId);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { RequirementSchema, type Requirement } from "@reqord/shared";
|
|
2
|
+
import type { RequirementRepository } from "./repository";
|
|
3
|
+
import * as fs from "./file-system";
|
|
4
|
+
import { generateNextId } from "./id-generator";
|
|
5
|
+
import { getReqordRoot, getRequirementsDir } from "./reqord-root";
|
|
6
|
+
|
|
7
|
+
export class LocalRequirementRepository implements RequirementRepository {
|
|
8
|
+
async findAll(): Promise<Requirement[]> {
|
|
9
|
+
const reqDir = getRequirementsDir();
|
|
10
|
+
const files = await fs.readdirFiles(reqDir, (name) =>
|
|
11
|
+
/^req-\d{6}\.yaml$/.test(name),
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const requirements: Requirement[] = [];
|
|
15
|
+
for (const file of files.sort()) {
|
|
16
|
+
const raw = await fs.readYAML<unknown>(fs.joinPath(reqDir, file));
|
|
17
|
+
const result = RequirementSchema.safeParse(raw);
|
|
18
|
+
if (result.success) {
|
|
19
|
+
requirements.push(result.data);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return requirements;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async findById(id: string): Promise<Requirement | null> {
|
|
26
|
+
const reqDir = getRequirementsDir();
|
|
27
|
+
const yamlPath = fs.joinPath(reqDir, `${id}.yaml`);
|
|
28
|
+
|
|
29
|
+
if (!(await fs.exists(yamlPath))) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const raw = await fs.readYAML<unknown>(yamlPath);
|
|
34
|
+
const result = RequirementSchema.safeParse(raw);
|
|
35
|
+
if (!result.success) {
|
|
36
|
+
throw new Error(`Invalid requirement ${id}: ${result.error.message}`);
|
|
37
|
+
}
|
|
38
|
+
return result.data;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async loadDescription(id: string): Promise<string | null> {
|
|
42
|
+
const reqDir = getRequirementsDir();
|
|
43
|
+
const descPath = fs.joinPath(reqDir, id, "description.md");
|
|
44
|
+
|
|
45
|
+
if (!(await fs.exists(descPath))) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return fs.readText(descPath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async save(requirement: Requirement): Promise<void> {
|
|
53
|
+
const reqDir = getRequirementsDir();
|
|
54
|
+
await fs.mkdirp(reqDir);
|
|
55
|
+
const yamlPath = fs.joinPath(reqDir, `${requirement.id}.yaml`);
|
|
56
|
+
await fs.writeYAML(yamlPath, requirement);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async saveDescription(id: string, content: string): Promise<void> {
|
|
60
|
+
const reqDir = getRequirementsDir();
|
|
61
|
+
const descDir = fs.joinPath(reqDir, id);
|
|
62
|
+
await fs.mkdirp(descDir);
|
|
63
|
+
await fs.writeText(fs.joinPath(descDir, "description.md"), content);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async deleteById(id: string): Promise<void> {
|
|
67
|
+
const reqDir = getRequirementsDir();
|
|
68
|
+
const yamlPath = fs.joinPath(reqDir, `${id}.yaml`);
|
|
69
|
+
const descDir = fs.joinPath(reqDir, id);
|
|
70
|
+
|
|
71
|
+
await fs.remove(yamlPath);
|
|
72
|
+
await fs.remove(descDir);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async generateNextId(): Promise<string> {
|
|
76
|
+
return generateNextId(getReqordRoot());
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { SpecificationSchema, type Specification } from "@reqord/shared";
|
|
2
|
+
import type { SpecificationRepository } from "./specification-repository";
|
|
3
|
+
import * as fs from "./file-system";
|
|
4
|
+
import { getSpecificationsDir } from "./reqord-root";
|
|
5
|
+
|
|
6
|
+
export class LocalSpecificationRepository implements SpecificationRepository {
|
|
7
|
+
async findAll(): Promise<Specification[]> {
|
|
8
|
+
const specDir = getSpecificationsDir();
|
|
9
|
+
const files = await fs.readdirFiles(specDir, (name) =>
|
|
10
|
+
/^spec-\d{6}\.yaml$/.test(name),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const specifications: Specification[] = [];
|
|
14
|
+
for (const file of files.sort()) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = await fs.readYAML<unknown>(fs.joinPath(specDir, file));
|
|
17
|
+
const result = SpecificationSchema.safeParse(raw);
|
|
18
|
+
if (result.success) {
|
|
19
|
+
specifications.push(result.data);
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
// Skip files with invalid YAML
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return specifications;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async findById(id: string): Promise<Specification | null> {
|
|
29
|
+
const specDir = getSpecificationsDir();
|
|
30
|
+
const yamlPath = fs.joinPath(specDir, `${id}.yaml`);
|
|
31
|
+
|
|
32
|
+
if (!(await fs.exists(yamlPath))) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const raw = await fs.readYAML<unknown>(yamlPath);
|
|
37
|
+
const result = SpecificationSchema.safeParse(raw);
|
|
38
|
+
if (!result.success) {
|
|
39
|
+
throw new Error(`Invalid specification ${id}: ${result.error.message}`);
|
|
40
|
+
}
|
|
41
|
+
return result.data;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async loadDesign(id: string): Promise<string | null> {
|
|
45
|
+
const specDir = getSpecificationsDir();
|
|
46
|
+
const designPath = fs.joinPath(specDir, id, "design.md");
|
|
47
|
+
|
|
48
|
+
if (!(await fs.exists(designPath))) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return fs.readText(designPath);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async findByRequirementId(
|
|
56
|
+
requirementId: string,
|
|
57
|
+
): Promise<Specification[]> {
|
|
58
|
+
const all = await this.findAll();
|
|
59
|
+
return all.filter((spec) => spec.requirementId === requirementId);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Requirement } from "@reqord/shared";
|
|
2
|
+
|
|
3
|
+
export interface RequirementRepository {
|
|
4
|
+
findAll(): Promise<Requirement[]>;
|
|
5
|
+
findById(id: string): Promise<Requirement | null>;
|
|
6
|
+
loadDescription(id: string): Promise<string | null>;
|
|
7
|
+
save(requirement: Requirement): Promise<void>;
|
|
8
|
+
saveDescription(id: string, content: string): Promise<void>;
|
|
9
|
+
deleteById(id: string): Promise<void>;
|
|
10
|
+
generateNextId(): Promise<string>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { REQORD_DIR, SPECIFICATIONS_DIR, ISSUES_DIR } from "@reqord/shared";
|
|
2
|
+
import * as fs from "./file-system";
|
|
3
|
+
|
|
4
|
+
let cachedRoot: string | null = null;
|
|
5
|
+
|
|
6
|
+
export function getReqordRoot(): string {
|
|
7
|
+
if (cachedRoot) {
|
|
8
|
+
return cachedRoot;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const envRoot = process.env.REQORD_ROOT;
|
|
12
|
+
if (!envRoot) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
"REQORD_ROOT environment variable is not set. " +
|
|
15
|
+
"Set it to the project root containing .reqord/ directory.",
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
cachedRoot = envRoot;
|
|
20
|
+
return cachedRoot;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getRequirementsDir(): string {
|
|
24
|
+
return fs.joinPath(getReqordRoot(), REQORD_DIR, "requirements");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getSpecificationsDir(): string {
|
|
28
|
+
return fs.joinPath(getReqordRoot(), REQORD_DIR, SPECIFICATIONS_DIR);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getIssuesDir(): string {
|
|
32
|
+
return fs.joinPath(getReqordRoot(), REQORD_DIR, ISSUES_DIR);
|
|
33
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Specification } from "@reqord/shared";
|
|
2
|
+
import { getSpecificationRepository } from "./get-repository";
|
|
3
|
+
|
|
4
|
+
export async function getAllSpecifications(): Promise<Specification[]> {
|
|
5
|
+
const repo = getSpecificationRepository();
|
|
6
|
+
return repo.findAll();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function getSpecificationById(
|
|
10
|
+
id: string,
|
|
11
|
+
): Promise<Specification | null> {
|
|
12
|
+
const repo = getSpecificationRepository();
|
|
13
|
+
return repo.findById(id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function getSpecificationDesign(
|
|
17
|
+
id: string,
|
|
18
|
+
): Promise<string | null> {
|
|
19
|
+
const repo = getSpecificationRepository();
|
|
20
|
+
return repo.loadDesign(id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function getSpecificationsByRequirementId(
|
|
24
|
+
requirementId: string,
|
|
25
|
+
): Promise<Specification[]> {
|
|
26
|
+
const repo = getSpecificationRepository();
|
|
27
|
+
return repo.findByRequirementId(requirementId);
|
|
28
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as fs from "./file-system";
|
|
2
|
+
import { getSpecificationsDir } from "./reqord-root";
|
|
3
|
+
|
|
4
|
+
export async function loadSpecFile(
|
|
5
|
+
specId: string,
|
|
6
|
+
filename: string,
|
|
7
|
+
): Promise<string | null> {
|
|
8
|
+
const specDir = getSpecificationsDir();
|
|
9
|
+
const filePath = fs.joinPath(specDir, specId, filename);
|
|
10
|
+
if (!(await fs.exists(filePath))) return null;
|
|
11
|
+
return fs.readText(filePath);
|
|
12
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Specification } from "@reqord/shared";
|
|
2
|
+
|
|
3
|
+
export interface SpecificationRepository {
|
|
4
|
+
findAll(): Promise<Specification[]>;
|
|
5
|
+
findById(id: string): Promise<Specification | null>;
|
|
6
|
+
loadDesign(id: string): Promise<string | null>;
|
|
7
|
+
findByRequirementId(requirementId: string): Promise<Specification[]>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TasksIndexSchema,
|
|
3
|
+
type TaskEntry,
|
|
4
|
+
type TasksIndex,
|
|
5
|
+
REQORD_DIR,
|
|
6
|
+
ISSUES_DIR,
|
|
7
|
+
} from "@reqord/shared";
|
|
8
|
+
import * as fs from "./file-system";
|
|
9
|
+
import { getReqordRoot } from "./reqord-root";
|
|
10
|
+
|
|
11
|
+
export function getTasksFilePath(): string {
|
|
12
|
+
return fs.joinPath(getReqordRoot(), REQORD_DIR, ISSUES_DIR, "tasks.yaml");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function loadTasksYaml(): Promise<TasksIndex> {
|
|
16
|
+
const filePath = getTasksFilePath();
|
|
17
|
+
try {
|
|
18
|
+
const raw = await fs.readYAML<unknown>(filePath);
|
|
19
|
+
const parsed = TasksIndexSchema.safeParse(raw);
|
|
20
|
+
if (!parsed.success) {
|
|
21
|
+
return { title: "Tasks", tasks: [] };
|
|
22
|
+
}
|
|
23
|
+
return parsed.data;
|
|
24
|
+
} catch {
|
|
25
|
+
return { title: "Tasks", tasks: [] };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function getAllTasks(): Promise<TaskEntry[]> {
|
|
30
|
+
const index = await loadTasksYaml();
|
|
31
|
+
return index.tasks;
|
|
32
|
+
}
|