next-issue 0.6.0 → 0.7.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 +41 -21
- package/dist/agents.mjs +76 -59
- package/dist/args.mjs +0 -4
- package/dist/cli.mjs +8 -6
- package/dist/config.mjs +95 -4
- package/dist/exec.mjs +22 -6
- package/dist/git.mjs +8 -7
- package/dist/github.mjs +30 -14
- package/dist/pipeline.mjs +100 -46
- package/dist/prompts.mjs +5 -0
- package/dist/state.mjs +9 -2
- package/dist/verdict.mjs +23 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,15 +29,17 @@ For each open issue, oldest first:
|
|
|
29
29
|
4. Let the implementer agent do the work, then commit and push.
|
|
30
30
|
5. Open a draft pull request that closes the issue.
|
|
31
31
|
6. Wait for the checks. A pending state waits again, up to
|
|
32
|
-
`checkTimeoutMinutes`. A repository
|
|
33
|
-
|
|
32
|
+
`checkTimeoutMinutes`. A repository that reports no check keeps asking for
|
|
33
|
+
`checkGraceSeconds`, because a new pull request often shows no check yet,
|
|
34
|
+
and only then goes straight to the review.
|
|
34
35
|
7. On a red build, give the failed job logs to the fixer agent, then go to step
|
|
35
36
|
6 again. The budget is `maxCiFixes`.
|
|
36
|
-
8. Let the reviewer agent judge the diff
|
|
37
|
-
verdict that rates each finding `blocking` or
|
|
38
|
-
result on the pull request.
|
|
37
|
+
8. Let the reviewer agent judge the diff, at most `diffMaxChars` of it. The
|
|
38
|
+
reviewer returns a structured verdict that rates each finding `blocking` or
|
|
39
|
+
`minor`. The harness puts the result on the pull request.
|
|
39
40
|
9. No blocking finding means approval: mark the pull request ready for review
|
|
40
|
-
and set `status:done`. The merge stays with you.
|
|
41
|
+
and set `status:done`. The merge stays with you. A blocking finding always
|
|
42
|
+
stops the approval, even when the reviewer also says approve.
|
|
41
43
|
10. With a blocking finding, run the fixer agent and go to step 6 again. The
|
|
42
44
|
budget is `maxReviewRounds`.
|
|
43
45
|
|
|
@@ -51,6 +53,8 @@ when:
|
|
|
51
53
|
- the reviewer repeats a finding set from an earlier round, which shows a
|
|
52
54
|
ping-pong between the fixer and the reviewer;
|
|
53
55
|
- the reviewer gives no verdict, or the checks do not finish in time;
|
|
56
|
+
- an agent does not finish in `agentTimeoutMinutes`;
|
|
57
|
+
- the setup command fails, or does not finish in `setupTimeoutMinutes`;
|
|
54
58
|
- a step of the run fails with an error.
|
|
55
59
|
|
|
56
60
|
The fixer sees the findings of all earlier rounds, not only the last one. From
|
|
@@ -78,8 +82,9 @@ command that writes a file outside `.gitignore` puts that file in the commit.
|
|
|
78
82
|
|
|
79
83
|
## Safety
|
|
80
84
|
|
|
81
|
-
The implementer and the fixer run with the permission checks off. They
|
|
82
|
-
|
|
85
|
+
The implementer and the fixer run with the permission checks off. They start in
|
|
86
|
+
the worktree, but nothing holds them there: they can run any command, with your
|
|
87
|
+
rights, anywhere on the machine. The prompt holds the title, the body and the
|
|
83
88
|
comments of the issue, which come from GitHub.
|
|
84
89
|
|
|
85
90
|
Thus a person who can write an issue or a comment can try to give an
|
|
@@ -144,7 +149,8 @@ already; `--force` replaces it. The file holds no `setupCommand`, because that
|
|
|
144
149
|
field has no default.
|
|
145
150
|
|
|
146
151
|
A field that is not in the file keeps its default. A file that is not valid
|
|
147
|
-
JSON
|
|
152
|
+
JSON, a field with a value of the wrong type and a field name that the harness
|
|
153
|
+
does not know all stop the run, so a typo cannot pass without a word.
|
|
148
154
|
|
|
149
155
|
```json
|
|
150
156
|
{
|
|
@@ -153,15 +159,15 @@ JSON stops the run.
|
|
|
153
159
|
"maxCiFixes": 3,
|
|
154
160
|
"maxReviewRounds": 3,
|
|
155
161
|
"checkIntervalSeconds": 15,
|
|
162
|
+
"checkGraceSeconds": 60,
|
|
156
163
|
"checkTimeoutMinutes": 60,
|
|
164
|
+
"agentTimeoutMinutes": 30,
|
|
165
|
+
"setupTimeoutMinutes": 15,
|
|
166
|
+
"commandTimeoutMinutes": 10,
|
|
157
167
|
"logMaxChars": 20000,
|
|
168
|
+
"diffMaxChars": 60000,
|
|
158
169
|
"draftPullRequest": true,
|
|
159
|
-
"
|
|
160
|
-
"models": {
|
|
161
|
-
"implementer": "claude-opus-5",
|
|
162
|
-
"reviewer": "claude-opus-5",
|
|
163
|
-
"fixer": "claude-opus-5"
|
|
164
|
-
},
|
|
170
|
+
"models": {},
|
|
165
171
|
"labels": {
|
|
166
172
|
"ready": "status:todo",
|
|
167
173
|
"inProgress": "status:in-progress",
|
|
@@ -180,8 +186,13 @@ JSON stops the run.
|
|
|
180
186
|
| `maxCiFixes` | `3` | The budget for check fixes per issue |
|
|
181
187
|
| `maxReviewRounds` | `3` | The budget for review rounds per issue |
|
|
182
188
|
| `checkIntervalSeconds` | `15` | The time between two check states |
|
|
189
|
+
| `checkGraceSeconds` | `60` | The time to wait for the first check to show |
|
|
183
190
|
| `checkTimeoutMinutes` | `60` | The limit for one check wait |
|
|
191
|
+
| `agentTimeoutMinutes` | `30` | The limit for one agent call |
|
|
192
|
+
| `setupTimeoutMinutes` | `15` | The limit for the setup command |
|
|
193
|
+
| `commandTimeoutMinutes` | `10` | The limit for one `git` or `gh` command |
|
|
184
194
|
| `logMaxChars` | `20000` | The maximum length of the failed job logs |
|
|
195
|
+
| `diffMaxChars` | `60000` | The maximum length of the diff that the reviewer reads |
|
|
185
196
|
| `draftPullRequest` | `true` | Open the pull request as a draft, until the review approves |
|
|
186
197
|
| `setupCommand` | none | A shell command to run in a new worktree, before the implementer |
|
|
187
198
|
| `models` | `{}` | The model per agent role |
|
|
@@ -191,12 +202,22 @@ An empty `labels.ready` turns the ready requirement off. The harness then
|
|
|
191
202
|
claims every open issue that no other label and no assignee holds back.
|
|
192
203
|
|
|
193
204
|
The log gives the reason for a skipped issue as `stop-label`, `not-ready`,
|
|
194
|
-
`in-flight` or `assigned`.
|
|
195
|
-
harness look at one issue.
|
|
205
|
+
`in-flight` or `assigned`.
|
|
196
206
|
|
|
197
207
|
A role without an entry in `models` uses the default model of the SDK. A
|
|
198
|
-
`setupCommand` that fails
|
|
199
|
-
|
|
208
|
+
`setupCommand` that fails, or that does not finish in `setupTimeoutMinutes`,
|
|
209
|
+
hands the issue to a person. The wait for the checks has its own limit, so
|
|
210
|
+
`commandTimeoutMinutes` holds for every other `git` and `gh` command. State per
|
|
211
|
+
issue goes to `.next-issue/<issue>.json`, so a new run continues where the last
|
|
212
|
+
run stopped.
|
|
213
|
+
|
|
214
|
+
## Retry an issue that waits for a person
|
|
215
|
+
|
|
216
|
+
A hand-over leaves the state file with the used budgets in it, so the next run
|
|
217
|
+
would hand the issue over again at once. To give the issue another try, take the
|
|
218
|
+
`status:needs-human` label off and run with `--reset`. The flag puts the budgets
|
|
219
|
+
and the findings back to zero and keeps the branch, the pull request and the
|
|
220
|
+
step that the last run reached, so the work carries on where it stopped.
|
|
200
221
|
|
|
201
222
|
## Commands and options
|
|
202
223
|
|
|
@@ -204,10 +225,9 @@ A role without an entry in `models` uses the default model of the SDK. A
|
|
|
204
225
|
| --- | --- |
|
|
205
226
|
| `init` | Write the config file with the defaults |
|
|
206
227
|
| `--force` | Replace an existing config file, with `init` |
|
|
207
|
-
| `--issue <n>` | Handle one issue only |
|
|
208
228
|
| `--once` | Stop after the first handled issue |
|
|
209
229
|
| `--max <n>` | Handle at most n issues |
|
|
210
|
-
| `--reset` | Drop the saved budgets and findings
|
|
230
|
+
| `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
|
|
211
231
|
| `--json` | Print the machine summary instead of the text one |
|
|
212
232
|
| `--verbose` | Show every command and all agent output |
|
|
213
233
|
| `--quiet` | Show only the milestones and the summary |
|
package/dist/agents.mjs
CHANGED
|
@@ -22,73 +22,90 @@ export async function runAgent(recorder, request) {
|
|
|
22
22
|
let sessionId;
|
|
23
23
|
let structured;
|
|
24
24
|
const started = Date.now();
|
|
25
|
-
log.event("agent.start", { model: request.model }, "normal");
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
25
|
+
log.event("agent.start", { model: request.model, timeoutMs: request.timeoutMs }, "normal");
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const timer = request.timeoutMs === undefined
|
|
28
|
+
? undefined
|
|
29
|
+
: setTimeout(() => controller.abort(), request.timeoutMs);
|
|
30
|
+
const late = () => new Error(`The ${request.name} agent did not finish in ${request.timeoutMs} ms.`);
|
|
31
|
+
try {
|
|
32
|
+
const stream = query({
|
|
33
|
+
prompt: request.prompt,
|
|
34
|
+
options: {
|
|
35
|
+
cwd: request.cwd,
|
|
36
|
+
model: request.model,
|
|
37
|
+
tools: request.profile.tools,
|
|
38
|
+
allowedTools: request.profile.tools,
|
|
39
|
+
permissionMode: request.profile.permissionMode,
|
|
40
|
+
allowDangerouslySkipPermissions: request.profile.allowDangerouslySkipPermissions,
|
|
41
|
+
systemPrompt: { type: "preset", preset: "claude_code" },
|
|
42
|
+
abortController: controller,
|
|
43
|
+
outputFormat: request.outputSchema === undefined
|
|
44
|
+
? undefined
|
|
45
|
+
: { type: "json_schema", schema: request.outputSchema },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
for await (const message of stream) {
|
|
49
|
+
if (message.type === "assistant") {
|
|
50
|
+
for (const block of message.message.content) {
|
|
51
|
+
if (block.type === "tool_use") {
|
|
52
|
+
toolCalls += 1;
|
|
53
|
+
toolNames.set(block.id, block.name);
|
|
54
|
+
log.event("tool", toolFields(block.name, block.input));
|
|
55
|
+
}
|
|
56
|
+
if (block.type === "text") {
|
|
57
|
+
text += block.text;
|
|
58
|
+
if (log.level === "verbose") {
|
|
59
|
+
process.stderr.write(block.text);
|
|
60
|
+
}
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
63
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
if (message.type === "user" && Array.isArray(message.message.content)) {
|
|
65
|
+
for (const block of message.message.content) {
|
|
66
|
+
if (block.type === "tool_result" && block.is_error === true) {
|
|
67
|
+
log.event("tool.error", { tool: toolNames.get(block.tool_use_id) }, "quiet");
|
|
68
|
+
}
|
|
61
69
|
}
|
|
62
70
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
71
|
+
if (message.type === "result") {
|
|
72
|
+
sessionId = message.session_id;
|
|
73
|
+
turns = message.num_turns;
|
|
74
|
+
costUsd = message.total_cost_usd;
|
|
75
|
+
model = main(message.modelUsage) ?? model;
|
|
76
|
+
for (const entry of Object.values(message.modelUsage)) {
|
|
77
|
+
usage.input += entry.inputTokens;
|
|
78
|
+
usage.output += entry.outputTokens;
|
|
79
|
+
usage.cacheRead += entry.cacheReadInputTokens;
|
|
80
|
+
usage.cacheWrite += entry.cacheCreationInputTokens;
|
|
81
|
+
usage.total +=
|
|
82
|
+
entry.inputTokens +
|
|
83
|
+
entry.outputTokens +
|
|
84
|
+
entry.cacheReadInputTokens +
|
|
85
|
+
entry.cacheCreationInputTokens;
|
|
86
|
+
}
|
|
87
|
+
if (message.subtype === "error_during_execution") {
|
|
88
|
+
throw new Error(`The ${request.name} agent failed: ${message.errors.join("; ")}`);
|
|
89
|
+
}
|
|
90
|
+
if (message.subtype === "success") {
|
|
91
|
+
text = message.result.length > 0 ? message.result : text;
|
|
92
|
+
structured = message.structured_output;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
log.event("agent.incomplete", { subtype: message.subtype }, "quiet");
|
|
96
|
+
}
|
|
89
97
|
}
|
|
90
98
|
}
|
|
91
99
|
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
throw controller.signal.aborted ? late() : error;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
}
|
|
106
|
+
if (controller.signal.aborted) {
|
|
107
|
+
throw late();
|
|
108
|
+
}
|
|
92
109
|
const ms = Date.now() - started;
|
|
93
110
|
recorder.usage(request.name, usage, ms, {
|
|
94
111
|
turns,
|
package/dist/args.mjs
CHANGED
|
@@ -3,7 +3,6 @@ export const USAGE = `next-issue [command] [options]
|
|
|
3
3
|
|
|
4
4
|
init write ${CONFIG_FILE} with the defaults
|
|
5
5
|
|
|
6
|
-
--issue <n> handle one issue only
|
|
7
6
|
--once stop after the first handled issue
|
|
8
7
|
--max <n> handle at most n issues
|
|
9
8
|
--reset drop the saved budgets and findings first
|
|
@@ -52,9 +51,6 @@ export function parseArgs(argv) {
|
|
|
52
51
|
else if (arg === "--quiet") {
|
|
53
52
|
args.level = "quiet";
|
|
54
53
|
}
|
|
55
|
-
else if (arg === "--issue") {
|
|
56
|
-
args.issue = whole(argv[++index], arg);
|
|
57
|
-
}
|
|
58
54
|
else if (arg === "--max") {
|
|
59
55
|
args.max = whole(argv[++index], arg);
|
|
60
56
|
}
|
package/dist/cli.mjs
CHANGED
|
@@ -3,12 +3,12 @@ import { writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { parseArgs, USAGE } from "./args.mjs";
|
|
5
5
|
import { loadConfig, writeDefaultConfig } from "./config.mjs";
|
|
6
|
-
import { setCommandObserver } from "./exec.mjs";
|
|
6
|
+
import { setCommandObserver, setDefaultTimeout } from "./exec.mjs";
|
|
7
7
|
import { detectRepo, ensureIgnored, repoRoot } from "./git.mjs";
|
|
8
8
|
import { currentLogin, defaultBranch, openIssues } from "./github.mjs";
|
|
9
9
|
import { message, Recorder } from "./observe.mjs";
|
|
10
|
-
import { processIssue } from "./pipeline.mjs";
|
|
11
|
-
import {
|
|
10
|
+
import { PORTS, processIssue } from "./pipeline.mjs";
|
|
11
|
+
import { resetState, STATE_DIR, takeStop } from "./state.mjs";
|
|
12
12
|
import { formatSummary } from "./summary.mjs";
|
|
13
13
|
function write(stream, text) {
|
|
14
14
|
return new Promise((resolve, reject) => {
|
|
@@ -26,6 +26,7 @@ async function main() {
|
|
|
26
26
|
return init(root, args.force);
|
|
27
27
|
}
|
|
28
28
|
const config = await loadConfig(root);
|
|
29
|
+
setDefaultTimeout(config.commandTimeoutMinutes * 60_000);
|
|
29
30
|
const repo = await detectRepo(root, config.remote);
|
|
30
31
|
const recorder = await Recorder.create(repo.root, args.level);
|
|
31
32
|
setCommandObserver((record) => {
|
|
@@ -34,6 +35,7 @@ async function main() {
|
|
|
34
35
|
code: record.code,
|
|
35
36
|
ms: record.ms,
|
|
36
37
|
stderr: record.stderr.length > 0 ? record.stderr : undefined,
|
|
38
|
+
timedOut: record.timedOut ? true : undefined,
|
|
37
39
|
}, record.code === 0 ? "verbose" : "quiet");
|
|
38
40
|
});
|
|
39
41
|
try {
|
|
@@ -53,9 +55,9 @@ async function init(root, force) {
|
|
|
53
55
|
}
|
|
54
56
|
async function handle(args, config, repo, recorder) {
|
|
55
57
|
const [base, login] = await Promise.all([defaultBranch(repo), currentLogin(repo)]);
|
|
56
|
-
const context = { repo, config, base, login, recorder };
|
|
58
|
+
const context = { repo, config, base, login, recorder, ports: PORTS };
|
|
57
59
|
recorder.event("run.start", { repo: `${repo.owner}/${repo.name}`, base, login, log: recorder.logFile }, "quiet");
|
|
58
|
-
const issues =
|
|
60
|
+
const issues = await openIssues(repo, config.issueLimit);
|
|
59
61
|
recorder.event("issues", { open: issues.length }, "quiet");
|
|
60
62
|
await takeStop(repo);
|
|
61
63
|
const reports = [];
|
|
@@ -65,7 +67,7 @@ async function handle(args, config, repo, recorder) {
|
|
|
65
67
|
break;
|
|
66
68
|
}
|
|
67
69
|
if (args.reset) {
|
|
68
|
-
await
|
|
70
|
+
await resetState(repo, issue.number);
|
|
69
71
|
}
|
|
70
72
|
const report = await processIssue(context, issue);
|
|
71
73
|
reports.push(report);
|
package/dist/config.mjs
CHANGED
|
@@ -7,8 +7,13 @@ const DEFAULTS = {
|
|
|
7
7
|
maxCiFixes: 3,
|
|
8
8
|
maxReviewRounds: 3,
|
|
9
9
|
checkIntervalSeconds: 15,
|
|
10
|
+
checkGraceSeconds: 60,
|
|
10
11
|
checkTimeoutMinutes: 60,
|
|
12
|
+
agentTimeoutMinutes: 30,
|
|
13
|
+
setupTimeoutMinutes: 15,
|
|
14
|
+
commandTimeoutMinutes: 10,
|
|
11
15
|
logMaxChars: 20000,
|
|
16
|
+
diffMaxChars: 60000,
|
|
12
17
|
draftPullRequest: true,
|
|
13
18
|
models: {},
|
|
14
19
|
labels: {
|
|
@@ -20,6 +25,22 @@ const DEFAULTS = {
|
|
|
20
25
|
skip: "status:blocked",
|
|
21
26
|
},
|
|
22
27
|
};
|
|
28
|
+
const FIELDS = [...Object.keys(DEFAULTS), "setupCommand"];
|
|
29
|
+
const WHOLE_FIELDS = [
|
|
30
|
+
["issueLimit", 1],
|
|
31
|
+
["maxCiFixes", 0],
|
|
32
|
+
["maxReviewRounds", 1],
|
|
33
|
+
["checkIntervalSeconds", 1],
|
|
34
|
+
["checkGraceSeconds", 0],
|
|
35
|
+
["checkTimeoutMinutes", 1],
|
|
36
|
+
["agentTimeoutMinutes", 1],
|
|
37
|
+
["setupTimeoutMinutes", 1],
|
|
38
|
+
["commandTimeoutMinutes", 1],
|
|
39
|
+
["logMaxChars", 1],
|
|
40
|
+
["diffMaxChars", 1],
|
|
41
|
+
];
|
|
42
|
+
const MODEL_ROLES = ["implementer", "reviewer", "fixer"];
|
|
43
|
+
const LABEL_NAMES = ["ready", "inProgress", "inReview", "done", "needsHuman", "skip"];
|
|
23
44
|
export const CONFIG_FILE = "next-issue.config.json";
|
|
24
45
|
export async function writeDefaultConfig(root, force) {
|
|
25
46
|
const path = join(root, CONFIG_FILE);
|
|
@@ -49,12 +70,82 @@ export async function loadConfig(root) {
|
|
|
49
70
|
catch (error) {
|
|
50
71
|
throw new Error(`${CONFIG_FILE} is not valid JSON: ${message(error)}`);
|
|
51
72
|
}
|
|
52
|
-
return
|
|
73
|
+
return parseConfig(parsed);
|
|
74
|
+
}
|
|
75
|
+
export function parseConfig(value) {
|
|
76
|
+
const file = record(value, "the file");
|
|
77
|
+
for (const key of Object.keys(file)) {
|
|
78
|
+
if (!FIELDS.includes(key)) {
|
|
79
|
+
throw new Error(`${CONFIG_FILE} holds an unknown field: ${key}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const config = {
|
|
53
83
|
...DEFAULTS,
|
|
54
|
-
...
|
|
55
|
-
|
|
56
|
-
labels: { ...DEFAULTS.labels, ...parsed.labels },
|
|
84
|
+
models: { ...DEFAULTS.models },
|
|
85
|
+
labels: { ...DEFAULTS.labels },
|
|
57
86
|
};
|
|
87
|
+
if (file.remote !== undefined) {
|
|
88
|
+
config.remote = text(file.remote, "remote", false);
|
|
89
|
+
}
|
|
90
|
+
for (const [field, least] of WHOLE_FIELDS) {
|
|
91
|
+
if (file[field] !== undefined) {
|
|
92
|
+
config[field] = whole(file[field], field, least);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (file.draftPullRequest !== undefined) {
|
|
96
|
+
if (typeof file.draftPullRequest !== "boolean") {
|
|
97
|
+
throw new Error(`${CONFIG_FILE}: draftPullRequest must be true or false.`);
|
|
98
|
+
}
|
|
99
|
+
config.draftPullRequest = file.draftPullRequest;
|
|
100
|
+
}
|
|
101
|
+
if (file.setupCommand !== undefined) {
|
|
102
|
+
config.setupCommand = text(file.setupCommand, "setupCommand", false);
|
|
103
|
+
}
|
|
104
|
+
if (file.models !== undefined) {
|
|
105
|
+
const models = record(file.models, "models");
|
|
106
|
+
for (const key of Object.keys(models)) {
|
|
107
|
+
if (!MODEL_ROLES.includes(key)) {
|
|
108
|
+
throw new Error(`${CONFIG_FILE} holds an unknown model role: ${key}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const role of MODEL_ROLES) {
|
|
112
|
+
if (models[role] !== undefined) {
|
|
113
|
+
config.models[role] = text(models[role], `models.${role}`, false);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (file.labels !== undefined) {
|
|
118
|
+
const labels = record(file.labels, "labels");
|
|
119
|
+
for (const key of Object.keys(labels)) {
|
|
120
|
+
if (!LABEL_NAMES.includes(key)) {
|
|
121
|
+
throw new Error(`${CONFIG_FILE} holds an unknown label name: ${key}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const name of LABEL_NAMES) {
|
|
125
|
+
if (labels[name] !== undefined) {
|
|
126
|
+
config.labels[name] = text(labels[name], `labels.${name}`, name === "ready");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return config;
|
|
131
|
+
}
|
|
132
|
+
function record(value, what) {
|
|
133
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
134
|
+
throw new Error(`${CONFIG_FILE}: ${what} must be an object.`);
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
function whole(value, field, least) {
|
|
139
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < least) {
|
|
140
|
+
throw new Error(`${CONFIG_FILE}: ${field} must be a whole number of ${least} or more.`);
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
function text(value, field, allowEmpty) {
|
|
145
|
+
if (typeof value !== "string" || (!allowEmpty && value.length === 0)) {
|
|
146
|
+
throw new Error(`${CONFIG_FILE}: ${field} must be ${allowEmpty ? "text" : "text that is not empty"}.`);
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
58
149
|
}
|
|
59
150
|
export function managedLabels(config) {
|
|
60
151
|
return [
|
package/dist/exec.mjs
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
let observer;
|
|
3
|
+
let defaultTimeoutMs = 10 * 60_000;
|
|
3
4
|
export function setCommandObserver(next) {
|
|
4
5
|
observer = next;
|
|
5
6
|
}
|
|
7
|
+
export function setDefaultTimeout(ms) {
|
|
8
|
+
defaultTimeoutMs = ms;
|
|
9
|
+
}
|
|
6
10
|
export function run(command, args, options = {}) {
|
|
7
11
|
const started = Date.now();
|
|
12
|
+
const limit = options.timeoutMs ?? defaultTimeoutMs;
|
|
8
13
|
return new Promise((resolve, reject) => {
|
|
9
14
|
const child = spawn(command, args, {
|
|
10
15
|
cwd: options.cwd,
|
|
@@ -21,22 +26,29 @@ export function run(command, args, options = {}) {
|
|
|
21
26
|
child.stderr?.setEncoding("utf8");
|
|
22
27
|
child.stdout?.on("data", (chunk) => {
|
|
23
28
|
stdout += chunk;
|
|
29
|
+
if (options.tailChars !== undefined && stdout.length > options.tailChars) {
|
|
30
|
+
stdout = stdout.slice(-options.tailChars);
|
|
31
|
+
}
|
|
24
32
|
});
|
|
25
33
|
child.stderr?.on("data", (chunk) => {
|
|
26
34
|
stderr += chunk;
|
|
27
35
|
});
|
|
28
|
-
const timer =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}, options.timeoutMs);
|
|
36
|
+
const timer = setTimeout(() => {
|
|
37
|
+
timedOut = true;
|
|
38
|
+
child.kill("SIGKILL");
|
|
39
|
+
}, limit);
|
|
40
|
+
let done = false;
|
|
34
41
|
child.on("error", (error) => {
|
|
35
42
|
clearTimeout(timer);
|
|
43
|
+
done = true;
|
|
36
44
|
reject(error);
|
|
37
45
|
});
|
|
38
46
|
child.on("close", (code) => {
|
|
39
47
|
clearTimeout(timer);
|
|
48
|
+
if (done) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
done = true;
|
|
40
52
|
const result = { code: code ?? 1, stdout, stderr, timedOut };
|
|
41
53
|
observer?.({
|
|
42
54
|
command,
|
|
@@ -45,6 +57,7 @@ export function run(command, args, options = {}) {
|
|
|
45
57
|
code: result.code,
|
|
46
58
|
ms: Date.now() - started,
|
|
47
59
|
stderr: result.code === 0 ? "" : stderr.trim(),
|
|
60
|
+
timedOut,
|
|
48
61
|
});
|
|
49
62
|
resolve(result);
|
|
50
63
|
});
|
|
@@ -56,6 +69,9 @@ export function run(command, args, options = {}) {
|
|
|
56
69
|
}
|
|
57
70
|
export async function must(command, args, options = {}) {
|
|
58
71
|
const result = await run(command, args, options);
|
|
72
|
+
if (result.timedOut) {
|
|
73
|
+
throw new Error(`${command} ${args.join(" ")} did not finish in time.`);
|
|
74
|
+
}
|
|
59
75
|
if (result.code !== 0) {
|
|
60
76
|
throw new Error(`${command} ${args.join(" ")} failed with code ${result.code}: ${result.stderr.trim()}`);
|
|
61
77
|
}
|
package/dist/git.mjs
CHANGED
|
@@ -36,7 +36,9 @@ export async function addWorktree(repo, issue, base, remote) {
|
|
|
36
36
|
if (await hasWorktree(repo, path)) {
|
|
37
37
|
return path;
|
|
38
38
|
}
|
|
39
|
-
const existing = await run("git", ["rev-parse", "--verify", branch], {
|
|
39
|
+
const existing = await run("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], {
|
|
40
|
+
cwd: repo.root,
|
|
41
|
+
});
|
|
40
42
|
const args = existing.code === 0
|
|
41
43
|
? ["worktree", "add", path, branch]
|
|
42
44
|
: ["worktree", "add", "-b", branch, path, `${remote}/${base}`];
|
|
@@ -45,12 +47,11 @@ export async function addWorktree(repo, issue, base, remote) {
|
|
|
45
47
|
}
|
|
46
48
|
export async function updateBase(repo, base, remote) {
|
|
47
49
|
const head = await run("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: repo.root });
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return fetch.code === 0;
|
|
50
|
+
const args = head.code === 0 && head.stdout.trim() === base
|
|
51
|
+
? ["merge", "--ff-only", `${remote}/${base}`]
|
|
52
|
+
: ["fetch", ".", `${remote}/${base}:${base}`];
|
|
53
|
+
const result = await run("git", args, { cwd: repo.root });
|
|
54
|
+
return result.code === 0;
|
|
54
55
|
}
|
|
55
56
|
export async function hasWorktree(repo, path) {
|
|
56
57
|
const list = await must("git", ["worktree", "list", "--porcelain"], { cwd: repo.root });
|
package/dist/github.mjs
CHANGED
|
@@ -107,8 +107,8 @@ async function readChecks(repo, branch) {
|
|
|
107
107
|
const checks = JSON.parse(output);
|
|
108
108
|
return checks.length === 0 ? "none" : checks;
|
|
109
109
|
}
|
|
110
|
-
async function watchOnce(repo, branch, intervalSeconds, timeoutMs) {
|
|
111
|
-
const watch = await run("gh", ["pr", "checks", branch, "--watch", "--fail-fast", "--interval", String(intervalSeconds), "--repo", slug(repo)], { cwd: repo.root, inherit
|
|
110
|
+
async function watchOnce(repo, branch, intervalSeconds, timeoutMs, inherit) {
|
|
111
|
+
const watch = await run("gh", ["pr", "checks", branch, "--watch", "--fail-fast", "--interval", String(intervalSeconds), "--repo", slug(repo)], { cwd: repo.root, inherit, timeoutMs });
|
|
112
112
|
if (watch.timedOut) {
|
|
113
113
|
return "timeout";
|
|
114
114
|
}
|
|
@@ -120,22 +120,37 @@ async function watchOnce(repo, branch, intervalSeconds, timeoutMs) {
|
|
|
120
120
|
}
|
|
121
121
|
return "fail";
|
|
122
122
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
const REAL_CLOCK = {
|
|
124
|
+
now: () => Date.now(),
|
|
125
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
126
|
+
};
|
|
127
|
+
export async function pollChecks(probe, options, clock = REAL_CLOCK) {
|
|
128
|
+
const started = clock.now();
|
|
129
|
+
const deadline = started + options.timeoutMs;
|
|
130
|
+
const rest = () => Math.max(0, deadline - clock.now());
|
|
131
|
+
for (;;) {
|
|
132
|
+
if (rest() === 0) {
|
|
133
|
+
return "timeout";
|
|
134
|
+
}
|
|
135
|
+
if ((await probe.list()) === "none") {
|
|
136
|
+
if (clock.now() - started >= options.graceMs) {
|
|
137
|
+
return "none";
|
|
138
|
+
}
|
|
128
139
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
140
|
+
else {
|
|
141
|
+
const state = await probe.watch(rest());
|
|
142
|
+
if (state !== "pending") {
|
|
143
|
+
return state;
|
|
144
|
+
}
|
|
132
145
|
}
|
|
133
|
-
await
|
|
146
|
+
await clock.sleep(Math.min(options.intervalSeconds * 1000, rest()));
|
|
134
147
|
}
|
|
135
|
-
return "timeout";
|
|
136
148
|
}
|
|
137
|
-
function
|
|
138
|
-
return
|
|
149
|
+
export function waitForChecks(repo, branch, options) {
|
|
150
|
+
return pollChecks({
|
|
151
|
+
list: async () => ((await readChecks(repo, branch)) === "none" ? "none" : "some"),
|
|
152
|
+
watch: (timeoutMs) => watchOnce(repo, branch, options.intervalSeconds, timeoutMs, options.inherit),
|
|
153
|
+
}, options);
|
|
139
154
|
}
|
|
140
155
|
export async function failedCheckLogs(repo, branch, maxChars) {
|
|
141
156
|
const checks = await readChecks(repo, branch);
|
|
@@ -152,6 +167,7 @@ export async function failedCheckLogs(repo, branch, maxChars) {
|
|
|
152
167
|
}
|
|
153
168
|
const logs = await run("gh", ["run", "view", runId, "--log-failed", "--repo", slug(repo)], {
|
|
154
169
|
cwd: repo.root,
|
|
170
|
+
tailChars: maxChars,
|
|
155
171
|
});
|
|
156
172
|
parts.push(logs.stdout.slice(-maxChars));
|
|
157
173
|
}
|
package/dist/pipeline.mjs
CHANGED
|
@@ -4,9 +4,28 @@ import { run } from "./exec.mjs";
|
|
|
4
4
|
import { addWorktree, branchName, commitAll, diff, hasWorktree, push, removeWorktree, worktreePath, } from "./git.mjs";
|
|
5
5
|
import { assignIssue, commentOnPr, createPr, failedCheckLogs, findPr, issueComments, markPrReady, setLabel, waitForChecks, } from "./github.mjs";
|
|
6
6
|
import { message } from "./observe.mjs";
|
|
7
|
-
import { fixPrompt, implementPrompt, parseCommitSubject, reviewPrompt } from "./prompts.mjs";
|
|
7
|
+
import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, reviewPrompt } from "./prompts.mjs";
|
|
8
8
|
import { readState, writeState } from "./state.mjs";
|
|
9
9
|
import { blockingFindings, fingerprint, formatFindings, formatVerdict, isApproved, readVerdict, VERDICT_SCHEMA, } from "./verdict.mjs";
|
|
10
|
+
export const PORTS = {
|
|
11
|
+
addWorktree,
|
|
12
|
+
assignIssue,
|
|
13
|
+
commentOnPr,
|
|
14
|
+
commitAll,
|
|
15
|
+
createPr,
|
|
16
|
+
diff,
|
|
17
|
+
failedCheckLogs,
|
|
18
|
+
findPr,
|
|
19
|
+
hasWorktree,
|
|
20
|
+
issueComments,
|
|
21
|
+
markPrReady,
|
|
22
|
+
push,
|
|
23
|
+
removeWorktree,
|
|
24
|
+
runAgent,
|
|
25
|
+
runSetup: async (command, cwd, timeoutMs) => run("bash", ["-lc", command], { cwd, timeoutMs }),
|
|
26
|
+
setLabel,
|
|
27
|
+
waitForChecks,
|
|
28
|
+
};
|
|
10
29
|
export function skipReason(issue, config, login, resuming) {
|
|
11
30
|
const labels = new Set(issue.labels);
|
|
12
31
|
const names = config.labels;
|
|
@@ -69,15 +88,15 @@ export async function processIssue(context, issue) {
|
|
|
69
88
|
}
|
|
70
89
|
async function work(context, issue, log, state) {
|
|
71
90
|
const started = Date.now();
|
|
72
|
-
const { repo, config } = context;
|
|
91
|
+
const { repo, config, ports } = context;
|
|
73
92
|
const branch = state.branch;
|
|
74
93
|
await log.step("claim", { label: config.labels.inProgress }, async () => {
|
|
75
|
-
await assignIssue(repo, issue.number, context.login);
|
|
94
|
+
await ports.assignIssue(repo, issue.number, context.login);
|
|
76
95
|
await setStatus(context, issue.number, state.pr, config.labels.inProgress);
|
|
77
96
|
await writeState(repo, state);
|
|
78
97
|
});
|
|
79
|
-
const comments = await log.step("comments", {}, () => issueComments(repo, issue.number));
|
|
80
|
-
const worktree = await log.step("worktree", { branch }, () => addWorktree(repo, issue.number, context.base, config.remote));
|
|
98
|
+
const comments = await log.step("comments", {}, () => ports.issueComments(repo, issue.number));
|
|
99
|
+
const worktree = await log.step("worktree", { branch }, () => ports.addWorktree(repo, issue.number, context.base, config.remote));
|
|
81
100
|
const job = { context, issue, log, state, branch, worktree, comments };
|
|
82
101
|
const escalate = (reason) => handOver(context, log, issue.number, state, reason);
|
|
83
102
|
const finish = async (outcome, reason) => {
|
|
@@ -93,23 +112,27 @@ async function work(context, issue, log, state) {
|
|
|
93
112
|
};
|
|
94
113
|
};
|
|
95
114
|
if (config.setupCommand !== undefined) {
|
|
96
|
-
const setup = await log.step("setup", { cmd: config.setupCommand }, () =>
|
|
115
|
+
const setup = await log.step("setup", { cmd: config.setupCommand }, () => ports.runSetup(config.setupCommand, worktree, config.setupTimeoutMinutes * 60_000));
|
|
97
116
|
if (setup.code !== 0) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
117
|
+
const late = setup.timedOut === true;
|
|
118
|
+
log.event("setup.fail", { code: setup.code, timedOut: late, stderr: setup.stderr.trim().slice(-2000) }, "quiet");
|
|
119
|
+
await escalate(late
|
|
120
|
+
? `The setup command did not finish in ${config.setupTimeoutMinutes} minutes.`
|
|
121
|
+
: "The setup command failed in the worktree.");
|
|
122
|
+
return finish("needs-human", late ? "setup timeout" : "setup failed");
|
|
101
123
|
}
|
|
102
124
|
}
|
|
103
125
|
if (state.phase === "claimed") {
|
|
104
|
-
const result = await log.step("implement", {}, () => runAgent(log, {
|
|
126
|
+
const result = await log.step("implement", {}, () => ports.runAgent(log, {
|
|
105
127
|
name: "implementer",
|
|
106
128
|
cwd: worktree,
|
|
107
129
|
prompt: implementPrompt(issue, comments),
|
|
108
130
|
profile: CODING,
|
|
109
131
|
model: config.models.implementer,
|
|
132
|
+
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
110
133
|
}));
|
|
111
134
|
const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
|
|
112
|
-
if (!(await commitAll(worktree, `${subject}\n\nRefs #${issue.number}`))) {
|
|
135
|
+
if (!(await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`))) {
|
|
113
136
|
await escalate("The implementer produced no change.");
|
|
114
137
|
return finish("needs-human", "no implementation commit");
|
|
115
138
|
}
|
|
@@ -117,36 +140,51 @@ async function work(context, issue, log, state) {
|
|
|
117
140
|
state.phase = "implemented";
|
|
118
141
|
await writeState(repo, state);
|
|
119
142
|
}
|
|
120
|
-
await log.step("push", { branch }, () => push(worktree, config.remote, branch));
|
|
143
|
+
await log.step("push", { branch }, () => ports.push(worktree, config.remote, branch));
|
|
121
144
|
state.pr = await log.step("pull-request", { branch }, async () => {
|
|
122
|
-
const existing = await findPr(repo, branch);
|
|
145
|
+
const existing = await ports.findPr(repo, branch);
|
|
123
146
|
return (existing ??
|
|
124
|
-
createPr(repo, branch, context.base, issue.title, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
|
|
147
|
+
ports.createPr(repo, branch, context.base, issue.title, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
|
|
125
148
|
});
|
|
126
149
|
state.phase = "review";
|
|
127
150
|
await writeState(repo, state);
|
|
128
151
|
log.event("pull-request.ready", { pr: state.pr }, "quiet");
|
|
129
|
-
|
|
130
|
-
const checks = await log.step("checks", { pr: state.pr }, () => waitForChecks(repo, branch,
|
|
152
|
+
const gate = async () => {
|
|
153
|
+
const checks = await log.step("checks", { pr: state.pr }, () => ports.waitForChecks(repo, branch, {
|
|
154
|
+
intervalSeconds: config.checkIntervalSeconds,
|
|
155
|
+
graceMs: config.checkGraceSeconds * 1000,
|
|
156
|
+
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
157
|
+
inherit: log.level === "verbose",
|
|
158
|
+
}));
|
|
131
159
|
log.event("checks.state", { state: checks });
|
|
132
160
|
if (checks === "timeout") {
|
|
133
161
|
await escalate("The checks did not finish in time.");
|
|
134
|
-
return finish("needs-human", "checks timeout");
|
|
162
|
+
return { done: true, report: await finish("needs-human", "checks timeout") };
|
|
135
163
|
}
|
|
136
|
-
if (checks
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
164
|
+
if (checks !== "fail") {
|
|
165
|
+
return { done: false, fixed: false };
|
|
166
|
+
}
|
|
167
|
+
if (state.ciFixes >= config.maxCiFixes) {
|
|
168
|
+
await escalate(`The limit of ${config.maxCiFixes} check fixes was reached.`);
|
|
169
|
+
return { done: true, report: await finish("needs-human", "check budget") };
|
|
170
|
+
}
|
|
171
|
+
state.ciFixes += 1;
|
|
172
|
+
await writeState(repo, state);
|
|
173
|
+
const logs = await ports.failedCheckLogs(repo, branch, config.logMaxChars);
|
|
174
|
+
log.event("checks.logs", { chars: logs.length });
|
|
175
|
+
const committed = await fixRound(job, "The continuous integration checks failed.", logs, history(state.reviewLog));
|
|
176
|
+
if (!committed) {
|
|
177
|
+
await escalate("The fixer added no commit for the failed checks.");
|
|
178
|
+
return { done: true, report: await finish("needs-human", "no fix commit") };
|
|
179
|
+
}
|
|
180
|
+
return { done: false, fixed: true };
|
|
181
|
+
};
|
|
182
|
+
for (;;) {
|
|
183
|
+
const before = await gate();
|
|
184
|
+
if (before.done) {
|
|
185
|
+
return before.report;
|
|
186
|
+
}
|
|
187
|
+
if (before.fixed) {
|
|
150
188
|
continue;
|
|
151
189
|
}
|
|
152
190
|
if (state.reviewRounds >= config.maxReviewRounds) {
|
|
@@ -156,13 +194,20 @@ async function work(context, issue, log, state) {
|
|
|
156
194
|
state.reviewRounds += 1;
|
|
157
195
|
await writeState(repo, state);
|
|
158
196
|
await setStatus(context, issue.number, state.pr, config.labels.inReview);
|
|
159
|
-
const
|
|
160
|
-
const
|
|
197
|
+
const full = await ports.diff(worktree, context.base, config.remote);
|
|
198
|
+
const patch = capDiff(full, config.diffMaxChars);
|
|
199
|
+
const fields = {
|
|
200
|
+
round: state.reviewRounds,
|
|
201
|
+
diffChars: full.length,
|
|
202
|
+
cut: full.length > config.diffMaxChars,
|
|
203
|
+
};
|
|
204
|
+
const review = await log.step("review", fields, () => ports.runAgent(log, {
|
|
161
205
|
name: "reviewer",
|
|
162
206
|
cwd: worktree,
|
|
163
207
|
prompt: reviewPrompt(issue, comments, patch, state.reviewRounds, history(state.reviewLog)),
|
|
164
208
|
profile: READ_ONLY,
|
|
165
209
|
model: config.models.reviewer,
|
|
210
|
+
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
166
211
|
outputSchema: VERDICT_SCHEMA,
|
|
167
212
|
}));
|
|
168
213
|
const verdict = readVerdict(review.structured);
|
|
@@ -178,9 +223,16 @@ async function work(context, issue, log, state) {
|
|
|
178
223
|
minor: verdict.findings.length - blocking.length,
|
|
179
224
|
summary: verdict.summary,
|
|
180
225
|
}, "quiet");
|
|
181
|
-
await commentOnPr(repo, state.pr, formatVerdict(verdict));
|
|
226
|
+
await ports.commentOnPr(repo, state.pr, formatVerdict(verdict));
|
|
182
227
|
if (isApproved(verdict)) {
|
|
183
|
-
|
|
228
|
+
const after = await gate();
|
|
229
|
+
if (after.done) {
|
|
230
|
+
return after.report;
|
|
231
|
+
}
|
|
232
|
+
if (after.fixed) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (config.draftPullRequest && !(await ports.markPrReady(repo, state.pr))) {
|
|
184
236
|
log.event("pull-request.undraft.fail", { pr: state.pr }, "quiet");
|
|
185
237
|
}
|
|
186
238
|
await setStatus(context, issue.number, state.pr, config.labels.done);
|
|
@@ -203,31 +255,33 @@ async function work(context, issue, log, state) {
|
|
|
203
255
|
}
|
|
204
256
|
}
|
|
205
257
|
}
|
|
206
|
-
async function fixRound(job, reason, detail, earlier
|
|
258
|
+
async function fixRound(job, reason, detail, earlier) {
|
|
207
259
|
const { context, issue, log, worktree, branch } = job;
|
|
208
|
-
const
|
|
260
|
+
const { ports } = context;
|
|
261
|
+
const result = await log.step("fix", { reason }, () => ports.runAgent(log, {
|
|
209
262
|
name: "fixer",
|
|
210
263
|
cwd: worktree,
|
|
211
|
-
prompt: fixPrompt(issue, reason, detail, earlier
|
|
264
|
+
prompt: fixPrompt(issue, reason, detail, earlier),
|
|
212
265
|
profile: CODING,
|
|
213
266
|
model: context.config.models.fixer,
|
|
267
|
+
timeoutMs: context.config.agentTimeoutMinutes * 60_000,
|
|
214
268
|
}));
|
|
215
269
|
const subject = parseCommitSubject(result.text, `fix: address feedback on issue #${issue.number}`);
|
|
216
|
-
if (!(await commitAll(worktree, `${subject}\n\nRefs #${issue.number}`))) {
|
|
270
|
+
if (!(await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`))) {
|
|
217
271
|
log.event("commit.empty", { reason }, "quiet");
|
|
218
272
|
return false;
|
|
219
273
|
}
|
|
220
274
|
log.event("commit", { subject });
|
|
221
|
-
await push(worktree, context.config.remote, branch);
|
|
275
|
+
await ports.push(worktree, context.config.remote, branch);
|
|
222
276
|
return true;
|
|
223
277
|
}
|
|
224
278
|
async function cleanUp(context, log, issue) {
|
|
225
279
|
const path = worktreePath(context.repo, issue);
|
|
226
280
|
try {
|
|
227
|
-
if (!(await hasWorktree(context.repo, path))) {
|
|
281
|
+
if (!(await context.ports.hasWorktree(context.repo, path))) {
|
|
228
282
|
return;
|
|
229
283
|
}
|
|
230
|
-
if (!(await removeWorktree(context.repo, issue))) {
|
|
284
|
+
if (!(await context.ports.removeWorktree(context.repo, issue))) {
|
|
231
285
|
log.event("worktree.kept", { path }, "quiet");
|
|
232
286
|
}
|
|
233
287
|
}
|
|
@@ -237,18 +291,18 @@ async function cleanUp(context, log, issue) {
|
|
|
237
291
|
}
|
|
238
292
|
async function handOver(context, log, issue, state, reason) {
|
|
239
293
|
log.event("hand-over", { reason }, "quiet");
|
|
240
|
-
state.
|
|
294
|
+
state.handedOver = true;
|
|
241
295
|
await writeState(context.repo, state);
|
|
242
296
|
await setStatus(context, issue, state.pr, context.config.labels.needsHuman);
|
|
243
297
|
if (state.pr !== undefined) {
|
|
244
|
-
await commentOnPr(context.repo, state.pr, `next-issue needs help: ${reason}`);
|
|
298
|
+
await context.ports.commentOnPr(context.repo, state.pr, `next-issue needs help: ${reason}`);
|
|
245
299
|
}
|
|
246
300
|
}
|
|
247
301
|
async function setStatus(context, issue, pr, label) {
|
|
248
302
|
const managed = managedLabels(context.config);
|
|
249
|
-
await setLabel(context.repo, "issue", issue, label, managed);
|
|
303
|
+
await context.ports.setLabel(context.repo, "issue", issue, label, managed);
|
|
250
304
|
if (pr !== undefined) {
|
|
251
|
-
await setLabel(context.repo, "pr", pr, label, managed);
|
|
305
|
+
await context.ports.setLabel(context.repo, "pr", pr, label, managed);
|
|
252
306
|
}
|
|
253
307
|
}
|
|
254
308
|
function history(rounds) {
|
package/dist/prompts.mjs
CHANGED
|
@@ -21,6 +21,11 @@ export function implementPrompt(issue, comments) {
|
|
|
21
21
|
COMMIT_RULES,
|
|
22
22
|
].join("\n\n");
|
|
23
23
|
}
|
|
24
|
+
export function capDiff(diff, maxChars) {
|
|
25
|
+
return diff.length <= maxChars
|
|
26
|
+
? diff
|
|
27
|
+
: `${diff.slice(0, maxChars)}\n... The diff is cut here, after ${maxChars} characters.`;
|
|
28
|
+
}
|
|
24
29
|
export function reviewPrompt(issue, comments, diff, round, earlier) {
|
|
25
30
|
const scope = round === 1
|
|
26
31
|
? "Check correctness, scope, repository conventions and test cover."
|
package/dist/state.mjs
CHANGED
|
@@ -15,8 +15,15 @@ export async function readState(repo, issue) {
|
|
|
15
15
|
return undefined;
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
-
export async function
|
|
19
|
-
await
|
|
18
|
+
export async function resetState(repo, issue) {
|
|
19
|
+
const state = await readState(repo, issue);
|
|
20
|
+
if (state === undefined) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const next = { ...state, ciFixes: 0, reviewRounds: 0, reviewLog: [] };
|
|
24
|
+
delete next.handedOver;
|
|
25
|
+
await writeState(repo, next);
|
|
26
|
+
return true;
|
|
20
27
|
}
|
|
21
28
|
export async function writeState(repo, state) {
|
|
22
29
|
await mkdir(dir(repo), { recursive: true });
|
package/dist/verdict.mjs
CHANGED
|
@@ -26,18 +26,37 @@ export const VERDICT_SCHEMA = {
|
|
|
26
26
|
},
|
|
27
27
|
required: ["verdict", "summary", "findings"],
|
|
28
28
|
};
|
|
29
|
+
const SEVERITIES = ["blocking", "minor"];
|
|
30
|
+
const VERDICTS = ["approve", "request_changes"];
|
|
29
31
|
export function readVerdict(value) {
|
|
30
|
-
|
|
31
|
-
if (verdict === undefined || typeof verdict.summary !== "string" || !Array.isArray(verdict.findings)) {
|
|
32
|
+
if (typeof value !== "object" || value === null) {
|
|
32
33
|
return undefined;
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
+
const { verdict, summary, findings } = value;
|
|
36
|
+
if (typeof verdict !== "string" || !VERDICTS.includes(verdict)) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
if (typeof summary !== "string" || !Array.isArray(findings)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const checked = [];
|
|
43
|
+
for (const item of findings) {
|
|
44
|
+
if (typeof item !== "object" || item === null) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const { severity, detail } = item;
|
|
48
|
+
if (typeof severity !== "string" || !SEVERITIES.includes(severity) || typeof detail !== "string") {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
checked.push({ severity: severity, detail });
|
|
52
|
+
}
|
|
53
|
+
return { verdict: verdict, summary, findings: checked };
|
|
35
54
|
}
|
|
36
55
|
export function blockingFindings(verdict) {
|
|
37
56
|
return verdict.findings.filter((finding) => finding.severity === "blocking");
|
|
38
57
|
}
|
|
39
58
|
export function isApproved(verdict) {
|
|
40
|
-
return
|
|
59
|
+
return blockingFindings(verdict).length === 0;
|
|
41
60
|
}
|
|
42
61
|
export function formatVerdict(verdict) {
|
|
43
62
|
const head = isApproved(verdict) ? "Review: approved" : "Review: changes requested";
|