@nyxa/nyx-agent 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/dist/runtime/runPipeline.js +92 -26
- package/dist/runtime/selectionConfirmation.js +70 -22
- package/dist/runtime/workItems.js +349 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,8 +7,9 @@ GitHub issue at a time, each phase with fresh context.
|
|
|
7
7
|
|
|
8
8
|
For every run NyxAgent:
|
|
9
9
|
|
|
10
|
-
1. **Selects** open GitHub issues to work on (read-only),
|
|
11
|
-
|
|
10
|
+
1. **Selects** executable open GitHub issues to work on (read-only), presenting
|
|
11
|
+
GitHub sub-issues in parent PRD/plan sections, then asks the user to confirm
|
|
12
|
+
the proposed checklist with the same section order.
|
|
12
13
|
2. For each selected issue, in an isolated git **worktree**:
|
|
13
14
|
- **implements** it (the agent — the only customizable prompt),
|
|
14
15
|
- optionally **reviews** it in bounded discovery rounds, then revises only
|
|
@@ -20,7 +21,9 @@ For every run NyxAgent:
|
|
|
20
21
|
The agent only implements, reviews, and revises. Every git/gh side effect —
|
|
21
22
|
commit, push, pull request — is performed by the engine, so closing the loop
|
|
22
23
|
never depends on the model. Issues are closed by GitHub when the PR merges
|
|
23
|
-
(`Closes #n` in the PR body);
|
|
24
|
+
(`Closes #n` in the PR body); parent PRD/plan issues are closed only when
|
|
25
|
+
NyxAgent can prove the PR completes their remaining open executable children.
|
|
26
|
+
The human merges the PR.
|
|
24
27
|
|
|
25
28
|
The workflow shape is fixed (not configurable). Only `.nyxagent/prompts/execution.md`
|
|
26
29
|
is editable.
|
|
@@ -11,15 +11,15 @@ 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 { confirmWorkItemSelection, } from "./selectionConfirmation.js";
|
|
14
|
+
import { buildSelectionSections, confirmWorkItemSelection, } from "./selectionConfirmation.js";
|
|
15
15
|
import { createRunId } from "./time.js";
|
|
16
|
-
import { filterAvailable,
|
|
16
|
+
import { filterAvailable, listGitHubWorkItemInventory, resolveSelectedQueue, } from "./workItems.js";
|
|
17
17
|
const MAX_CANDIDATES = 50;
|
|
18
18
|
const EXCERPT_CHARS = 800;
|
|
19
19
|
const CORRECTION_VALIDATION_MAX_ATTEMPTS = 3;
|
|
20
20
|
export function defaultPipelineDependencies() {
|
|
21
21
|
return {
|
|
22
|
-
listIssues:
|
|
22
|
+
listIssues: listGitHubWorkItemInventory,
|
|
23
23
|
runPhase: runAgentPhase,
|
|
24
24
|
pushBranch,
|
|
25
25
|
createPullRequest,
|
|
@@ -59,12 +59,13 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
|
|
|
59
59
|
const ledger = await readWorkItemLedger(nyxDir);
|
|
60
60
|
reporter.detail(`Completed work items already in ledger: ${ledger.completed_work_item_keys.length}`);
|
|
61
61
|
// 1. Selection runs read-only in the main checkout, before any branch exists.
|
|
62
|
+
const inventory = normalizeWorkItemInventory(await deps.listIssues({
|
|
63
|
+
repo: config.tracker.repo,
|
|
64
|
+
maxCandidates: MAX_CANDIDATES,
|
|
65
|
+
excerptChars: EXCERPT_CHARS,
|
|
66
|
+
}));
|
|
62
67
|
const candidates = filterAvailable({
|
|
63
|
-
candidates:
|
|
64
|
-
repo: config.tracker.repo,
|
|
65
|
-
maxCandidates: MAX_CANDIDATES,
|
|
66
|
-
excerptChars: EXCERPT_CHARS,
|
|
67
|
-
}),
|
|
68
|
+
candidates: inventory.candidates,
|
|
68
69
|
completedKeys: ledger.completed_work_item_keys,
|
|
69
70
|
});
|
|
70
71
|
reporter.detail(`Available work item candidates: ${candidates.length}`);
|
|
@@ -187,7 +188,10 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
|
|
|
187
188
|
base: git.base,
|
|
188
189
|
head: git.branch,
|
|
189
190
|
title: buildPrTitle(completed),
|
|
190
|
-
body: buildPrBody(
|
|
191
|
+
body: buildPrBody({
|
|
192
|
+
items: completed,
|
|
193
|
+
parents: inventory.parents,
|
|
194
|
+
}),
|
|
191
195
|
});
|
|
192
196
|
reporter.success(`\nPull request opened: ${prUrl}`);
|
|
193
197
|
success = true;
|
|
@@ -203,6 +207,7 @@ export async function runPipeline(input = {}, deps = defaultPipelineDependencies
|
|
|
203
207
|
producedCommits,
|
|
204
208
|
completed,
|
|
205
209
|
config,
|
|
210
|
+
parents: inventory.parents,
|
|
206
211
|
deps,
|
|
207
212
|
reporter,
|
|
208
213
|
});
|
|
@@ -252,7 +257,11 @@ async function salvageFailedRun(input) {
|
|
|
252
257
|
base: input.git.base,
|
|
253
258
|
head: input.git.branch,
|
|
254
259
|
title: buildDraftPrTitle(input.completed),
|
|
255
|
-
body: buildDraftPrBody(
|
|
260
|
+
body: buildDraftPrBody({
|
|
261
|
+
items: input.completed,
|
|
262
|
+
parents: input.parents,
|
|
263
|
+
reason,
|
|
264
|
+
}),
|
|
256
265
|
draft: true,
|
|
257
266
|
});
|
|
258
267
|
input.reporter.warn(`\nRun failed, but the work was salvaged into a DRAFT pull request: ${url}`);
|
|
@@ -276,12 +285,9 @@ async function runSelection(input) {
|
|
|
276
285
|
["Max work items this run", input.config.max_iterations],
|
|
277
286
|
[
|
|
278
287
|
"Available candidates",
|
|
279
|
-
input.candidates.map((
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
title: candidate.title,
|
|
283
|
-
labels: candidate.labels,
|
|
284
|
-
excerpt: candidate.excerpt,
|
|
288
|
+
buildSelectionSections(input.candidates).map((section) => ({
|
|
289
|
+
section: section.label ?? "Ungrouped issues",
|
|
290
|
+
candidates: section.candidates.map(selectionCandidateSummary),
|
|
285
291
|
})),
|
|
286
292
|
],
|
|
287
293
|
]);
|
|
@@ -867,6 +873,17 @@ function workItemSummary(item) {
|
|
|
867
873
|
locator: item.source.locator,
|
|
868
874
|
url: item.url,
|
|
869
875
|
labels: item.labels,
|
|
876
|
+
parent: item.parent,
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
function selectionCandidateSummary(candidate) {
|
|
880
|
+
return {
|
|
881
|
+
key: candidate.key,
|
|
882
|
+
number: candidate.number,
|
|
883
|
+
title: candidate.title,
|
|
884
|
+
labels: candidate.labels,
|
|
885
|
+
parent: candidate.parent,
|
|
886
|
+
excerpt: candidate.excerpt,
|
|
870
887
|
};
|
|
871
888
|
}
|
|
872
889
|
function buildCommitMessage(item) {
|
|
@@ -878,35 +895,84 @@ function buildPrTitle(items) {
|
|
|
878
895
|
}
|
|
879
896
|
return `NyxAgent: ${items.length} work items`;
|
|
880
897
|
}
|
|
881
|
-
function
|
|
882
|
-
|
|
898
|
+
function normalizeWorkItemInventory(result) {
|
|
899
|
+
if (Array.isArray(result)) {
|
|
900
|
+
return { candidates: result, parents: [] };
|
|
901
|
+
}
|
|
902
|
+
return {
|
|
903
|
+
candidates: result.candidates,
|
|
904
|
+
parents: result.parents ?? [],
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
function buildPrBody(input) {
|
|
908
|
+
const list = input.items
|
|
883
909
|
.map((item) => `- ${item.title} (#${item.number})`)
|
|
884
910
|
.join("\n");
|
|
885
|
-
const
|
|
886
|
-
|
|
911
|
+
const completedParents = completedParentClosures(input);
|
|
912
|
+
const closeNumbers = [
|
|
913
|
+
...uniqueNumbers(input.items.map((item) => item.number)),
|
|
914
|
+
...uniqueNumbers(completedParents.map((parent) => parent.number)),
|
|
915
|
+
];
|
|
916
|
+
const closes = closeNumbers.map((number) => `Closes #${number}`).join("\n");
|
|
917
|
+
const sections = [
|
|
887
918
|
"Automated changes by NyxAgent.",
|
|
888
919
|
"",
|
|
889
920
|
"## Work items",
|
|
890
921
|
"",
|
|
891
922
|
list,
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
923
|
+
];
|
|
924
|
+
if (completedParents.length > 0) {
|
|
925
|
+
sections.push("", "## Completed plans", "", completedParents
|
|
926
|
+
.map((parent) => `- ${parent.title ?? `Parent issue ${parent.number}`} (#${parent.number})`)
|
|
927
|
+
.join("\n"));
|
|
928
|
+
}
|
|
929
|
+
sections.push("", closes);
|
|
930
|
+
return sections.join("\n");
|
|
895
931
|
}
|
|
896
932
|
function buildDraftPrTitle(items) {
|
|
897
933
|
return `[Draft] ${buildPrTitle(items)}`;
|
|
898
934
|
}
|
|
899
|
-
function buildDraftPrBody(
|
|
935
|
+
function buildDraftPrBody(input) {
|
|
900
936
|
return [
|
|
901
937
|
"> [!WARNING]",
|
|
902
938
|
"> This pull request was opened automatically by NyxAgent after the run",
|
|
903
939
|
"> **failed review**. The work is preserved here for a human to finish.",
|
|
904
940
|
"",
|
|
905
|
-
`**Why the run failed:** ${reason}`,
|
|
941
|
+
`**Why the run failed:** ${input.reason}`,
|
|
906
942
|
"",
|
|
907
|
-
buildPrBody(
|
|
943
|
+
buildPrBody({
|
|
944
|
+
items: input.items,
|
|
945
|
+
parents: input.parents,
|
|
946
|
+
}),
|
|
908
947
|
].join("\n");
|
|
909
948
|
}
|
|
949
|
+
function completedParentClosures(input) {
|
|
950
|
+
const completedKeys = new Set(input.items.map((item) => item.key));
|
|
951
|
+
const completedParentKeys = new Set(input.items
|
|
952
|
+
.map((item) => item.parent?.key)
|
|
953
|
+
.filter((key) => Boolean(key)));
|
|
954
|
+
const completedParents = [];
|
|
955
|
+
for (const parent of input.parents ?? []) {
|
|
956
|
+
if (!parent.closable || !completedParentKeys.has(parent.key)) {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
const openChildren = parent.children.filter((child) => child.state !== "closed");
|
|
960
|
+
const openContainerChild = openChildren.some((child) => !child.executable);
|
|
961
|
+
if (openContainerChild) {
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
const completedChildInThisPr = parent.children.some((child) => completedKeys.has(child.key));
|
|
965
|
+
if (!completedChildInThisPr ||
|
|
966
|
+
openChildren.some((child) => !completedKeys.has(child.key))) {
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
completedParents.push(parent);
|
|
970
|
+
}
|
|
971
|
+
return completedParents;
|
|
972
|
+
}
|
|
973
|
+
function uniqueNumbers(numbers) {
|
|
974
|
+
return [...new Set(numbers)];
|
|
975
|
+
}
|
|
910
976
|
/** Render unresolved blockers as a bullet list to append to a failure message. */
|
|
911
977
|
function formatBlockers(blockers) {
|
|
912
978
|
if (blockers.length === 0) {
|
|
@@ -7,41 +7,72 @@ export async function confirmWorkItemSelection(input) {
|
|
|
7
7
|
if (!isInteractive) {
|
|
8
8
|
throw new Error('Interactive work item selection requires a TTY. Re-run with "nyxagent run --yes" to accept the agent selection.');
|
|
9
9
|
}
|
|
10
|
+
const choiceItems = buildSelectionChoiceItems(input);
|
|
10
11
|
const selectedKeys = await checkbox({
|
|
11
12
|
message: `Select work items to run (max ${input.maxItems})`,
|
|
12
|
-
choices:
|
|
13
|
-
pageSize: Math.min(Math.max(
|
|
13
|
+
choices: choiceItems.map(toInquirerChoice),
|
|
14
|
+
pageSize: Math.min(Math.max(choiceItems.length, 7), 20),
|
|
14
15
|
required: false,
|
|
15
16
|
validate: (selected) => selected.length <= input.maxItems ||
|
|
16
17
|
`Select at most ${input.maxItems} work item(s).`,
|
|
17
18
|
shortcuts: {
|
|
18
19
|
all: null,
|
|
19
|
-
invert: null
|
|
20
|
-
}
|
|
20
|
+
invert: null,
|
|
21
|
+
},
|
|
21
22
|
});
|
|
22
23
|
const selected = new Set(selectedKeys);
|
|
23
|
-
|
|
24
|
+
const candidatesByKey = new Map(input.candidates.map((candidate) => [candidate.key, candidate]));
|
|
25
|
+
return choiceItems.flatMap((item) => {
|
|
26
|
+
if (item.type !== "choice" || !selected.has(item.value)) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const candidate = candidatesByKey.get(item.value);
|
|
30
|
+
return candidate ? [candidate] : [];
|
|
31
|
+
});
|
|
24
32
|
}
|
|
25
33
|
export function buildSelectionChoiceItems(input) {
|
|
26
34
|
const proposedKeys = new Set(input.proposed.map((item) => item.key));
|
|
27
35
|
const items = [];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
for (const section of buildSelectionSections(input.candidates)) {
|
|
37
|
+
if (section.label) {
|
|
38
|
+
items.push({ type: "separator", label: section.label });
|
|
39
|
+
}
|
|
40
|
+
for (const candidate of section.candidates) {
|
|
41
|
+
const proposed = proposedKeys.has(candidate.key);
|
|
42
|
+
items.push({
|
|
43
|
+
type: "choice",
|
|
44
|
+
value: candidate.key,
|
|
45
|
+
name: `#${candidate.number} ${candidate.title}${proposed ? " (agent)" : ""}`,
|
|
46
|
+
checked: proposed,
|
|
47
|
+
});
|
|
33
48
|
}
|
|
34
|
-
currentGroup = group;
|
|
35
|
-
const proposed = proposedKeys.has(candidate.key);
|
|
36
|
-
items.push({
|
|
37
|
-
type: "choice",
|
|
38
|
-
value: candidate.key,
|
|
39
|
-
name: `#${candidate.number} ${candidate.title}${proposed ? " (agent)" : ""}`,
|
|
40
|
-
checked: proposed
|
|
41
|
-
});
|
|
42
49
|
}
|
|
43
50
|
return items;
|
|
44
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);
|
|
62
|
+
}
|
|
63
|
+
ungroupedSection.candidates.push(candidate);
|
|
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);
|
|
71
|
+
}
|
|
72
|
+
section.candidates.push(candidate);
|
|
73
|
+
}
|
|
74
|
+
return sections;
|
|
75
|
+
}
|
|
45
76
|
function toInquirerChoice(item) {
|
|
46
77
|
if (item.type === "separator") {
|
|
47
78
|
return new Separator(item.label);
|
|
@@ -49,17 +80,34 @@ function toInquirerChoice(item) {
|
|
|
49
80
|
return {
|
|
50
81
|
value: item.value,
|
|
51
82
|
name: item.name,
|
|
52
|
-
checked: item.checked
|
|
83
|
+
checked: item.checked,
|
|
53
84
|
};
|
|
54
85
|
}
|
|
55
|
-
function
|
|
86
|
+
function detectSelectionGroup(candidate) {
|
|
87
|
+
if (candidate.parent) {
|
|
88
|
+
return {
|
|
89
|
+
key: `parent:${candidate.parent.key}`,
|
|
90
|
+
label: formatParentGroup(candidate.parent),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
56
93
|
for (const label of candidate.labels ?? []) {
|
|
57
94
|
const group = parseGroupLabel(label);
|
|
58
95
|
if (group) {
|
|
59
|
-
return group;
|
|
96
|
+
return { key: `display:${group.toLowerCase()}`, label: group };
|
|
60
97
|
}
|
|
61
98
|
}
|
|
62
|
-
|
|
99
|
+
const titleGroup = parseBracketedTitleGroup(candidate.title);
|
|
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
|
+
return parent.title
|
|
109
|
+
? `Parent #${parent.number}: ${parent.title}`
|
|
110
|
+
: `Parent #${parent.number}`;
|
|
63
111
|
}
|
|
64
112
|
function parseGroupLabel(label) {
|
|
65
113
|
const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(label.trim());
|
|
@@ -1,6 +1,96 @@
|
|
|
1
1
|
/** Work items: lists GitHub issues via `gh`, normalizes them to candidates, and resolves the selected queue. */
|
|
2
2
|
import { execa } from "execa";
|
|
3
3
|
export async function listGitHubIssues(input) {
|
|
4
|
+
const records = await listOpenGitHubIssueRecords(input);
|
|
5
|
+
return records.map((record) => record.candidate);
|
|
6
|
+
}
|
|
7
|
+
export async function listGitHubWorkItemInventory(input) {
|
|
8
|
+
const records = await listOpenGitHubIssueRecords(input);
|
|
9
|
+
const issueByNumber = new Map(records.map((record) => [record.number, record]));
|
|
10
|
+
const nativeParentNumberByChild = new Map();
|
|
11
|
+
const explicitParentNumberByChild = new Map();
|
|
12
|
+
const parentDetails = new Map();
|
|
13
|
+
const subIssueResults = new Map();
|
|
14
|
+
const repoParts = parseGitHubRepo(input.repo);
|
|
15
|
+
for (const record of records) {
|
|
16
|
+
const parent = await getGitHubParentIssue({
|
|
17
|
+
...repoParts,
|
|
18
|
+
issueNumber: record.number,
|
|
19
|
+
});
|
|
20
|
+
if (parent) {
|
|
21
|
+
nativeParentNumberByChild.set(record.number, parent.number);
|
|
22
|
+
parentDetails.set(parent.number, parent);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const numbersToInspectForSubIssues = new Set([
|
|
26
|
+
...records.map((record) => record.number),
|
|
27
|
+
...nativeParentNumberByChild.values(),
|
|
28
|
+
]);
|
|
29
|
+
for (const issueNumber of numbersToInspectForSubIssues) {
|
|
30
|
+
const result = await listGitHubSubIssues({
|
|
31
|
+
...repoParts,
|
|
32
|
+
issueNumber,
|
|
33
|
+
});
|
|
34
|
+
subIssueResults.set(issueNumber, result);
|
|
35
|
+
if (result.ok && result.issues.length > 0) {
|
|
36
|
+
const existing = issueByNumber.get(issueNumber);
|
|
37
|
+
if (existing) {
|
|
38
|
+
parentDetails.set(issueNumber, existing.details);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const record of records) {
|
|
43
|
+
if (nativeParentNumberByChild.has(record.number)) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const explicitParent = parseExplicitParentReference(record);
|
|
47
|
+
if (explicitParent && explicitParent !== record.number) {
|
|
48
|
+
explicitParentNumberByChild.set(record.number, explicitParent);
|
|
49
|
+
const parentRecord = issueByNumber.get(explicitParent);
|
|
50
|
+
if (parentRecord) {
|
|
51
|
+
parentDetails.set(explicitParent, parentRecord.details);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const parentNumbers = new Set([
|
|
56
|
+
...nativeParentNumberByChild.values(),
|
|
57
|
+
...explicitParentNumberByChild.values(),
|
|
58
|
+
]);
|
|
59
|
+
for (const [issueNumber, result] of subIssueResults) {
|
|
60
|
+
if (result.ok && result.issues.length > 0) {
|
|
61
|
+
parentNumbers.add(issueNumber);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const parentInventory = buildParentInventory({
|
|
65
|
+
repo: input.repo,
|
|
66
|
+
records,
|
|
67
|
+
parentNumbers,
|
|
68
|
+
nativeParentNumberByChild,
|
|
69
|
+
explicitParentNumberByChild,
|
|
70
|
+
parentDetails,
|
|
71
|
+
subIssueResults,
|
|
72
|
+
});
|
|
73
|
+
const parentByNumber = new Map(parentInventory.map((parent) => [parent.number, toCandidateParent(parent)]));
|
|
74
|
+
const candidates = records
|
|
75
|
+
.filter((record) => !parentNumbers.has(record.number))
|
|
76
|
+
.map((record) => {
|
|
77
|
+
const candidate = { ...record.candidate };
|
|
78
|
+
const parentNumber = nativeParentNumberByChild.get(record.number) ??
|
|
79
|
+
explicitParentNumberByChild.get(record.number);
|
|
80
|
+
const parent = parentNumber
|
|
81
|
+
? parentByNumber.get(parentNumber)
|
|
82
|
+
: undefined;
|
|
83
|
+
if (parent) {
|
|
84
|
+
candidate.parent = parent;
|
|
85
|
+
}
|
|
86
|
+
return candidate;
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
candidates,
|
|
90
|
+
parents: parentInventory,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async function listOpenGitHubIssueRecords(input) {
|
|
4
94
|
const result = await execa("gh", [
|
|
5
95
|
"issue",
|
|
6
96
|
"list",
|
|
@@ -11,7 +101,7 @@ export async function listGitHubIssues(input) {
|
|
|
11
101
|
"--limit",
|
|
12
102
|
String(input.maxCandidates),
|
|
13
103
|
"--json",
|
|
14
|
-
"number,title,url,labels,updatedAt,body"
|
|
104
|
+
"number,title,url,labels,updatedAt,body,state",
|
|
15
105
|
], { reject: false });
|
|
16
106
|
if (result.exitCode !== 0) {
|
|
17
107
|
const detail = (result.stderr || result.stdout || "unknown error").trim();
|
|
@@ -28,12 +118,10 @@ export async function listGitHubIssues(input) {
|
|
|
28
118
|
if (!Array.isArray(issues)) {
|
|
29
119
|
throw new Error("gh issue list returned JSON that is not an array");
|
|
30
120
|
}
|
|
31
|
-
return issues
|
|
32
|
-
.slice(0, input.maxCandidates)
|
|
33
|
-
.map((issue) => normalizeIssue({
|
|
121
|
+
return issues.slice(0, input.maxCandidates).map((issue) => normalizeIssueRecord({
|
|
34
122
|
issue,
|
|
35
123
|
repo: input.repo,
|
|
36
|
-
excerptChars: input.excerptChars
|
|
124
|
+
excerptChars: input.excerptChars,
|
|
37
125
|
}));
|
|
38
126
|
}
|
|
39
127
|
export function filterAvailable(input) {
|
|
@@ -59,7 +147,7 @@ export function resolveSelectedQueue(input) {
|
|
|
59
147
|
if (!candidate) {
|
|
60
148
|
return {
|
|
61
149
|
ok: false,
|
|
62
|
-
error: `selection key "${key}" is not an available candidate
|
|
150
|
+
error: `selection key "${key}" is not an available candidate`,
|
|
63
151
|
};
|
|
64
152
|
}
|
|
65
153
|
seen.add(key);
|
|
@@ -67,7 +155,7 @@ export function resolveSelectedQueue(input) {
|
|
|
67
155
|
}
|
|
68
156
|
return { ok: true, queue };
|
|
69
157
|
}
|
|
70
|
-
function
|
|
158
|
+
function normalizeIssueRecord(input) {
|
|
71
159
|
if (!isRecord(input.issue)) {
|
|
72
160
|
throw new Error("gh issue list returned a non-object issue");
|
|
73
161
|
}
|
|
@@ -80,24 +168,53 @@ function normalizeIssue(input) {
|
|
|
80
168
|
throw new Error(`GitHub issue #${number} is missing a title`);
|
|
81
169
|
}
|
|
82
170
|
const locator = `${input.repo}#${number}`;
|
|
171
|
+
const labels = normalizeLabels(input.issue.labels);
|
|
83
172
|
const candidate = {
|
|
84
173
|
key: `github:${locator}`,
|
|
85
174
|
title,
|
|
86
175
|
number,
|
|
87
176
|
source: { type: "github", locator },
|
|
88
|
-
labels
|
|
177
|
+
labels,
|
|
89
178
|
};
|
|
90
|
-
|
|
91
|
-
|
|
179
|
+
const url = typeof input.issue.url === "string"
|
|
180
|
+
? input.issue.url
|
|
181
|
+
: typeof input.issue.html_url === "string"
|
|
182
|
+
? input.issue.html_url
|
|
183
|
+
: undefined;
|
|
184
|
+
if (url && url.length > 0) {
|
|
185
|
+
candidate.url = url;
|
|
92
186
|
}
|
|
93
|
-
|
|
94
|
-
input.issue.updatedAt
|
|
95
|
-
|
|
187
|
+
const updatedAt = typeof input.issue.updatedAt === "string"
|
|
188
|
+
? input.issue.updatedAt
|
|
189
|
+
: typeof input.issue.updated_at === "string"
|
|
190
|
+
? input.issue.updated_at
|
|
191
|
+
: undefined;
|
|
192
|
+
if (updatedAt && updatedAt.length > 0) {
|
|
193
|
+
candidate.updated_at = updatedAt;
|
|
96
194
|
}
|
|
195
|
+
let body;
|
|
97
196
|
if (typeof input.issue.body === "string") {
|
|
98
|
-
|
|
197
|
+
body = input.issue.body;
|
|
198
|
+
candidate.excerpt = buildExcerpt(body, input.excerptChars);
|
|
99
199
|
}
|
|
100
|
-
|
|
200
|
+
const state = typeof input.issue.state === "string" ? input.issue.state : undefined;
|
|
201
|
+
const details = {
|
|
202
|
+
number,
|
|
203
|
+
title,
|
|
204
|
+
state,
|
|
205
|
+
};
|
|
206
|
+
if (typeof input.issue.id === "number" && Number.isInteger(input.issue.id)) {
|
|
207
|
+
details.id = input.issue.id;
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
number,
|
|
211
|
+
title,
|
|
212
|
+
body,
|
|
213
|
+
state,
|
|
214
|
+
labels,
|
|
215
|
+
candidate,
|
|
216
|
+
details,
|
|
217
|
+
};
|
|
101
218
|
}
|
|
102
219
|
function buildExcerpt(content, maxChars) {
|
|
103
220
|
if (maxChars <= 0) {
|
|
@@ -132,6 +249,223 @@ function normalizeLabels(labels) {
|
|
|
132
249
|
.filter((label) => Boolean(label));
|
|
133
250
|
return normalized.length > 0 ? normalized : undefined;
|
|
134
251
|
}
|
|
252
|
+
function buildParentInventory(input) {
|
|
253
|
+
const parentNumbers = new Set([
|
|
254
|
+
...input.parentNumbers,
|
|
255
|
+
...input.nativeParentNumberByChild.values(),
|
|
256
|
+
...input.explicitParentNumberByChild.values(),
|
|
257
|
+
]);
|
|
258
|
+
const recordsByNumber = new Map(input.records.map((record) => [record.number, record]));
|
|
259
|
+
const parents = [];
|
|
260
|
+
for (const parentNumber of [...parentNumbers].sort((a, b) => a - b)) {
|
|
261
|
+
const nativeChildren = input.records
|
|
262
|
+
.filter((record) => input.nativeParentNumberByChild.get(record.number) === parentNumber)
|
|
263
|
+
.map((record) => issueDetailsToParentChild({
|
|
264
|
+
repo: input.repo,
|
|
265
|
+
issue: record.details,
|
|
266
|
+
parentNumbers,
|
|
267
|
+
}));
|
|
268
|
+
const explicitChildren = input.records
|
|
269
|
+
.filter((record) => input.explicitParentNumberByChild.get(record.number) === parentNumber)
|
|
270
|
+
.map((record) => issueDetailsToParentChild({
|
|
271
|
+
repo: input.repo,
|
|
272
|
+
issue: record.details,
|
|
273
|
+
parentNumbers,
|
|
274
|
+
}));
|
|
275
|
+
const subIssueResult = input.subIssueResults.get(parentNumber);
|
|
276
|
+
const children = subIssueResult?.ok && subIssueResult.issues.length > 0
|
|
277
|
+
? subIssueResult.issues.map((issue) => issueDetailsToParentChild({
|
|
278
|
+
repo: input.repo,
|
|
279
|
+
issue,
|
|
280
|
+
parentNumbers,
|
|
281
|
+
}))
|
|
282
|
+
: [...nativeChildren, ...explicitChildren];
|
|
283
|
+
const relationship = nativeChildren.length > 0 ||
|
|
284
|
+
(subIssueResult?.ok && subIssueResult.issues.length > 0)
|
|
285
|
+
? "github-sub-issue"
|
|
286
|
+
: "explicit-ref";
|
|
287
|
+
const parentDetail = input.parentDetails.get(parentNumber) ??
|
|
288
|
+
recordsByNumber.get(parentNumber)?.details;
|
|
289
|
+
const parentState = parentDetail?.state;
|
|
290
|
+
const closable = relationship === "github-sub-issue" &&
|
|
291
|
+
subIssueResult?.ok === true &&
|
|
292
|
+
subIssueResult.issues.length > 0 &&
|
|
293
|
+
parentState === "open";
|
|
294
|
+
parents.push({
|
|
295
|
+
...buildParentMetadata({
|
|
296
|
+
repo: input.repo,
|
|
297
|
+
number: parentNumber,
|
|
298
|
+
title: parentDetail?.title,
|
|
299
|
+
relationship,
|
|
300
|
+
closable,
|
|
301
|
+
}),
|
|
302
|
+
state: parentState,
|
|
303
|
+
children: dedupeParentChildren(children),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return parents;
|
|
307
|
+
}
|
|
308
|
+
function buildParentMetadata(input) {
|
|
309
|
+
const locator = `${input.repo}#${input.number}`;
|
|
310
|
+
const parent = {
|
|
311
|
+
key: `github:${locator}`,
|
|
312
|
+
number: input.number,
|
|
313
|
+
source: { type: "github", locator },
|
|
314
|
+
relationship: input.relationship,
|
|
315
|
+
closable: input.closable,
|
|
316
|
+
};
|
|
317
|
+
if (input.title) {
|
|
318
|
+
parent.title = input.title;
|
|
319
|
+
}
|
|
320
|
+
return parent;
|
|
321
|
+
}
|
|
322
|
+
function toCandidateParent(parent) {
|
|
323
|
+
const candidateParent = {
|
|
324
|
+
key: parent.key,
|
|
325
|
+
number: parent.number,
|
|
326
|
+
source: parent.source,
|
|
327
|
+
relationship: parent.relationship,
|
|
328
|
+
closable: parent.closable,
|
|
329
|
+
};
|
|
330
|
+
if (parent.title) {
|
|
331
|
+
candidateParent.title = parent.title;
|
|
332
|
+
}
|
|
333
|
+
return candidateParent;
|
|
334
|
+
}
|
|
335
|
+
function issueDetailsToParentChild(input) {
|
|
336
|
+
const locator = `${input.repo}#${input.issue.number}`;
|
|
337
|
+
const child = {
|
|
338
|
+
key: `github:${locator}`,
|
|
339
|
+
number: input.issue.number,
|
|
340
|
+
executable: !input.parentNumbers.has(input.issue.number),
|
|
341
|
+
};
|
|
342
|
+
if (input.issue.title) {
|
|
343
|
+
child.title = input.issue.title;
|
|
344
|
+
}
|
|
345
|
+
if (input.issue.state) {
|
|
346
|
+
child.state = input.issue.state;
|
|
347
|
+
}
|
|
348
|
+
return child;
|
|
349
|
+
}
|
|
350
|
+
function dedupeParentChildren(children) {
|
|
351
|
+
const byNumber = new Map();
|
|
352
|
+
for (const child of children) {
|
|
353
|
+
byNumber.set(child.number, child);
|
|
354
|
+
}
|
|
355
|
+
return [...byNumber.values()].sort((a, b) => a.number - b.number);
|
|
356
|
+
}
|
|
357
|
+
function parseExplicitParentReference(record) {
|
|
358
|
+
const refs = new Set();
|
|
359
|
+
for (const label of record.labels ?? []) {
|
|
360
|
+
const ref = parseExplicitParentReferenceLine(label);
|
|
361
|
+
if (ref) {
|
|
362
|
+
refs.add(ref);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
for (const line of record.body?.split(/\r?\n/) ?? []) {
|
|
366
|
+
const ref = parseExplicitParentReferenceLine(line);
|
|
367
|
+
if (ref) {
|
|
368
|
+
refs.add(ref);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return refs.size === 1 ? [...refs][0] : undefined;
|
|
372
|
+
}
|
|
373
|
+
function parseExplicitParentReferenceLine(line) {
|
|
374
|
+
const trimmed = line
|
|
375
|
+
.trim()
|
|
376
|
+
.replace(/^[-*]\s+/, "")
|
|
377
|
+
.replace(/^>\s+/, "");
|
|
378
|
+
const issueRef = "(?:#|[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#|https://github\\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/issues/)(\\d+)";
|
|
379
|
+
const match = new RegExp(`^(?:parent(?:\\s+issue)?|prd|plan)\\s*(?::|=|-)?\\s*${issueRef}\\s*$`, "i").exec(trimmed);
|
|
380
|
+
if (!match) {
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
const number = Number.parseInt(match[1], 10);
|
|
384
|
+
return Number.isInteger(number) && number > 0 ? number : undefined;
|
|
385
|
+
}
|
|
386
|
+
function parseGitHubRepo(repo) {
|
|
387
|
+
const [owner, repoName, ...rest] = repo.split("/");
|
|
388
|
+
if (!owner || !repoName || rest.length > 0) {
|
|
389
|
+
throw new Error(`GitHub repo must be in owner/repo form: ${repo}`);
|
|
390
|
+
}
|
|
391
|
+
return { owner, repoName };
|
|
392
|
+
}
|
|
393
|
+
async function getGitHubParentIssue(input) {
|
|
394
|
+
const result = await ghApi([
|
|
395
|
+
`repos/${input.owner}/${input.repoName}/issues/${input.issueNumber}/parent`,
|
|
396
|
+
]);
|
|
397
|
+
if (!result.ok || result.value === undefined) {
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
return normalizeGitHubIssueDetails(result.value);
|
|
401
|
+
}
|
|
402
|
+
async function listGitHubSubIssues(input) {
|
|
403
|
+
const result = await ghApi([
|
|
404
|
+
`repos/${input.owner}/${input.repoName}/issues/${input.issueNumber}/sub_issues?per_page=100`,
|
|
405
|
+
]);
|
|
406
|
+
if (!result.ok) {
|
|
407
|
+
return { ok: false, issues: [] };
|
|
408
|
+
}
|
|
409
|
+
if (result.value === undefined) {
|
|
410
|
+
return { ok: true, issues: [] };
|
|
411
|
+
}
|
|
412
|
+
if (!Array.isArray(result.value)) {
|
|
413
|
+
return { ok: false, issues: [] };
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
ok: true,
|
|
417
|
+
issues: result.value
|
|
418
|
+
.map((issue) => normalizeGitHubIssueDetails(issue))
|
|
419
|
+
.filter((issue) => Boolean(issue)),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
async function ghApi(args) {
|
|
423
|
+
const result = await execa("gh", [
|
|
424
|
+
"api",
|
|
425
|
+
"-H",
|
|
426
|
+
"Accept: application/vnd.github+json",
|
|
427
|
+
"-H",
|
|
428
|
+
"X-GitHub-Api-Version: 2026-03-10",
|
|
429
|
+
...args,
|
|
430
|
+
], { reject: false });
|
|
431
|
+
if (result.exitCode !== 0) {
|
|
432
|
+
return isMissingGitHubRelation(result.stderr || result.stdout)
|
|
433
|
+
? { ok: true, value: undefined }
|
|
434
|
+
: { ok: false };
|
|
435
|
+
}
|
|
436
|
+
if (result.stdout.trim().length === 0) {
|
|
437
|
+
return { ok: true, value: undefined };
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
return { ok: true, value: JSON.parse(result.stdout) };
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return { ok: false };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function normalizeGitHubIssueDetails(issue) {
|
|
447
|
+
if (!isRecord(issue)) {
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
const number = issue.number;
|
|
451
|
+
if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
const details = { number };
|
|
455
|
+
if (typeof issue.title === "string" && issue.title.length > 0) {
|
|
456
|
+
details.title = issue.title;
|
|
457
|
+
}
|
|
458
|
+
if (typeof issue.state === "string" && issue.state.length > 0) {
|
|
459
|
+
details.state = issue.state;
|
|
460
|
+
}
|
|
461
|
+
if (typeof issue.id === "number" && Number.isInteger(issue.id)) {
|
|
462
|
+
details.id = issue.id;
|
|
463
|
+
}
|
|
464
|
+
return details;
|
|
465
|
+
}
|
|
466
|
+
function isMissingGitHubRelation(output) {
|
|
467
|
+
return /HTTP\s+(404|410)\b|not found|gone/i.test(output);
|
|
468
|
+
}
|
|
135
469
|
function isRecord(value) {
|
|
136
470
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
137
471
|
}
|