@h-rig/dependency-graph-plugin 0.0.6-alpha.154 → 0.0.6-alpha.156
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,20 @@
|
|
|
1
|
+
import type { TaskLike } from "@rig/core/task-io";
|
|
2
|
+
import type { BlockerClassification, RunRecord, WorkspaceRollups } from "@rig/contracts";
|
|
3
|
+
import { type WorkspaceBlockers } from "@rig/blocker-classifier-plugin";
|
|
4
|
+
export interface SelectWorkspaceRollupsInput {
|
|
5
|
+
readonly tasks: readonly TaskLike[];
|
|
6
|
+
readonly runs?: readonly RunRecord[];
|
|
7
|
+
readonly classifications?: ReadonlyMap<string, BlockerClassification>;
|
|
8
|
+
readonly generatedAt?: string;
|
|
9
|
+
readonly workspaceId?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface GetWorkspaceRollupsDeps {
|
|
12
|
+
readonly listTasks: (projectRoot: string) => Promise<readonly TaskLike[]>;
|
|
13
|
+
readonly listRuns?: (projectRoot: string) => Promise<readonly RunRecord[]>;
|
|
14
|
+
readonly classifyBlockers?: (projectRoot: string) => Promise<WorkspaceBlockers>;
|
|
15
|
+
readonly generatedAt?: string;
|
|
16
|
+
readonly workspaceId?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function selectWorkspaceRollups(input: SelectWorkspaceRollupsInput): WorkspaceRollups;
|
|
19
|
+
export declare function getWorkspaceRollups(projectRoot: string, deps: GetWorkspaceRollupsDeps): Promise<WorkspaceRollups>;
|
|
20
|
+
export declare const getWorkspaceStatus: typeof getWorkspaceRollups;
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
|
|
164
|
+
// packages/dependency-graph-plugin/src/rollups.ts
|
|
165
|
+
import { toTaskSummary } from "@rig/contracts";
|
|
166
|
+
import { classifyTasks } from "@rig/blocker-classifier-plugin";
|
|
167
|
+
function blockerClassMap(classifications) {
|
|
168
|
+
return new Map([...classifications.entries()].map(([taskId, classification]) => [taskId, classification.blockerClass]));
|
|
169
|
+
}
|
|
170
|
+
function selectWorkspaceRollups(input) {
|
|
171
|
+
const tasks = input.tasks.map((task) => toTaskSummary(task, input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}));
|
|
172
|
+
const runs = input.runs ?? [];
|
|
173
|
+
const classifications = input.classifications ?? classifyTasks(input.tasks, runs, input.generatedAt !== undefined ? { generatedAt: input.generatedAt } : {}).byTaskId;
|
|
174
|
+
return {
|
|
175
|
+
epics: [...rollupByEpic(tasks, blockerClassMap(classifications))],
|
|
176
|
+
assignees: [...rollupByAssignee(tasks, runs.map((run) => run.projection))],
|
|
177
|
+
generatedAt: input.generatedAt ?? new Date().toISOString()
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
async function getWorkspaceRollups(projectRoot, deps) {
|
|
181
|
+
const [tasks, runs, blockers] = await Promise.all([
|
|
182
|
+
deps.listTasks(projectRoot),
|
|
183
|
+
deps.listRuns ? deps.listRuns(projectRoot) : Promise.resolve([]),
|
|
184
|
+
deps.classifyBlockers ? deps.classifyBlockers(projectRoot) : Promise.resolve(null)
|
|
185
|
+
]);
|
|
186
|
+
return selectWorkspaceRollups({
|
|
187
|
+
tasks,
|
|
188
|
+
runs,
|
|
189
|
+
...blockers?.byTaskId !== undefined ? { classifications: blockers.byTaskId } : {},
|
|
190
|
+
...deps.generatedAt !== undefined ? { generatedAt: deps.generatedAt } : {},
|
|
191
|
+
...deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
var getWorkspaceStatus = getWorkspaceRollups;
|
|
195
|
+
export {
|
|
196
|
+
selectWorkspaceRollups,
|
|
197
|
+
getWorkspaceStatus,
|
|
198
|
+
getWorkspaceRollups
|
|
199
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type TaskDependencyProjection } from "@rig/contracts";
|
|
2
|
+
export declare function selectNextReadyTaskByPriority<T extends TaskDependencyProjection>(tasks: readonly T[], options?: {
|
|
3
|
+
readonly excludeTaskIds?: Iterable<string>;
|
|
4
|
+
readonly filter?: (task: T) => boolean;
|
|
5
|
+
}): T | null;
|
|
6
|
+
export type ReadyTaskSelectionMode = "all-ready" | "blocking-only" | "max-unblock";
|
|
7
|
+
export interface RankedReadyTask<T extends TaskDependencyProjection> {
|
|
8
|
+
readonly task: T;
|
|
9
|
+
readonly score: number;
|
|
10
|
+
readonly priority: number;
|
|
11
|
+
readonly unblockCount: number;
|
|
12
|
+
readonly scope: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
export interface RankedReadyTaskOptions<T extends TaskDependencyProjection> {
|
|
15
|
+
readonly excludeTaskIds?: Iterable<string>;
|
|
16
|
+
readonly activeTaskIds?: Iterable<string>;
|
|
17
|
+
readonly filter?: (task: T) => boolean;
|
|
18
|
+
readonly selection?: ReadyTaskSelectionMode;
|
|
19
|
+
readonly requireDisjointScopes?: boolean;
|
|
20
|
+
readonly disjointWithScopes?: Iterable<string>;
|
|
21
|
+
readonly limit?: number;
|
|
22
|
+
}
|
|
23
|
+
export declare function rankReadyTasks<T extends TaskDependencyProjection>(tasks: readonly T[], options?: Omit<RankedReadyTaskOptions<T>, "limit" | "requireDisjointScopes" | "disjointWithScopes">): readonly RankedReadyTask<T>[];
|
|
24
|
+
export declare function selectRankedReadyTasks<T extends TaskDependencyProjection>(tasks: readonly T[], options?: RankedReadyTaskOptions<T>): readonly T[];
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/dependency-graph-plugin/src/taskRanking.ts
|
|
3
|
+
import {
|
|
4
|
+
computeTaskDependencyBadges,
|
|
5
|
+
isTaskTerminalStatus,
|
|
6
|
+
readTaskScope
|
|
7
|
+
} from "@rig/contracts";
|
|
8
|
+
|
|
9
|
+
// packages/dependency-graph-plugin/src/taskScore.ts
|
|
10
|
+
var ROLE_WEIGHT = {
|
|
11
|
+
architect: 25,
|
|
12
|
+
extractor: 10
|
|
13
|
+
};
|
|
14
|
+
var CRITICALITY_WEIGHT = {
|
|
15
|
+
core: 20,
|
|
16
|
+
high: 10,
|
|
17
|
+
normal: 0
|
|
18
|
+
};
|
|
19
|
+
function finiteNumber(value, fallback) {
|
|
20
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
21
|
+
}
|
|
22
|
+
function scoreTask(input) {
|
|
23
|
+
const priority = finiteNumber(input.priority, 3);
|
|
24
|
+
const unblockCount = Math.max(0, finiteNumber(input.unblockCount, 0));
|
|
25
|
+
const roleWeight = input.role ? ROLE_WEIGHT[input.role] ?? 0 : 0;
|
|
26
|
+
const criticalityWeight = input.criticality ? CRITICALITY_WEIGHT[input.criticality] ?? 0 : 0;
|
|
27
|
+
const validationWeight = (input.validation ?? []).some((entry) => entry.includes("test-contract") || entry.includes("test-boundary")) ? 8 : 0;
|
|
28
|
+
const queueWeight = finiteNumber(input.queueWeight, 0);
|
|
29
|
+
return unblockCount * 100 + (10 - priority) + roleWeight + criticalityWeight + validationWeight + queueWeight;
|
|
30
|
+
}
|
|
31
|
+
function rankTasks(tasks, scoreInput, idOf) {
|
|
32
|
+
return tasks.map((task) => {
|
|
33
|
+
const input = scoreInput(task);
|
|
34
|
+
return {
|
|
35
|
+
task,
|
|
36
|
+
score: scoreTask(input),
|
|
37
|
+
priority: finiteNumber(input.priority, 3),
|
|
38
|
+
unblockCount: Math.max(0, finiteNumber(input.unblockCount, 0))
|
|
39
|
+
};
|
|
40
|
+
}).toSorted((left, right) => {
|
|
41
|
+
const scoreDelta = right.score - left.score;
|
|
42
|
+
if (scoreDelta !== 0)
|
|
43
|
+
return scoreDelta;
|
|
44
|
+
const unblockDelta = right.unblockCount - left.unblockCount;
|
|
45
|
+
if (unblockDelta !== 0)
|
|
46
|
+
return unblockDelta;
|
|
47
|
+
const priorityDelta = left.priority - right.priority;
|
|
48
|
+
if (priorityDelta !== 0)
|
|
49
|
+
return priorityDelta;
|
|
50
|
+
return idOf(left.task).localeCompare(idOf(right.task));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// packages/dependency-graph-plugin/src/taskRanking.ts
|
|
55
|
+
function isObjectRecord(value) {
|
|
56
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
function readStringList(value) {
|
|
59
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string" && entry.length > 0) : [];
|
|
60
|
+
}
|
|
61
|
+
function priorityValue(task) {
|
|
62
|
+
return typeof task.priority === "number" && Number.isFinite(task.priority) ? task.priority : Number.MAX_SAFE_INTEGER;
|
|
63
|
+
}
|
|
64
|
+
function readTaskRole(task) {
|
|
65
|
+
if (typeof task.role === "string" && task.role.trim())
|
|
66
|
+
return task.role.trim();
|
|
67
|
+
const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
|
|
68
|
+
return typeof metadata?.role === "string" && metadata.role.trim() ? metadata.role.trim() : null;
|
|
69
|
+
}
|
|
70
|
+
function readTaskCriticality(task) {
|
|
71
|
+
const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
|
|
72
|
+
return typeof metadata?.criticality === "string" && metadata.criticality.trim() ? metadata.criticality.trim() : null;
|
|
73
|
+
}
|
|
74
|
+
function readTaskValidationKeys(task) {
|
|
75
|
+
const taskRecord = task;
|
|
76
|
+
const topLevel = readStringList(taskRecord.validationKeys);
|
|
77
|
+
if (topLevel.length > 0)
|
|
78
|
+
return topLevel;
|
|
79
|
+
const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
|
|
80
|
+
return readStringList(metadata?.validation);
|
|
81
|
+
}
|
|
82
|
+
function readTaskQueueWeight(task) {
|
|
83
|
+
const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
|
|
84
|
+
const queueWeight = metadata?.queueWeight ?? metadata?.queue_weight;
|
|
85
|
+
return typeof queueWeight === "number" && Number.isFinite(queueWeight) ? queueWeight : 0;
|
|
86
|
+
}
|
|
87
|
+
function scoreInputForTask(task, unblockCount) {
|
|
88
|
+
return {
|
|
89
|
+
priority: task.priority,
|
|
90
|
+
unblockCount,
|
|
91
|
+
role: readTaskRole(task),
|
|
92
|
+
criticality: readTaskCriticality(task),
|
|
93
|
+
validation: readTaskValidationKeys(task),
|
|
94
|
+
queueWeight: readTaskQueueWeight(task)
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function selectNextReadyTaskByPriority(tasks, options = {}) {
|
|
98
|
+
const excluded = new Set(options.excludeTaskIds ?? []);
|
|
99
|
+
const badges = computeTaskDependencyBadges(tasks);
|
|
100
|
+
const candidates = tasks.filter((task) => !excluded.has(task.id)).filter((task) => options.filter?.(task) ?? true).filter((task) => badges.get(task.id)?.ready === true).toSorted((left, right) => {
|
|
101
|
+
const priorityDelta = priorityValue(left) - priorityValue(right);
|
|
102
|
+
if (priorityDelta !== 0)
|
|
103
|
+
return priorityDelta;
|
|
104
|
+
const createdDelta = (left.createdAt ?? "").localeCompare(right.createdAt ?? "");
|
|
105
|
+
if (createdDelta !== 0)
|
|
106
|
+
return createdDelta;
|
|
107
|
+
return left.id.localeCompare(right.id);
|
|
108
|
+
});
|
|
109
|
+
return candidates[0] ?? null;
|
|
110
|
+
}
|
|
111
|
+
function rankReadyTasks(tasks, options = {}) {
|
|
112
|
+
const excluded = new Set(options.excludeTaskIds ?? []);
|
|
113
|
+
const activeTaskIds = new Set(options.activeTaskIds ?? []);
|
|
114
|
+
const badges = computeTaskDependencyBadges(tasks);
|
|
115
|
+
const tasksById = new Map(tasks.map((task) => [String(task.id), task]));
|
|
116
|
+
const openBlockCount = (task) => (badges.get(task.id)?.blocks ?? []).filter((blockedTaskId) => {
|
|
117
|
+
const blockedTask = tasksById.get(blockedTaskId);
|
|
118
|
+
return blockedTask ? !isTaskTerminalStatus(blockedTask.status) : false;
|
|
119
|
+
}).length;
|
|
120
|
+
const readyTasks = tasks.filter((task) => !excluded.has(task.id)).filter((task) => !activeTaskIds.has(task.id)).filter((task) => options.filter?.(task) ?? true).filter((task) => badges.get(task.id)?.ready === true);
|
|
121
|
+
const maxUnblockCount = Math.max(0, ...readyTasks.map(openBlockCount));
|
|
122
|
+
const selectedByMode = readyTasks.filter((task) => {
|
|
123
|
+
const unblockCount = openBlockCount(task);
|
|
124
|
+
if (options.selection === "blocking-only")
|
|
125
|
+
return unblockCount > 0;
|
|
126
|
+
if (options.selection === "max-unblock")
|
|
127
|
+
return maxUnblockCount > 0 && unblockCount === maxUnblockCount;
|
|
128
|
+
return true;
|
|
129
|
+
});
|
|
130
|
+
return rankTasks(selectedByMode, (task) => scoreInputForTask(task, openBlockCount(task)), (task) => task.id).map((entry) => ({
|
|
131
|
+
task: entry.task,
|
|
132
|
+
score: entry.score,
|
|
133
|
+
priority: entry.priority,
|
|
134
|
+
unblockCount: entry.unblockCount,
|
|
135
|
+
scope: readTaskScope(entry.task)
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
function selectRankedReadyTasks(tasks, options = {}) {
|
|
139
|
+
const ranked = rankReadyTasks(tasks, options);
|
|
140
|
+
if (options.requireDisjointScopes !== true && !options.disjointWithScopes) {
|
|
141
|
+
return ranked.slice(0, options.limit).map((entry) => entry.task);
|
|
142
|
+
}
|
|
143
|
+
const occupiedScopes = new Set(options.disjointWithScopes ?? []);
|
|
144
|
+
const selected = [];
|
|
145
|
+
for (const entry of ranked) {
|
|
146
|
+
if (entry.scope.some((scope) => occupiedScopes.has(scope)))
|
|
147
|
+
continue;
|
|
148
|
+
selected.push(entry.task);
|
|
149
|
+
for (const scope of entry.scope)
|
|
150
|
+
occupiedScopes.add(scope);
|
|
151
|
+
if (options.limit !== undefined && selected.length >= options.limit)
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
return selected;
|
|
155
|
+
}
|
|
156
|
+
export {
|
|
157
|
+
selectRankedReadyTasks,
|
|
158
|
+
selectNextReadyTaskByPriority,
|
|
159
|
+
rankReadyTasks
|
|
160
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type TaskCriticality = "core" | "high" | "normal" | string;
|
|
2
|
+
export interface TaskScoreInput {
|
|
3
|
+
readonly priority?: number | null;
|
|
4
|
+
readonly unblockCount?: number | null;
|
|
5
|
+
readonly role?: string | null;
|
|
6
|
+
readonly criticality?: TaskCriticality | null;
|
|
7
|
+
readonly validation?: readonly string[] | null;
|
|
8
|
+
readonly queueWeight?: number | null;
|
|
9
|
+
}
|
|
10
|
+
export interface RankedTask<T> {
|
|
11
|
+
readonly task: T;
|
|
12
|
+
readonly score: number;
|
|
13
|
+
readonly priority: number;
|
|
14
|
+
readonly unblockCount: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function scoreTask(input: TaskScoreInput): number;
|
|
17
|
+
export declare function rankTasks<T>(tasks: readonly T[], scoreInput: (task: T) => TaskScoreInput, idOf: (task: T) => string): readonly RankedTask<T>[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/dependency-graph-plugin/src/taskScore.ts
|
|
3
|
+
var ROLE_WEIGHT = {
|
|
4
|
+
architect: 25,
|
|
5
|
+
extractor: 10
|
|
6
|
+
};
|
|
7
|
+
var CRITICALITY_WEIGHT = {
|
|
8
|
+
core: 20,
|
|
9
|
+
high: 10,
|
|
10
|
+
normal: 0
|
|
11
|
+
};
|
|
12
|
+
function finiteNumber(value, fallback) {
|
|
13
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
14
|
+
}
|
|
15
|
+
function scoreTask(input) {
|
|
16
|
+
const priority = finiteNumber(input.priority, 3);
|
|
17
|
+
const unblockCount = Math.max(0, finiteNumber(input.unblockCount, 0));
|
|
18
|
+
const roleWeight = input.role ? ROLE_WEIGHT[input.role] ?? 0 : 0;
|
|
19
|
+
const criticalityWeight = input.criticality ? CRITICALITY_WEIGHT[input.criticality] ?? 0 : 0;
|
|
20
|
+
const validationWeight = (input.validation ?? []).some((entry) => entry.includes("test-contract") || entry.includes("test-boundary")) ? 8 : 0;
|
|
21
|
+
const queueWeight = finiteNumber(input.queueWeight, 0);
|
|
22
|
+
return unblockCount * 100 + (10 - priority) + roleWeight + criticalityWeight + validationWeight + queueWeight;
|
|
23
|
+
}
|
|
24
|
+
function rankTasks(tasks, scoreInput, idOf) {
|
|
25
|
+
return tasks.map((task) => {
|
|
26
|
+
const input = scoreInput(task);
|
|
27
|
+
return {
|
|
28
|
+
task,
|
|
29
|
+
score: scoreTask(input),
|
|
30
|
+
priority: finiteNumber(input.priority, 3),
|
|
31
|
+
unblockCount: Math.max(0, finiteNumber(input.unblockCount, 0))
|
|
32
|
+
};
|
|
33
|
+
}).toSorted((left, right) => {
|
|
34
|
+
const scoreDelta = right.score - left.score;
|
|
35
|
+
if (scoreDelta !== 0)
|
|
36
|
+
return scoreDelta;
|
|
37
|
+
const unblockDelta = right.unblockCount - left.unblockCount;
|
|
38
|
+
if (unblockDelta !== 0)
|
|
39
|
+
return unblockDelta;
|
|
40
|
+
const priorityDelta = left.priority - right.priority;
|
|
41
|
+
if (priorityDelta !== 0)
|
|
42
|
+
return priorityDelta;
|
|
43
|
+
return idOf(left.task).localeCompare(idOf(right.task));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export {
|
|
47
|
+
scoreTask,
|
|
48
|
+
rankTasks
|
|
49
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type TaskLike } from "@rig/core/task-io";
|
|
2
|
+
import type { TaskDependencyProjection } from "@rig/contracts";
|
|
3
|
+
export type TaskFilters = {
|
|
4
|
+
readonly assignee?: string;
|
|
5
|
+
readonly state?: "open" | "closed";
|
|
6
|
+
readonly limit?: number;
|
|
7
|
+
readonly search?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function readTaskStatus(task: TaskLike): string;
|
|
10
|
+
export declare function isReadyTask(task: TaskLike): boolean;
|
|
11
|
+
export declare function asDependencyProjection(task: TaskLike): TaskLike & TaskDependencyProjection;
|
|
12
|
+
export declare function currentLogin(env?: NodeJS.ProcessEnv): string | undefined;
|
|
13
|
+
export declare function resolveAssignee(value: string | undefined | null, env?: NodeJS.ProcessEnv): string | undefined;
|
|
14
|
+
export declare const normalizeAssigneeFilter: typeof resolveAssignee;
|
|
15
|
+
export declare function taskMatchesState(task: TaskLike, state: "open" | "closed" | undefined): boolean;
|
|
16
|
+
export declare function taskMatchesSearch(task: TaskLike, search: string | undefined): boolean;
|
|
17
|
+
export declare function collectAssigneeLogins(value: unknown): string[];
|
|
18
|
+
export declare function taskAssignees(task: TaskLike): string[];
|
|
19
|
+
export declare function taskMatchesAssignee(task: TaskLike, assignee: string | undefined): boolean;
|
|
20
|
+
export declare function applyFilters(tasks: readonly TaskLike[], filters?: TaskFilters): TaskLike[];
|
|
21
|
+
export declare function selectNextReadyTask(tasks: readonly TaskLike[], filters?: TaskFilters): TaskLike | null;
|
|
22
|
+
export declare const selectNextTask: typeof selectNextReadyTask;
|
|
23
|
+
export declare function resolveStartTask(input: {
|
|
24
|
+
readonly projectRoot: string;
|
|
25
|
+
readonly taskId?: string | null;
|
|
26
|
+
readonly next?: boolean;
|
|
27
|
+
readonly filters?: TaskFilters;
|
|
28
|
+
readonly listTasks?: (projectRoot: string) => Promise<readonly TaskLike[]>;
|
|
29
|
+
readonly getTask?: (projectRoot: string, taskId: string) => Promise<TaskLike | null>;
|
|
30
|
+
}): Promise<{
|
|
31
|
+
readonly taskId: string;
|
|
32
|
+
readonly task: TaskLike | null;
|
|
33
|
+
}>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/dependency-graph-plugin/src/taskSelection.ts
|
|
3
|
+
import {
|
|
4
|
+
getTaskForCommand,
|
|
5
|
+
listTasksForCommand,
|
|
6
|
+
normalizeTaskId
|
|
7
|
+
} from "@rig/core/task-io";
|
|
8
|
+
|
|
9
|
+
// packages/dependency-graph-plugin/src/taskRanking.ts
|
|
10
|
+
import {
|
|
11
|
+
computeTaskDependencyBadges,
|
|
12
|
+
isTaskTerminalStatus,
|
|
13
|
+
readTaskScope
|
|
14
|
+
} from "@rig/contracts";
|
|
15
|
+
function priorityValue(task) {
|
|
16
|
+
return typeof task.priority === "number" && Number.isFinite(task.priority) ? task.priority : Number.MAX_SAFE_INTEGER;
|
|
17
|
+
}
|
|
18
|
+
function selectNextReadyTaskByPriority(tasks, options = {}) {
|
|
19
|
+
const excluded = new Set(options.excludeTaskIds ?? []);
|
|
20
|
+
const badges = computeTaskDependencyBadges(tasks);
|
|
21
|
+
const candidates = tasks.filter((task) => !excluded.has(task.id)).filter((task) => options.filter?.(task) ?? true).filter((task) => badges.get(task.id)?.ready === true).toSorted((left, right) => {
|
|
22
|
+
const priorityDelta = priorityValue(left) - priorityValue(right);
|
|
23
|
+
if (priorityDelta !== 0)
|
|
24
|
+
return priorityDelta;
|
|
25
|
+
const createdDelta = (left.createdAt ?? "").localeCompare(right.createdAt ?? "");
|
|
26
|
+
if (createdDelta !== 0)
|
|
27
|
+
return createdDelta;
|
|
28
|
+
return left.id.localeCompare(right.id);
|
|
29
|
+
});
|
|
30
|
+
return candidates[0] ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// packages/dependency-graph-plugin/src/taskSelection.ts
|
|
34
|
+
var CLOSED_STATUSES = {
|
|
35
|
+
closed: true,
|
|
36
|
+
completed: true,
|
|
37
|
+
complete: true,
|
|
38
|
+
done: true,
|
|
39
|
+
cancelled: true,
|
|
40
|
+
canceled: true,
|
|
41
|
+
merged: true,
|
|
42
|
+
resolved: true
|
|
43
|
+
};
|
|
44
|
+
var NOT_READY_STATUSES = {
|
|
45
|
+
...CLOSED_STATUSES,
|
|
46
|
+
blocked: true,
|
|
47
|
+
in_progress: true,
|
|
48
|
+
"in-progress": true,
|
|
49
|
+
running: true,
|
|
50
|
+
active: true
|
|
51
|
+
};
|
|
52
|
+
function isRecord(value) {
|
|
53
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54
|
+
}
|
|
55
|
+
function readTaskStatus(task) {
|
|
56
|
+
const value = task.status;
|
|
57
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
58
|
+
}
|
|
59
|
+
function isReadyTask(task) {
|
|
60
|
+
const status = readTaskStatus(task);
|
|
61
|
+
return status.length === 0 || NOT_READY_STATUSES[status] !== true;
|
|
62
|
+
}
|
|
63
|
+
function asDependencyProjection(task) {
|
|
64
|
+
const record = task;
|
|
65
|
+
return {
|
|
66
|
+
...record,
|
|
67
|
+
status: typeof record.status === "string" && record.status.trim() ? record.status : "open",
|
|
68
|
+
priority: record.priority ?? "P2",
|
|
69
|
+
metadata: isRecord(record.metadata) ? record.metadata : {}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function currentLogin(env = process.env) {
|
|
73
|
+
for (const key of ["RIG_GITHUB_LOGIN", "GITHUB_ACTOR", "GH_USER", "USER", "USERNAME"]) {
|
|
74
|
+
const value = env[key]?.trim();
|
|
75
|
+
if (value)
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
function resolveAssignee(value, env = process.env) {
|
|
81
|
+
const trimmed = value?.trim();
|
|
82
|
+
if (!trimmed)
|
|
83
|
+
return;
|
|
84
|
+
const lowered = trimmed.toLowerCase();
|
|
85
|
+
if (lowered === "me" || lowered === "@me")
|
|
86
|
+
return currentLogin(env) ?? "@me";
|
|
87
|
+
return trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
88
|
+
}
|
|
89
|
+
var normalizeAssigneeFilter = resolveAssignee;
|
|
90
|
+
function taskMatchesState(task, state) {
|
|
91
|
+
if (!state)
|
|
92
|
+
return true;
|
|
93
|
+
const closed = CLOSED_STATUSES[readTaskStatus(task)] === true;
|
|
94
|
+
return state === "closed" ? closed : !closed;
|
|
95
|
+
}
|
|
96
|
+
function taskMatchesSearch(task, search) {
|
|
97
|
+
const needle = search?.trim().toLowerCase();
|
|
98
|
+
if (!needle)
|
|
99
|
+
return true;
|
|
100
|
+
const title = typeof task.title === "string" ? task.title : "";
|
|
101
|
+
const body = typeof task.body === "string" ? task.body : "";
|
|
102
|
+
const description = typeof task.description === "string" ? task.description : "";
|
|
103
|
+
return `${task.id}
|
|
104
|
+
${title}
|
|
105
|
+
${body}
|
|
106
|
+
${description}`.toLowerCase().includes(needle);
|
|
107
|
+
}
|
|
108
|
+
function collectAssigneeLogins(value) {
|
|
109
|
+
if (typeof value === "string")
|
|
110
|
+
return [value];
|
|
111
|
+
if (Array.isArray(value))
|
|
112
|
+
return value.flatMap((entry) => collectAssigneeLogins(entry));
|
|
113
|
+
if (isRecord(value))
|
|
114
|
+
return [value.login, value.username, value.name, value.id].flatMap((entry) => collectAssigneeLogins(entry));
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
function taskAssignees(task) {
|
|
118
|
+
const raw = isRecord(task.raw) ? task.raw : null;
|
|
119
|
+
return [
|
|
120
|
+
task.assignee,
|
|
121
|
+
task.assignees,
|
|
122
|
+
task.assignedTo,
|
|
123
|
+
task.owner,
|
|
124
|
+
raw?.assignee,
|
|
125
|
+
raw?.assignees,
|
|
126
|
+
raw?.assignedTo,
|
|
127
|
+
raw?.owner
|
|
128
|
+
].flatMap((value) => collectAssigneeLogins(value));
|
|
129
|
+
}
|
|
130
|
+
function taskMatchesAssignee(task, assignee) {
|
|
131
|
+
if (!assignee)
|
|
132
|
+
return true;
|
|
133
|
+
const needle = assignee.startsWith("@") ? assignee.slice(1) : assignee;
|
|
134
|
+
return taskAssignees(task).some((login) => {
|
|
135
|
+
const normalized = login.startsWith("@") ? login.slice(1) : login;
|
|
136
|
+
return normalized.localeCompare(needle, undefined, { sensitivity: "accent" }) === 0;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function applyFilters(tasks, filters = {}) {
|
|
140
|
+
const filtered = tasks.filter((task) => taskMatchesAssignee(task, resolveAssignee(filters.assignee))).filter((task) => taskMatchesState(task, filters.state)).filter((task) => taskMatchesSearch(task, filters.search));
|
|
141
|
+
return filters.limit === undefined ? filtered : filtered.slice(0, filters.limit);
|
|
142
|
+
}
|
|
143
|
+
function selectNextReadyTask(tasks, filters = {}) {
|
|
144
|
+
const matching = applyFilters(tasks, { ...filters, limit: undefined }).filter(isReadyTask);
|
|
145
|
+
const selected = selectNextReadyTaskByPriority(matching.map(asDependencyProjection), {
|
|
146
|
+
filter: (task) => isReadyTask(task)
|
|
147
|
+
});
|
|
148
|
+
return selected ?? matching[0] ?? null;
|
|
149
|
+
}
|
|
150
|
+
var selectNextTask = selectNextReadyTask;
|
|
151
|
+
async function resolveStartTask(input) {
|
|
152
|
+
if (input.next) {
|
|
153
|
+
const task2 = selectNextReadyTask(await listTasksForCommand(input.projectRoot, { listTasks: input.listTasks }), input.filters ?? {});
|
|
154
|
+
const taskId2 = normalizeTaskId(task2?.id);
|
|
155
|
+
if (!task2 || !taskId2)
|
|
156
|
+
throw new Error("No ready task found.");
|
|
157
|
+
return { taskId: taskId2, task: task2 };
|
|
158
|
+
}
|
|
159
|
+
const taskId = normalizeTaskId(input.taskId ?? undefined);
|
|
160
|
+
if (!taskId)
|
|
161
|
+
throw new Error("Missing task id.");
|
|
162
|
+
const task = await getTaskForCommand(input.projectRoot, taskId, { getTask: input.getTask }).catch(() => null);
|
|
163
|
+
return { taskId, task };
|
|
164
|
+
}
|
|
165
|
+
export {
|
|
166
|
+
taskMatchesState,
|
|
167
|
+
taskMatchesSearch,
|
|
168
|
+
taskMatchesAssignee,
|
|
169
|
+
taskAssignees,
|
|
170
|
+
selectNextTask,
|
|
171
|
+
selectNextReadyTask,
|
|
172
|
+
resolveStartTask,
|
|
173
|
+
resolveAssignee,
|
|
174
|
+
readTaskStatus,
|
|
175
|
+
normalizeAssigneeFilter,
|
|
176
|
+
isReadyTask,
|
|
177
|
+
currentLogin,
|
|
178
|
+
collectAssigneeLogins,
|
|
179
|
+
asDependencyProjection,
|
|
180
|
+
applyFilters
|
|
181
|
+
};
|