@nyxa/nyx-agent 0.9.2 → 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,9 +11,10 @@ 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
|
-
import { filterAvailable, listGitHubWorkItemInventory,
|
|
17
|
+
import { filterAvailable, listGitHubWorkItemInventory, resolveSelectedQueue, } from "./workItems.js";
|
|
17
18
|
const MAX_CANDIDATES = 50;
|
|
18
19
|
const EXCERPT_CHARS = 800;
|
|
19
20
|
const CORRECTION_VALIDATION_MAX_ATTEMPTS = 3;
|
|
@@ -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,14 +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
|
|
100
|
-
|
|
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) {
|
|
101
125
|
reporter.detail(`Dependency cycle detected among selected work items; preserving confirmed order inside cycle(s): ${formatDependencyCycles({
|
|
102
|
-
cycles:
|
|
126
|
+
cycles: finalSelection.cycleKeys,
|
|
103
127
|
items: selected,
|
|
104
128
|
})}`);
|
|
105
129
|
}
|
|
106
|
-
const planned =
|
|
130
|
+
const planned = finalSelection.queue;
|
|
107
131
|
reporter.info(`Selected ${planned.length} work item(s):`);
|
|
108
132
|
for (const item of planned) {
|
|
109
133
|
reporter.info(` - ${item.title} (#${item.number})`);
|
|
@@ -287,14 +311,30 @@ async function runSelection(input) {
|
|
|
287
311
|
config: input.config,
|
|
288
312
|
cliHarness: input.cliHarness,
|
|
289
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);
|
|
290
321
|
const context = buildContextBlock([
|
|
291
322
|
["Repository", input.config.tracker.repo],
|
|
292
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
|
+
],
|
|
293
332
|
[
|
|
294
333
|
"Available candidates",
|
|
295
|
-
|
|
334
|
+
selectionPlan.sections.map((section) => ({
|
|
296
335
|
section: section.label ?? "Ungrouped issues",
|
|
297
|
-
|
|
336
|
+
recommended_order: section.recommended.map(workItemReference),
|
|
337
|
+
candidates: section.candidates.map((candidate) => selectionCandidateSummary(candidate, candidateKeys, completedKeys)),
|
|
298
338
|
})),
|
|
299
339
|
],
|
|
300
340
|
]);
|
|
@@ -328,7 +368,23 @@ async function runSelection(input) {
|
|
|
328
368
|
if (!resolved.ok) {
|
|
329
369
|
throw new Error(`Selection produced an invalid queue: ${resolved.error}`);
|
|
330
370
|
}
|
|
331
|
-
|
|
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;
|
|
332
388
|
}
|
|
333
389
|
async function runExecution(input) {
|
|
334
390
|
const agent = resolveAgentProfile({
|
|
@@ -884,7 +940,22 @@ function workItemSummary(item) {
|
|
|
884
940
|
blocked_by: item.blocked_by,
|
|
885
941
|
};
|
|
886
942
|
}
|
|
887
|
-
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) {
|
|
888
959
|
return {
|
|
889
960
|
key: candidate.key,
|
|
890
961
|
number: candidate.number,
|
|
@@ -892,9 +963,50 @@ function selectionCandidateSummary(candidate) {
|
|
|
892
963
|
labels: candidate.labels,
|
|
893
964
|
parent: candidate.parent,
|
|
894
965
|
blocked_by: candidate.blocked_by,
|
|
966
|
+
dependency_status: dependencyStatusSummary({
|
|
967
|
+
dependencies: candidate.blocked_by ?? [],
|
|
968
|
+
candidateKeys,
|
|
969
|
+
completedKeys,
|
|
970
|
+
}),
|
|
895
971
|
excerpt: candidate.excerpt,
|
|
896
972
|
};
|
|
897
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
|
+
}
|
|
898
1010
|
function buildCommitMessage(item) {
|
|
899
1011
|
return `${item.title}\n\nWork item: ${item.source.locator}`;
|
|
900
1012
|
}
|
|
@@ -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,104 +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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const candidate = candidateByKey.get(proposed.key);
|
|
39
|
-
if (!candidate || proposedKeys.has(candidate.key)) {
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
proposedCandidates.push(candidate);
|
|
43
|
-
proposedKeys.add(candidate.key);
|
|
44
|
-
}
|
|
45
|
-
const remainingCandidates = input.candidates.filter((candidate) => !proposedKeys.has(candidate.key));
|
|
46
|
-
const items = [];
|
|
47
|
-
let activeSectionLabel;
|
|
48
|
-
activeSectionLabel = appendSelectionChoices({
|
|
49
|
-
items,
|
|
50
|
-
sections: buildSelectionSections(proposedCandidates),
|
|
51
|
-
proposedKeys,
|
|
52
|
-
activeSectionLabel,
|
|
53
|
+
const plan = buildWorkItemSelectionPlan({
|
|
54
|
+
candidates: input.candidates,
|
|
55
|
+
proposed: input.proposed,
|
|
56
|
+
maxItems: input.maxItems ?? input.candidates.length,
|
|
53
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]));
|
|
60
|
+
const items = [];
|
|
54
61
|
appendSelectionChoices({
|
|
55
62
|
items,
|
|
56
|
-
sections:
|
|
63
|
+
sections: plan.sections,
|
|
57
64
|
proposedKeys,
|
|
58
|
-
|
|
65
|
+
blockedByKey,
|
|
59
66
|
});
|
|
60
67
|
return items;
|
|
61
68
|
}
|
|
62
69
|
function appendSelectionChoices(input) {
|
|
63
|
-
let activeSectionLabel = input.activeSectionLabel;
|
|
64
70
|
for (const section of input.sections) {
|
|
65
71
|
if (section.label) {
|
|
66
|
-
|
|
67
|
-
input.items.push({ type: "separator", label: section.label });
|
|
68
|
-
}
|
|
69
|
-
activeSectionLabel = section.label;
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
activeSectionLabel = undefined;
|
|
72
|
+
input.items.push({ type: "separator", label: section.label });
|
|
73
73
|
}
|
|
74
74
|
for (const candidate of section.candidates) {
|
|
75
75
|
const proposed = input.proposedKeys.has(candidate.key);
|
|
76
|
-
input.
|
|
76
|
+
const blocked = input.blockedByKey.get(candidate.key);
|
|
77
|
+
const item = {
|
|
77
78
|
type: "choice",
|
|
78
79
|
value: candidate.key,
|
|
79
80
|
name: `#${candidate.number} ${candidate.title}${proposed ? " (agent)" : ""}`,
|
|
80
81
|
checked: proposed,
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
export function buildSelectionSections(candidates) {
|
|
87
|
-
const sections = [];
|
|
88
|
-
const groupedSections = new Map();
|
|
89
|
-
let ungroupedSection;
|
|
90
|
-
for (const candidate of candidates) {
|
|
91
|
-
const group = detectSelectionGroup(candidate);
|
|
92
|
-
if (!group) {
|
|
93
|
-
if (!ungroupedSection) {
|
|
94
|
-
ungroupedSection = { candidates: [] };
|
|
95
|
-
sections.push(ungroupedSection);
|
|
82
|
+
};
|
|
83
|
+
if (blocked) {
|
|
84
|
+
item.disabled = `blocked by ${blocked.blockers
|
|
85
|
+
.map((blocker) => `#${blocker.number}`)
|
|
86
|
+
.join(", ")}`;
|
|
96
87
|
}
|
|
97
|
-
|
|
98
|
-
continue;
|
|
88
|
+
input.items.push(item);
|
|
99
89
|
}
|
|
100
|
-
let section = groupedSections.get(group.key);
|
|
101
|
-
if (!section) {
|
|
102
|
-
section = { label: group.label, candidates: [] };
|
|
103
|
-
groupedSections.set(group.key, section);
|
|
104
|
-
sections.push(section);
|
|
105
|
-
}
|
|
106
|
-
section.candidates.push(candidate);
|
|
107
90
|
}
|
|
108
|
-
return sections;
|
|
109
91
|
}
|
|
110
92
|
function toInquirerChoice(item) {
|
|
111
93
|
if (item.type === "separator") {
|
|
@@ -115,72 +97,21 @@ function toInquirerChoice(item) {
|
|
|
115
97
|
value: item.value,
|
|
116
98
|
name: item.name,
|
|
117
99
|
checked: item.checked,
|
|
100
|
+
disabled: item.disabled,
|
|
118
101
|
};
|
|
119
102
|
}
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return titleGroup
|
|
135
|
-
? { key: `display:${titleGroup.toLowerCase()}`, label: titleGroup }
|
|
136
|
-
: undefined;
|
|
137
|
-
}
|
|
138
|
-
function formatParentGroup(parent) {
|
|
139
|
-
if (!parent) {
|
|
140
|
-
return "Parent";
|
|
141
|
-
}
|
|
142
|
-
if (!parent.title) {
|
|
143
|
-
return `Parent #${parent.number}`;
|
|
144
|
-
}
|
|
145
|
-
const titledGroup = parseLeadingTitleGroup(parent.title);
|
|
146
|
-
if (titledGroup) {
|
|
147
|
-
return `${titledGroup.kind} #${parent.number}: ${titledGroup.name}`;
|
|
148
|
-
}
|
|
149
|
-
return `Parent #${parent.number}: ${parent.title}`;
|
|
150
|
-
}
|
|
151
|
-
function parseGroupLabel(label) {
|
|
152
|
-
const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(label.trim());
|
|
153
|
-
if (!match) {
|
|
154
|
-
return undefined;
|
|
155
|
-
}
|
|
156
|
-
return formatGroupLabel(match[1], match[2]);
|
|
157
|
-
}
|
|
158
|
-
function parseBracketedTitleGroup(title) {
|
|
159
|
-
const match = /^\[(plan|prd)\s*[:/=-]\s*([^\]]+)\]/i.exec(title.trim());
|
|
160
|
-
if (!match) {
|
|
161
|
-
return undefined;
|
|
162
|
-
}
|
|
163
|
-
return formatGroupLabel(match[1], match[2]);
|
|
164
|
-
}
|
|
165
|
-
function parseLeadingTitleGroup(title) {
|
|
166
|
-
const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(title.trim());
|
|
167
|
-
if (!match) {
|
|
168
|
-
return undefined;
|
|
169
|
-
}
|
|
170
|
-
const name = match[2].trim();
|
|
171
|
-
if (!name) {
|
|
172
|
-
return undefined;
|
|
173
|
-
}
|
|
174
|
-
return {
|
|
175
|
-
kind: match[1].toLowerCase() === "prd" ? "PRD" : "Plan",
|
|
176
|
-
name,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
function formatGroupLabel(kind, rawName) {
|
|
180
|
-
const name = rawName.trim();
|
|
181
|
-
if (!name) {
|
|
182
|
-
return undefined;
|
|
183
|
-
}
|
|
184
|
-
const prefix = kind.toLowerCase() === "prd" ? "PRD" : "Plan";
|
|
185
|
-
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
|
+
});
|
|
186
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
|
+
}
|
|
@@ -123,7 +123,7 @@ async function listOpenGitHubIssueRecords(input) {
|
|
|
123
123
|
repo: input.repo,
|
|
124
124
|
excerptChars: input.excerptChars,
|
|
125
125
|
}));
|
|
126
|
-
attachBlockedByDependencies({ records, repo: input.repo });
|
|
126
|
+
await attachBlockedByDependencies({ records, repo: input.repo });
|
|
127
127
|
return records;
|
|
128
128
|
}
|
|
129
129
|
export function filterAvailable(input) {
|
|
@@ -291,14 +291,29 @@ function normalizeIssueRecord(input) {
|
|
|
291
291
|
details,
|
|
292
292
|
};
|
|
293
293
|
}
|
|
294
|
-
function attachBlockedByDependencies(input) {
|
|
294
|
+
async function attachBlockedByDependencies(input) {
|
|
295
295
|
const detailsByKey = new Map(input.records.map((record) => [record.candidate.key, record.details]));
|
|
296
|
+
const dependenciesByRecord = new Map();
|
|
297
|
+
const externalDependencies = new Map();
|
|
296
298
|
for (const record of input.records) {
|
|
297
299
|
const dependencies = parseBlockedByDependencies({
|
|
298
300
|
body: record.body,
|
|
299
301
|
repo: input.repo,
|
|
300
|
-
})
|
|
301
|
-
|
|
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);
|
|
302
317
|
if (!details) {
|
|
303
318
|
return dependency;
|
|
304
319
|
}
|
|
@@ -316,6 +331,20 @@ function attachBlockedByDependencies(input) {
|
|
|
316
331
|
}
|
|
317
332
|
}
|
|
318
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
|
+
}
|
|
319
348
|
function buildExcerpt(content, maxChars) {
|
|
320
349
|
if (maxChars <= 0) {
|
|
321
350
|
return undefined;
|
|
@@ -693,6 +722,26 @@ function parseGitHubRepo(repo) {
|
|
|
693
722
|
}
|
|
694
723
|
return { owner, repoName };
|
|
695
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
|
+
}
|
|
696
745
|
async function getGitHubParentIssue(input) {
|
|
697
746
|
const result = await ghApi([
|
|
698
747
|
`repos/${input.owner}/${input.repoName}/issues/${input.issueNumber}/parent`,
|
|
@@ -722,6 +771,15 @@ async function listGitHubSubIssues(input) {
|
|
|
722
771
|
.filter((issue) => Boolean(issue)),
|
|
723
772
|
};
|
|
724
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
|
+
}
|
|
725
783
|
async function ghApi(args) {
|
|
726
784
|
const result = await execa("gh", [
|
|
727
785
|
"api",
|