opencode-magi 0.4.0 → 0.5.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 +53 -45
- package/dist/github/commands.js +14 -47
- package/dist/index.js +41 -2
- package/dist/orchestrator/findings.js +3 -4
- package/dist/orchestrator/inline-comments.js +0 -6
- package/dist/orchestrator/merge.js +16 -38
- package/dist/orchestrator/report.js +7 -18
- package/dist/orchestrator/review-context.js +37 -4
- package/dist/orchestrator/review.js +12 -25
- package/dist/orchestrator/run-manager.js +54 -8
- package/dist/prompts/contracts.js +30 -37
- package/dist/prompts/output.js +22 -40
- package/dist/prompts/templates/merge/edit.md +1 -2
- package/dist/prompts/templates/review/close-reconsideration.md +1 -0
- package/dist/prompts/templates/review/rereview.md +3 -0
- package/dist/prompts/templates/review/review.md +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,26 +61,33 @@ Add the following content to the configuration file.
|
|
|
61
61
|
```json
|
|
62
62
|
{
|
|
63
63
|
"$schema": "https://raw.githubusercontent.com/magi-ai/opencode-magi/main/schema.json",
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
{
|
|
67
|
-
"
|
|
68
|
-
"
|
|
64
|
+
"agents": {
|
|
65
|
+
"refs": {
|
|
66
|
+
"account-1": {
|
|
67
|
+
"model": "openai/gpt-5.5",
|
|
68
|
+
"account": "account-1"
|
|
69
69
|
},
|
|
70
|
-
{
|
|
71
|
-
"
|
|
72
|
-
"
|
|
70
|
+
"account-2": {
|
|
71
|
+
"model": "anthropic/claude-opus-4-7",
|
|
72
|
+
"account": "account-2"
|
|
73
73
|
},
|
|
74
|
-
{
|
|
75
|
-
"
|
|
76
|
-
"
|
|
74
|
+
"account-3": {
|
|
75
|
+
"model": "opencode/kimi-k2-6",
|
|
76
|
+
"account": "account-3"
|
|
77
77
|
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"review": {
|
|
81
|
+
"agents": [
|
|
82
|
+
{ "ref": "account-1" },
|
|
83
|
+
{ "ref": "account-2" },
|
|
84
|
+
{ "ref": "account-3" }
|
|
78
85
|
]
|
|
79
86
|
}
|
|
80
87
|
}
|
|
81
88
|
```
|
|
82
89
|
|
|
83
|
-
`review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique.
|
|
90
|
+
After refs are expanded, `review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique.
|
|
84
91
|
|
|
85
92
|
#### Set project config
|
|
86
93
|
|
|
@@ -101,53 +108,54 @@ Add the following content to the configuration file.
|
|
|
101
108
|
"owner": "your-owner",
|
|
102
109
|
"repo": "your-repo"
|
|
103
110
|
},
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
{
|
|
107
|
-
"
|
|
108
|
-
"
|
|
111
|
+
"agents": {
|
|
112
|
+
"refs": {
|
|
113
|
+
"account-1": {
|
|
114
|
+
"model": "openai/gpt-5.5",
|
|
115
|
+
"account": "account-1"
|
|
109
116
|
},
|
|
110
|
-
{
|
|
111
|
-
"
|
|
112
|
-
"
|
|
117
|
+
"account-2": {
|
|
118
|
+
"model": "anthropic/claude-opus-4-7",
|
|
119
|
+
"account": "account-2"
|
|
113
120
|
},
|
|
114
|
-
{
|
|
115
|
-
"
|
|
116
|
-
"
|
|
121
|
+
"account-3": {
|
|
122
|
+
"model": "opencode/kimi-k2-6",
|
|
123
|
+
"account": "account-3"
|
|
124
|
+
},
|
|
125
|
+
"account-4": {
|
|
126
|
+
"model": "openai/gpt-5.5",
|
|
127
|
+
"account": "account-4",
|
|
128
|
+
"author": {
|
|
129
|
+
"name": "account-4",
|
|
130
|
+
"email": "your-email@example.com"
|
|
131
|
+
}
|
|
117
132
|
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"review": {
|
|
136
|
+
"agents": [
|
|
137
|
+
{ "ref": "account-1" },
|
|
138
|
+
{ "ref": "account-2" },
|
|
139
|
+
{ "ref": "account-3" }
|
|
118
140
|
]
|
|
119
141
|
},
|
|
120
142
|
"merge": {
|
|
121
|
-
"editor": {
|
|
122
|
-
"account": "your-editor-account",
|
|
123
|
-
"model": "openai/gpt-5.5",
|
|
124
|
-
"author": {
|
|
125
|
-
"name": "your-account",
|
|
126
|
-
"email": "your-email@example.com"
|
|
127
|
-
}
|
|
128
|
-
}
|
|
143
|
+
"editor": { "ref": "account-4" }
|
|
129
144
|
},
|
|
130
145
|
"triage": {
|
|
131
|
-
"account": "
|
|
146
|
+
"account": "account-5",
|
|
132
147
|
"agents": [
|
|
133
|
-
{
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
},
|
|
137
|
-
{
|
|
138
|
-
"id": "maintenance",
|
|
139
|
-
"model": "anthropic/claude-opus-4-7"
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
"id": "product",
|
|
143
|
-
"model": "opencode/kimi-k2-6"
|
|
144
|
-
}
|
|
148
|
+
{ "ref": "account-1" },
|
|
149
|
+
{ "ref": "account-2" },
|
|
150
|
+
{ "ref": "account-3" }
|
|
145
151
|
]
|
|
146
152
|
}
|
|
147
153
|
}
|
|
148
154
|
```
|
|
149
155
|
|
|
150
|
-
|
|
156
|
+
Entries with `ref` are expanded from `agents.refs`. Fields set alongside `ref` override fields from the preset.
|
|
157
|
+
|
|
158
|
+
After refs are expanded, `review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique. `merge.editor.account` is used by `/magi:merge` to push fixes, close PRs, and merge PRs.
|
|
151
159
|
|
|
152
160
|
#### Validate config
|
|
153
161
|
|
package/dist/github/commands.js
CHANGED
|
@@ -246,6 +246,12 @@ function duplicateReferences(text) {
|
|
|
246
246
|
refs.add(Number(match[1]));
|
|
247
247
|
return [...refs];
|
|
248
248
|
}
|
|
249
|
+
function issueTitleSearchQuery(title, fallback) {
|
|
250
|
+
return (title
|
|
251
|
+
.replaceAll(/[^\p{L}\p{N}_]+/gu, " ")
|
|
252
|
+
.replaceAll(/\s+/g, " ")
|
|
253
|
+
.trim() || fallback);
|
|
254
|
+
}
|
|
249
255
|
async function fetchIssueCandidate(exec, repository, number, whyCandidate) {
|
|
250
256
|
const raw = await exec(`gh issue view ${number} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,url,state,body,createdAt`).catch(() => undefined);
|
|
251
257
|
if (!raw)
|
|
@@ -254,7 +260,7 @@ async function fetchIssueCandidate(exec, repository, number, whyCandidate) {
|
|
|
254
260
|
return { ...data, whyCandidate };
|
|
255
261
|
}
|
|
256
262
|
export async function searchDuplicateIssues(exec, repository, issue, limit = 5) {
|
|
257
|
-
const query = issue.title;
|
|
263
|
+
const query = issueTitleSearchQuery(issue.title, String(issue.number));
|
|
258
264
|
const explicitCandidates = await Promise.all(duplicateReferences(issue.body)
|
|
259
265
|
.filter((number) => number !== issue.number)
|
|
260
266
|
.map((number) => fetchIssueCandidate(exec, repository, number, "Issue body explicitly references a duplicate target.")));
|
|
@@ -509,9 +515,6 @@ export async function postCloseComment(exec, repository, pr, account, body) {
|
|
|
509
515
|
await rm(payloadPath, { force: true });
|
|
510
516
|
}
|
|
511
517
|
}
|
|
512
|
-
function isInlineFinding(finding) {
|
|
513
|
-
return finding.line != null;
|
|
514
|
-
}
|
|
515
518
|
function findingComment(finding) {
|
|
516
519
|
const comment = {
|
|
517
520
|
body: `**Issue:** ${finding.issue}\n\n**Fix:** ${finding.fix}`,
|
|
@@ -525,54 +528,18 @@ function findingComment(finding) {
|
|
|
525
528
|
}
|
|
526
529
|
return comment;
|
|
527
530
|
}
|
|
528
|
-
function
|
|
529
|
-
return
|
|
530
|
-
|
|
531
|
-
`
|
|
532
|
-
` Fix: ${finding.fix}`,
|
|
533
|
-
].join("\n");
|
|
534
|
-
}
|
|
535
|
-
function findingLocation(finding) {
|
|
536
|
-
if (finding.line == null)
|
|
537
|
-
return finding.path;
|
|
538
|
-
if (finding.startLine == null)
|
|
539
|
-
return `${finding.path}:${finding.line}`;
|
|
540
|
-
return `${finding.path}:${finding.startLine}-${finding.line}`;
|
|
541
|
-
}
|
|
542
|
-
function findingSummary(finding) {
|
|
543
|
-
return [
|
|
544
|
-
`- ${findingLocation(finding)}: ${finding.issue}`,
|
|
545
|
-
` Fix: ${finding.fix}`,
|
|
546
|
-
]
|
|
547
|
-
.filter(Boolean)
|
|
548
|
-
.join("\n");
|
|
549
|
-
}
|
|
550
|
-
function changesRequestedBody(findings, requirementFindings) {
|
|
551
|
-
const inlineFindings = findings.filter(isInlineFinding);
|
|
552
|
-
const fileLevelFindings = findings.filter((finding) => !isInlineFinding(finding));
|
|
553
|
-
const sections = [];
|
|
554
|
-
if (inlineFindings.length) {
|
|
555
|
-
sections.push(["Inline findings:", ...inlineFindings.map(findingSummary)].join("\n"));
|
|
556
|
-
}
|
|
557
|
-
if (fileLevelFindings.length) {
|
|
558
|
-
sections.push(["File-level findings:", ...fileLevelFindings.map(findingSummary)].join("\n"));
|
|
559
|
-
}
|
|
560
|
-
if (requirementFindings.length) {
|
|
561
|
-
sections.push([
|
|
562
|
-
"Requirement findings:",
|
|
563
|
-
...requirementFindings.map(requirementFindingSummary),
|
|
564
|
-
].join("\n"));
|
|
565
|
-
}
|
|
566
|
-
return sections.join("\n\n");
|
|
531
|
+
function changesRequestedBody(findings) {
|
|
532
|
+
return findings.length === 1
|
|
533
|
+
? "Changes requested: 1 inline comment."
|
|
534
|
+
: `Changes requested: ${findings.length} inline comments.`;
|
|
567
535
|
}
|
|
568
|
-
export async function postChangesRequested(exec, repository, pr, account, findings
|
|
536
|
+
export async function postChangesRequested(exec, repository, pr, account, findings) {
|
|
569
537
|
const token = await ghToken(exec, repository, account);
|
|
570
538
|
const payloadPath = join(tmpdir(), `magi-review-${process.pid}-${Date.now()}.json`);
|
|
571
|
-
const
|
|
572
|
-
const body = changesRequestedBody(findings, requirementFindings);
|
|
539
|
+
const body = changesRequestedBody(findings);
|
|
573
540
|
await writeFile(payloadPath, JSON.stringify({
|
|
574
541
|
body,
|
|
575
|
-
comments:
|
|
542
|
+
comments: findings.map(findingComment),
|
|
576
543
|
event: "REQUEST_CHANGES",
|
|
577
544
|
}));
|
|
578
545
|
try {
|
package/dist/index.js
CHANGED
|
@@ -95,12 +95,17 @@ export function parseRunArguments(value, dryRun = false, command = "review") {
|
|
|
95
95
|
const tokens = value.split(/[\s,]+/).filter(Boolean);
|
|
96
96
|
const configOverrides = {};
|
|
97
97
|
const prTokens = [];
|
|
98
|
+
let sync = false;
|
|
98
99
|
for (let index = 0; index < tokens.length; index++) {
|
|
99
100
|
const token = tokens[index];
|
|
100
101
|
if (token === "--dry-run") {
|
|
101
102
|
dryRun = true;
|
|
102
103
|
continue;
|
|
103
104
|
}
|
|
105
|
+
if (token === "--sync") {
|
|
106
|
+
sync = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
104
109
|
switch (token) {
|
|
105
110
|
case "--language":
|
|
106
111
|
setConfigOverride(configOverrides, ["language"], nextFlagValue(tokens, ++index, token));
|
|
@@ -146,18 +151,23 @@ export function parseRunArguments(value, dryRun = false, command = "review") {
|
|
|
146
151
|
prTokens.push(token);
|
|
147
152
|
}
|
|
148
153
|
}
|
|
149
|
-
return { configOverrides, dryRun, prs: parsePrs(prTokens.join(" ")) };
|
|
154
|
+
return { configOverrides, dryRun, prs: parsePrs(prTokens.join(" ")), sync };
|
|
150
155
|
}
|
|
151
156
|
export function parseIssueRunArguments(value, dryRun = false) {
|
|
152
157
|
const tokens = value.split(/[\s,]+/).filter(Boolean);
|
|
153
158
|
const configOverrides = {};
|
|
154
159
|
const issueTokens = [];
|
|
160
|
+
let sync = false;
|
|
155
161
|
for (let index = 0; index < tokens.length; index++) {
|
|
156
162
|
const token = tokens[index];
|
|
157
163
|
if (token === "--dry-run") {
|
|
158
164
|
dryRun = true;
|
|
159
165
|
continue;
|
|
160
166
|
}
|
|
167
|
+
if (token === "--sync") {
|
|
168
|
+
sync = true;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
161
171
|
switch (token) {
|
|
162
172
|
case "--language":
|
|
163
173
|
setConfigOverride(configOverrides, ["language"], nextFlagValue(tokens, ++index, token));
|
|
@@ -195,7 +205,12 @@ export function parseIssueRunArguments(value, dryRun = false) {
|
|
|
195
205
|
issueTokens.push(token);
|
|
196
206
|
}
|
|
197
207
|
}
|
|
198
|
-
return {
|
|
208
|
+
return {
|
|
209
|
+
configOverrides,
|
|
210
|
+
dryRun,
|
|
211
|
+
issues: parseIssues(issueTokens.join(" ")),
|
|
212
|
+
sync,
|
|
213
|
+
};
|
|
199
214
|
}
|
|
200
215
|
function nextFlagValue(tokens, index, flag) {
|
|
201
216
|
const value = tokens[index];
|
|
@@ -203,6 +218,15 @@ function nextFlagValue(tokens, index, flag) {
|
|
|
203
218
|
throw new Error(`${flag} requires a value.`);
|
|
204
219
|
return value;
|
|
205
220
|
}
|
|
221
|
+
async function syncResult(runManager, states) {
|
|
222
|
+
const output = await runManager.formatStatesWithReports(states, {
|
|
223
|
+
verbose: true,
|
|
224
|
+
});
|
|
225
|
+
const failed = states.filter((state) => state.status !== "completed");
|
|
226
|
+
if (failed.length)
|
|
227
|
+
throw new Error(output);
|
|
228
|
+
return output;
|
|
229
|
+
}
|
|
206
230
|
function parseIntegerFlag(value, flag, minimum) {
|
|
207
231
|
const parsed = Number.parseInt(value, 10);
|
|
208
232
|
if (!Number.isInteger(parsed) ||
|
|
@@ -432,6 +456,7 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
432
456
|
args: {
|
|
433
457
|
prs: tool.schema.string(),
|
|
434
458
|
dryRun: tool.schema.boolean().optional(),
|
|
459
|
+
sync: tool.schema.boolean().optional(),
|
|
435
460
|
},
|
|
436
461
|
async execute(args, context) {
|
|
437
462
|
const parsed = parseRunArguments(args.prs, args.dryRun ?? false, "merge");
|
|
@@ -448,6 +473,7 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
448
473
|
if (!validation.ok)
|
|
449
474
|
return JSON.stringify(validation, null, 2);
|
|
450
475
|
const repository = resolveRepository(config);
|
|
476
|
+
const sync = parsed.sync || args.sync === true;
|
|
451
477
|
const states = await mapPool(parsed.prs, repository.concurrency.runs, (pr) => runManager.startMerge({
|
|
452
478
|
config,
|
|
453
479
|
dryRun: parsed.dryRun,
|
|
@@ -455,7 +481,10 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
455
481
|
pr,
|
|
456
482
|
parentSessionId: context.sessionID,
|
|
457
483
|
signal: context.abort,
|
|
484
|
+
sync,
|
|
458
485
|
}), { signal: context.abort });
|
|
486
|
+
if (sync)
|
|
487
|
+
return syncResult(runManager, states);
|
|
459
488
|
return states
|
|
460
489
|
.map((state) => `Started reviewing ${prMarkdownLink(repository, state.pr)}.`)
|
|
461
490
|
.join("\n");
|
|
@@ -469,6 +498,7 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
469
498
|
args: {
|
|
470
499
|
prs: tool.schema.string(),
|
|
471
500
|
dryRun: tool.schema.boolean().optional(),
|
|
501
|
+
sync: tool.schema.boolean().optional(),
|
|
472
502
|
},
|
|
473
503
|
async execute(args, context) {
|
|
474
504
|
const parsed = parseRunArguments(args.prs, args.dryRun ?? false);
|
|
@@ -484,6 +514,7 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
484
514
|
if (!validation.ok)
|
|
485
515
|
return JSON.stringify(validation, null, 2);
|
|
486
516
|
const repository = resolveRepository(config);
|
|
517
|
+
const sync = parsed.sync || args.sync === true;
|
|
487
518
|
const states = await mapPool(parsed.prs, repository.concurrency.runs, (pr) => runManager.startReview({
|
|
488
519
|
config,
|
|
489
520
|
dryRun: parsed.dryRun,
|
|
@@ -491,7 +522,10 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
491
522
|
pr,
|
|
492
523
|
parentSessionId: context.sessionID,
|
|
493
524
|
signal: context.abort,
|
|
525
|
+
sync,
|
|
494
526
|
}), { signal: context.abort });
|
|
527
|
+
if (sync)
|
|
528
|
+
return syncResult(runManager, states);
|
|
495
529
|
return states
|
|
496
530
|
.map((state) => `Started reviewing ${prMarkdownLink(repository, state.pr)}.`)
|
|
497
531
|
.join("\n");
|
|
@@ -502,6 +536,7 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
502
536
|
args: {
|
|
503
537
|
issues: tool.schema.string(),
|
|
504
538
|
dryRun: tool.schema.boolean().optional(),
|
|
539
|
+
sync: tool.schema.boolean().optional(),
|
|
505
540
|
},
|
|
506
541
|
async execute(args, context) {
|
|
507
542
|
const parsed = parseIssueRunArguments(args.issues, args.dryRun ?? false);
|
|
@@ -523,6 +558,7 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
523
558
|
const repository = resolveRepository(config);
|
|
524
559
|
if (!repository.triage)
|
|
525
560
|
return JSON.stringify({ errors: ["triage configuration is required"], ok: false }, null, 2);
|
|
561
|
+
const sync = parsed.sync || args.sync === true;
|
|
526
562
|
const states = await mapPool(parsed.issues, repository.triage.concurrency.runs, (issue) => runManager.startTriage({
|
|
527
563
|
config,
|
|
528
564
|
dryRun: parsed.dryRun,
|
|
@@ -530,7 +566,10 @@ export const MagiPlugin = async ({ client, directory }) => {
|
|
|
530
566
|
parentSessionId: context.sessionID,
|
|
531
567
|
repository,
|
|
532
568
|
signal: context.abort,
|
|
569
|
+
sync,
|
|
533
570
|
}), { signal: context.abort });
|
|
571
|
+
if (sync)
|
|
572
|
+
return syncResult(runManager, states);
|
|
534
573
|
return states
|
|
535
574
|
.map((state) => `Started triaging ${issueMarkdownLink(repository, state.issue)}.`)
|
|
536
575
|
.join("\n");
|
|
@@ -58,10 +58,9 @@ export function applyFindingValidation(input) {
|
|
|
58
58
|
discarded.push(target);
|
|
59
59
|
return false;
|
|
60
60
|
});
|
|
61
|
-
next[reviewer] =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
: { findings: [], requirementFindings: [], verdict: "MERGE" };
|
|
61
|
+
next[reviewer] = findings.length
|
|
62
|
+
? { ...output, findings }
|
|
63
|
+
: { findings: [], verdict: "MERGE" };
|
|
65
64
|
}
|
|
66
65
|
return { outputs: next, summary: { discarded, kept } };
|
|
67
66
|
}
|
|
@@ -52,12 +52,6 @@ function assertPositiveInteger(value, name) {
|
|
|
52
52
|
export function validateInlineCommentTargets(findings, targets, label = "findings") {
|
|
53
53
|
for (const [index, finding] of findings.entries()) {
|
|
54
54
|
const name = `${label}[${index}]`;
|
|
55
|
-
if (finding.line == null) {
|
|
56
|
-
if (finding.startLine != null) {
|
|
57
|
-
throw new Error(`${name}.startLine requires line`);
|
|
58
|
-
}
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
55
|
assertPositiveInteger(finding.line, `${name}.line`);
|
|
62
56
|
if (finding.startLine != null) {
|
|
63
57
|
assertPositiveInteger(finding.startLine, `${name}.startLine`);
|
|
@@ -146,14 +146,14 @@ async function postRereviewOutput(input, reviewerKey, output) {
|
|
|
146
146
|
if (output.verdict === "CLOSE") {
|
|
147
147
|
return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
|
|
148
148
|
}
|
|
149
|
-
if (output.newFindings.length
|
|
149
|
+
if (output.newFindings.length) {
|
|
150
150
|
return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
|
|
151
151
|
fix: "Please address this before merging.",
|
|
152
152
|
issue: finding.body,
|
|
153
153
|
path: finding.path,
|
|
154
|
-
|
|
154
|
+
line: finding.line,
|
|
155
155
|
startLine: finding.startLine,
|
|
156
|
-
}))
|
|
156
|
+
})));
|
|
157
157
|
}
|
|
158
158
|
return replies[0] ?? "";
|
|
159
159
|
}
|
|
@@ -166,45 +166,29 @@ function newFindingToEditorFinding(reviewer, finding) {
|
|
|
166
166
|
return {
|
|
167
167
|
body: finding.body,
|
|
168
168
|
fix: "Please address this before merging.",
|
|
169
|
+
line: finding.line,
|
|
169
170
|
path: finding.path,
|
|
170
171
|
reviewer,
|
|
171
|
-
...(finding.line == null ? {} : { line: finding.line }),
|
|
172
172
|
...(finding.startLine == null ? {} : { startLine: finding.startLine }),
|
|
173
|
-
type:
|
|
173
|
+
type: "inline",
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
176
|
export function blockingReviewFindings(outputs) {
|
|
177
177
|
return Object.entries(outputs).flatMap(([reviewer, output]) => {
|
|
178
178
|
if (output.verdict !== "CHANGES_REQUESTED")
|
|
179
179
|
return [];
|
|
180
|
-
const requirementFindings = output.requirementFindings.map((finding) => ({
|
|
181
|
-
evidence: finding.evidence,
|
|
182
|
-
fix: finding.fix,
|
|
183
|
-
issueNumber: finding.issueNumber,
|
|
184
|
-
requirement: finding.requirement,
|
|
185
|
-
reviewer,
|
|
186
|
-
type: "requirement",
|
|
187
|
-
}));
|
|
188
180
|
if ("findings" in output) {
|
|
189
|
-
return
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
: { startLine: finding.startLine }),
|
|
199
|
-
type: finding.line == null ? "file" : "inline",
|
|
200
|
-
})),
|
|
201
|
-
...requirementFindings,
|
|
202
|
-
];
|
|
181
|
+
return output.findings.map((finding) => ({
|
|
182
|
+
fix: finding.fix,
|
|
183
|
+
issue: finding.issue,
|
|
184
|
+
line: finding.line,
|
|
185
|
+
path: finding.path,
|
|
186
|
+
reviewer,
|
|
187
|
+
...(finding.startLine == null ? {} : { startLine: finding.startLine }),
|
|
188
|
+
type: "inline",
|
|
189
|
+
}));
|
|
203
190
|
}
|
|
204
|
-
return
|
|
205
|
-
...output.newFindings.map((finding) => newFindingToEditorFinding(reviewer, finding)),
|
|
206
|
-
...requirementFindings,
|
|
207
|
-
];
|
|
191
|
+
return output.newFindings.map((finding) => newFindingToEditorFinding(reviewer, finding));
|
|
208
192
|
});
|
|
209
193
|
}
|
|
210
194
|
async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionIds, ciFailureContext, options = {}) {
|
|
@@ -507,8 +491,6 @@ function syntheticReviewThreads(outputs) {
|
|
|
507
491
|
for (const [reviewer, output] of Object.entries(outputs)) {
|
|
508
492
|
if ("findings" in output) {
|
|
509
493
|
threads[reviewer] = output.findings.flatMap((finding) => {
|
|
510
|
-
if (finding.line == null)
|
|
511
|
-
return [];
|
|
512
494
|
const commentId = nextCommentId--;
|
|
513
495
|
return [
|
|
514
496
|
{
|
|
@@ -531,8 +513,6 @@ function syntheticReviewThreads(outputs) {
|
|
|
531
513
|
continue;
|
|
532
514
|
}
|
|
533
515
|
threads[reviewer] = output.newFindings.flatMap((finding) => {
|
|
534
|
-
if (finding.line == null)
|
|
535
|
-
return [];
|
|
536
516
|
const commentId = nextCommentId--;
|
|
537
517
|
return [
|
|
538
518
|
{
|
|
@@ -721,9 +701,7 @@ export async function runMerge(input) {
|
|
|
721
701
|
threads: unresolvedThreads,
|
|
722
702
|
});
|
|
723
703
|
const editorFindings = blockingReviewFindings(reportOutputs);
|
|
724
|
-
const editableFindings = editableThreads.length
|
|
725
|
-
? editorFindings
|
|
726
|
-
: editorFindings.filter((finding) => finding.type !== "inline");
|
|
704
|
+
const editableFindings = editableThreads.length ? editorFindings : [];
|
|
727
705
|
const findingAttemptsExhausted = input.repository.merge.maxThreadResolutionCycles !== 0 &&
|
|
728
706
|
cycle > input.repository.merge.maxThreadResolutionCycles;
|
|
729
707
|
if (!editableThreads.length &&
|
|
@@ -16,24 +16,17 @@ function pullRequestLine(input) {
|
|
|
16
16
|
return `- **Pull Request**: [#${input.pr}](${url})`;
|
|
17
17
|
}
|
|
18
18
|
function formatFinding(finding) {
|
|
19
|
-
const line = finding.
|
|
20
|
-
? finding.path
|
|
21
|
-
: finding.startLine
|
|
22
|
-
? `${finding.path}:${finding.line}`
|
|
23
|
-
: `${finding.path}:${finding.startLine}-${finding.line}`;
|
|
19
|
+
const line = finding.startLine == null
|
|
20
|
+
? `${finding.path}:${finding.line}`
|
|
21
|
+
: `${finding.path}:${finding.startLine}-${finding.line}`;
|
|
24
22
|
return `\`${line}\`: ${finding.issue}`;
|
|
25
23
|
}
|
|
26
24
|
function formatRereviewFinding(finding) {
|
|
27
|
-
const line = finding.
|
|
28
|
-
? finding.path
|
|
29
|
-
: finding.startLine
|
|
30
|
-
? `${finding.path}:${finding.line}`
|
|
31
|
-
: `${finding.path}:${finding.startLine}-${finding.line}`;
|
|
25
|
+
const line = finding.startLine == null
|
|
26
|
+
? `${finding.path}:${finding.line}`
|
|
27
|
+
: `${finding.path}:${finding.startLine}-${finding.line}`;
|
|
32
28
|
return `\`${line}\`: ${finding.body}`;
|
|
33
29
|
}
|
|
34
|
-
function formatRequirementFinding(finding) {
|
|
35
|
-
return `Issue #${finding.issueNumber}: ${finding.requirement}`;
|
|
36
|
-
}
|
|
37
30
|
function isReviewOutput(output) {
|
|
38
31
|
return "findings" in output;
|
|
39
32
|
}
|
|
@@ -85,10 +78,7 @@ function reviewerDetailLines(output) {
|
|
|
85
78
|
return output.reason ? [output.reason] : [];
|
|
86
79
|
if (output.verdict !== "CHANGES_REQUESTED")
|
|
87
80
|
return [];
|
|
88
|
-
return
|
|
89
|
-
...output.findings.map(formatFinding),
|
|
90
|
-
...output.requirementFindings.map(formatRequirementFinding),
|
|
91
|
-
];
|
|
81
|
+
return output.findings.map(formatFinding);
|
|
92
82
|
}
|
|
93
83
|
if (output.verdict === "CLOSE")
|
|
94
84
|
return output.reason ? [output.reason] : [];
|
|
@@ -96,7 +86,6 @@ function reviewerDetailLines(output) {
|
|
|
96
86
|
return [];
|
|
97
87
|
return [
|
|
98
88
|
...output.newFindings.map(formatRereviewFinding),
|
|
99
|
-
...output.requirementFindings.map(formatRequirementFinding),
|
|
100
89
|
...output.followUps.map((item) => `Comment #${item.commentId}: ${item.body}`),
|
|
101
90
|
];
|
|
102
91
|
}
|
|
@@ -37,6 +37,23 @@ function quoteEvidence(value) {
|
|
|
37
37
|
const compact = value.replaceAll(/\s+/g, " ").trim();
|
|
38
38
|
return compact.length > 120 ? `${compact.slice(0, 117)}...` : compact;
|
|
39
39
|
}
|
|
40
|
+
function errorText(error) {
|
|
41
|
+
if (!error || typeof error !== "object")
|
|
42
|
+
return String(error);
|
|
43
|
+
const value = error;
|
|
44
|
+
return [value.message, value.stderr, value.stdout]
|
|
45
|
+
.filter((item) => typeof item === "string")
|
|
46
|
+
.join("\n");
|
|
47
|
+
}
|
|
48
|
+
function isIssueLookupFailure(error) {
|
|
49
|
+
const text = errorText(error);
|
|
50
|
+
return (/could not resolve to an issue/i.test(text) ||
|
|
51
|
+
/could not fetch issue #\d+/i.test(text) ||
|
|
52
|
+
/not an issue/i.test(text));
|
|
53
|
+
}
|
|
54
|
+
function isIssueUrl(url) {
|
|
55
|
+
return /\/issues\/\d+(?:$|[/?#])/i.test(url);
|
|
56
|
+
}
|
|
40
57
|
function issueReferencePattern(repository) {
|
|
41
58
|
const host = escapeRegExp(repository.github.host || "github.com");
|
|
42
59
|
const owner = escapeRegExp(repository.github.owner);
|
|
@@ -120,6 +137,9 @@ export function collectIssueRelationships(input) {
|
|
|
120
137
|
async function contextIssue(input) {
|
|
121
138
|
const issue = input.issue ??
|
|
122
139
|
(await fetchIssue(input.exec, input.repository, input.relationship.number));
|
|
140
|
+
if (!isIssueUrl(issue.url)) {
|
|
141
|
+
throw new Error(`Reference #${issue.number} resolved to ${issue.url}, not an Issue`);
|
|
142
|
+
}
|
|
123
143
|
const commentPage = await fetchIssueCommentPage(input.exec, input.repository, issue.number, input.limit);
|
|
124
144
|
return {
|
|
125
145
|
author: issue.author,
|
|
@@ -138,6 +158,19 @@ async function contextIssue(input) {
|
|
|
138
158
|
url: issue.url,
|
|
139
159
|
};
|
|
140
160
|
}
|
|
161
|
+
async function contextIssueIfIssue(input) {
|
|
162
|
+
try {
|
|
163
|
+
return await contextIssue(input);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (isIssueLookupFailure(error))
|
|
167
|
+
return undefined;
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function presentIssue(issue) {
|
|
172
|
+
return Boolean(issue);
|
|
173
|
+
}
|
|
141
174
|
function orderReviewThreads(threads) {
|
|
142
175
|
return [...threads]
|
|
143
176
|
.sort((a, b) => {
|
|
@@ -183,13 +216,13 @@ export async function buildReviewContextSnapshot(input) {
|
|
|
183
216
|
const closingRelationships = relationships.filter((relationship) => relationship.relationship === "closing");
|
|
184
217
|
const referencedRelationships = relationships.filter((relationship) => relationship.relationship === "referenced");
|
|
185
218
|
return {
|
|
186
|
-
closingIssues: await Promise.all(closingRelationships.map((relationship) =>
|
|
219
|
+
closingIssues: (await Promise.all(closingRelationships.map((relationship) => contextIssueIfIssue({
|
|
187
220
|
exec: input.exec,
|
|
188
221
|
issue: closingIssueMap.get(relationship.number),
|
|
189
222
|
limit: LIMITS.closingIssueComments,
|
|
190
223
|
relationship,
|
|
191
224
|
repository: input.repository,
|
|
192
|
-
}))),
|
|
225
|
+
})))).filter(presentIssue),
|
|
193
226
|
pullRequest: {
|
|
194
227
|
author: input.pr.author?.login ?? safetyMeta.author,
|
|
195
228
|
baseRef: input.pr.baseRefName,
|
|
@@ -207,12 +240,12 @@ export async function buildReviewContextSnapshot(input) {
|
|
|
207
240
|
title: input.pr.title,
|
|
208
241
|
url: input.pr.url,
|
|
209
242
|
},
|
|
210
|
-
referencedIssues: await Promise.all(referencedRelationships.map((relationship) =>
|
|
243
|
+
referencedIssues: (await Promise.all(referencedRelationships.map((relationship) => contextIssueIfIssue({
|
|
211
244
|
exec: input.exec,
|
|
212
245
|
limit: LIMITS.referencedIssueComments,
|
|
213
246
|
relationship,
|
|
214
247
|
repository: input.repository,
|
|
215
|
-
}))),
|
|
248
|
+
})))).filter(presentIssue),
|
|
216
249
|
reviewDiscussion: {
|
|
217
250
|
prComments: boundedComments(prComments, LIMITS.prComments),
|
|
218
251
|
prCommentsOmitted,
|
|
@@ -39,7 +39,7 @@ async function postReviewOutput(input, reviewerKey, output) {
|
|
|
39
39
|
return postApproval(input.exec, input.repository, input.pr, reviewer.account);
|
|
40
40
|
if (output.verdict === "CLOSE")
|
|
41
41
|
return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
|
|
42
|
-
return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.findings
|
|
42
|
+
return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.findings);
|
|
43
43
|
}
|
|
44
44
|
function dryRunReviewPost(key, output) {
|
|
45
45
|
if (output.verdict === "MERGE")
|
|
@@ -147,11 +147,10 @@ function parsePostedFindingLocation(location) {
|
|
|
147
147
|
const line = /^(.*):(\d+)$/.exec(location);
|
|
148
148
|
if (line)
|
|
149
149
|
return { line: Number(line[2]), path: line[1] ?? location };
|
|
150
|
-
return
|
|
150
|
+
return undefined;
|
|
151
151
|
}
|
|
152
152
|
function reviewFindingsFromBody(body) {
|
|
153
153
|
const findings = [];
|
|
154
|
-
const requirementFindings = [];
|
|
155
154
|
const lines = (body ?? "").split(/\r?\n/);
|
|
156
155
|
let section;
|
|
157
156
|
for (let index = 0; index < lines.length; index += 1) {
|
|
@@ -161,7 +160,7 @@ function reviewFindingsFromBody(body) {
|
|
|
161
160
|
continue;
|
|
162
161
|
}
|
|
163
162
|
if (line === "Requirement findings:") {
|
|
164
|
-
section =
|
|
163
|
+
section = undefined;
|
|
165
164
|
continue;
|
|
166
165
|
}
|
|
167
166
|
if (section === "finding") {
|
|
@@ -169,30 +168,19 @@ function reviewFindingsFromBody(body) {
|
|
|
169
168
|
const fix = /^\s+Fix: (.+)$/.exec(lines[index + 1] ?? "");
|
|
170
169
|
if (!match || !fix)
|
|
171
170
|
continue;
|
|
171
|
+
const location = parsePostedFindingLocation(match[1] ?? "");
|
|
172
|
+
if (!location)
|
|
173
|
+
continue;
|
|
172
174
|
findings.push({
|
|
173
|
-
...
|
|
175
|
+
...location,
|
|
174
176
|
fix: fix[1] ?? "Please address this before merging.",
|
|
175
177
|
issue: match[2] ?? "Review finding.",
|
|
176
178
|
});
|
|
177
179
|
index += 1;
|
|
178
180
|
continue;
|
|
179
181
|
}
|
|
180
|
-
if (section !== "requirement")
|
|
181
|
-
continue;
|
|
182
|
-
const match = /^- Missing issue #(\d+) requirement: (.+)$/.exec(line ?? "");
|
|
183
|
-
const evidence = /^\s+Evidence: (.+)$/.exec(lines[index + 1] ?? "");
|
|
184
|
-
const fix = /^\s+Fix: (.+)$/.exec(lines[index + 2] ?? "");
|
|
185
|
-
if (!match || !evidence || !fix)
|
|
186
|
-
continue;
|
|
187
|
-
requirementFindings.push({
|
|
188
|
-
evidence: evidence[1] ?? "See review body.",
|
|
189
|
-
fix: fix[1] ?? "Please address this before merging.",
|
|
190
|
-
issueNumber: Number(match[1]),
|
|
191
|
-
requirement: match[2] ?? "Review requirement.",
|
|
192
|
-
});
|
|
193
|
-
index += 2;
|
|
194
182
|
}
|
|
195
|
-
return { findings
|
|
183
|
+
return { findings };
|
|
196
184
|
}
|
|
197
185
|
export function reviewOutputFromState(review) {
|
|
198
186
|
const verdict = reviewStateToVerdict(review.state);
|
|
@@ -202,10 +190,9 @@ export function reviewOutputFromState(review) {
|
|
|
202
190
|
? {
|
|
203
191
|
findings: [],
|
|
204
192
|
reason: review.body || "Close requested.",
|
|
205
|
-
requirementFindings: [],
|
|
206
193
|
verdict,
|
|
207
194
|
}
|
|
208
|
-
: { findings: [],
|
|
195
|
+
: { findings: [], verdict };
|
|
209
196
|
}
|
|
210
197
|
export function hasPendingThreadReply(threads, reviewerAccount) {
|
|
211
198
|
return threads.some((thread) => {
|
|
@@ -229,15 +216,15 @@ async function postRereviewOutput(input, reviewerKey, output) {
|
|
|
229
216
|
return postApproval(input.exec, input.repository, input.pr, reviewer.account);
|
|
230
217
|
if (output.verdict === "CLOSE")
|
|
231
218
|
return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
|
|
232
|
-
if (!output.newFindings.length
|
|
219
|
+
if (!output.newFindings.length)
|
|
233
220
|
return "";
|
|
234
221
|
return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
|
|
235
222
|
fix: "Please address this before merging.",
|
|
236
223
|
issue: finding.body,
|
|
237
224
|
path: finding.path,
|
|
238
|
-
|
|
225
|
+
line: finding.line,
|
|
239
226
|
startLine: finding.startLine,
|
|
240
|
-
}))
|
|
227
|
+
})));
|
|
241
228
|
}
|
|
242
229
|
function isReviewOutput(output) {
|
|
243
230
|
return "findings" in output;
|
|
@@ -15,6 +15,7 @@ const DEFAULT_CLEAR_OPTIONS = {
|
|
|
15
15
|
session: true,
|
|
16
16
|
worktree: true,
|
|
17
17
|
};
|
|
18
|
+
const SYNC_RUN_TIMEOUT_MS = 600_000;
|
|
18
19
|
function createRunId() {
|
|
19
20
|
return `run-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
|
20
21
|
}
|
|
@@ -453,11 +454,14 @@ export class MagiRunManager {
|
|
|
453
454
|
await this.notify(state, `Started Magi review for ${prMarkdownLink(state)}.`);
|
|
454
455
|
const controller = new AbortController();
|
|
455
456
|
this.controllers.set(runId, controller);
|
|
456
|
-
|
|
457
|
+
const execute = () => this.executeReview({
|
|
457
458
|
...input,
|
|
458
459
|
runId,
|
|
459
460
|
signal: controller.signal,
|
|
460
|
-
})
|
|
461
|
+
});
|
|
462
|
+
if (input.sync)
|
|
463
|
+
return this.executeSync(state, controller, execute);
|
|
464
|
+
void execute().catch(async (error) => {
|
|
461
465
|
await this.failRun(runId, error);
|
|
462
466
|
});
|
|
463
467
|
return state;
|
|
@@ -511,11 +515,14 @@ export class MagiRunManager {
|
|
|
511
515
|
await this.notify(state, `Started Magi merge for ${prMarkdownLink(state)}.`);
|
|
512
516
|
const controller = new AbortController();
|
|
513
517
|
this.controllers.set(runId, controller);
|
|
514
|
-
|
|
518
|
+
const execute = () => this.executeMerge({
|
|
515
519
|
...input,
|
|
516
520
|
runId,
|
|
517
521
|
signal: controller.signal,
|
|
518
|
-
})
|
|
522
|
+
});
|
|
523
|
+
if (input.sync)
|
|
524
|
+
return this.executeSync(state, controller, execute);
|
|
525
|
+
void execute().catch(async (error) => {
|
|
519
526
|
await this.failRun(runId, error);
|
|
520
527
|
});
|
|
521
528
|
return state;
|
|
@@ -568,11 +575,14 @@ export class MagiRunManager {
|
|
|
568
575
|
await this.notify(state, `Started Magi triage for ${issueMarkdownLink(state)}.`);
|
|
569
576
|
const controller = new AbortController();
|
|
570
577
|
this.controllers.set(runId, controller);
|
|
571
|
-
|
|
578
|
+
const execute = () => this.executeTriage({
|
|
572
579
|
...input,
|
|
573
580
|
runId,
|
|
574
581
|
signal: controller.signal,
|
|
575
|
-
})
|
|
582
|
+
});
|
|
583
|
+
if (input.sync)
|
|
584
|
+
return this.executeSync(state, controller, execute);
|
|
585
|
+
void execute().catch(async (error) => {
|
|
576
586
|
await this.failRun(runId, error);
|
|
577
587
|
});
|
|
578
588
|
return state;
|
|
@@ -1210,6 +1220,36 @@ export class MagiRunManager {
|
|
|
1210
1220
|
hasBlockedAgents(state) {
|
|
1211
1221
|
return this.agentEntries(state).some(([, agent]) => agent.status === "blocked");
|
|
1212
1222
|
}
|
|
1223
|
+
async executeSync(state, controller, execute) {
|
|
1224
|
+
let timeout;
|
|
1225
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
1226
|
+
timeout = setTimeout(() => resolve("timeout"), SYNC_RUN_TIMEOUT_MS);
|
|
1227
|
+
});
|
|
1228
|
+
try {
|
|
1229
|
+
const result = await Promise.race([
|
|
1230
|
+
execute().then(() => "completed"),
|
|
1231
|
+
timeoutPromise,
|
|
1232
|
+
]);
|
|
1233
|
+
if (result === "timeout") {
|
|
1234
|
+
controller.abort();
|
|
1235
|
+
await this.failRun(state.runId, new Error("Magi sync run timed out after 600 seconds."));
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
catch (error) {
|
|
1239
|
+
controller.abort();
|
|
1240
|
+
await this.failRun(state.runId, error);
|
|
1241
|
+
}
|
|
1242
|
+
finally {
|
|
1243
|
+
if (timeout)
|
|
1244
|
+
clearTimeout(timeout);
|
|
1245
|
+
}
|
|
1246
|
+
return (await this.readStateByRunId(state.runId)) ?? state;
|
|
1247
|
+
}
|
|
1248
|
+
assertSuccessfulSyncFollowUp(state) {
|
|
1249
|
+
if (state.status === "completed")
|
|
1250
|
+
return;
|
|
1251
|
+
throw new Error(`Synchronous follow-up ${state.command} run ${state.runId} finished with status ${state.status}.`);
|
|
1252
|
+
}
|
|
1213
1253
|
async executeReview(input) {
|
|
1214
1254
|
const result = await runReview({
|
|
1215
1255
|
approvalPolicy: input.repository.merge.approvalPolicy,
|
|
@@ -1347,24 +1387,30 @@ export class MagiRunManager {
|
|
|
1347
1387
|
: undefined;
|
|
1348
1388
|
const triageAutomation = input.repository.triage?.automation;
|
|
1349
1389
|
if (followUpPr != null && triageAutomation?.merge) {
|
|
1350
|
-
await this.startMerge({
|
|
1390
|
+
const followUp = await this.startMerge({
|
|
1351
1391
|
config: input.config,
|
|
1352
1392
|
dryRun: input.dryRun,
|
|
1353
1393
|
parentSessionId: input.parentSessionId,
|
|
1354
1394
|
pr: followUpPr,
|
|
1355
1395
|
repository: input.repository,
|
|
1356
1396
|
signal: input.signal,
|
|
1397
|
+
sync: input.sync,
|
|
1357
1398
|
});
|
|
1399
|
+
if (input.sync)
|
|
1400
|
+
this.assertSuccessfulSyncFollowUp(followUp);
|
|
1358
1401
|
}
|
|
1359
1402
|
else if (followUpPr != null && triageAutomation?.review) {
|
|
1360
|
-
await this.startReview({
|
|
1403
|
+
const followUp = await this.startReview({
|
|
1361
1404
|
config: input.config,
|
|
1362
1405
|
dryRun: input.dryRun,
|
|
1363
1406
|
parentSessionId: input.parentSessionId,
|
|
1364
1407
|
pr: followUpPr,
|
|
1365
1408
|
repository: input.repository,
|
|
1366
1409
|
signal: input.signal,
|
|
1410
|
+
sync: input.sync,
|
|
1367
1411
|
});
|
|
1412
|
+
if (input.sync)
|
|
1413
|
+
this.assertSuccessfulSyncFollowUp(followUp);
|
|
1368
1414
|
}
|
|
1369
1415
|
this.active.delete(input.runId);
|
|
1370
1416
|
this.controllers.delete(input.runId);
|
|
@@ -15,26 +15,20 @@ The object must match this shape:
|
|
|
15
15
|
"perspective": "Optional review perspective."
|
|
16
16
|
}
|
|
17
17
|
],
|
|
18
|
-
"requirementFindings": [
|
|
19
|
-
{
|
|
20
|
-
"issueNumber": 47,
|
|
21
|
-
"requirement": "Required closing-issue behavior that is missing.",
|
|
22
|
-
"evidence": "Why the PR does not satisfy the requirement.",
|
|
23
|
-
"fix": "How to satisfy the requirement."
|
|
24
|
-
}
|
|
25
|
-
],
|
|
26
18
|
"reason": "Required only for CLOSE."
|
|
27
19
|
}
|
|
28
20
|
|
|
29
21
|
Rules:
|
|
30
|
-
- MERGE requires empty findings
|
|
31
|
-
- CHANGES_REQUESTED requires at least one finding
|
|
32
|
-
- CLOSE requires a reason and empty findings
|
|
22
|
+
- MERGE requires an empty findings array.
|
|
23
|
+
- CHANGES_REQUESTED requires at least one finding.
|
|
24
|
+
- CLOSE requires a reason and an empty findings array.
|
|
33
25
|
- path must be repository-relative.
|
|
34
|
-
- line is
|
|
35
|
-
- startLine is
|
|
36
|
-
- Omit startLine for single-line findings
|
|
37
|
-
-
|
|
26
|
+
- line is required and must target a valid right-side line inside the PR diff hunk.
|
|
27
|
+
- startLine is optional and must also target a valid right-side line inside the same PR diff hunk range.
|
|
28
|
+
- Omit startLine for single-line findings.
|
|
29
|
+
- Do not omit line. Do not create file-level or body-only findings.
|
|
30
|
+
- Missing closing-issue requirements must be normal findings anchored to the nearest responsible changed line.
|
|
31
|
+
- If the problem itself does not have an exact changed line, choose the nearest changed line that represents the cause, responsibility, missing implementation, or affected behavior. This includes but is not limited to missing validation, missing wiring, missing requirements, missing tests, missing documentation, affected configuration, or relevant call sites.
|
|
38
32
|
</output_contract>`.trim();
|
|
39
33
|
export const rereviewOutputContract = `
|
|
40
34
|
<output_contract>
|
|
@@ -46,18 +40,19 @@ The object must match this shape:
|
|
|
46
40
|
"resolve": [{ "commentId": 123, "threadId": "..." }],
|
|
47
41
|
"followUps": [{ "commentId": 123, "body": "..." }],
|
|
48
42
|
"newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }],
|
|
49
|
-
"requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }],
|
|
50
43
|
"reason": "Required only for CLOSE."
|
|
51
44
|
}
|
|
52
45
|
|
|
53
46
|
Rules:
|
|
54
|
-
- MERGE requires empty followUps
|
|
55
|
-
- CHANGES_REQUESTED requires at least one followUp
|
|
56
|
-
- CLOSE requires a reason and empty followUps
|
|
57
|
-
- line is
|
|
58
|
-
- startLine is
|
|
59
|
-
- Omit startLine for single-line findings
|
|
60
|
-
-
|
|
47
|
+
- MERGE requires empty followUps and newFindings arrays.
|
|
48
|
+
- CHANGES_REQUESTED requires at least one followUp or newFinding.
|
|
49
|
+
- CLOSE requires a reason and empty followUps and newFindings arrays.
|
|
50
|
+
- line is required and must target a valid right-side line inside the latest PR diff hunk.
|
|
51
|
+
- startLine is optional and must also target a valid right-side line inside the same latest PR diff hunk range.
|
|
52
|
+
- Omit startLine for single-line findings.
|
|
53
|
+
- Do not omit line. Do not create file-level or body-only findings.
|
|
54
|
+
- Missing closing-issue requirements must be normal newFindings anchored to the nearest responsible changed line.
|
|
55
|
+
- If the problem itself does not have an exact changed line, choose the nearest changed line that represents the cause, responsibility, missing implementation, or affected behavior. This includes but is not limited to missing validation, missing wiring, missing requirements, missing tests, missing documentation, affected configuration, or relevant call sites.
|
|
61
56
|
</output_contract>`.trim();
|
|
62
57
|
export const findingValidationOutputContract = `
|
|
63
58
|
<output_contract>
|
|
@@ -96,17 +91,16 @@ The object must match this shape:
|
|
|
96
91
|
"issue": "What is wrong.",
|
|
97
92
|
"fix": "How to fix it."
|
|
98
93
|
}
|
|
99
|
-
]
|
|
100
|
-
"requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }]
|
|
94
|
+
]
|
|
101
95
|
}
|
|
102
96
|
|
|
103
97
|
Rules:
|
|
104
|
-
- MERGE requires empty findings
|
|
105
|
-
- CHANGES_REQUESTED requires at least one finding
|
|
98
|
+
- MERGE requires an empty findings array.
|
|
99
|
+
- CHANGES_REQUESTED requires at least one finding.
|
|
106
100
|
- CLOSE is not allowed in this reconsideration step.
|
|
107
|
-
- line is
|
|
108
|
-
- startLine is
|
|
109
|
-
-
|
|
101
|
+
- line is required and must target a valid right-side line inside the PR diff hunk.
|
|
102
|
+
- startLine is optional and must also target a valid right-side line inside the same PR diff hunk range.
|
|
103
|
+
- Do not omit line. Do not create file-level or body-only findings.
|
|
110
104
|
</output_contract>`.trim();
|
|
111
105
|
export const rereviewCloseReconsiderationOutputContract = `
|
|
112
106
|
<output_contract>
|
|
@@ -117,17 +111,16 @@ The object must match this shape:
|
|
|
117
111
|
"verdict": "MERGE" | "CHANGES_REQUESTED",
|
|
118
112
|
"resolve": [{ "commentId": 123, "threadId": "..." }],
|
|
119
113
|
"followUps": [{ "commentId": 123, "body": "..." }],
|
|
120
|
-
"newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }]
|
|
121
|
-
"requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }]
|
|
114
|
+
"newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }]
|
|
122
115
|
}
|
|
123
116
|
|
|
124
117
|
Rules:
|
|
125
|
-
- MERGE requires empty followUps
|
|
126
|
-
- CHANGES_REQUESTED requires at least one followUp
|
|
118
|
+
- MERGE requires empty followUps and newFindings arrays.
|
|
119
|
+
- CHANGES_REQUESTED requires at least one followUp or newFinding.
|
|
127
120
|
- CLOSE is not allowed in this reconsideration step.
|
|
128
|
-
- line is
|
|
129
|
-
- startLine is
|
|
130
|
-
-
|
|
121
|
+
- line is required and must target a valid right-side line inside the latest PR diff hunk.
|
|
122
|
+
- startLine is optional and must also target a valid right-side line inside the same latest PR diff hunk range.
|
|
123
|
+
- Do not omit line. Do not create file-level or body-only findings.
|
|
131
124
|
</output_contract>`.trim();
|
|
132
125
|
export const editOutputContract = `
|
|
133
126
|
<output_contract>
|
package/dist/prompts/output.js
CHANGED
|
@@ -71,27 +71,16 @@ function requireNumber(value, path) {
|
|
|
71
71
|
throw new Error(`${path} must be an integer`);
|
|
72
72
|
return value;
|
|
73
73
|
}
|
|
74
|
-
function
|
|
75
|
-
|
|
74
|
+
function requireLine(value, path) {
|
|
75
|
+
if (value == null)
|
|
76
|
+
throw new Error(`${path} is required`);
|
|
77
|
+
return requireNumber(value, path);
|
|
76
78
|
}
|
|
77
79
|
function optionalStartLine(input) {
|
|
78
80
|
if (input.value == null)
|
|
79
81
|
return undefined;
|
|
80
|
-
if (input.line == null)
|
|
81
|
-
throw new Error(`${input.path} requires line`);
|
|
82
82
|
return requireNumber(input.value, input.path);
|
|
83
83
|
}
|
|
84
|
-
function parseRequirementFindings(value) {
|
|
85
|
-
return (value == null ? [] : requireArray(value, "requirementFindings")).map((finding, index) => {
|
|
86
|
-
const item = finding;
|
|
87
|
-
return {
|
|
88
|
-
evidence: requireString(item.evidence, `requirementFindings[${index}].evidence`),
|
|
89
|
-
fix: requireString(item.fix, `requirementFindings[${index}].fix`),
|
|
90
|
-
issueNumber: requireNumber(item.issueNumber, `requirementFindings[${index}].issueNumber`),
|
|
91
|
-
requirement: requireString(item.requirement, `requirementFindings[${index}].requirement`),
|
|
92
|
-
};
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
84
|
function requireOneOf(value, path, values) {
|
|
96
85
|
const text = requireString(value, path);
|
|
97
86
|
if (!values.includes(text)) {
|
|
@@ -179,11 +168,13 @@ export function parseReviewOutput(text) {
|
|
|
179
168
|
const data = extractJson(text);
|
|
180
169
|
if (!data || typeof data !== "object")
|
|
181
170
|
throw new Error("review output must be an object");
|
|
171
|
+
if (data.requirementFindings != null)
|
|
172
|
+
throw new Error("requirementFindings is not accepted");
|
|
182
173
|
if (!isVerdict(data.verdict))
|
|
183
174
|
throw new Error("verdict must be MERGE, CHANGES_REQUESTED, or CLOSE");
|
|
184
175
|
const findings = requireArray(data.findings, "findings").map((finding, index) => {
|
|
185
176
|
const item = finding;
|
|
186
|
-
const line =
|
|
177
|
+
const line = requireLine(item.line, `findings[${index}].line`);
|
|
187
178
|
return {
|
|
188
179
|
fix: requireString(item.fix, `findings[${index}].fix`),
|
|
189
180
|
issue: requireString(item.issue, `findings[${index}].issue`),
|
|
@@ -199,17 +190,12 @@ export function parseReviewOutput(text) {
|
|
|
199
190
|
}),
|
|
200
191
|
};
|
|
201
192
|
});
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
throw new Error("
|
|
206
|
-
if (data.verdict === "
|
|
207
|
-
|
|
208
|
-
!requirementFindings.length)
|
|
209
|
-
throw new Error("CHANGES_REQUESTED requires findings or requirementFindings");
|
|
210
|
-
if (data.verdict === "CLOSE" &&
|
|
211
|
-
(findings.length || requirementFindings.length))
|
|
212
|
-
throw new Error("CLOSE requires no findings or requirementFindings");
|
|
193
|
+
if (data.verdict === "MERGE" && findings.length)
|
|
194
|
+
throw new Error("MERGE requires no findings");
|
|
195
|
+
if (data.verdict === "CHANGES_REQUESTED" && !findings.length)
|
|
196
|
+
throw new Error("CHANGES_REQUESTED requires findings");
|
|
197
|
+
if (data.verdict === "CLOSE" && findings.length)
|
|
198
|
+
throw new Error("CLOSE requires no findings");
|
|
213
199
|
const reason = typeof data.reason === "string" && data.reason.trim()
|
|
214
200
|
? data.reason
|
|
215
201
|
: undefined;
|
|
@@ -218,7 +204,6 @@ export function parseReviewOutput(text) {
|
|
|
218
204
|
return {
|
|
219
205
|
findings,
|
|
220
206
|
reason,
|
|
221
|
-
requirementFindings,
|
|
222
207
|
verdict: data.verdict,
|
|
223
208
|
};
|
|
224
209
|
}
|
|
@@ -226,6 +211,8 @@ export function parseRereviewOutput(text) {
|
|
|
226
211
|
const data = extractJson(text);
|
|
227
212
|
if (!isVerdict(data.verdict))
|
|
228
213
|
throw new Error("rereview verdict must be MERGE, CHANGES_REQUESTED, or CLOSE");
|
|
214
|
+
if (data.requirementFindings != null)
|
|
215
|
+
throw new Error("requirementFindings is not accepted");
|
|
229
216
|
const resolve = requireArray(data.resolve, "resolve").map((item, index) => {
|
|
230
217
|
const value = item;
|
|
231
218
|
return {
|
|
@@ -242,7 +229,7 @@ export function parseRereviewOutput(text) {
|
|
|
242
229
|
});
|
|
243
230
|
const newFindings = requireArray(data.newFindings, "newFindings").map((item, index) => {
|
|
244
231
|
const value = item;
|
|
245
|
-
const line =
|
|
232
|
+
const line = requireLine(value.line, `newFindings[${index}].line`);
|
|
246
233
|
return {
|
|
247
234
|
body: requireString(value.body, `newFindings[${index}].body`),
|
|
248
235
|
line,
|
|
@@ -254,29 +241,24 @@ export function parseRereviewOutput(text) {
|
|
|
254
241
|
}),
|
|
255
242
|
};
|
|
256
243
|
});
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
(followUps.length || newFindings.length || requirementFindings.length)) {
|
|
260
|
-
throw new Error("MERGE requires no followUps, newFindings, or requirementFindings");
|
|
244
|
+
if (data.verdict === "MERGE" && (followUps.length || newFindings.length)) {
|
|
245
|
+
throw new Error("MERGE requires no followUps or newFindings");
|
|
261
246
|
}
|
|
262
|
-
if (data.verdict === "CLOSE" &&
|
|
263
|
-
(followUps
|
|
264
|
-
throw new Error("CLOSE requires no followUps, newFindings, or requirementFindings");
|
|
247
|
+
if (data.verdict === "CLOSE" && (followUps.length || newFindings.length)) {
|
|
248
|
+
throw new Error("CLOSE requires no followUps or newFindings");
|
|
265
249
|
}
|
|
266
250
|
if (data.verdict === "CLOSE" && !data.reason) {
|
|
267
251
|
throw new Error("CLOSE requires reason");
|
|
268
252
|
}
|
|
269
253
|
if (data.verdict === "CHANGES_REQUESTED" &&
|
|
270
254
|
!followUps.length &&
|
|
271
|
-
!newFindings.length
|
|
272
|
-
|
|
273
|
-
throw new Error("CHANGES_REQUESTED requires followUps, newFindings, or requirementFindings");
|
|
255
|
+
!newFindings.length) {
|
|
256
|
+
throw new Error("CHANGES_REQUESTED requires followUps or newFindings");
|
|
274
257
|
}
|
|
275
258
|
return {
|
|
276
259
|
followUps,
|
|
277
260
|
newFindings,
|
|
278
261
|
reason: data.reason == null ? undefined : requireString(data.reason, "reason"),
|
|
279
|
-
requirementFindings,
|
|
280
262
|
resolve,
|
|
281
263
|
verdict: data.verdict,
|
|
282
264
|
};
|
|
@@ -2,7 +2,7 @@ Fix pull request #{pr} for {owner}/{repo}.
|
|
|
2
2
|
The PR worktree is {worktreePath}.
|
|
3
3
|
|
|
4
4
|
Act as the PR author and address every blocking review finding listed below.
|
|
5
|
-
Review findings are the complete set of requested changes.
|
|
5
|
+
Review findings are the complete set of requested changes. Each finding targets a PR diff line and should have a corresponding GitHub review thread unless it comes from legacy read-side state.
|
|
6
6
|
{reviewFindings}
|
|
7
7
|
|
|
8
8
|
Unresolved GitHub review threads are conversations that may need replies or resolution.
|
|
@@ -12,5 +12,4 @@ For each review finding and thread, decide whether you agree with the reviewer.
|
|
|
12
12
|
If you understand and agree with the requested change, edit the code, stage changes, commit, and reply with action FIXED for each related thread.
|
|
13
13
|
If a requested change in a thread is incorrect or unnecessary and you have a clear reason, do not edit for that thread; reply with action DISAGREE and explain why.
|
|
14
14
|
If you cannot determine whether a threaded request is correct or what change is expected, do not blindly edit; reply with action ASK and ask a concrete question.
|
|
15
|
-
File-level and requirement findings may not have a thread to reply to, but they are still blocking and must be addressed.
|
|
16
15
|
Do not make changes just because a reviewer requested them. Do not push.
|
|
@@ -2,4 +2,5 @@ You requested CLOSE for pull request #{pr} in {owner}/{repo}, but the other revi
|
|
|
2
2
|
Reconsider your decision using the existing session context and choose MERGE or CHANGES_REQUESTED instead.
|
|
3
3
|
Original close reason:
|
|
4
4
|
{closeReason}
|
|
5
|
+
Every finding must target a valid right-side line in the PR diff. If the problem itself does not have an exact changed line, choose the nearest changed line that represents the cause, responsibility, missing implementation, or affected behavior.
|
|
5
6
|
Do not edit files or perform write operations.
|
|
@@ -9,6 +9,9 @@ If there is no new commit, still reconsider the thread when a user replied after
|
|
|
9
9
|
If you agree with the user's explanation or the code is fixed, resolve the thread.
|
|
10
10
|
If you do not agree, reply in the same thread with a followUp explaining why the issue still needs changes and keep CHANGES_REQUESTED.
|
|
11
11
|
Do not duplicate an existing unresolved thread as a newFinding. Use newFindings only for separate new issues.
|
|
12
|
+
Every newFinding must target a valid right-side line in the PR diff.
|
|
13
|
+
If the problem itself does not have an exact changed line, choose the nearest changed line that represents the cause, responsibility, missing implementation, or affected behavior. This includes but is not limited to missing validation, missing wiring, missing requirements, missing tests, missing documentation, affected configuration, or relevant call sites.
|
|
14
|
+
Do not omit line. Do not create file-level or body-only newFindings.
|
|
12
15
|
|
|
13
16
|
{ciFailureContextBlock}
|
|
14
17
|
Do not edit files or perform write operations.
|
|
@@ -10,4 +10,8 @@ Request changes if a closing issue requirement is missing, only documented, only
|
|
|
10
10
|
Do not approve solely because the PR improves the codebase if it claims to close an issue that remains incomplete.
|
|
11
11
|
For referenced non-closing issues, use them as context only unless the PR body explicitly claims to complete them.
|
|
12
12
|
|
|
13
|
+
Every finding must target a valid right-side line in the PR diff.
|
|
14
|
+
If the problem itself does not have an exact changed line, choose the nearest changed line that represents the cause, responsibility, missing implementation, or affected behavior. This includes but is not limited to missing validation, missing wiring, missing requirements, missing tests, missing documentation, affected configuration, or relevant call sites.
|
|
15
|
+
Do not omit line. Do not create file-level or body-only findings.
|
|
16
|
+
|
|
13
17
|
{ciFailureContextBlock}
|
package/package.json
CHANGED