@nyxa/nyx-agent 0.8.0 → 0.8.1
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 +79 -19
- package/dist/runtime/selectionConfirmation.js +15 -4
- 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), grouping
|
|
11
|
+
GitHub sub-issues under non-executable parent PRD/plan issues, then asks the
|
|
12
|
+
user to confirm the proposed checklist.
|
|
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.
|
|
@@ -13,13 +13,13 @@ import { REVIEW_CHALLENGE_SCHEMA, REVIEW_DISCOVERY_SCHEMA, GLOBAL_REVIEW_SCHEMA,
|
|
|
13
13
|
import { commitAll, commitsAhead, createPullRequest, pushBranch, rangeDiff, stageAllAndDiff, } from "./scm.js";
|
|
14
14
|
import { 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}`);
|
|
@@ -281,6 +290,7 @@ async function runSelection(input) {
|
|
|
281
290
|
number: candidate.number,
|
|
282
291
|
title: candidate.title,
|
|
283
292
|
labels: candidate.labels,
|
|
293
|
+
parent: candidate.parent,
|
|
284
294
|
excerpt: candidate.excerpt,
|
|
285
295
|
})),
|
|
286
296
|
],
|
|
@@ -867,6 +877,7 @@ function workItemSummary(item) {
|
|
|
867
877
|
locator: item.source.locator,
|
|
868
878
|
url: item.url,
|
|
869
879
|
labels: item.labels,
|
|
880
|
+
parent: item.parent,
|
|
870
881
|
};
|
|
871
882
|
}
|
|
872
883
|
function buildCommitMessage(item) {
|
|
@@ -878,35 +889,84 @@ function buildPrTitle(items) {
|
|
|
878
889
|
}
|
|
879
890
|
return `NyxAgent: ${items.length} work items`;
|
|
880
891
|
}
|
|
881
|
-
function
|
|
882
|
-
|
|
892
|
+
function normalizeWorkItemInventory(result) {
|
|
893
|
+
if (Array.isArray(result)) {
|
|
894
|
+
return { candidates: result, parents: [] };
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
candidates: result.candidates,
|
|
898
|
+
parents: result.parents ?? [],
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function buildPrBody(input) {
|
|
902
|
+
const list = input.items
|
|
883
903
|
.map((item) => `- ${item.title} (#${item.number})`)
|
|
884
904
|
.join("\n");
|
|
885
|
-
const
|
|
886
|
-
|
|
905
|
+
const completedParents = completedParentClosures(input);
|
|
906
|
+
const closeNumbers = [
|
|
907
|
+
...uniqueNumbers(input.items.map((item) => item.number)),
|
|
908
|
+
...uniqueNumbers(completedParents.map((parent) => parent.number)),
|
|
909
|
+
];
|
|
910
|
+
const closes = closeNumbers.map((number) => `Closes #${number}`).join("\n");
|
|
911
|
+
const sections = [
|
|
887
912
|
"Automated changes by NyxAgent.",
|
|
888
913
|
"",
|
|
889
914
|
"## Work items",
|
|
890
915
|
"",
|
|
891
916
|
list,
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
917
|
+
];
|
|
918
|
+
if (completedParents.length > 0) {
|
|
919
|
+
sections.push("", "## Completed plans", "", completedParents
|
|
920
|
+
.map((parent) => `- ${parent.title ?? `Parent issue ${parent.number}`} (#${parent.number})`)
|
|
921
|
+
.join("\n"));
|
|
922
|
+
}
|
|
923
|
+
sections.push("", closes);
|
|
924
|
+
return sections.join("\n");
|
|
895
925
|
}
|
|
896
926
|
function buildDraftPrTitle(items) {
|
|
897
927
|
return `[Draft] ${buildPrTitle(items)}`;
|
|
898
928
|
}
|
|
899
|
-
function buildDraftPrBody(
|
|
929
|
+
function buildDraftPrBody(input) {
|
|
900
930
|
return [
|
|
901
931
|
"> [!WARNING]",
|
|
902
932
|
"> This pull request was opened automatically by NyxAgent after the run",
|
|
903
933
|
"> **failed review**. The work is preserved here for a human to finish.",
|
|
904
934
|
"",
|
|
905
|
-
`**Why the run failed:** ${reason}`,
|
|
935
|
+
`**Why the run failed:** ${input.reason}`,
|
|
906
936
|
"",
|
|
907
|
-
buildPrBody(
|
|
937
|
+
buildPrBody({
|
|
938
|
+
items: input.items,
|
|
939
|
+
parents: input.parents,
|
|
940
|
+
}),
|
|
908
941
|
].join("\n");
|
|
909
942
|
}
|
|
943
|
+
function completedParentClosures(input) {
|
|
944
|
+
const completedKeys = new Set(input.items.map((item) => item.key));
|
|
945
|
+
const completedParentKeys = new Set(input.items
|
|
946
|
+
.map((item) => item.parent?.key)
|
|
947
|
+
.filter((key) => Boolean(key)));
|
|
948
|
+
const completedParents = [];
|
|
949
|
+
for (const parent of input.parents ?? []) {
|
|
950
|
+
if (!parent.closable || !completedParentKeys.has(parent.key)) {
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
const openChildren = parent.children.filter((child) => child.state !== "closed");
|
|
954
|
+
const openContainerChild = openChildren.some((child) => !child.executable);
|
|
955
|
+
if (openContainerChild) {
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
const completedChildInThisPr = parent.children.some((child) => completedKeys.has(child.key));
|
|
959
|
+
if (!completedChildInThisPr ||
|
|
960
|
+
openChildren.some((child) => !completedKeys.has(child.key))) {
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
completedParents.push(parent);
|
|
964
|
+
}
|
|
965
|
+
return completedParents;
|
|
966
|
+
}
|
|
967
|
+
function uniqueNumbers(numbers) {
|
|
968
|
+
return [...new Set(numbers)];
|
|
969
|
+
}
|
|
910
970
|
/** Render unresolved blockers as a bullet list to append to a failure message. */
|
|
911
971
|
function formatBlockers(blockers) {
|
|
912
972
|
if (blockers.length === 0) {
|
|
@@ -16,8 +16,8 @@ export async function confirmWorkItemSelection(input) {
|
|
|
16
16
|
`Select at most ${input.maxItems} work item(s).`,
|
|
17
17
|
shortcuts: {
|
|
18
18
|
all: null,
|
|
19
|
-
invert: null
|
|
20
|
-
}
|
|
19
|
+
invert: null,
|
|
20
|
+
},
|
|
21
21
|
});
|
|
22
22
|
const selected = new Set(selectedKeys);
|
|
23
23
|
return input.candidates.filter((candidate) => selected.has(candidate.key));
|
|
@@ -37,7 +37,7 @@ export function buildSelectionChoiceItems(input) {
|
|
|
37
37
|
type: "choice",
|
|
38
38
|
value: candidate.key,
|
|
39
39
|
name: `#${candidate.number} ${candidate.title}${proposed ? " (agent)" : ""}`,
|
|
40
|
-
checked: proposed
|
|
40
|
+
checked: proposed,
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
return items;
|
|
@@ -49,10 +49,13 @@ function toInquirerChoice(item) {
|
|
|
49
49
|
return {
|
|
50
50
|
value: item.value,
|
|
51
51
|
name: item.name,
|
|
52
|
-
checked: item.checked
|
|
52
|
+
checked: item.checked,
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
function detectPlanGroup(candidate) {
|
|
56
|
+
if (candidate.parent) {
|
|
57
|
+
return formatParentGroup(candidate.parent);
|
|
58
|
+
}
|
|
56
59
|
for (const label of candidate.labels ?? []) {
|
|
57
60
|
const group = parseGroupLabel(label);
|
|
58
61
|
if (group) {
|
|
@@ -61,6 +64,14 @@ function detectPlanGroup(candidate) {
|
|
|
61
64
|
}
|
|
62
65
|
return parseBracketedTitleGroup(candidate.title);
|
|
63
66
|
}
|
|
67
|
+
function formatParentGroup(parent) {
|
|
68
|
+
if (!parent) {
|
|
69
|
+
return "Parent";
|
|
70
|
+
}
|
|
71
|
+
return parent.title
|
|
72
|
+
? `Parent #${parent.number}: ${parent.title}`
|
|
73
|
+
: `Parent #${parent.number}`;
|
|
74
|
+
}
|
|
64
75
|
function parseGroupLabel(label) {
|
|
65
76
|
const match = /^(plan|prd)\s*[:/=-]\s*(.+)$/i.exec(label.trim());
|
|
66
77
|
if (!match) {
|
|
@@ -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
|
}
|