@nyxa/nyx-agent 0.9.1 → 0.9.3
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/runtime/prompts.js
CHANGED
|
@@ -6,15 +6,17 @@
|
|
|
6
6
|
* prompt is pure guidance — NyxAgent prepends an engine-owned context block and,
|
|
7
7
|
* for review-style phases, appends the required-result contract + JSON Schema.
|
|
8
8
|
*/
|
|
9
|
-
export const SELECTION_PROMPT = `Select
|
|
9
|
+
export const SELECTION_PROMPT = `Select the GitHub issue series to work on in this run.
|
|
10
10
|
|
|
11
|
-
The
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
The context lists recommended executable series, blockers, and available open
|
|
12
|
+
issues by PRD/Plan section. Choose the coherent series or issue set that should be
|
|
13
|
+
started now. Prefer the recommended executable order when it matches the intent.
|
|
14
|
+
Skip issues with open blockers. Closed blockers are satisfied. Do not invent keys
|
|
15
|
+
— only use keys present in the candidates.
|
|
15
16
|
|
|
16
|
-
Return outcome "selected" with the
|
|
17
|
-
|
|
17
|
+
Return outcome "selected" with the chosen intent keys in a work_item_keys array;
|
|
18
|
+
the engine will expand PRD/Plan series and prerequisites safely. If nothing is
|
|
19
|
+
worth working on, return outcome "no_work" instead.`;
|
|
18
20
|
export const EXECUTION_PROMPT = `Implement the selected work item described in the context above.
|
|
19
21
|
|
|
20
22
|
Work only on this item. Keep changes focused and coherent. Use a
|
|
@@ -11,7 +11,8 @@ import { createRunReporter } from "./reporter.js";
|
|
|
11
11
|
import { runAgentPhase, } from "./runPhase.js";
|
|
12
12
|
import { REVIEW_CHALLENGE_SCHEMA, REVIEW_DISCOVERY_SCHEMA, GLOBAL_REVIEW_SCHEMA, REVIEW_VALIDATION_SCHEMA, SELECTION_SCHEMA, } from "./schemas.js";
|
|
13
13
|
import { commitAll, commitsAhead, createPullRequest, pushBranch, rangeDiff, stageAllAndDiff, } from "./scm.js";
|
|
14
|
-
import {
|
|
14
|
+
import { confirmWorkItemSelection, } from "./selectionConfirmation.js";
|
|
15
|
+
import { buildWorkItemSelectionPlan, formatSelectionBlockers, isDependencySatisfied, normalizeWorkItemSelection, } from "./selectionPlanner.js";
|
|
15
16
|
import { createRunId } from "./time.js";
|
|
16
17
|
import { filterAvailable, listGitHubWorkItemInventory, resolveSelectedQueue, } from "./workItems.js";
|
|
17
18
|
const MAX_CANDIDATES = 50;
|
|
@@ -73,12 +74,18 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
|
|
|
73
74
|
reporter.info("No open work items available. Nothing to do.");
|
|
74
75
|
return;
|
|
75
76
|
}
|
|
77
|
+
const selectionPlan = buildWorkItemSelectionPlan({
|
|
78
|
+
candidates,
|
|
79
|
+
maxItems: config.max_iterations,
|
|
80
|
+
completedKeys: ledger.completed_work_item_keys,
|
|
81
|
+
});
|
|
76
82
|
const proposed = await runSelection({
|
|
77
83
|
projectRoot,
|
|
78
84
|
runDir,
|
|
79
85
|
cliHarness: input.harness,
|
|
80
86
|
config,
|
|
81
87
|
candidates,
|
|
88
|
+
completedKeys: ledger.completed_work_item_keys,
|
|
82
89
|
runPhase: deps.runPhase,
|
|
83
90
|
reporter,
|
|
84
91
|
});
|
|
@@ -87,7 +94,7 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
|
|
|
87
94
|
return;
|
|
88
95
|
}
|
|
89
96
|
const selected = await deps.confirmSelection({
|
|
90
|
-
candidates,
|
|
97
|
+
candidates: selectionPlan.candidateOrder,
|
|
91
98
|
proposed,
|
|
92
99
|
maxItems: config.max_iterations,
|
|
93
100
|
autoAccept: input.autoAcceptSelection ?? false,
|
|
@@ -96,7 +103,31 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
|
|
|
96
103
|
reporter.info("No work items selected. Nothing to do.");
|
|
97
104
|
return;
|
|
98
105
|
}
|
|
99
|
-
const
|
|
106
|
+
const finalSelection = normalizeWorkItemSelection({
|
|
107
|
+
candidates,
|
|
108
|
+
selected,
|
|
109
|
+
maxItems: config.max_iterations,
|
|
110
|
+
expandGroups: false,
|
|
111
|
+
strictMax: true,
|
|
112
|
+
completedKeys: ledger.completed_work_item_keys,
|
|
113
|
+
});
|
|
114
|
+
if (!finalSelection.ok) {
|
|
115
|
+
throw new Error(finalSelection.error);
|
|
116
|
+
}
|
|
117
|
+
if (finalSelection.blockedCandidates.length > 0) {
|
|
118
|
+
reporter.warn(`Skipping blocked work item(s): ${formatSelectionBlockers(finalSelection.blockedCandidates)}`);
|
|
119
|
+
}
|
|
120
|
+
if (finalSelection.queue.length === 0) {
|
|
121
|
+
reporter.info("No executable work items selected. Nothing to do.");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (finalSelection.cycleKeys.length > 0) {
|
|
125
|
+
reporter.detail(`Dependency cycle detected among selected work items; preserving confirmed order inside cycle(s): ${formatDependencyCycles({
|
|
126
|
+
cycles: finalSelection.cycleKeys,
|
|
127
|
+
items: selected,
|
|
128
|
+
})}`);
|
|
129
|
+
}
|
|
130
|
+
const planned = finalSelection.queue;
|
|
100
131
|
reporter.info(`Selected ${planned.length} work item(s):`);
|
|
101
132
|
for (const item of planned) {
|
|
102
133
|
reporter.info(` - ${item.title} (#${item.number})`);
|
|
@@ -280,14 +311,30 @@ async function runSelection(input) {
|
|
|
280
311
|
config: input.config,
|
|
281
312
|
cliHarness: input.cliHarness,
|
|
282
313
|
});
|
|
314
|
+
const selectionPlan = buildWorkItemSelectionPlan({
|
|
315
|
+
candidates: input.candidates,
|
|
316
|
+
maxItems: input.config.max_iterations,
|
|
317
|
+
completedKeys: input.completedKeys,
|
|
318
|
+
});
|
|
319
|
+
const candidateKeys = new Set(input.candidates.map((candidate) => candidate.key));
|
|
320
|
+
const completedKeys = new Set(input.completedKeys);
|
|
283
321
|
const context = buildContextBlock([
|
|
284
322
|
["Repository", input.config.tracker.repo],
|
|
285
323
|
["Max work items this run", input.config.max_iterations],
|
|
324
|
+
[
|
|
325
|
+
"Recommended executable series",
|
|
326
|
+
selectionPlan.recommendedSeries.map(selectionSeriesSummary),
|
|
327
|
+
],
|
|
328
|
+
[
|
|
329
|
+
"Blocked candidates",
|
|
330
|
+
selectionPlan.blockedCandidates.map(selectionBlockerSummary),
|
|
331
|
+
],
|
|
286
332
|
[
|
|
287
333
|
"Available candidates",
|
|
288
|
-
|
|
334
|
+
selectionPlan.sections.map((section) => ({
|
|
289
335
|
section: section.label ?? "Ungrouped issues",
|
|
290
|
-
|
|
336
|
+
recommended_order: section.recommended.map(workItemReference),
|
|
337
|
+
candidates: section.candidates.map((candidate) => selectionCandidateSummary(candidate, candidateKeys, completedKeys)),
|
|
291
338
|
})),
|
|
292
339
|
],
|
|
293
340
|
]);
|
|
@@ -321,7 +368,23 @@ async function runSelection(input) {
|
|
|
321
368
|
if (!resolved.ok) {
|
|
322
369
|
throw new Error(`Selection produced an invalid queue: ${resolved.error}`);
|
|
323
370
|
}
|
|
324
|
-
|
|
371
|
+
const normalized = normalizeWorkItemSelection({
|
|
372
|
+
candidates: input.candidates,
|
|
373
|
+
selected: resolved.queue,
|
|
374
|
+
maxItems: input.config.max_iterations,
|
|
375
|
+
expandGroups: true,
|
|
376
|
+
completedKeys: input.completedKeys,
|
|
377
|
+
});
|
|
378
|
+
if (!normalized.ok) {
|
|
379
|
+
throw new Error(normalized.error);
|
|
380
|
+
}
|
|
381
|
+
if (normalized.blockedCandidates.length > 0) {
|
|
382
|
+
input.reporter.detail(`Selection omitted blocked work item(s): ${formatSelectionBlockers(normalized.blockedCandidates)}`);
|
|
383
|
+
}
|
|
384
|
+
if (normalized.truncated) {
|
|
385
|
+
input.reporter.detail(`Selection truncated to ${input.config.max_iterations} work item(s) after dependency planning.`);
|
|
386
|
+
}
|
|
387
|
+
return normalized.queue;
|
|
325
388
|
}
|
|
326
389
|
async function runExecution(input) {
|
|
327
390
|
const agent = resolveAgentProfile({
|
|
@@ -874,18 +937,76 @@ function workItemSummary(item) {
|
|
|
874
937
|
url: item.url,
|
|
875
938
|
labels: item.labels,
|
|
876
939
|
parent: item.parent,
|
|
940
|
+
blocked_by: item.blocked_by,
|
|
877
941
|
};
|
|
878
942
|
}
|
|
879
|
-
function
|
|
943
|
+
function selectionSeriesSummary(series) {
|
|
944
|
+
return {
|
|
945
|
+
section: series.section ?? "Ungrouped issues",
|
|
946
|
+
work_item_keys: series.candidates.map((candidate) => candidate.key),
|
|
947
|
+
order: series.candidates.map(workItemReference),
|
|
948
|
+
blocked: series.blocked.map(selectionBlockerSummary),
|
|
949
|
+
truncated: series.truncated,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
function selectionBlockerSummary(blocked) {
|
|
953
|
+
return {
|
|
954
|
+
candidate: workItemReference(blocked.candidate),
|
|
955
|
+
blockers: blocked.blockers.map(dependencyReference),
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
function selectionCandidateSummary(candidate, candidateKeys, completedKeys) {
|
|
880
959
|
return {
|
|
881
960
|
key: candidate.key,
|
|
882
961
|
number: candidate.number,
|
|
883
962
|
title: candidate.title,
|
|
884
963
|
labels: candidate.labels,
|
|
885
964
|
parent: candidate.parent,
|
|
965
|
+
blocked_by: candidate.blocked_by,
|
|
966
|
+
dependency_status: dependencyStatusSummary({
|
|
967
|
+
dependencies: candidate.blocked_by ?? [],
|
|
968
|
+
candidateKeys,
|
|
969
|
+
completedKeys,
|
|
970
|
+
}),
|
|
886
971
|
excerpt: candidate.excerpt,
|
|
887
972
|
};
|
|
888
973
|
}
|
|
974
|
+
function dependencyStatusSummary(input) {
|
|
975
|
+
const selectablePrerequisites = [];
|
|
976
|
+
const satisfiedBlockers = [];
|
|
977
|
+
const openBlockers = [];
|
|
978
|
+
for (const dependency of input.dependencies) {
|
|
979
|
+
if (input.candidateKeys.has(dependency.key)) {
|
|
980
|
+
selectablePrerequisites.push(dependency);
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
if (isDependencySatisfied(dependency, input.completedKeys)) {
|
|
984
|
+
satisfiedBlockers.push(dependency);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
openBlockers.push(dependency);
|
|
988
|
+
}
|
|
989
|
+
return {
|
|
990
|
+
selectable_prerequisites: selectablePrerequisites.map(dependencyReference),
|
|
991
|
+
satisfied_blockers: satisfiedBlockers.map(dependencyReference),
|
|
992
|
+
open_blockers: openBlockers.map(dependencyReference),
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
function workItemReference(item) {
|
|
996
|
+
return {
|
|
997
|
+
key: item.key,
|
|
998
|
+
number: item.number,
|
|
999
|
+
title: item.title,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
function dependencyReference(dependency) {
|
|
1003
|
+
return {
|
|
1004
|
+
key: dependency.key,
|
|
1005
|
+
number: dependency.number,
|
|
1006
|
+
title: dependency.title,
|
|
1007
|
+
state: dependency.state,
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
889
1010
|
function buildCommitMessage(item) {
|
|
890
1011
|
return `${item.title}\n\nWork item: ${item.source.locator}`;
|
|
891
1012
|
}
|
|
@@ -973,6 +1094,17 @@ function completedParentClosures(input) {
|
|
|
973
1094
|
function uniqueNumbers(numbers) {
|
|
974
1095
|
return [...new Set(numbers)];
|
|
975
1096
|
}
|
|
1097
|
+
function formatDependencyCycles(input) {
|
|
1098
|
+
const itemByKey = new Map(input.items.map((item) => [item.key, item]));
|
|
1099
|
+
return input.cycles
|
|
1100
|
+
.map((cycle) => cycle
|
|
1101
|
+
.map((key) => {
|
|
1102
|
+
const item = itemByKey.get(key);
|
|
1103
|
+
return item ? `#${item.number}` : key;
|
|
1104
|
+
})
|
|
1105
|
+
.join(" -> "))
|
|
1106
|
+
.join("; ");
|
|
1107
|
+
}
|
|
976
1108
|
/** Render unresolved blockers as a bullet list to append to a failure message. */
|
|
977
1109
|
function formatBlockers(blockers) {
|
|
978
1110
|
if (blockers.length === 0) {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { checkbox, Separator } from "@inquirer/prompts";
|
|
2
|
+
import { buildWorkItemSelectionPlan, formatSelectionBlockers, normalizeWorkItemSelection, } from "./selectionPlanner.js";
|
|
3
|
+
export { buildSelectionSections } from "./selectionPlanner.js";
|
|
2
4
|
export async function confirmWorkItemSelection(input) {
|
|
3
5
|
if (input.autoAccept) {
|
|
4
6
|
return input.proposed;
|
|
@@ -8,70 +10,84 @@ export async function confirmWorkItemSelection(input) {
|
|
|
8
10
|
throw new Error('Interactive work item selection requires a TTY. Re-run with "nyxagent run --yes" to accept the agent selection.');
|
|
9
11
|
}
|
|
10
12
|
const choiceItems = buildSelectionChoiceItems(input);
|
|
13
|
+
const choiceOrder = choiceItems
|
|
14
|
+
.filter((item) => item.type === "choice")
|
|
15
|
+
.map((item) => item.value);
|
|
11
16
|
const selectedKeys = await checkbox({
|
|
12
17
|
message: `Select work items to run (max ${input.maxItems})`,
|
|
13
18
|
choices: choiceItems.map(toInquirerChoice),
|
|
14
19
|
pageSize: Math.min(Math.max(choiceItems.length, 7), 20),
|
|
15
20
|
required: false,
|
|
16
|
-
validate: (selected) =>
|
|
17
|
-
|
|
21
|
+
validate: (selected) => {
|
|
22
|
+
const normalized = normalizeSelectedKeys({
|
|
23
|
+
selectedKeys: selected.map((choice) => choice.value),
|
|
24
|
+
choiceOrder,
|
|
25
|
+
candidates: input.candidates,
|
|
26
|
+
maxItems: input.maxItems,
|
|
27
|
+
});
|
|
28
|
+
if (!normalized.ok) {
|
|
29
|
+
return normalized.error;
|
|
30
|
+
}
|
|
31
|
+
if (normalized.blockedCandidates.length > 0) {
|
|
32
|
+
return `Selection contains blocked work item(s): ${formatSelectionBlockers(normalized.blockedCandidates)}`;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
},
|
|
18
36
|
shortcuts: {
|
|
19
37
|
all: null,
|
|
20
38
|
invert: null,
|
|
21
39
|
},
|
|
22
40
|
});
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
const candidate = candidatesByKey.get(item.value);
|
|
30
|
-
return candidate ? [candidate] : [];
|
|
41
|
+
const normalized = normalizeSelectedKeys({
|
|
42
|
+
selectedKeys,
|
|
43
|
+
choiceOrder,
|
|
44
|
+
candidates: input.candidates,
|
|
45
|
+
maxItems: input.maxItems,
|
|
31
46
|
});
|
|
47
|
+
if (!normalized.ok) {
|
|
48
|
+
throw new Error(normalized.error);
|
|
49
|
+
}
|
|
50
|
+
return normalized.queue;
|
|
32
51
|
}
|
|
33
52
|
export function buildSelectionChoiceItems(input) {
|
|
34
|
-
const
|
|
53
|
+
const plan = buildWorkItemSelectionPlan({
|
|
54
|
+
candidates: input.candidates,
|
|
55
|
+
proposed: input.proposed,
|
|
56
|
+
maxItems: input.maxItems ?? input.candidates.length,
|
|
57
|
+
});
|
|
58
|
+
const proposedKeys = new Set(plan.recommendedQueue.map((item) => item.key));
|
|
59
|
+
const blockedByKey = new Map(plan.blockedCandidates.map((blocked) => [blocked.candidate.key, blocked]));
|
|
35
60
|
const items = [];
|
|
36
|
-
|
|
61
|
+
appendSelectionChoices({
|
|
62
|
+
items,
|
|
63
|
+
sections: plan.sections,
|
|
64
|
+
proposedKeys,
|
|
65
|
+
blockedByKey,
|
|
66
|
+
});
|
|
67
|
+
return items;
|
|
68
|
+
}
|
|
69
|
+
function appendSelectionChoices(input) {
|
|
70
|
+
for (const section of input.sections) {
|
|
37
71
|
if (section.label) {
|
|
38
|
-
items.push({ type: "separator", label: section.label });
|
|
72
|
+
input.items.push({ type: "separator", label: section.label });
|
|
39
73
|
}
|
|
40
74
|
for (const candidate of section.candidates) {
|
|
41
|
-
const proposed = proposedKeys.has(candidate.key);
|
|
42
|
-
|
|
75
|
+
const proposed = input.proposedKeys.has(candidate.key);
|
|
76
|
+
const blocked = input.blockedByKey.get(candidate.key);
|
|
77
|
+
const item = {
|
|
43
78
|
type: "choice",
|
|
44
79
|
value: candidate.key,
|
|
45
80
|
name: `#${candidate.number} ${candidate.title}${proposed ? " (agent)" : ""}`,
|
|
46
81
|
checked: proposed,
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
export function buildSelectionSections(candidates) {
|
|
53
|
-
const sections = [];
|
|
54
|
-
const groupedSections = new Map();
|
|
55
|
-
let ungroupedSection;
|
|
56
|
-
for (const candidate of candidates) {
|
|
57
|
-
const group = detectSelectionGroup(candidate);
|
|
58
|
-
if (!group) {
|
|
59
|
-
if (!ungroupedSection) {
|
|
60
|
-
ungroupedSection = { candidates: [] };
|
|
61
|
-
sections.push(ungroupedSection);
|
|
82
|
+
};
|
|
83
|
+
if (blocked) {
|
|
84
|
+
item.disabled = `blocked by ${blocked.blockers
|
|
85
|
+
.map((blocker) => `#${blocker.number}`)
|
|
86
|
+
.join(", ")}`;
|
|
62
87
|
}
|
|
63
|
-
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
let section = groupedSections.get(group.key);
|
|
67
|
-
if (!section) {
|
|
68
|
-
section = { label: group.label, candidates: [] };
|
|
69
|
-
groupedSections.set(group.key, section);
|
|
70
|
-
sections.push(section);
|
|
88
|
+
input.items.push(item);
|
|
71
89
|
}
|
|
72
|
-
section.candidates.push(candidate);
|
|
73
90
|
}
|
|
74
|
-
return sections;
|
|
75
91
|
}
|
|
76
92
|
function toInquirerChoice(item) {
|
|
77
93
|
if (item.type === "separator") {
|
|
@@ -81,72 +97,21 @@ function toInquirerChoice(item) {
|
|
|
81
97
|
value: item.value,
|
|
82
98
|
name: item.name,
|
|
83
99
|
checked: item.checked,
|
|
100
|
+
disabled: item.disabled,
|
|
84
101
|
};
|
|
85
102
|
}
|
|
86
|
-
function
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return titleGroup
|
|
101
|
-
? { key: `display:${titleGroup.toLowerCase()}`, label: titleGroup }
|
|
102
|
-
: undefined;
|
|
103
|
-
}
|
|
104
|
-
function formatParentGroup(parent) {
|
|
105
|
-
if (!parent) {
|
|
106
|
-
return "Parent";
|
|
107
|
-
}
|
|
108
|
-
if (!parent.title) {
|
|
109
|
-
return `Parent #${parent.number}`;
|
|
110
|
-
}
|
|
111
|
-
const titledGroup = parseLeadingTitleGroup(parent.title);
|
|
112
|
-
if (titledGroup) {
|
|
113
|
-
return `${titledGroup.kind} #${parent.number}: ${titledGroup.name}`;
|
|
114
|
-
}
|
|
115
|
-
return `Parent #${parent.number}: ${parent.title}`;
|
|
116
|
-
}
|
|
117
|
-
function parseGroupLabel(label) {
|
|
118
|
-
const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(label.trim());
|
|
119
|
-
if (!match) {
|
|
120
|
-
return undefined;
|
|
121
|
-
}
|
|
122
|
-
return formatGroupLabel(match[1], match[2]);
|
|
123
|
-
}
|
|
124
|
-
function parseBracketedTitleGroup(title) {
|
|
125
|
-
const match = /^\[(plan|prd)\s*[:/=-]\s*([^\]]+)\]/i.exec(title.trim());
|
|
126
|
-
if (!match) {
|
|
127
|
-
return undefined;
|
|
128
|
-
}
|
|
129
|
-
return formatGroupLabel(match[1], match[2]);
|
|
130
|
-
}
|
|
131
|
-
function parseLeadingTitleGroup(title) {
|
|
132
|
-
const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(title.trim());
|
|
133
|
-
if (!match) {
|
|
134
|
-
return undefined;
|
|
135
|
-
}
|
|
136
|
-
const name = match[2].trim();
|
|
137
|
-
if (!name) {
|
|
138
|
-
return undefined;
|
|
139
|
-
}
|
|
140
|
-
return {
|
|
141
|
-
kind: match[1].toLowerCase() === "prd" ? "PRD" : "Plan",
|
|
142
|
-
name,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
function formatGroupLabel(kind, rawName) {
|
|
146
|
-
const name = rawName.trim();
|
|
147
|
-
if (!name) {
|
|
148
|
-
return undefined;
|
|
149
|
-
}
|
|
150
|
-
const prefix = kind.toLowerCase() === "prd" ? "PRD" : "Plan";
|
|
151
|
-
return `${prefix}: ${name}`;
|
|
103
|
+
function normalizeSelectedKeys(input) {
|
|
104
|
+
const selected = new Set(input.selectedKeys);
|
|
105
|
+
const candidatesByKey = new Map(input.candidates.map((candidate) => [candidate.key, candidate]));
|
|
106
|
+
const orderedSelected = input.choiceOrder.flatMap((key) => {
|
|
107
|
+
const candidate = candidatesByKey.get(key);
|
|
108
|
+
return candidate && selected.has(key) ? [candidate] : [];
|
|
109
|
+
});
|
|
110
|
+
return normalizeWorkItemSelection({
|
|
111
|
+
candidates: input.candidates,
|
|
112
|
+
selected: orderedSelected,
|
|
113
|
+
maxItems: input.maxItems,
|
|
114
|
+
expandGroups: false,
|
|
115
|
+
strictMax: true,
|
|
116
|
+
});
|
|
152
117
|
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { orderWorkItemsByDependencies, } from "./workItems.js";
|
|
2
|
+
export function buildWorkItemSelectionPlan(input) {
|
|
3
|
+
const context = createPlanningContext(input);
|
|
4
|
+
const recommended = input.proposed
|
|
5
|
+
? normalizeWithContext(context, {
|
|
6
|
+
selected: input.proposed,
|
|
7
|
+
maxItems: input.maxItems,
|
|
8
|
+
expandGroups: true,
|
|
9
|
+
})
|
|
10
|
+
: emptyNormalizedSelection();
|
|
11
|
+
const plannedSections = context.sections.map((section) => {
|
|
12
|
+
const sectionSeries = buildSectionSeries({
|
|
13
|
+
context,
|
|
14
|
+
section,
|
|
15
|
+
maxItems: input.maxItems,
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
label: section.label,
|
|
19
|
+
candidates: section.candidates,
|
|
20
|
+
recommended: sectionSeries.queue,
|
|
21
|
+
blocked: section.candidates.flatMap((candidate) => blockerForCandidate(context, candidate)),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
sections: plannedSections,
|
|
26
|
+
recommendedSeries: plannedSections
|
|
27
|
+
.map((section) => ({
|
|
28
|
+
section: section.label,
|
|
29
|
+
candidates: section.recommended,
|
|
30
|
+
blocked: section.blocked,
|
|
31
|
+
truncated: section.recommended.length > 0 &&
|
|
32
|
+
section.recommended.length === input.maxItems &&
|
|
33
|
+
section.candidates.some((candidate) => !section.recommended.some((item) => item.key === candidate.key)),
|
|
34
|
+
}))
|
|
35
|
+
.filter((series) => series.candidates.length > 0 || series.blocked.length > 0),
|
|
36
|
+
recommendedQueue: recommended.ok ? recommended.queue : recommended.queue,
|
|
37
|
+
candidateOrder: context.sections.flatMap((section) => section.candidates),
|
|
38
|
+
blockedCandidates: [...context.blockedByKey.values()],
|
|
39
|
+
cycleKeys: recommended.cycleKeys,
|
|
40
|
+
truncated: recommended.truncated,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function normalizeWorkItemSelection(input) {
|
|
44
|
+
return normalizeWithContext(createPlanningContext(input), input);
|
|
45
|
+
}
|
|
46
|
+
export function buildSelectionSections(candidates) {
|
|
47
|
+
const sections = [];
|
|
48
|
+
const groupedSections = new Map();
|
|
49
|
+
let ungroupedSection;
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
const group = detectSelectionGroup(candidate);
|
|
52
|
+
if (!group) {
|
|
53
|
+
if (!ungroupedSection) {
|
|
54
|
+
ungroupedSection = { candidates: [] };
|
|
55
|
+
sections.push(ungroupedSection);
|
|
56
|
+
}
|
|
57
|
+
ungroupedSection.candidates.push(candidate);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
let section = groupedSections.get(group.key);
|
|
61
|
+
if (!section) {
|
|
62
|
+
section = { label: group.label, candidates: [] };
|
|
63
|
+
groupedSections.set(group.key, section);
|
|
64
|
+
sections.push(section);
|
|
65
|
+
}
|
|
66
|
+
section.candidates.push(candidate);
|
|
67
|
+
}
|
|
68
|
+
return sections.map((section) => ({
|
|
69
|
+
label: section.label,
|
|
70
|
+
candidates: orderCandidatesForPlanning(section.candidates),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
export function isDependencySatisfied(dependency, completedKeys = new Set()) {
|
|
74
|
+
return (completedKeys.has(dependency.key) ||
|
|
75
|
+
dependency.state?.toLowerCase() === "closed");
|
|
76
|
+
}
|
|
77
|
+
export function formatSelectionBlockers(blocked) {
|
|
78
|
+
return blocked
|
|
79
|
+
.map((item) => `#${item.candidate.number} blocked by ${item.blockers
|
|
80
|
+
.map(formatDependencyReference)
|
|
81
|
+
.join(", ")}`)
|
|
82
|
+
.join("; ");
|
|
83
|
+
}
|
|
84
|
+
function buildSectionSeries(input) {
|
|
85
|
+
const first = input.section.candidates[0];
|
|
86
|
+
if (!first) {
|
|
87
|
+
return emptyNormalizedSelection();
|
|
88
|
+
}
|
|
89
|
+
return normalizeWithContext(input.context, {
|
|
90
|
+
selected: [first],
|
|
91
|
+
maxItems: input.maxItems,
|
|
92
|
+
expandGroups: Boolean(input.section.label),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function normalizeWithContext(context, input) {
|
|
96
|
+
if (input.selected.length === 0) {
|
|
97
|
+
return emptyNormalizedSelection();
|
|
98
|
+
}
|
|
99
|
+
const selected = dedupeCandidates(input.selected.flatMap((candidate) => {
|
|
100
|
+
const resolved = context.candidateByKey.get(candidate.key);
|
|
101
|
+
return resolved ? [resolved] : [];
|
|
102
|
+
}));
|
|
103
|
+
const selectedOrder = new Map(selected.map((candidate, index) => [candidate.key, index]));
|
|
104
|
+
const targetKeys = selectionTargetKeys({
|
|
105
|
+
context,
|
|
106
|
+
selected,
|
|
107
|
+
expandGroups: input.expandGroups,
|
|
108
|
+
});
|
|
109
|
+
const targetIndex = new Map(targetKeys.map((key, index) => [key, index]));
|
|
110
|
+
const closureKeys = new Set();
|
|
111
|
+
const blockedByKey = new Map();
|
|
112
|
+
const visiting = new Set();
|
|
113
|
+
const resolved = new Map();
|
|
114
|
+
const includeCandidate = (key) => {
|
|
115
|
+
const cached = resolved.get(key);
|
|
116
|
+
if (cached !== undefined) {
|
|
117
|
+
return cached;
|
|
118
|
+
}
|
|
119
|
+
const candidate = context.candidateByKey.get(key);
|
|
120
|
+
if (!candidate) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
if (visiting.has(key)) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
visiting.add(key);
|
|
127
|
+
const blockers = [];
|
|
128
|
+
for (const dependency of candidate.blocked_by ?? []) {
|
|
129
|
+
const dependencyCandidate = context.candidateByKey.get(dependency.key);
|
|
130
|
+
if (dependencyCandidate) {
|
|
131
|
+
if (!includeCandidate(dependencyCandidate.key)) {
|
|
132
|
+
blockers.push(enrichDependencyFromCandidate(dependency, dependencyCandidate));
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!isDependencySatisfied(dependency, context.completedKeys)) {
|
|
137
|
+
blockers.push(dependency);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
visiting.delete(key);
|
|
141
|
+
const ok = blockers.length === 0;
|
|
142
|
+
if (ok) {
|
|
143
|
+
closureKeys.add(key);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
blockedByKey.set(candidate.key, {
|
|
147
|
+
candidate,
|
|
148
|
+
blockers: dedupeDependencies(blockers),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
resolved.set(key, ok);
|
|
152
|
+
return ok;
|
|
153
|
+
};
|
|
154
|
+
for (const key of targetKeys) {
|
|
155
|
+
includeCandidate(key);
|
|
156
|
+
}
|
|
157
|
+
const preOrdered = [...closureKeys]
|
|
158
|
+
.flatMap((key) => {
|
|
159
|
+
const candidate = context.candidateByKey.get(key);
|
|
160
|
+
return candidate ? [candidate] : [];
|
|
161
|
+
})
|
|
162
|
+
.sort((a, b) => compareSelectionCandidates({
|
|
163
|
+
a,
|
|
164
|
+
b,
|
|
165
|
+
targetIndex,
|
|
166
|
+
selectedOrder,
|
|
167
|
+
canonicalIndex: context.canonicalIndex,
|
|
168
|
+
}));
|
|
169
|
+
const ordered = orderWorkItemsByDependencies(preOrdered);
|
|
170
|
+
const requiredCount = ordered.queue.length;
|
|
171
|
+
if (input.strictMax && requiredCount > input.maxItems) {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
error: `Selection expands to ${requiredCount} work item(s) after prerequisites; max is ${input.maxItems}.`,
|
|
175
|
+
queue: ordered.queue.slice(0, input.maxItems),
|
|
176
|
+
cycleKeys: ordered.cycleKeys,
|
|
177
|
+
blockedCandidates: [...blockedByKey.values()],
|
|
178
|
+
requiredCount,
|
|
179
|
+
truncated: true,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
ok: true,
|
|
184
|
+
queue: ordered.queue.slice(0, input.maxItems),
|
|
185
|
+
cycleKeys: ordered.cycleKeys,
|
|
186
|
+
blockedCandidates: [...blockedByKey.values()],
|
|
187
|
+
requiredCount,
|
|
188
|
+
truncated: requiredCount > input.maxItems,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function createPlanningContext(input) {
|
|
192
|
+
const sections = buildSelectionSections(input.candidates);
|
|
193
|
+
const candidateOrder = sections.flatMap((section) => section.candidates);
|
|
194
|
+
const candidateByKey = new Map(input.candidates.map((candidate) => [candidate.key, candidate]));
|
|
195
|
+
const sectionByCandidateKey = new Map();
|
|
196
|
+
const canonicalIndex = new Map();
|
|
197
|
+
const completedKeys = new Set(input.completedKeys ?? []);
|
|
198
|
+
for (const [index, candidate] of candidateOrder.entries()) {
|
|
199
|
+
canonicalIndex.set(candidate.key, index);
|
|
200
|
+
}
|
|
201
|
+
for (const section of sections) {
|
|
202
|
+
for (const candidate of section.candidates) {
|
|
203
|
+
sectionByCandidateKey.set(candidate.key, section);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const context = {
|
|
207
|
+
candidates: input.candidates,
|
|
208
|
+
candidateByKey,
|
|
209
|
+
completedKeys,
|
|
210
|
+
sections,
|
|
211
|
+
sectionByCandidateKey,
|
|
212
|
+
canonicalIndex,
|
|
213
|
+
blockedByKey: new Map(),
|
|
214
|
+
};
|
|
215
|
+
context.blockedByKey = collectBlockedCandidates(context);
|
|
216
|
+
return context;
|
|
217
|
+
}
|
|
218
|
+
function collectBlockedCandidates(context) {
|
|
219
|
+
const blockedByKey = new Map();
|
|
220
|
+
const visiting = new Set();
|
|
221
|
+
const blockersFor = (candidate) => {
|
|
222
|
+
const cached = blockedByKey.get(candidate.key);
|
|
223
|
+
if (cached) {
|
|
224
|
+
return cached.blockers;
|
|
225
|
+
}
|
|
226
|
+
if (visiting.has(candidate.key)) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
visiting.add(candidate.key);
|
|
230
|
+
const blockers = [];
|
|
231
|
+
for (const dependency of candidate.blocked_by ?? []) {
|
|
232
|
+
const dependencyCandidate = context.candidateByKey.get(dependency.key);
|
|
233
|
+
if (dependencyCandidate) {
|
|
234
|
+
if (blockersFor(dependencyCandidate).length > 0) {
|
|
235
|
+
blockers.push(enrichDependencyFromCandidate(dependency, dependencyCandidate));
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (!isDependencySatisfied(dependency, context.completedKeys)) {
|
|
240
|
+
blockers.push(dependency);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
visiting.delete(candidate.key);
|
|
244
|
+
if (blockers.length > 0) {
|
|
245
|
+
blockedByKey.set(candidate.key, {
|
|
246
|
+
candidate,
|
|
247
|
+
blockers: dedupeDependencies(blockers),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return blockers;
|
|
251
|
+
};
|
|
252
|
+
for (const candidate of context.candidates) {
|
|
253
|
+
blockersFor(candidate);
|
|
254
|
+
}
|
|
255
|
+
return blockedByKey;
|
|
256
|
+
}
|
|
257
|
+
function selectionTargetKeys(input) {
|
|
258
|
+
const keys = [];
|
|
259
|
+
const seen = new Set();
|
|
260
|
+
for (const candidate of input.selected) {
|
|
261
|
+
const section = input.context.sectionByCandidateKey.get(candidate.key);
|
|
262
|
+
const sectionCandidates = input.expandGroups && section?.label ? section.candidates : [candidate];
|
|
263
|
+
for (const item of sectionCandidates) {
|
|
264
|
+
if (!seen.has(item.key)) {
|
|
265
|
+
seen.add(item.key);
|
|
266
|
+
keys.push(item.key);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return keys;
|
|
271
|
+
}
|
|
272
|
+
function orderCandidatesForPlanning(candidates) {
|
|
273
|
+
const byNumber = [...candidates].sort(compareByIssueNumber);
|
|
274
|
+
return orderWorkItemsByDependencies(byNumber).queue;
|
|
275
|
+
}
|
|
276
|
+
function compareSelectionCandidates(input) {
|
|
277
|
+
const aRank = selectionCandidateRank(input.a, input);
|
|
278
|
+
const bRank = selectionCandidateRank(input.b, input);
|
|
279
|
+
if (aRank !== bRank) {
|
|
280
|
+
return aRank - bRank;
|
|
281
|
+
}
|
|
282
|
+
return compareByIssueNumber(input.a, input.b);
|
|
283
|
+
}
|
|
284
|
+
function selectionCandidateRank(candidate, input) {
|
|
285
|
+
return (input.targetIndex.get(candidate.key) ??
|
|
286
|
+
input.selectedOrder.get(candidate.key) ??
|
|
287
|
+
input.canonicalIndex.get(candidate.key) ??
|
|
288
|
+
Number.MAX_SAFE_INTEGER);
|
|
289
|
+
}
|
|
290
|
+
function compareByIssueNumber(a, b) {
|
|
291
|
+
if (a.number !== b.number) {
|
|
292
|
+
return a.number - b.number;
|
|
293
|
+
}
|
|
294
|
+
return a.title.localeCompare(b.title);
|
|
295
|
+
}
|
|
296
|
+
function blockerForCandidate(context, candidate) {
|
|
297
|
+
const blocker = context.blockedByKey.get(candidate.key);
|
|
298
|
+
return blocker ? [blocker] : [];
|
|
299
|
+
}
|
|
300
|
+
function enrichDependencyFromCandidate(dependency, candidate) {
|
|
301
|
+
return {
|
|
302
|
+
...dependency,
|
|
303
|
+
title: dependency.title ?? candidate.title,
|
|
304
|
+
state: dependency.state ?? "open",
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function dedupeCandidates(candidates) {
|
|
308
|
+
const seen = new Set();
|
|
309
|
+
const deduped = [];
|
|
310
|
+
for (const candidate of candidates) {
|
|
311
|
+
if (seen.has(candidate.key)) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
seen.add(candidate.key);
|
|
315
|
+
deduped.push(candidate);
|
|
316
|
+
}
|
|
317
|
+
return deduped;
|
|
318
|
+
}
|
|
319
|
+
function dedupeDependencies(dependencies) {
|
|
320
|
+
const byKey = new Map();
|
|
321
|
+
for (const dependency of dependencies) {
|
|
322
|
+
byKey.set(dependency.key, dependency);
|
|
323
|
+
}
|
|
324
|
+
return [...byKey.values()];
|
|
325
|
+
}
|
|
326
|
+
function formatDependencyReference(dependency) {
|
|
327
|
+
const state = dependency.state ? ` (${dependency.state})` : "";
|
|
328
|
+
return `#${dependency.number}${state}`;
|
|
329
|
+
}
|
|
330
|
+
function emptyNormalizedSelection() {
|
|
331
|
+
return {
|
|
332
|
+
ok: true,
|
|
333
|
+
queue: [],
|
|
334
|
+
cycleKeys: [],
|
|
335
|
+
blockedCandidates: [],
|
|
336
|
+
requiredCount: 0,
|
|
337
|
+
truncated: false,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function detectSelectionGroup(candidate) {
|
|
341
|
+
if (candidate.parent) {
|
|
342
|
+
return {
|
|
343
|
+
key: `parent:${candidate.parent.key}`,
|
|
344
|
+
label: formatParentGroup(candidate.parent),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
for (const label of candidate.labels ?? []) {
|
|
348
|
+
const group = parseGroupLabel(label);
|
|
349
|
+
if (group) {
|
|
350
|
+
return { key: `display:${group.toLowerCase()}`, label: group };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const titleGroup = parseBracketedTitleGroup(candidate.title);
|
|
354
|
+
return titleGroup
|
|
355
|
+
? { key: `display:${titleGroup.toLowerCase()}`, label: titleGroup }
|
|
356
|
+
: undefined;
|
|
357
|
+
}
|
|
358
|
+
function formatParentGroup(parent) {
|
|
359
|
+
if (!parent) {
|
|
360
|
+
return "Parent";
|
|
361
|
+
}
|
|
362
|
+
if (!parent.title) {
|
|
363
|
+
return `Parent #${parent.number}`;
|
|
364
|
+
}
|
|
365
|
+
const titledGroup = parseLeadingTitleGroup(parent.title);
|
|
366
|
+
if (titledGroup) {
|
|
367
|
+
return `${titledGroup.kind} #${parent.number}: ${titledGroup.name}`;
|
|
368
|
+
}
|
|
369
|
+
return `Parent #${parent.number}: ${parent.title}`;
|
|
370
|
+
}
|
|
371
|
+
function parseGroupLabel(label) {
|
|
372
|
+
const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(label.trim());
|
|
373
|
+
if (!match) {
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
return formatGroupLabel(match[1], match[2]);
|
|
377
|
+
}
|
|
378
|
+
function parseBracketedTitleGroup(title) {
|
|
379
|
+
const match = /^\[(plan|prd)\s*[:/=-]\s*([^\]]+)\]/i.exec(title.trim());
|
|
380
|
+
if (!match) {
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
return formatGroupLabel(match[1], match[2]);
|
|
384
|
+
}
|
|
385
|
+
function parseLeadingTitleGroup(title) {
|
|
386
|
+
const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(title.trim());
|
|
387
|
+
if (!match) {
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
const name = match[2].trim();
|
|
391
|
+
if (!name) {
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
kind: match[1].toLowerCase() === "prd" ? "PRD" : "Plan",
|
|
396
|
+
name,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
function formatGroupLabel(kind, rawName) {
|
|
400
|
+
const name = rawName.trim();
|
|
401
|
+
if (!name) {
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
const prefix = kind.toLowerCase() === "prd" ? "PRD" : "Plan";
|
|
405
|
+
return `${prefix}: ${name}`;
|
|
406
|
+
}
|
|
@@ -118,11 +118,13 @@ async function listOpenGitHubIssueRecords(input) {
|
|
|
118
118
|
if (!Array.isArray(issues)) {
|
|
119
119
|
throw new Error("gh issue list returned JSON that is not an array");
|
|
120
120
|
}
|
|
121
|
-
|
|
121
|
+
const records = issues.slice(0, input.maxCandidates).map((issue) => normalizeIssueRecord({
|
|
122
122
|
issue,
|
|
123
123
|
repo: input.repo,
|
|
124
124
|
excerptChars: input.excerptChars,
|
|
125
125
|
}));
|
|
126
|
+
await attachBlockedByDependencies({ records, repo: input.repo });
|
|
127
|
+
return records;
|
|
126
128
|
}
|
|
127
129
|
export function filterAvailable(input) {
|
|
128
130
|
const completed = new Set(input.completedKeys);
|
|
@@ -155,6 +157,79 @@ export function resolveSelectedQueue(input) {
|
|
|
155
157
|
}
|
|
156
158
|
return { ok: true, queue };
|
|
157
159
|
}
|
|
160
|
+
export function orderWorkItemsByDependencies(queue) {
|
|
161
|
+
const selectedKeys = new Set(queue.map((item) => item.key));
|
|
162
|
+
const itemByKey = new Map(queue.map((item) => [item.key, item]));
|
|
163
|
+
const originalIndex = new Map(queue.map((item, index) => [item.key, index]));
|
|
164
|
+
const dependenciesByKey = new Map();
|
|
165
|
+
for (const item of queue) {
|
|
166
|
+
const seen = new Set();
|
|
167
|
+
const dependencies = (item.blocked_by ?? [])
|
|
168
|
+
.map((dependency) => dependency.key)
|
|
169
|
+
.filter((key) => {
|
|
170
|
+
if (!selectedKeys.has(key) || seen.has(key)) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
seen.add(key);
|
|
174
|
+
return true;
|
|
175
|
+
});
|
|
176
|
+
dependenciesByKey.set(item.key, dependencies);
|
|
177
|
+
}
|
|
178
|
+
const components = findDependencyComponents({
|
|
179
|
+
keys: queue.map((item) => item.key),
|
|
180
|
+
dependenciesByKey,
|
|
181
|
+
});
|
|
182
|
+
const componentByKey = new Map();
|
|
183
|
+
for (const [index, component] of components.entries()) {
|
|
184
|
+
for (const key of component) {
|
|
185
|
+
componentByKey.set(key, index);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const componentDependsOn = new Map();
|
|
189
|
+
const dependentsByComponent = new Map();
|
|
190
|
+
for (const [key, dependencies] of dependenciesByKey) {
|
|
191
|
+
const component = componentByKey.get(key);
|
|
192
|
+
if (component === undefined) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
for (const dependencyKey of dependencies) {
|
|
196
|
+
const dependencyComponent = componentByKey.get(dependencyKey);
|
|
197
|
+
if (dependencyComponent === undefined ||
|
|
198
|
+
dependencyComponent === component) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
let dependsOn = componentDependsOn.get(component);
|
|
202
|
+
if (!dependsOn) {
|
|
203
|
+
dependsOn = new Set();
|
|
204
|
+
componentDependsOn.set(component, dependsOn);
|
|
205
|
+
}
|
|
206
|
+
dependsOn.add(dependencyComponent);
|
|
207
|
+
let dependents = dependentsByComponent.get(dependencyComponent);
|
|
208
|
+
if (!dependents) {
|
|
209
|
+
dependents = new Set();
|
|
210
|
+
dependentsByComponent.set(dependencyComponent, dependents);
|
|
211
|
+
}
|
|
212
|
+
dependents.add(component);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const orderedComponentIndexes = orderDependencyComponents({
|
|
216
|
+
components,
|
|
217
|
+
componentDependsOn,
|
|
218
|
+
dependentsByComponent,
|
|
219
|
+
itemByKey,
|
|
220
|
+
originalIndex,
|
|
221
|
+
});
|
|
222
|
+
const ordered = orderedComponentIndexes.flatMap((componentIndex) => sortKeysByOriginalOrder(components[componentIndex], originalIndex).flatMap((key) => {
|
|
223
|
+
const item = itemByKey.get(key);
|
|
224
|
+
return item ? [item] : [];
|
|
225
|
+
}));
|
|
226
|
+
return {
|
|
227
|
+
queue: ordered,
|
|
228
|
+
cycleKeys: components
|
|
229
|
+
.filter((component) => isCyclicDependencyComponent(component, dependenciesByKey))
|
|
230
|
+
.map((component) => sortKeysByOriginalOrder(component, originalIndex)),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
158
233
|
function normalizeIssueRecord(input) {
|
|
159
234
|
if (!isRecord(input.issue)) {
|
|
160
235
|
throw new Error("gh issue list returned a non-object issue");
|
|
@@ -216,6 +291,60 @@ function normalizeIssueRecord(input) {
|
|
|
216
291
|
details,
|
|
217
292
|
};
|
|
218
293
|
}
|
|
294
|
+
async function attachBlockedByDependencies(input) {
|
|
295
|
+
const detailsByKey = new Map(input.records.map((record) => [record.candidate.key, record.details]));
|
|
296
|
+
const dependenciesByRecord = new Map();
|
|
297
|
+
const externalDependencies = new Map();
|
|
298
|
+
for (const record of input.records) {
|
|
299
|
+
const dependencies = parseBlockedByDependencies({
|
|
300
|
+
body: record.body,
|
|
301
|
+
repo: input.repo,
|
|
302
|
+
});
|
|
303
|
+
dependenciesByRecord.set(record, dependencies);
|
|
304
|
+
for (const dependency of dependencies) {
|
|
305
|
+
if (!detailsByKey.has(dependency.key)) {
|
|
306
|
+
externalDependencies.set(dependency.key, dependency);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const externalDetailsByKey = await fetchExternalDependencyDetails([
|
|
311
|
+
...externalDependencies.values(),
|
|
312
|
+
]);
|
|
313
|
+
for (const record of input.records) {
|
|
314
|
+
const dependencies = (dependenciesByRecord.get(record) ?? []).map((dependency) => {
|
|
315
|
+
const details = detailsByKey.get(dependency.key) ??
|
|
316
|
+
externalDetailsByKey.get(dependency.key);
|
|
317
|
+
if (!details) {
|
|
318
|
+
return dependency;
|
|
319
|
+
}
|
|
320
|
+
const enriched = { ...dependency };
|
|
321
|
+
if (details.title) {
|
|
322
|
+
enriched.title = details.title;
|
|
323
|
+
}
|
|
324
|
+
if (details.state) {
|
|
325
|
+
enriched.state = details.state;
|
|
326
|
+
}
|
|
327
|
+
return enriched;
|
|
328
|
+
});
|
|
329
|
+
if (dependencies.length > 0) {
|
|
330
|
+
record.candidate.blocked_by = dependencies;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async function fetchExternalDependencyDetails(dependencies) {
|
|
335
|
+
const detailsByKey = new Map();
|
|
336
|
+
for (const dependency of dependencies) {
|
|
337
|
+
const locator = parseGitHubIssueLocator(dependency.source.locator);
|
|
338
|
+
if (!locator) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const details = await getGitHubIssue(locator);
|
|
342
|
+
if (details) {
|
|
343
|
+
detailsByKey.set(dependency.key, details);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return detailsByKey;
|
|
347
|
+
}
|
|
219
348
|
function buildExcerpt(content, maxChars) {
|
|
220
349
|
if (maxChars <= 0) {
|
|
221
350
|
return undefined;
|
|
@@ -383,6 +512,209 @@ function parseExplicitParentReferenceLine(line) {
|
|
|
383
512
|
const number = Number.parseInt(match[1], 10);
|
|
384
513
|
return Number.isInteger(number) && number > 0 ? number : undefined;
|
|
385
514
|
}
|
|
515
|
+
function parseBlockedByDependencies(input) {
|
|
516
|
+
if (!input.body) {
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
const dependencies = [];
|
|
520
|
+
let blockedBySectionLevel;
|
|
521
|
+
for (const line of input.body.split(/\r?\n/)) {
|
|
522
|
+
const heading = parseMarkdownHeading(line);
|
|
523
|
+
if (heading) {
|
|
524
|
+
if (blockedBySectionLevel !== undefined &&
|
|
525
|
+
heading.level <= blockedBySectionLevel) {
|
|
526
|
+
blockedBySectionLevel = undefined;
|
|
527
|
+
}
|
|
528
|
+
if (isBlockedByHeading(heading.title)) {
|
|
529
|
+
blockedBySectionLevel = heading.level;
|
|
530
|
+
}
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
dependencies.push(...parseDirectBlockedByLine({
|
|
534
|
+
line,
|
|
535
|
+
repo: input.repo,
|
|
536
|
+
}));
|
|
537
|
+
if (blockedBySectionLevel !== undefined) {
|
|
538
|
+
dependencies.push(...parseBlockedBySectionBullet({
|
|
539
|
+
line,
|
|
540
|
+
repo: input.repo,
|
|
541
|
+
}));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return dedupeDependencies(dependencies);
|
|
545
|
+
}
|
|
546
|
+
function parseDirectBlockedByLine(input) {
|
|
547
|
+
const line = normalizeMarkdownLinePrefix(input.line);
|
|
548
|
+
const match = /^blocked\s+by\s*:\s*(.+)$/i.exec(line);
|
|
549
|
+
return match
|
|
550
|
+
? parseIssueReferences({ text: match[1], repo: input.repo })
|
|
551
|
+
: [];
|
|
552
|
+
}
|
|
553
|
+
function parseBlockedBySectionBullet(input) {
|
|
554
|
+
const line = normalizeMarkdownLinePrefix(input.line);
|
|
555
|
+
const match = /^[-*+]\s+(?:\[[ xX]\]\s+)?(.+)$/.exec(line);
|
|
556
|
+
return match
|
|
557
|
+
? parseIssueReferences({ text: match[1], repo: input.repo })
|
|
558
|
+
: [];
|
|
559
|
+
}
|
|
560
|
+
function parseIssueReferences(input) {
|
|
561
|
+
const dependencies = [];
|
|
562
|
+
const issueRefPattern = /#(\d+)|([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/issues\/(\d+)/gi;
|
|
563
|
+
for (const match of input.text.matchAll(issueRefPattern)) {
|
|
564
|
+
const repo = match[2] ??
|
|
565
|
+
(match[4] && match[5] ? `${match[4]}/${match[5]}` : input.repo);
|
|
566
|
+
const rawNumber = match[1] ?? match[3] ?? match[6];
|
|
567
|
+
const number = Number.parseInt(rawNumber, 10);
|
|
568
|
+
if (!repo || !Number.isInteger(number) || number <= 0) {
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const locator = `${repo}#${number}`;
|
|
572
|
+
dependencies.push({
|
|
573
|
+
key: `github:${locator}`,
|
|
574
|
+
number,
|
|
575
|
+
source: { type: "github", locator },
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
return dedupeDependencies(dependencies);
|
|
579
|
+
}
|
|
580
|
+
function parseMarkdownHeading(line) {
|
|
581
|
+
const match = /^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
582
|
+
if (!match) {
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
level: match[1].length,
|
|
587
|
+
title: match[2].trim().replace(/[::]\s*$/, ""),
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
function isBlockedByHeading(title) {
|
|
591
|
+
return /^blocked\s+by$/i.test(title.trim());
|
|
592
|
+
}
|
|
593
|
+
function normalizeMarkdownLinePrefix(line) {
|
|
594
|
+
return line.trim().replace(/^>\s+/, "").trim();
|
|
595
|
+
}
|
|
596
|
+
function dedupeDependencies(dependencies) {
|
|
597
|
+
const byKey = new Map();
|
|
598
|
+
for (const dependency of dependencies) {
|
|
599
|
+
byKey.set(dependency.key, dependency);
|
|
600
|
+
}
|
|
601
|
+
return [...byKey.values()];
|
|
602
|
+
}
|
|
603
|
+
function findDependencyComponents(input) {
|
|
604
|
+
const components = [];
|
|
605
|
+
const indexByKey = new Map();
|
|
606
|
+
const lowLinkByKey = new Map();
|
|
607
|
+
const stack = [];
|
|
608
|
+
const onStack = new Set();
|
|
609
|
+
let nextIndex = 0;
|
|
610
|
+
const visit = (key) => {
|
|
611
|
+
indexByKey.set(key, nextIndex);
|
|
612
|
+
lowLinkByKey.set(key, nextIndex);
|
|
613
|
+
nextIndex += 1;
|
|
614
|
+
stack.push(key);
|
|
615
|
+
onStack.add(key);
|
|
616
|
+
for (const dependencyKey of input.dependenciesByKey.get(key) ?? []) {
|
|
617
|
+
if (!indexByKey.has(dependencyKey)) {
|
|
618
|
+
visit(dependencyKey);
|
|
619
|
+
lowLinkByKey.set(key, Math.min(lowLinkByKey.get(key) ?? 0, lowLinkByKey.get(dependencyKey) ?? 0));
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (onStack.has(dependencyKey)) {
|
|
623
|
+
lowLinkByKey.set(key, Math.min(lowLinkByKey.get(key) ?? 0, indexByKey.get(dependencyKey) ?? 0));
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (lowLinkByKey.get(key) !== indexByKey.get(key)) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const component = [];
|
|
630
|
+
let member;
|
|
631
|
+
do {
|
|
632
|
+
member = stack.pop();
|
|
633
|
+
if (!member) {
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
onStack.delete(member);
|
|
637
|
+
component.push(member);
|
|
638
|
+
} while (member !== key);
|
|
639
|
+
components.push(component);
|
|
640
|
+
};
|
|
641
|
+
for (const key of input.keys) {
|
|
642
|
+
if (!indexByKey.has(key)) {
|
|
643
|
+
visit(key);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return components;
|
|
647
|
+
}
|
|
648
|
+
function orderDependencyComponents(input) {
|
|
649
|
+
const indegree = input.components.map((_, index) => input.componentDependsOn.get(index)?.size ?? 0);
|
|
650
|
+
const available = input.components
|
|
651
|
+
.map((_, index) => index)
|
|
652
|
+
.filter((index) => indegree[index] === 0);
|
|
653
|
+
const ordered = [];
|
|
654
|
+
while (available.length > 0) {
|
|
655
|
+
available.sort((a, b) => compareDependencyComponents({
|
|
656
|
+
a,
|
|
657
|
+
b,
|
|
658
|
+
components: input.components,
|
|
659
|
+
itemByKey: input.itemByKey,
|
|
660
|
+
originalIndex: input.originalIndex,
|
|
661
|
+
}));
|
|
662
|
+
const component = available.shift();
|
|
663
|
+
if (component === undefined) {
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
ordered.push(component);
|
|
667
|
+
for (const dependent of input.dependentsByComponent.get(component) ?? []) {
|
|
668
|
+
indegree[dependent] -= 1;
|
|
669
|
+
if (indegree[dependent] === 0) {
|
|
670
|
+
available.push(dependent);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (ordered.length === input.components.length) {
|
|
675
|
+
return ordered;
|
|
676
|
+
}
|
|
677
|
+
const orderedSet = new Set(ordered);
|
|
678
|
+
return [
|
|
679
|
+
...ordered,
|
|
680
|
+
...input.components
|
|
681
|
+
.map((_, index) => index)
|
|
682
|
+
.filter((index) => !orderedSet.has(index))
|
|
683
|
+
.sort((a, b) => compareDependencyComponents({
|
|
684
|
+
a,
|
|
685
|
+
b,
|
|
686
|
+
components: input.components,
|
|
687
|
+
itemByKey: input.itemByKey,
|
|
688
|
+
originalIndex: input.originalIndex,
|
|
689
|
+
})),
|
|
690
|
+
];
|
|
691
|
+
}
|
|
692
|
+
function compareDependencyComponents(input) {
|
|
693
|
+
const aIndex = minOriginalIndex(input.components[input.a], input.originalIndex);
|
|
694
|
+
const bIndex = minOriginalIndex(input.components[input.b], input.originalIndex);
|
|
695
|
+
if (aIndex !== bIndex) {
|
|
696
|
+
return aIndex - bIndex;
|
|
697
|
+
}
|
|
698
|
+
return (minIssueNumber(input.components[input.a], input.itemByKey) -
|
|
699
|
+
minIssueNumber(input.components[input.b], input.itemByKey));
|
|
700
|
+
}
|
|
701
|
+
function minOriginalIndex(keys, originalIndex) {
|
|
702
|
+
return Math.min(...keys.map((key) => originalIndex.get(key) ?? Number.MAX_SAFE_INTEGER));
|
|
703
|
+
}
|
|
704
|
+
function minIssueNumber(keys, itemByKey) {
|
|
705
|
+
return Math.min(...keys.map((key) => itemByKey.get(key)?.number ?? Number.MAX_SAFE_INTEGER));
|
|
706
|
+
}
|
|
707
|
+
function sortKeysByOriginalOrder(keys, originalIndex) {
|
|
708
|
+
return [...keys].sort((a, b) => (originalIndex.get(a) ?? Number.MAX_SAFE_INTEGER) -
|
|
709
|
+
(originalIndex.get(b) ?? Number.MAX_SAFE_INTEGER));
|
|
710
|
+
}
|
|
711
|
+
function isCyclicDependencyComponent(component, dependenciesByKey) {
|
|
712
|
+
if (component.length > 1) {
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
const key = component[0];
|
|
716
|
+
return (dependenciesByKey.get(key) ?? []).includes(key);
|
|
717
|
+
}
|
|
386
718
|
function parseGitHubRepo(repo) {
|
|
387
719
|
const [owner, repoName, ...rest] = repo.split("/");
|
|
388
720
|
if (!owner || !repoName || rest.length > 0) {
|
|
@@ -390,6 +722,26 @@ function parseGitHubRepo(repo) {
|
|
|
390
722
|
}
|
|
391
723
|
return { owner, repoName };
|
|
392
724
|
}
|
|
725
|
+
function parseGitHubIssueLocator(locator) {
|
|
726
|
+
const separator = locator.lastIndexOf("#");
|
|
727
|
+
if (separator <= 0 || separator === locator.length - 1) {
|
|
728
|
+
return undefined;
|
|
729
|
+
}
|
|
730
|
+
const repo = locator.slice(0, separator);
|
|
731
|
+
const issueNumber = Number.parseInt(locator.slice(separator + 1), 10);
|
|
732
|
+
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
|
|
733
|
+
return undefined;
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
return {
|
|
737
|
+
...parseGitHubRepo(repo),
|
|
738
|
+
issueNumber,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
return undefined;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
393
745
|
async function getGitHubParentIssue(input) {
|
|
394
746
|
const result = await ghApi([
|
|
395
747
|
`repos/${input.owner}/${input.repoName}/issues/${input.issueNumber}/parent`,
|
|
@@ -419,6 +771,15 @@ async function listGitHubSubIssues(input) {
|
|
|
419
771
|
.filter((issue) => Boolean(issue)),
|
|
420
772
|
};
|
|
421
773
|
}
|
|
774
|
+
async function getGitHubIssue(input) {
|
|
775
|
+
const result = await ghApi([
|
|
776
|
+
`repos/${input.owner}/${input.repoName}/issues/${input.issueNumber}`,
|
|
777
|
+
]);
|
|
778
|
+
if (!result.ok || result.value === undefined) {
|
|
779
|
+
return undefined;
|
|
780
|
+
}
|
|
781
|
+
return normalizeGitHubIssueDetails(result.value);
|
|
782
|
+
}
|
|
422
783
|
async function ghApi(args) {
|
|
423
784
|
const result = await execa("gh", [
|
|
424
785
|
"api",
|