next-issue 1.0.8 → 1.1.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 +28 -0
- package/dist/args.mjs +5 -0
- package/dist/cli.mjs +19 -4
- package/dist/interrupt.mjs +21 -1
- package/dist/loop.mjs +25 -0
- package/dist/observe.mjs +26 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -358,6 +358,7 @@ that a label still holds back stays as it was.
|
|
|
358
358
|
| `--once` | Stop after the first handled issue |
|
|
359
359
|
| `--max <n>` | Handle at most n issues |
|
|
360
360
|
| `--merge` | Squash-merge the pull request after the approval, delete the remote branch and fix a failure of the checks on the base branch |
|
|
361
|
+
| `--watch` | Keep running, and read the open issues again after each pass |
|
|
361
362
|
| `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
|
|
362
363
|
| `--json` | Print the machine summary instead of the text one |
|
|
363
364
|
| `--verbose` | Show every command and all agent output |
|
|
@@ -367,6 +368,33 @@ that a label still holds back stays as it was.
|
|
|
367
368
|
The exit code is 1 when an issue needs a person or failed, and 0 when it does
|
|
368
369
|
not, so a cron job or a CI step can read the result of the run.
|
|
369
370
|
|
|
371
|
+
## Watch for new issues
|
|
372
|
+
|
|
373
|
+
A run without `--watch` ends when no unseen issue is left. With `--watch`, the
|
|
374
|
+
harness then waits, reads the open issues again and runs another pass, so it
|
|
375
|
+
picks up an issue that someone opens during the run, or one whose blocker
|
|
376
|
+
merges. Each pass tries every open issue again, also one that an earlier pass
|
|
377
|
+
skipped.
|
|
378
|
+
|
|
379
|
+
The waits follow a fixed back-off in minutes: 1, 2, 3, 5, 10, then 10 from then
|
|
380
|
+
on. A pass that handles an issue, with any outcome other than `skipped`, resets
|
|
381
|
+
the back-off to 1 minute. A pass where every issue is skipped does not reset
|
|
382
|
+
it. Each wait prints a line on standard error with the minutes until the next
|
|
383
|
+
pass.
|
|
384
|
+
|
|
385
|
+
After each pass that handled an issue, the harness prints the summary of that
|
|
386
|
+
pass. The token totals in it cover that pass only. The exit code at the end
|
|
387
|
+
covers all passes. `--max <n>` counts the handled issues across all passes,
|
|
388
|
+
and `--once` is the same as `--max 1`.
|
|
389
|
+
|
|
390
|
+
The watch stops:
|
|
391
|
+
|
|
392
|
+
- at the first `Ctrl-C` during a wait, at once;
|
|
393
|
+
- at the first `Ctrl-C` during an issue, after that issue;
|
|
394
|
+
- after a pass that halts the run, for example when the base branch is broken
|
|
395
|
+
after `--merge`;
|
|
396
|
+
- when the open issues cannot be read.
|
|
397
|
+
|
|
370
398
|
## Development
|
|
371
399
|
|
|
372
400
|
```bash
|
package/dist/args.mjs
CHANGED
|
@@ -6,6 +6,7 @@ export const USAGE = `next-issue [command] [options]
|
|
|
6
6
|
--once stop after the first handled issue
|
|
7
7
|
--max <n> handle at most n issues
|
|
8
8
|
--merge squash-merge the pull request after the approval
|
|
9
|
+
--watch keep running and read the issues again after each pass
|
|
9
10
|
--reset drop the saved budgets and findings first
|
|
10
11
|
--json print the machine summary instead of the text one
|
|
11
12
|
--force replace an existing config file, with init
|
|
@@ -22,6 +23,7 @@ export function parseArgs(argv) {
|
|
|
22
23
|
reset: false,
|
|
23
24
|
force: false,
|
|
24
25
|
merge: false,
|
|
26
|
+
watch: false,
|
|
25
27
|
level: "normal",
|
|
26
28
|
};
|
|
27
29
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -44,6 +46,9 @@ export function parseArgs(argv) {
|
|
|
44
46
|
else if (arg === "--merge") {
|
|
45
47
|
args.merge = true;
|
|
46
48
|
}
|
|
49
|
+
else if (arg === "--watch") {
|
|
50
|
+
args.watch = true;
|
|
51
|
+
}
|
|
47
52
|
else if (arg === "--reset") {
|
|
48
53
|
args.reset = true;
|
|
49
54
|
}
|
package/dist/cli.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { setCommandObserver, setDefaultTimeout } from "./exec.mjs";
|
|
|
7
7
|
import { detectRepo, ensureIgnored, repoRoot, WORKTREE_DIR } from "./git.mjs";
|
|
8
8
|
import { defaultBranch } from "./github.mjs";
|
|
9
9
|
import { listenForInterrupts } from "./interrupt.mjs";
|
|
10
|
-
import { exitCode, runIssues } from "./loop.mjs";
|
|
10
|
+
import { exitCode, runIssues, watchIssues } from "./loop.mjs";
|
|
11
11
|
import { message, pruneRuns, Recorder } from "./observe.mjs";
|
|
12
12
|
import { pendingIssues, PORTS, processIssue } from "./pipeline.mjs";
|
|
13
13
|
import { STATE_DIR } from "./state.mjs";
|
|
@@ -82,7 +82,7 @@ async function handle(args, config, repo, recorder) {
|
|
|
82
82
|
base,
|
|
83
83
|
log: recorder.logFile,
|
|
84
84
|
}, "quiet");
|
|
85
|
-
const
|
|
85
|
+
const loop = {
|
|
86
86
|
issues: async () => {
|
|
87
87
|
const issues = await pendingIssues(context);
|
|
88
88
|
recorder.event("issues", { open: issues.length }, "quiet");
|
|
@@ -90,7 +90,20 @@ async function handle(args, config, repo, recorder) {
|
|
|
90
90
|
},
|
|
91
91
|
process: (issue) => processIssue(context, issue),
|
|
92
92
|
stop: interrupts.afterIssue,
|
|
93
|
-
}
|
|
93
|
+
};
|
|
94
|
+
const print = (summary) => write(process.stdout, args.json ? `${JSON.stringify(summary, null, 2)}\n` : formatSummary(summary));
|
|
95
|
+
let passStart = recorder.summary();
|
|
96
|
+
const { reports, stoppedAt, error } = args.watch
|
|
97
|
+
? await watchIssues(args, {
|
|
98
|
+
...loop,
|
|
99
|
+
wait: (minutes) => {
|
|
100
|
+
recorder.event("watch.wait", { minutes }, "quiet");
|
|
101
|
+
passStart = recorder.summary();
|
|
102
|
+
return interrupts.sleep(minutes * 60_000);
|
|
103
|
+
},
|
|
104
|
+
passEnd: (issues) => print({ ...recorder.summary(passStart), issues }),
|
|
105
|
+
})
|
|
106
|
+
: await runIssues(args, loop);
|
|
94
107
|
if (stoppedAt !== undefined) {
|
|
95
108
|
recorder.event("run.stop", { issue: stoppedAt }, "quiet");
|
|
96
109
|
}
|
|
@@ -107,7 +120,9 @@ async function handle(args, config, repo, recorder) {
|
|
|
107
120
|
const summary = { ...recorder.summary(), issues: reports };
|
|
108
121
|
const summaryFile = join(repo.root, STATE_DIR, "runs", `${recorder.runId}.summary.json`);
|
|
109
122
|
await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
|
|
110
|
-
|
|
123
|
+
if (!args.watch) {
|
|
124
|
+
await print(summary);
|
|
125
|
+
}
|
|
111
126
|
return error === undefined ? exitCode(reports) : 1;
|
|
112
127
|
}
|
|
113
128
|
function count(reports, outcome) {
|
package/dist/interrupt.mjs
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { stopChildren } from "./exec.mjs";
|
|
2
2
|
export function listenForInterrupts(say) {
|
|
3
3
|
let presses = 0;
|
|
4
|
+
let wake;
|
|
4
5
|
const press = () => {
|
|
5
6
|
presses += 1;
|
|
6
|
-
if (presses === 1) {
|
|
7
|
+
if (presses === 1 && wake !== undefined) {
|
|
8
|
+
say("Stopping now.");
|
|
9
|
+
wake();
|
|
10
|
+
}
|
|
11
|
+
else if (presses === 1) {
|
|
7
12
|
say("Stopping after this issue. Press Ctrl-C again to stop after this round.");
|
|
8
13
|
}
|
|
9
14
|
else if (presses === 2) {
|
|
@@ -18,5 +23,20 @@ export function listenForInterrupts(say) {
|
|
|
18
23
|
return {
|
|
19
24
|
afterIssue: () => presses >= 1,
|
|
20
25
|
afterRound: () => presses >= 2,
|
|
26
|
+
sleep: (ms) => new Promise((resolve) => {
|
|
27
|
+
if (presses >= 1) {
|
|
28
|
+
resolve(false);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
wake = undefined;
|
|
33
|
+
resolve(true);
|
|
34
|
+
}, ms);
|
|
35
|
+
wake = () => {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
wake = undefined;
|
|
38
|
+
resolve(false);
|
|
39
|
+
};
|
|
40
|
+
}),
|
|
21
41
|
};
|
|
22
42
|
}
|
package/dist/loop.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export const BACK_OFF_MINUTES = [1, 2, 3, 5, 10];
|
|
1
2
|
export async function runIssues(args, loop) {
|
|
2
3
|
const reports = [];
|
|
3
4
|
const seen = new Set();
|
|
@@ -25,6 +26,30 @@ export async function runIssues(args, loop) {
|
|
|
25
26
|
}
|
|
26
27
|
return { reports };
|
|
27
28
|
}
|
|
29
|
+
export async function watchIssues(args, watch) {
|
|
30
|
+
const limit = args.once ? 1 : args.max;
|
|
31
|
+
const reports = [];
|
|
32
|
+
let step = 0;
|
|
33
|
+
for (;;) {
|
|
34
|
+
const pass = await runIssues({ once: false, max: limit === undefined ? undefined : limit - handled(reports) }, watch);
|
|
35
|
+
reports.push(...pass.reports);
|
|
36
|
+
if (handled(pass.reports) > 0) {
|
|
37
|
+
await watch.passEnd(pass.reports);
|
|
38
|
+
step = 0;
|
|
39
|
+
}
|
|
40
|
+
if (pass.error !== undefined || pass.stoppedAt !== undefined) {
|
|
41
|
+
return { ...pass, reports };
|
|
42
|
+
}
|
|
43
|
+
if ((limit !== undefined && handled(reports) >= limit) || watch.stop()) {
|
|
44
|
+
return { reports };
|
|
45
|
+
}
|
|
46
|
+
const minutes = BACK_OFF_MINUTES[Math.min(step, BACK_OFF_MINUTES.length - 1)];
|
|
47
|
+
step += 1;
|
|
48
|
+
if (!(await watch.wait(minutes))) {
|
|
49
|
+
return { reports };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
28
53
|
export function exitCode(reports) {
|
|
29
54
|
return reports.some((report) => report.outcome === "needs-human" || report.outcome === "error") ? 1 : 0;
|
|
30
55
|
}
|
package/dist/observe.mjs
CHANGED
|
@@ -13,6 +13,15 @@ export function addUsage(target, source) {
|
|
|
13
13
|
target.cacheWrite += source.cacheWrite;
|
|
14
14
|
target.total += source.total;
|
|
15
15
|
}
|
|
16
|
+
function negate(usage) {
|
|
17
|
+
return {
|
|
18
|
+
input: -usage.input,
|
|
19
|
+
output: -usage.output,
|
|
20
|
+
cacheRead: -usage.cacheRead,
|
|
21
|
+
cacheWrite: -usage.cacheWrite,
|
|
22
|
+
total: -usage.total,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
16
25
|
class Sink {
|
|
17
26
|
totals = new Map();
|
|
18
27
|
counts = new Map();
|
|
@@ -98,20 +107,27 @@ export class Recorder {
|
|
|
98
107
|
throw error;
|
|
99
108
|
}
|
|
100
109
|
}
|
|
101
|
-
summary() {
|
|
110
|
+
summary(since) {
|
|
102
111
|
const total = emptyUsage();
|
|
103
112
|
const roles = {};
|
|
104
113
|
for (const [role, value] of this.sink.totals) {
|
|
105
|
-
roles[role]
|
|
106
|
-
|
|
114
|
+
const earlier = since?.roles[role] ?? { ...emptyUsage(), calls: 0, ms: 0 };
|
|
115
|
+
if (value.calls === earlier.calls) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const usage = { ...value, calls: value.calls - earlier.calls, ms: value.ms - earlier.ms };
|
|
119
|
+
addUsage(usage, negate(earlier));
|
|
120
|
+
roles[role] = usage;
|
|
121
|
+
addUsage(total, usage);
|
|
107
122
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
123
|
+
const counts = {};
|
|
124
|
+
for (const [kind, value] of this.sink.counts) {
|
|
125
|
+
const count = value - (since?.counts[kind] ?? 0);
|
|
126
|
+
if (count > 0) {
|
|
127
|
+
counts[kind] = count;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { runId: this.runId, logFile: this.sink.file, roles, counts, total };
|
|
115
131
|
}
|
|
116
132
|
async close() {
|
|
117
133
|
await this.sink.close();
|