@h-rig/dependency-graph-plugin 0.0.6-alpha.155 → 0.0.6-alpha.157
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/dist/src/graph-model/dependencyGraph.d.ts +43 -0
- package/dist/src/graph-model/dependencyGraph.js +485 -0
- package/dist/src/graph-model/rollups.d.ts +6 -0
- package/dist/src/graph-model/rollups.js +166 -0
- package/dist/src/graph-model/taskGraphCodes.d.ts +3 -0
- package/dist/src/graph-model/taskGraphCodes.js +26 -0
- package/dist/src/graph-model/taskGraphLayout.d.ts +61 -0
- package/dist/src/graph-model/taskGraphLayout.js +346 -0
- package/dist/src/graph.d.ts +33 -0
- package/dist/src/graph.js +572 -0
- package/dist/src/index.js +1084 -35
- package/dist/src/plugin.d.ts +7 -2
- package/dist/src/plugin.js +1084 -35
- package/dist/src/rollups.d.ts +20 -0
- package/dist/src/rollups.js +199 -0
- package/dist/src/taskRanking.d.ts +24 -0
- package/dist/src/taskRanking.js +160 -0
- package/dist/src/taskScore.d.ts +17 -0
- package/dist/src/taskScore.js +49 -0
- package/dist/src/taskSelection.d.ts +33 -0
- package/dist/src/taskSelection.js +181 -0
- package/package.json +6 -4
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { DependencyEdge as ContractDependencyEdge, DependencyNode as ContractDependencyNode, EngineReadModel, TaskSummary } from "@rig/contracts";
|
|
2
|
+
import { type TaskGraphLayout } from "./taskGraphLayout";
|
|
3
|
+
export type DependencyEdgeType = ContractDependencyEdge["type"];
|
|
4
|
+
export interface DependencyEdge {
|
|
5
|
+
readonly fromTaskId: string;
|
|
6
|
+
readonly toTaskId: string;
|
|
7
|
+
readonly type: DependencyEdgeType;
|
|
8
|
+
}
|
|
9
|
+
export interface DependencyNode {
|
|
10
|
+
readonly taskId: string;
|
|
11
|
+
readonly title: string;
|
|
12
|
+
readonly status: ContractDependencyNode["status"];
|
|
13
|
+
readonly priority: ContractDependencyNode["priority"];
|
|
14
|
+
readonly assignee: string | null;
|
|
15
|
+
readonly blockedBy: readonly string[];
|
|
16
|
+
readonly blocks: readonly string[];
|
|
17
|
+
readonly blockingDepth: number;
|
|
18
|
+
readonly blockerClass: null;
|
|
19
|
+
readonly actionRiskTier: null;
|
|
20
|
+
readonly epicKey: string | null;
|
|
21
|
+
readonly groupKey?: string | null;
|
|
22
|
+
readonly externalId?: string | null;
|
|
23
|
+
readonly sourceIssueId?: string | null;
|
|
24
|
+
readonly scope?: readonly string[];
|
|
25
|
+
readonly validationKeys?: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
export interface DependencyGraphModel {
|
|
28
|
+
readonly graphId: string;
|
|
29
|
+
readonly nodes: readonly DependencyNode[];
|
|
30
|
+
readonly edges: readonly DependencyEdge[];
|
|
31
|
+
readonly layout: TaskGraphLayout;
|
|
32
|
+
readonly cycles: readonly (readonly string[])[];
|
|
33
|
+
readonly unresolvedRefs: readonly string[];
|
|
34
|
+
readonly degraded: boolean;
|
|
35
|
+
readonly generatedAt: string;
|
|
36
|
+
}
|
|
37
|
+
export interface BuildDependencyGraphModelOptions {
|
|
38
|
+
readonly snapshot?: EngineReadModel | null;
|
|
39
|
+
readonly generatedAt?: string;
|
|
40
|
+
readonly graphId?: string;
|
|
41
|
+
readonly showParentChild?: boolean;
|
|
42
|
+
}
|
|
43
|
+
export declare function buildDependencyGraphModel(tasks: readonly TaskSummary[], options?: BuildDependencyGraphModelOptions): DependencyGraphModel;
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/dependency-graph-plugin/src/graph-model/dependencyGraph.ts
|
|
3
|
+
import { computeTaskDependencyBadges, readTaskMetadataStringList as readTaskMetadataStringList2, resolveTaskReference as resolveTaskReference2, buildTaskReferenceIndex as buildTaskReferenceIndex2 } from "@rig/contracts";
|
|
4
|
+
|
|
5
|
+
// packages/dependency-graph-plugin/src/graph-model/taskGraphCodes.ts
|
|
6
|
+
var TASK_CODE_RE = /^\[([A-Z0-9]+(?:-[A-Z0-9]+)*)\]\s*/;
|
|
7
|
+
function extractTaskCode(title) {
|
|
8
|
+
const match = title.match(TASK_CODE_RE);
|
|
9
|
+
return match?.[1] ?? null;
|
|
10
|
+
}
|
|
11
|
+
function extractTaskGroupKey(title) {
|
|
12
|
+
const code = extractTaskCode(title);
|
|
13
|
+
if (!code)
|
|
14
|
+
return null;
|
|
15
|
+
const parts = code.split("-");
|
|
16
|
+
const suffix = parts.at(-1) ?? "";
|
|
17
|
+
if (/^\d+$/.test(suffix)) {
|
|
18
|
+
return parts.slice(0, -1).join("-");
|
|
19
|
+
}
|
|
20
|
+
return parts[0] ?? code;
|
|
21
|
+
}
|
|
22
|
+
function stripTaskCode(label) {
|
|
23
|
+
return label.replace(TASK_CODE_RE, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// packages/dependency-graph-plugin/src/graph-model/taskGraphLayout.ts
|
|
27
|
+
import {
|
|
28
|
+
buildTaskReferenceIndex,
|
|
29
|
+
readTaskMetadataStringList,
|
|
30
|
+
resolveTaskReference
|
|
31
|
+
} from "@rig/contracts";
|
|
32
|
+
import {
|
|
33
|
+
selectPendingApprovals,
|
|
34
|
+
selectRunsByTask,
|
|
35
|
+
selectUserInputsForRun
|
|
36
|
+
} from "@rig/read-model-plugin";
|
|
37
|
+
var CARD_WIDTH = 200;
|
|
38
|
+
var CARD_HEIGHT = 110;
|
|
39
|
+
var CELL_V_PAD = 12;
|
|
40
|
+
var CELL_H_PAD = 12;
|
|
41
|
+
var ROW_GAP = 28;
|
|
42
|
+
var COL_GAP = 40;
|
|
43
|
+
var LANE_LABEL_W = 120;
|
|
44
|
+
var STAGE_HDR_H = 32;
|
|
45
|
+
var PALETTE = [
|
|
46
|
+
{ bg: "#3a2d12", border: "#8d6b19", edge: "#d6a11d" },
|
|
47
|
+
{ bg: "#102642", border: "#245fbf", edge: "#66a2ff" },
|
|
48
|
+
{ bg: "#2c173f", border: "#7b39d4", edge: "#a76df5" },
|
|
49
|
+
{ bg: "#112d1c", border: "#2d8f4e", edge: "#62d882" },
|
|
50
|
+
{ bg: "#3a2314", border: "#c86d1c", edge: "#e69654" },
|
|
51
|
+
{ bg: "#31152b", border: "#bf3d88", edge: "#f07ebb" },
|
|
52
|
+
{ bg: "#132c35", border: "#1783a6", edge: "#53c4e5" },
|
|
53
|
+
{ bg: "#26310f", border: "#6d9a19", edge: "#a7da42" }
|
|
54
|
+
];
|
|
55
|
+
function isObjectRecord(value) {
|
|
56
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
function readIssueType(task) {
|
|
59
|
+
const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
|
|
60
|
+
if (typeof metadata?.issueType === "string")
|
|
61
|
+
return metadata.issueType;
|
|
62
|
+
const raw = isObjectRecord(metadata?.raw) ? metadata.raw : null;
|
|
63
|
+
return typeof raw?.issueType === "string" ? raw.issueType : null;
|
|
64
|
+
}
|
|
65
|
+
function isGraphTask(task) {
|
|
66
|
+
return readIssueType(task) !== "epic";
|
|
67
|
+
}
|
|
68
|
+
function findEpicAncestor(task, resolve, tasksById) {
|
|
69
|
+
const visited = new Set;
|
|
70
|
+
let current = task;
|
|
71
|
+
let epic = null;
|
|
72
|
+
while (!visited.has(current.id)) {
|
|
73
|
+
visited.add(current.id);
|
|
74
|
+
const parentRef = readTaskMetadataStringList(current, "parentChildDeps")[0];
|
|
75
|
+
if (!parentRef)
|
|
76
|
+
break;
|
|
77
|
+
const parentId = resolve(parentRef);
|
|
78
|
+
if (!parentId)
|
|
79
|
+
break;
|
|
80
|
+
const parent = tasksById.get(parentId);
|
|
81
|
+
if (!parent)
|
|
82
|
+
break;
|
|
83
|
+
if (readIssueType(parent) === "epic") {
|
|
84
|
+
epic = parent;
|
|
85
|
+
}
|
|
86
|
+
current = parent;
|
|
87
|
+
}
|
|
88
|
+
return epic;
|
|
89
|
+
}
|
|
90
|
+
function getRowKey(task, resolve, tasksById) {
|
|
91
|
+
const epic = findEpicAncestor(task, resolve, tasksById);
|
|
92
|
+
if (epic) {
|
|
93
|
+
return `group:${epic.id}`;
|
|
94
|
+
}
|
|
95
|
+
const codeGroup = extractTaskGroupKey(task.title);
|
|
96
|
+
if (codeGroup) {
|
|
97
|
+
return `code:${codeGroup}`;
|
|
98
|
+
}
|
|
99
|
+
return `type:${readIssueType(task) ?? "task"}`;
|
|
100
|
+
}
|
|
101
|
+
function getRowLabel(task, rowKey, resolve, tasksById) {
|
|
102
|
+
if (rowKey.startsWith("group:")) {
|
|
103
|
+
const groupId = rowKey.slice("group:".length);
|
|
104
|
+
return tasksById.get(groupId)?.title ?? groupId;
|
|
105
|
+
}
|
|
106
|
+
if (rowKey.startsWith("code:")) {
|
|
107
|
+
return rowKey.slice("code:".length);
|
|
108
|
+
}
|
|
109
|
+
const code = extractTaskCode(task.title);
|
|
110
|
+
if (code)
|
|
111
|
+
return code;
|
|
112
|
+
const issueType = readIssueType(task);
|
|
113
|
+
if (issueType === "task")
|
|
114
|
+
return "Tasks";
|
|
115
|
+
if (issueType)
|
|
116
|
+
return `${issueType[0]?.toUpperCase() ?? ""}${issueType.slice(1)}`;
|
|
117
|
+
const parentRef = readTaskMetadataStringList(task, "parentChildDeps")[0];
|
|
118
|
+
if (parentRef) {
|
|
119
|
+
const parentId = resolve(parentRef);
|
|
120
|
+
if (parentId) {
|
|
121
|
+
return tasksById.get(parentId)?.title ?? "Tasks";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return "Tasks";
|
|
125
|
+
}
|
|
126
|
+
function computeDepths(ids, edges) {
|
|
127
|
+
const blockers = new Map;
|
|
128
|
+
for (const edge of edges) {
|
|
129
|
+
if (!ids.has(edge.source) || !ids.has(edge.target))
|
|
130
|
+
continue;
|
|
131
|
+
const current = blockers.get(edge.target) ?? [];
|
|
132
|
+
current.push(edge.source);
|
|
133
|
+
blockers.set(edge.target, current);
|
|
134
|
+
}
|
|
135
|
+
const memo = new Map;
|
|
136
|
+
const visit = (id, stack) => {
|
|
137
|
+
const cached = memo.get(id);
|
|
138
|
+
if (cached !== undefined)
|
|
139
|
+
return cached;
|
|
140
|
+
if (stack.has(id))
|
|
141
|
+
return 0;
|
|
142
|
+
stack.add(id);
|
|
143
|
+
const deps = blockers.get(id);
|
|
144
|
+
if (!deps || deps.length === 0) {
|
|
145
|
+
memo.set(id, 0);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
let maxDepth = 0;
|
|
149
|
+
for (const dep of deps) {
|
|
150
|
+
maxDepth = Math.max(maxDepth, visit(dep, stack) + 1);
|
|
151
|
+
}
|
|
152
|
+
stack.delete(id);
|
|
153
|
+
memo.set(id, maxDepth);
|
|
154
|
+
return maxDepth;
|
|
155
|
+
};
|
|
156
|
+
for (const id of ids) {
|
|
157
|
+
visit(id, new Set);
|
|
158
|
+
}
|
|
159
|
+
return memo;
|
|
160
|
+
}
|
|
161
|
+
function buildTaskGraphLayout(snapshot, tasks, options) {
|
|
162
|
+
const showParentChild = options?.showParentChild ?? false;
|
|
163
|
+
const graphTasks = tasks.filter(isGraphTask);
|
|
164
|
+
if (graphTasks.length === 0) {
|
|
165
|
+
return {
|
|
166
|
+
lanes: [],
|
|
167
|
+
stages: [],
|
|
168
|
+
nodes: [],
|
|
169
|
+
edges: [],
|
|
170
|
+
totalWidth: 0,
|
|
171
|
+
totalHeight: 0,
|
|
172
|
+
taskCount: 0
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const { tasksById, taskIdByExternalRef, taskIdBySourceIssueId } = buildTaskReferenceIndex(tasks);
|
|
176
|
+
const resolve = (ref) => resolveTaskReference(ref, tasksById, taskIdByExternalRef, taskIdBySourceIssueId);
|
|
177
|
+
const rows = new Map;
|
|
178
|
+
const rowLabelByKey = new Map;
|
|
179
|
+
for (const task of graphTasks) {
|
|
180
|
+
const rowKey = getRowKey(task, resolve, tasksById);
|
|
181
|
+
const current = rows.get(rowKey) ?? [];
|
|
182
|
+
current.push(task);
|
|
183
|
+
rows.set(rowKey, current);
|
|
184
|
+
if (!rowLabelByKey.has(rowKey)) {
|
|
185
|
+
rowLabelByKey.set(rowKey, getRowLabel(task, rowKey, resolve, tasksById));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const orderedRows = [...rows.entries()].sort((left, right) => {
|
|
189
|
+
if (left[1].length !== right[1].length)
|
|
190
|
+
return right[1].length - left[1].length;
|
|
191
|
+
return (rowLabelByKey.get(left[0]) ?? left[0]).localeCompare(rowLabelByKey.get(right[0]) ?? right[0]);
|
|
192
|
+
});
|
|
193
|
+
const rowIndexByKey = new Map(orderedRows.map(([key], index) => [key, index]));
|
|
194
|
+
const rowIndexByTaskId = new Map;
|
|
195
|
+
for (const task of graphTasks) {
|
|
196
|
+
rowIndexByTaskId.set(task.id, rowIndexByKey.get(getRowKey(task, resolve, tasksById)) ?? 0);
|
|
197
|
+
}
|
|
198
|
+
const blockingEdgesRaw = [];
|
|
199
|
+
const depsIn = new Map;
|
|
200
|
+
const depsOut = new Map;
|
|
201
|
+
const edges = [];
|
|
202
|
+
for (const task of graphTasks) {
|
|
203
|
+
for (const ref of readTaskMetadataStringList(task, "dependencies")) {
|
|
204
|
+
const sourceId = resolve(ref);
|
|
205
|
+
if (!sourceId)
|
|
206
|
+
continue;
|
|
207
|
+
blockingEdgesRaw.push({ source: sourceId, target: task.id });
|
|
208
|
+
depsOut.set(sourceId, (depsOut.get(sourceId) ?? 0) + 1);
|
|
209
|
+
depsIn.set(task.id, (depsIn.get(task.id) ?? 0) + 1);
|
|
210
|
+
const color = PALETTE[(rowIndexByTaskId.get(sourceId) ?? 0) % PALETTE.length].edge;
|
|
211
|
+
edges.push({
|
|
212
|
+
id: `blocking:${sourceId}:${task.id}`,
|
|
213
|
+
sourceId,
|
|
214
|
+
targetId: task.id,
|
|
215
|
+
color,
|
|
216
|
+
kind: "blocking"
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (!showParentChild)
|
|
220
|
+
continue;
|
|
221
|
+
for (const ref of readTaskMetadataStringList(task, "parentChildDeps")) {
|
|
222
|
+
const sourceId = resolve(ref);
|
|
223
|
+
if (!sourceId)
|
|
224
|
+
continue;
|
|
225
|
+
edges.push({
|
|
226
|
+
id: `parent:${sourceId}:${task.id}`,
|
|
227
|
+
sourceId,
|
|
228
|
+
targetId: task.id,
|
|
229
|
+
color: "#5b6472",
|
|
230
|
+
kind: "parent-child"
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const graphTaskIds = new Set(graphTasks.map((task) => task.id));
|
|
235
|
+
const depths = computeDepths(graphTaskIds, blockingEdgesRaw);
|
|
236
|
+
const maxStage = Math.max(0, ...depths.values());
|
|
237
|
+
const rowMaxStack = new Array(orderedRows.length).fill(0);
|
|
238
|
+
const cells = new Map;
|
|
239
|
+
for (const task of graphTasks) {
|
|
240
|
+
const rowIndex = rowIndexByTaskId.get(task.id) ?? 0;
|
|
241
|
+
const stage = depths.get(task.id) ?? 0;
|
|
242
|
+
const key = `${rowIndex}:${stage}`;
|
|
243
|
+
const current = cells.get(key) ?? [];
|
|
244
|
+
current.push(task);
|
|
245
|
+
cells.set(key, current);
|
|
246
|
+
}
|
|
247
|
+
for (const [cellKey, cellTasks] of cells) {
|
|
248
|
+
const [rowIndexText] = cellKey.split(":");
|
|
249
|
+
const rowIndex = Number.parseInt(rowIndexText ?? "0", 10) || 0;
|
|
250
|
+
cellTasks.sort((left, right) => {
|
|
251
|
+
const leftFanout = depsOut.get(left.id) ?? 0;
|
|
252
|
+
const rightFanout = depsOut.get(right.id) ?? 0;
|
|
253
|
+
if (leftFanout !== rightFanout)
|
|
254
|
+
return rightFanout - leftFanout;
|
|
255
|
+
return left.title.localeCompare(right.title);
|
|
256
|
+
});
|
|
257
|
+
rowMaxStack[rowIndex] = Math.max(rowMaxStack[rowIndex] ?? 0, cellTasks.length);
|
|
258
|
+
}
|
|
259
|
+
const rowHeights = rowMaxStack.map((count) => Math.max(count, 1) * (CARD_HEIGHT + CELL_V_PAD) - CELL_V_PAD + CELL_V_PAD * 2);
|
|
260
|
+
const colWidths = new Array(maxStage + 1).fill(CARD_WIDTH + CELL_H_PAD * 2);
|
|
261
|
+
const colX = [];
|
|
262
|
+
let currentX = LANE_LABEL_W;
|
|
263
|
+
for (let index = 0;index <= maxStage; index += 1) {
|
|
264
|
+
colX.push(currentX);
|
|
265
|
+
currentX += (colWidths[index] ?? 0) + COL_GAP;
|
|
266
|
+
}
|
|
267
|
+
const totalWidth = currentX - COL_GAP;
|
|
268
|
+
const rowY = [];
|
|
269
|
+
let currentY = STAGE_HDR_H;
|
|
270
|
+
for (let rowIndex = 0;rowIndex < orderedRows.length; rowIndex += 1) {
|
|
271
|
+
rowY.push(currentY);
|
|
272
|
+
currentY += (rowHeights[rowIndex] ?? 0) + ROW_GAP;
|
|
273
|
+
}
|
|
274
|
+
const totalHeight = currentY - ROW_GAP;
|
|
275
|
+
const lanes = orderedRows.map(([rowKey, rowTasks], rowIndex) => ({
|
|
276
|
+
key: rowKey,
|
|
277
|
+
label: rowLabelByKey.get(rowKey) ?? rowKey,
|
|
278
|
+
rowIndex,
|
|
279
|
+
x: LANE_LABEL_W - 6,
|
|
280
|
+
y: (rowY[rowIndex] ?? 0) - 6,
|
|
281
|
+
width: totalWidth - LANE_LABEL_W + 12,
|
|
282
|
+
height: (rowHeights[rowIndex] ?? 0) + 12,
|
|
283
|
+
color: PALETTE[rowIndex % PALETTE.length].border,
|
|
284
|
+
taskCount: rowTasks.length
|
|
285
|
+
}));
|
|
286
|
+
const stages = Array.from({ length: maxStage + 1 }, (_, index) => ({
|
|
287
|
+
index,
|
|
288
|
+
label: index === 0 ? "Roots" : `Stage ${index}`,
|
|
289
|
+
x: (colX[index] ?? 0) + CELL_H_PAD,
|
|
290
|
+
width: CARD_WIDTH
|
|
291
|
+
}));
|
|
292
|
+
const pendingApprovalRunIds = new Set(selectPendingApprovals(snapshot).map((approval) => approval.runId));
|
|
293
|
+
const nodes = [];
|
|
294
|
+
for (const [rowIndex, [rowKey]] of orderedRows.entries()) {
|
|
295
|
+
for (let stage = 0;stage <= maxStage; stage += 1) {
|
|
296
|
+
const cellTasks = cells.get(`${rowIndex}:${stage}`) ?? [];
|
|
297
|
+
const baseX = (colX[stage] ?? 0) + CELL_H_PAD;
|
|
298
|
+
const baseY = (rowY[rowIndex] ?? 0) + CELL_V_PAD;
|
|
299
|
+
const palette = PALETTE[rowIndex % PALETTE.length];
|
|
300
|
+
for (const [stackIndex, task] of cellTasks.entries()) {
|
|
301
|
+
const runs = selectRunsByTask(snapshot, task.id);
|
|
302
|
+
const runIds = new Set(runs.map((run) => run.id));
|
|
303
|
+
const hasApprovals = runs.some((run) => pendingApprovalRunIds.has(run.id));
|
|
304
|
+
const hasPendingUserInput = runs.some((run) => selectUserInputsForRun(snapshot, run.id).some((request) => request.status === "pending"));
|
|
305
|
+
const hasRejectedReview = (snapshot?.reviews ?? []).some((review) => runIds.has(review.runId) && review.status === "rejected");
|
|
306
|
+
const hasFailedValidations = (snapshot?.validations ?? []).some((validation) => runIds.has(validation.runId) && validation.status === "failed");
|
|
307
|
+
const artifactCount = (snapshot?.artifacts ?? []).filter((artifact) => runIds.has(artifact.runId)).length;
|
|
308
|
+
nodes.push({
|
|
309
|
+
id: task.id,
|
|
310
|
+
taskId: task.id,
|
|
311
|
+
task,
|
|
312
|
+
rowKey,
|
|
313
|
+
rowLabel: rowLabelByKey.get(rowKey) ?? rowKey,
|
|
314
|
+
rowIndex,
|
|
315
|
+
stage,
|
|
316
|
+
x: baseX,
|
|
317
|
+
y: baseY + stackIndex * (CARD_HEIGHT + CELL_V_PAD),
|
|
318
|
+
width: CARD_WIDTH,
|
|
319
|
+
height: CARD_HEIGHT,
|
|
320
|
+
color: palette.border,
|
|
321
|
+
taskCode: extractTaskCode(task.title),
|
|
322
|
+
strippedTitle: stripTaskCode(task.title),
|
|
323
|
+
depsIn: depsIn.get(task.id) ?? 0,
|
|
324
|
+
depsOut: depsOut.get(task.id) ?? 0,
|
|
325
|
+
runCount: runs.length,
|
|
326
|
+
hasApprovals,
|
|
327
|
+
hasPendingUserInput,
|
|
328
|
+
hasRejectedReview,
|
|
329
|
+
hasFailedValidations,
|
|
330
|
+
artifactCount
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
lanes,
|
|
337
|
+
stages,
|
|
338
|
+
nodes,
|
|
339
|
+
edges,
|
|
340
|
+
totalWidth,
|
|
341
|
+
totalHeight,
|
|
342
|
+
taskCount: graphTasks.length
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// packages/dependency-graph-plugin/src/graph-model/dependencyGraph.ts
|
|
347
|
+
import { readTaskAssigneeLogins } from "@rig/read-model-plugin";
|
|
348
|
+
function uniqueSorted(values) {
|
|
349
|
+
return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right));
|
|
350
|
+
}
|
|
351
|
+
function deriveGraphId(tasks) {
|
|
352
|
+
const basis = tasks.map((task) => String(task.id)).toSorted((left, right) => left.localeCompare(right)).join("|");
|
|
353
|
+
let hash = 2166136261;
|
|
354
|
+
for (let index = 0;index < basis.length; index += 1) {
|
|
355
|
+
hash ^= basis.charCodeAt(index);
|
|
356
|
+
hash = Math.imul(hash, 16777619);
|
|
357
|
+
}
|
|
358
|
+
return `graph-${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
359
|
+
}
|
|
360
|
+
function dedupeEdges(edges) {
|
|
361
|
+
const byKey = new Map;
|
|
362
|
+
for (const edge of edges) {
|
|
363
|
+
byKey.set(`${edge.type}\x00${edge.fromTaskId}\x00${edge.toTaskId}`, edge);
|
|
364
|
+
}
|
|
365
|
+
return [...byKey.values()].toSorted((left, right) => {
|
|
366
|
+
const typeDelta = left.type.localeCompare(right.type);
|
|
367
|
+
if (typeDelta !== 0)
|
|
368
|
+
return typeDelta;
|
|
369
|
+
const fromDelta = left.fromTaskId.localeCompare(right.fromTaskId);
|
|
370
|
+
if (fromDelta !== 0)
|
|
371
|
+
return fromDelta;
|
|
372
|
+
return left.toTaskId.localeCompare(right.toTaskId);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
function detectBlockingCycles(tasks, edges) {
|
|
376
|
+
const taskIds = tasks.map((task) => String(task.id)).toSorted((left, right) => left.localeCompare(right));
|
|
377
|
+
const adjacency = new Map;
|
|
378
|
+
for (const taskId of taskIds)
|
|
379
|
+
adjacency.set(taskId, []);
|
|
380
|
+
for (const edge of edges) {
|
|
381
|
+
if (edge.type === "blocks")
|
|
382
|
+
adjacency.get(edge.fromTaskId)?.push(edge.toTaskId);
|
|
383
|
+
}
|
|
384
|
+
for (const targets of adjacency.values())
|
|
385
|
+
targets.sort((left, right) => left.localeCompare(right));
|
|
386
|
+
let nextIndex = 0;
|
|
387
|
+
const indexByNode = new Map;
|
|
388
|
+
const lowByNode = new Map;
|
|
389
|
+
const stack = [];
|
|
390
|
+
const onStack = new Set;
|
|
391
|
+
const cycles = [];
|
|
392
|
+
const visit = (node) => {
|
|
393
|
+
indexByNode.set(node, nextIndex);
|
|
394
|
+
lowByNode.set(node, nextIndex);
|
|
395
|
+
nextIndex += 1;
|
|
396
|
+
stack.push(node);
|
|
397
|
+
onStack.add(node);
|
|
398
|
+
for (const target of adjacency.get(node) ?? []) {
|
|
399
|
+
if (!indexByNode.has(target)) {
|
|
400
|
+
visit(target);
|
|
401
|
+
lowByNode.set(node, Math.min(lowByNode.get(node) ?? 0, lowByNode.get(target) ?? 0));
|
|
402
|
+
} else if (onStack.has(target)) {
|
|
403
|
+
lowByNode.set(node, Math.min(lowByNode.get(node) ?? 0, indexByNode.get(target) ?? 0));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (lowByNode.get(node) !== indexByNode.get(node))
|
|
407
|
+
return;
|
|
408
|
+
const component = [];
|
|
409
|
+
let current;
|
|
410
|
+
do {
|
|
411
|
+
current = stack.pop();
|
|
412
|
+
if (current) {
|
|
413
|
+
onStack.delete(current);
|
|
414
|
+
component.push(current);
|
|
415
|
+
}
|
|
416
|
+
} while (current && current !== node);
|
|
417
|
+
const selfLoop = (adjacency.get(node) ?? []).includes(node);
|
|
418
|
+
if (component.length > 1 || selfLoop)
|
|
419
|
+
cycles.push(component.sort((left, right) => left.localeCompare(right)));
|
|
420
|
+
};
|
|
421
|
+
for (const taskId of taskIds) {
|
|
422
|
+
if (!indexByNode.has(taskId))
|
|
423
|
+
visit(taskId);
|
|
424
|
+
}
|
|
425
|
+
return cycles.sort((left, right) => left.join("\x00").localeCompare(right.join("\x00")));
|
|
426
|
+
}
|
|
427
|
+
function buildDependencyGraphModel(tasks, options = {}) {
|
|
428
|
+
const badges = computeTaskDependencyBadges(tasks);
|
|
429
|
+
const index = buildTaskReferenceIndex2(tasks);
|
|
430
|
+
const edges = [];
|
|
431
|
+
const unresolvedRefs = [];
|
|
432
|
+
for (const task of tasks) {
|
|
433
|
+
for (const ref of readTaskMetadataStringList2(task, "dependencies")) {
|
|
434
|
+
const dependencyId = resolveTaskReference2(ref, index.tasksById, index.taskIdByExternalRef, index.taskIdBySourceIssueId);
|
|
435
|
+
if (dependencyId)
|
|
436
|
+
edges.push({ fromTaskId: dependencyId, toTaskId: String(task.id), type: "blocks" });
|
|
437
|
+
else
|
|
438
|
+
unresolvedRefs.push(ref);
|
|
439
|
+
}
|
|
440
|
+
for (const ref of readTaskMetadataStringList2(task, "parentChildDeps")) {
|
|
441
|
+
const parentId = resolveTaskReference2(ref, index.tasksById, index.taskIdByExternalRef, index.taskIdBySourceIssueId);
|
|
442
|
+
if (parentId)
|
|
443
|
+
edges.push({ fromTaskId: parentId, toTaskId: String(task.id), type: "parent-child" });
|
|
444
|
+
else
|
|
445
|
+
unresolvedRefs.push(ref);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const dedupedEdges = dedupeEdges(edges);
|
|
449
|
+
const nodes = tasks.map((task) => {
|
|
450
|
+
const summary = badges.get(task.id);
|
|
451
|
+
const assignees = readTaskAssigneeLogins(task);
|
|
452
|
+
const groupKey = extractTaskGroupKey(task.title);
|
|
453
|
+
return {
|
|
454
|
+
taskId: String(task.id),
|
|
455
|
+
title: task.title,
|
|
456
|
+
status: task.status,
|
|
457
|
+
priority: task.priority,
|
|
458
|
+
assignee: assignees[0] ?? null,
|
|
459
|
+
blockedBy: summary?.blockedBy ?? [],
|
|
460
|
+
blocks: summary?.blocks ?? [],
|
|
461
|
+
blockingDepth: summary?.blockingDepth ?? 0,
|
|
462
|
+
blockerClass: null,
|
|
463
|
+
actionRiskTier: null,
|
|
464
|
+
epicKey: groupKey,
|
|
465
|
+
groupKey,
|
|
466
|
+
externalId: task.externalId,
|
|
467
|
+
sourceIssueId: task.sourceIssueId ?? null,
|
|
468
|
+
scope: task.scope,
|
|
469
|
+
validationKeys: task.validationKeys
|
|
470
|
+
};
|
|
471
|
+
}).toSorted((left, right) => left.taskId.localeCompare(right.taskId));
|
|
472
|
+
return {
|
|
473
|
+
graphId: options.graphId ?? deriveGraphId(tasks),
|
|
474
|
+
nodes,
|
|
475
|
+
edges: dedupedEdges,
|
|
476
|
+
layout: buildTaskGraphLayout(options.snapshot ?? null, tasks, { showParentChild: options.showParentChild ?? true }),
|
|
477
|
+
cycles: detectBlockingCycles(tasks, dedupedEdges),
|
|
478
|
+
unresolvedRefs: uniqueSorted(unresolvedRefs),
|
|
479
|
+
degraded: false,
|
|
480
|
+
generatedAt: options.generatedAt ?? new Date().toISOString()
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
export {
|
|
484
|
+
buildDependencyGraphModel
|
|
485
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AssigneeRollup as ContractAssigneeRollup, BlockerClass as ContractBlockerClass, EpicRollup as ContractEpicRollup, RunJournalProjection, TaskSummary } from "@rig/contracts";
|
|
2
|
+
export type BlockerClass = ContractBlockerClass;
|
|
3
|
+
export type EpicRollup = ContractEpicRollup;
|
|
4
|
+
export type AssigneeRollup = ContractAssigneeRollup;
|
|
5
|
+
export declare function rollupByEpic(tasks: readonly TaskSummary[], classifications?: ReadonlyMap<string, BlockerClass>): readonly EpicRollup[];
|
|
6
|
+
export declare function rollupByAssignee(tasks: readonly TaskSummary[], runs: readonly RunJournalProjection[]): readonly AssigneeRollup[];
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/dependency-graph-plugin/src/graph-model/rollups.ts
|
|
3
|
+
import {
|
|
4
|
+
isOperatorActiveRunStatus,
|
|
5
|
+
buildTaskReferenceIndex,
|
|
6
|
+
computeTaskDependencyBadges,
|
|
7
|
+
isTaskTerminalStatus,
|
|
8
|
+
readTaskMetadataStringList,
|
|
9
|
+
resolveTaskReference
|
|
10
|
+
} from "@rig/contracts";
|
|
11
|
+
import { readTaskAssigneeLogins } from "@rig/read-model-plugin";
|
|
12
|
+
|
|
13
|
+
// packages/dependency-graph-plugin/src/graph-model/taskGraphCodes.ts
|
|
14
|
+
var TASK_CODE_RE = /^\[([A-Z0-9]+(?:-[A-Z0-9]+)*)\]\s*/;
|
|
15
|
+
function extractTaskCode(title) {
|
|
16
|
+
const match = title.match(TASK_CODE_RE);
|
|
17
|
+
return match?.[1] ?? null;
|
|
18
|
+
}
|
|
19
|
+
function extractTaskGroupKey(title) {
|
|
20
|
+
const code = extractTaskCode(title);
|
|
21
|
+
if (!code)
|
|
22
|
+
return null;
|
|
23
|
+
const parts = code.split("-");
|
|
24
|
+
const suffix = parts.at(-1) ?? "";
|
|
25
|
+
if (/^\d+$/.test(suffix)) {
|
|
26
|
+
return parts.slice(0, -1).join("-");
|
|
27
|
+
}
|
|
28
|
+
return parts[0] ?? code;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// packages/dependency-graph-plugin/src/graph-model/rollups.ts
|
|
32
|
+
var UNASSIGNED_EPIC = "(unassigned-epic)";
|
|
33
|
+
var UNASSIGNED_ASSIGNEE = "(unassigned)";
|
|
34
|
+
var HUMAN_BLOCKER_CLASSES = {
|
|
35
|
+
"human-decision": true,
|
|
36
|
+
"human-approval": true,
|
|
37
|
+
"external-input": true,
|
|
38
|
+
unknown: true
|
|
39
|
+
};
|
|
40
|
+
function isObjectRecord(value) {
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
function readIssueType(task) {
|
|
44
|
+
const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
|
|
45
|
+
const raw = isObjectRecord(metadata?.raw) ? metadata.raw : null;
|
|
46
|
+
const value = raw?.issueType ?? metadata?.issueType;
|
|
47
|
+
return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : null;
|
|
48
|
+
}
|
|
49
|
+
function isEpicTask(task) {
|
|
50
|
+
return readIssueType(task) === "epic";
|
|
51
|
+
}
|
|
52
|
+
function isInFlightTaskStatus(status) {
|
|
53
|
+
switch (status) {
|
|
54
|
+
case "queued":
|
|
55
|
+
case "running":
|
|
56
|
+
case "in_progress":
|
|
57
|
+
case "under_review":
|
|
58
|
+
return true;
|
|
59
|
+
default:
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function epicKeyForTask(task, tasksById, resolve) {
|
|
64
|
+
const parentRef = readTaskMetadataStringList(task, "parentChildDeps")[0];
|
|
65
|
+
const parentId = parentRef ? resolve(parentRef) : null;
|
|
66
|
+
const parent = parentId ? tasksById.get(parentId) : null;
|
|
67
|
+
if (parent)
|
|
68
|
+
return extractTaskGroupKey(parent.title) ?? parent.title ?? parent.id;
|
|
69
|
+
return extractTaskGroupKey(task.title) ?? UNASSIGNED_EPIC;
|
|
70
|
+
}
|
|
71
|
+
function rollupByEpic(tasks, classifications = new Map) {
|
|
72
|
+
const index = buildTaskReferenceIndex(tasks);
|
|
73
|
+
const badges = computeTaskDependencyBadges(tasks);
|
|
74
|
+
const resolve = (ref) => resolveTaskReference(ref, index.tasksById, index.taskIdByExternalRef, index.taskIdBySourceIssueId);
|
|
75
|
+
const buckets = new Map;
|
|
76
|
+
for (const task of tasks) {
|
|
77
|
+
if (isEpicTask(task))
|
|
78
|
+
continue;
|
|
79
|
+
const epicKey = epicKeyForTask(task, index.tasksById, resolve);
|
|
80
|
+
const current = buckets.get(epicKey) ?? {
|
|
81
|
+
total: 0,
|
|
82
|
+
completed: 0,
|
|
83
|
+
blockedCount: 0,
|
|
84
|
+
humanBlockedCount: 0,
|
|
85
|
+
inFlightCount: 0,
|
|
86
|
+
byStatus: {}
|
|
87
|
+
};
|
|
88
|
+
current.total += 1;
|
|
89
|
+
if (isTaskTerminalStatus(task.status))
|
|
90
|
+
current.completed += 1;
|
|
91
|
+
if (badges.get(task.id)?.blocked === true)
|
|
92
|
+
current.blockedCount += 1;
|
|
93
|
+
if (isInFlightTaskStatus(task.status))
|
|
94
|
+
current.inFlightCount += 1;
|
|
95
|
+
const classification = classifications.get(task.id);
|
|
96
|
+
if (classification && HUMAN_BLOCKER_CLASSES[classification])
|
|
97
|
+
current.humanBlockedCount += 1;
|
|
98
|
+
current.byStatus[task.status] = (current.byStatus[task.status] ?? 0) + 1;
|
|
99
|
+
buckets.set(epicKey, current);
|
|
100
|
+
}
|
|
101
|
+
return [...buckets.entries()].map(([epicKey, bucket]) => ({
|
|
102
|
+
epicKey,
|
|
103
|
+
total: bucket.total,
|
|
104
|
+
percentComplete: bucket.total === 0 ? 0 : Math.round(100 * bucket.completed / bucket.total),
|
|
105
|
+
blockedCount: bucket.blockedCount,
|
|
106
|
+
humanBlockedCount: bucket.humanBlockedCount,
|
|
107
|
+
inFlightCount: bucket.inFlightCount,
|
|
108
|
+
byStatus: Object.fromEntries(Object.entries(bucket.byStatus).sort(([left], [right]) => left.localeCompare(right)))
|
|
109
|
+
})).toSorted((left, right) => left.epicKey.localeCompare(right.epicKey));
|
|
110
|
+
}
|
|
111
|
+
function assigneesForTask(task) {
|
|
112
|
+
const assignees = readTaskAssigneeLogins(task);
|
|
113
|
+
return assignees.length > 0 ? assignees : [UNASSIGNED_ASSIGNEE];
|
|
114
|
+
}
|
|
115
|
+
function rollupByAssignee(tasks, runs) {
|
|
116
|
+
const tasksById = new Map(tasks.map((task) => [String(task.id), task]));
|
|
117
|
+
const badges = computeTaskDependencyBadges(tasks);
|
|
118
|
+
const buckets = new Map;
|
|
119
|
+
const ensureBucket = (assignee) => {
|
|
120
|
+
const existing = buckets.get(assignee);
|
|
121
|
+
if (existing)
|
|
122
|
+
return existing;
|
|
123
|
+
const created = { openTaskCount: 0, inFlightRunIds: new Set, prsAwaitingReview: 0, blockers: new Set };
|
|
124
|
+
buckets.set(assignee, created);
|
|
125
|
+
return created;
|
|
126
|
+
};
|
|
127
|
+
for (const task of tasks) {
|
|
128
|
+
if (isEpicTask(task))
|
|
129
|
+
continue;
|
|
130
|
+
for (const assignee of assigneesForTask(task)) {
|
|
131
|
+
const bucket = ensureBucket(assignee);
|
|
132
|
+
if (!isTaskTerminalStatus(task.status))
|
|
133
|
+
bucket.openTaskCount += 1;
|
|
134
|
+
if (badges.get(task.id)?.blocked === true)
|
|
135
|
+
bucket.blockers.add(task.id);
|
|
136
|
+
if (task.status === "under_review")
|
|
137
|
+
bucket.prsAwaitingReview += 1;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const run of runs) {
|
|
141
|
+
const taskId = run.record.taskId;
|
|
142
|
+
const task = taskId ? tasksById.get(taskId) : null;
|
|
143
|
+
if (!task)
|
|
144
|
+
continue;
|
|
145
|
+
if (isEpicTask(task))
|
|
146
|
+
continue;
|
|
147
|
+
for (const assignee of assigneesForTask(task)) {
|
|
148
|
+
const bucket = ensureBucket(assignee);
|
|
149
|
+
if (run.status && isOperatorActiveRunStatus(run.status))
|
|
150
|
+
bucket.inFlightRunIds.add(run.record.runId ?? `${String(task.id)}:${run.lastSeq}`);
|
|
151
|
+
if (run.status === "reviewing" && run.record.prUrl)
|
|
152
|
+
bucket.prsAwaitingReview += 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return [...buckets.entries()].map(([assignee, bucket]) => ({
|
|
156
|
+
assignee,
|
|
157
|
+
openTaskCount: bucket.openTaskCount,
|
|
158
|
+
inFlightRunCount: bucket.inFlightRunIds.size,
|
|
159
|
+
prsAwaitingReview: bucket.prsAwaitingReview,
|
|
160
|
+
blockers: [...bucket.blockers].toSorted((left, right) => String(left).localeCompare(String(right)))
|
|
161
|
+
})).toSorted((left, right) => left.assignee.localeCompare(right.assignee));
|
|
162
|
+
}
|
|
163
|
+
export {
|
|
164
|
+
rollupByEpic,
|
|
165
|
+
rollupByAssignee
|
|
166
|
+
};
|