@nyxa/nyx-agent 0.7.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 +19 -8
- package/dist/cli.js +3 -1
- package/dist/commands/init.js +53 -29
- package/dist/config/schema.js +45 -4
- package/dist/runtime/prompts.js +50 -17
- package/dist/runtime/runPipeline.js +580 -121
- package/dist/runtime/schemas.js +108 -22
- package/dist/runtime/selectionConfirmation.js +15 -4
- package/dist/runtime/workItems.js +349 -15
- package/package.json +1 -1
package/dist/runtime/schemas.js
CHANGED
|
@@ -12,41 +12,127 @@ export const SELECTION_SCHEMA = {
|
|
|
12
12
|
work_item_keys: {
|
|
13
13
|
type: "array",
|
|
14
14
|
items: { type: "string" },
|
|
15
|
-
description: "Ordered keys of the chosen candidates (prerequisites first)."
|
|
16
|
-
}
|
|
15
|
+
description: "Ordered keys of the chosen candidates (prerequisites first).",
|
|
16
|
+
},
|
|
17
17
|
},
|
|
18
18
|
allOf: [
|
|
19
19
|
{
|
|
20
20
|
if: { properties: { outcome: { const: "selected" } } },
|
|
21
|
-
then: { required: ["work_item_keys"] }
|
|
22
|
-
}
|
|
21
|
+
then: { required: ["work_item_keys"] },
|
|
22
|
+
},
|
|
23
23
|
],
|
|
24
|
-
additionalProperties: true
|
|
24
|
+
additionalProperties: true,
|
|
25
|
+
};
|
|
26
|
+
const evidenceSchema = {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
file: { type: "string" },
|
|
30
|
+
line: { type: "integer", minimum: 1 },
|
|
31
|
+
command: { type: "string" },
|
|
32
|
+
observation: { type: "string" },
|
|
33
|
+
detail: { type: "string" },
|
|
34
|
+
},
|
|
35
|
+
additionalProperties: true,
|
|
36
|
+
};
|
|
37
|
+
const findingSchema = {
|
|
38
|
+
type: "object",
|
|
39
|
+
required: ["title", "required_change", "confidence", "evidence"],
|
|
40
|
+
properties: {
|
|
41
|
+
title: { type: "string", minLength: 1 },
|
|
42
|
+
required_change: { type: "string", minLength: 1 },
|
|
43
|
+
confidence: {
|
|
44
|
+
type: "string",
|
|
45
|
+
enum: ["low", "medium", "high"],
|
|
46
|
+
},
|
|
47
|
+
evidence: {
|
|
48
|
+
type: "array",
|
|
49
|
+
minItems: 1,
|
|
50
|
+
items: evidenceSchema,
|
|
51
|
+
description: "Concrete evidence: file+line, command+observation, or an equivalent current-code observation.",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
additionalProperties: true,
|
|
25
55
|
};
|
|
26
|
-
const
|
|
56
|
+
const findingArraySchema = {
|
|
57
|
+
type: "array",
|
|
58
|
+
items: findingSchema,
|
|
59
|
+
default: [],
|
|
60
|
+
};
|
|
61
|
+
const reviewDiscoverySchema = {
|
|
27
62
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
28
63
|
type: "object",
|
|
29
|
-
required: [
|
|
64
|
+
required: [
|
|
65
|
+
"summary",
|
|
66
|
+
"blockers",
|
|
67
|
+
"test_gaps",
|
|
68
|
+
"advisory_findings",
|
|
69
|
+
"uncertain_findings",
|
|
70
|
+
"rejected_findings",
|
|
71
|
+
],
|
|
30
72
|
properties: {
|
|
31
|
-
outcome: { type: "string", enum: ["approved", "changes_requested"] },
|
|
32
73
|
summary: {
|
|
33
74
|
type: "string",
|
|
34
75
|
minLength: 1,
|
|
35
|
-
description: "A brief assessment of the work."
|
|
76
|
+
description: "A brief assessment of the work.",
|
|
36
77
|
},
|
|
37
|
-
|
|
78
|
+
blockers: findingArraySchema,
|
|
79
|
+
test_gaps: findingArraySchema,
|
|
80
|
+
advisory_findings: findingArraySchema,
|
|
81
|
+
uncertain_findings: findingArraySchema,
|
|
82
|
+
rejected_findings: findingArraySchema,
|
|
83
|
+
},
|
|
84
|
+
additionalProperties: true,
|
|
85
|
+
};
|
|
86
|
+
const reviewChallengeSchema = {
|
|
87
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
88
|
+
type: "object",
|
|
89
|
+
required: ["summary", "blockers", "rejected_findings"],
|
|
90
|
+
properties: {
|
|
91
|
+
summary: { type: "string", minLength: 1 },
|
|
92
|
+
blockers: findingArraySchema,
|
|
93
|
+
rejected_findings: findingArraySchema,
|
|
94
|
+
},
|
|
95
|
+
additionalProperties: true,
|
|
96
|
+
};
|
|
97
|
+
const validationSchema = {
|
|
98
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
99
|
+
type: "object",
|
|
100
|
+
required: ["summary", "validations"],
|
|
101
|
+
properties: {
|
|
102
|
+
summary: { type: "string", minLength: 1 },
|
|
103
|
+
validations: {
|
|
38
104
|
type: "array",
|
|
39
|
-
items: {
|
|
40
|
-
|
|
41
|
-
|
|
105
|
+
items: {
|
|
106
|
+
type: "object",
|
|
107
|
+
required: ["blocker_title", "status", "evidence"],
|
|
108
|
+
properties: {
|
|
109
|
+
blocker_title: { type: "string", minLength: 1 },
|
|
110
|
+
status: {
|
|
111
|
+
type: "string",
|
|
112
|
+
enum: [
|
|
113
|
+
"resolved",
|
|
114
|
+
"unresolved",
|
|
115
|
+
"false_positive",
|
|
116
|
+
"regression_from_correction",
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
required_change: {
|
|
120
|
+
type: "string",
|
|
121
|
+
description: "Required when the status remains unresolved or is a correction-caused regression.",
|
|
122
|
+
},
|
|
123
|
+
evidence: {
|
|
124
|
+
type: "array",
|
|
125
|
+
minItems: 1,
|
|
126
|
+
items: evidenceSchema,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
additionalProperties: true,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
42
132
|
},
|
|
43
|
-
|
|
44
|
-
{
|
|
45
|
-
if: { properties: { outcome: { const: "changes_requested" } } },
|
|
46
|
-
then: { required: ["required_changes"] }
|
|
47
|
-
}
|
|
48
|
-
],
|
|
49
|
-
additionalProperties: true
|
|
133
|
+
additionalProperties: true,
|
|
50
134
|
};
|
|
51
|
-
export const
|
|
52
|
-
export const GLOBAL_REVIEW_SCHEMA =
|
|
135
|
+
export const REVIEW_DISCOVERY_SCHEMA = reviewDiscoverySchema;
|
|
136
|
+
export const GLOBAL_REVIEW_SCHEMA = reviewDiscoverySchema;
|
|
137
|
+
export const REVIEW_CHALLENGE_SCHEMA = reviewChallengeSchema;
|
|
138
|
+
export const REVIEW_VALIDATION_SCHEMA = validationSchema;
|
|
@@ -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
|
}
|