@ravshansbox/pi-next-issue 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +52 -2
- package/index.ts +201 -0
- package/package.json +50 -4
- package/src/agent.ts +105 -0
- package/src/config.ts +69 -0
- package/src/exec.ts +34 -0
- package/src/git.ts +107 -0
- package/src/github.ts +242 -0
- package/src/ports.ts +36 -0
- package/src/prompts.ts +109 -0
- package/src/runner.ts +335 -0
- package/src/state.ts +40 -0
package/src/runner.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import type { Config } from './config.ts';
|
|
2
|
+
import {
|
|
3
|
+
cap,
|
|
4
|
+
fixPrompt,
|
|
5
|
+
formatFindings,
|
|
6
|
+
formatVerdict,
|
|
7
|
+
implementPrompt,
|
|
8
|
+
parseCommitSubject,
|
|
9
|
+
parseUnrelated,
|
|
10
|
+
reviewPrompt,
|
|
11
|
+
} from './prompts.ts';
|
|
12
|
+
|
|
13
|
+
export type Issue = { number: number; title: string; body: string; labels: string[] };
|
|
14
|
+
|
|
15
|
+
export type Finding = { severity: 'blocking' | 'minor'; detail: string };
|
|
16
|
+
|
|
17
|
+
export type Verdict = { summary: string; findings: Finding[] };
|
|
18
|
+
|
|
19
|
+
export type ChecksResult = 'pass' | 'fail' | 'timeout';
|
|
20
|
+
|
|
21
|
+
export type Phase = 'implement' | 'publish' | 'checks' | 'review' | 'merge';
|
|
22
|
+
|
|
23
|
+
export type IssueState = {
|
|
24
|
+
issue: number;
|
|
25
|
+
branch: string;
|
|
26
|
+
phase: Phase;
|
|
27
|
+
pr?: number;
|
|
28
|
+
title?: string | undefined;
|
|
29
|
+
ciFixes: number;
|
|
30
|
+
round: number;
|
|
31
|
+
earlier: string[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type AgentTask = {
|
|
35
|
+
cwd: string;
|
|
36
|
+
prompt: string;
|
|
37
|
+
signal: AbortSignal;
|
|
38
|
+
onTool: (label: string) => void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type Ports = {
|
|
42
|
+
openIssues(): Promise<Issue[]>;
|
|
43
|
+
comments(issue: number): Promise<string[]>;
|
|
44
|
+
setLabel(issue: number, add: string, remove: string[]): Promise<void>;
|
|
45
|
+
commentOnIssue(issue: number, body: string): Promise<void>;
|
|
46
|
+
worktree(issue: number, branch: string): Promise<string>;
|
|
47
|
+
commit(cwd: string, message: string): Promise<boolean>;
|
|
48
|
+
push(cwd: string, branch: string): Promise<void>;
|
|
49
|
+
discard(cwd: string): Promise<void>;
|
|
50
|
+
diff(cwd: string): Promise<string>;
|
|
51
|
+
openPr(branch: string, title: string, body: string): Promise<number>;
|
|
52
|
+
checks(pr: number, cwd: string, signal: AbortSignal): Promise<ChecksResult>;
|
|
53
|
+
failedLogs(pr: number): Promise<string>;
|
|
54
|
+
commentOnPr(pr: number, body: string): Promise<void>;
|
|
55
|
+
merge(pr: number, signal: AbortSignal): Promise<boolean>;
|
|
56
|
+
cleanup(issue: number, branch: string, cwd: string): Promise<void>;
|
|
57
|
+
work(task: AgentTask): Promise<string>;
|
|
58
|
+
review(task: AgentTask): Promise<Verdict | undefined>;
|
|
59
|
+
readState(issue: number): Promise<IssueState | undefined>;
|
|
60
|
+
writeState(state: IssueState): Promise<void>;
|
|
61
|
+
dropState(issue: number): Promise<void>;
|
|
62
|
+
savedIssues(): Promise<number[]>;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type Outcome = 'merged' | 'needs-human';
|
|
66
|
+
|
|
67
|
+
export type Status = 'idle' | 'running' | 'stopping' | 'finished' | 'stopped' | 'failed';
|
|
68
|
+
|
|
69
|
+
export type Progress = {
|
|
70
|
+
status: Status;
|
|
71
|
+
issue?: number | undefined;
|
|
72
|
+
title?: string | undefined;
|
|
73
|
+
stage?: string | undefined;
|
|
74
|
+
tools: number;
|
|
75
|
+
lastTool?: string | undefined;
|
|
76
|
+
issueStartedAt?: number;
|
|
77
|
+
handled: Array<{ issue: number; outcome: Outcome }>;
|
|
78
|
+
error?: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const LABELS = {
|
|
82
|
+
ready: 'status:todo',
|
|
83
|
+
inProgress: 'status:in-progress',
|
|
84
|
+
done: 'status:done',
|
|
85
|
+
needsHuman: 'status:needs-human',
|
|
86
|
+
blocked: 'status:blocked',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const STOP_LABELS = [LABELS.inProgress, LABELS.done, LABELS.needsHuman, LABELS.blocked];
|
|
90
|
+
|
|
91
|
+
function ready(issue: Issue): boolean {
|
|
92
|
+
return (
|
|
93
|
+
issue.labels.includes(LABELS.ready) &&
|
|
94
|
+
!issue.labels.some((label) => STOP_LABELS.includes(label))
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class Runner {
|
|
99
|
+
progress: Progress = { status: 'idle', tools: 0, handled: [] };
|
|
100
|
+
private readonly controller = new AbortController();
|
|
101
|
+
private readonly listeners = new Set<(progress: Progress) => void>();
|
|
102
|
+
|
|
103
|
+
private readonly ports: Ports;
|
|
104
|
+
private readonly config: Config;
|
|
105
|
+
|
|
106
|
+
constructor(ports: Ports, config: Config) {
|
|
107
|
+
this.ports = ports;
|
|
108
|
+
this.config = config;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
subscribe(listener: (progress: Progress) => void): () => void {
|
|
112
|
+
this.listeners.add(listener);
|
|
113
|
+
return () => this.listeners.delete(listener);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
stop(): void {
|
|
117
|
+
if (this.progress.status === 'running') {
|
|
118
|
+
this.update({ status: 'stopping' });
|
|
119
|
+
}
|
|
120
|
+
this.controller.abort();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async run(): Promise<void> {
|
|
124
|
+
this.update({ status: 'running' });
|
|
125
|
+
const seen = new Set<number>();
|
|
126
|
+
try {
|
|
127
|
+
for (;;) {
|
|
128
|
+
this.controller.signal.throwIfAborted();
|
|
129
|
+
const issues = await this.ports.openIssues();
|
|
130
|
+
const saved = new Set(await this.ports.savedIssues());
|
|
131
|
+
const fresh = issues.filter((item) => !seen.has(item.number));
|
|
132
|
+
const next =
|
|
133
|
+
fresh.find((item) => saved.has(item.number)) ?? fresh.find((item) => ready(item));
|
|
134
|
+
if (next === undefined) {
|
|
135
|
+
this.update({ status: 'finished', stage: undefined, issue: undefined, title: undefined });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
seen.add(next.number);
|
|
139
|
+
const outcome = await this.handle(next, await this.ports.readState(next.number));
|
|
140
|
+
this.update({ handled: [...this.progress.handled, { issue: next.number, outcome }] });
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (this.controller.signal.aborted) {
|
|
144
|
+
this.update({ status: 'stopped' });
|
|
145
|
+
} else {
|
|
146
|
+
this.update({
|
|
147
|
+
status: 'failed',
|
|
148
|
+
error: error instanceof Error ? error.message : String(error),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private update(change: Partial<Progress>): void {
|
|
155
|
+
this.progress = { ...this.progress, ...change };
|
|
156
|
+
for (const listener of this.listeners) {
|
|
157
|
+
listener(this.progress);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private stage(stage: string): void {
|
|
162
|
+
this.controller.signal.throwIfAborted();
|
|
163
|
+
this.update({ stage });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async agent(kind: 'work', cwd: string, prompt: string): Promise<string>;
|
|
167
|
+
private async agent(kind: 'review', cwd: string, prompt: string): Promise<Verdict | undefined>;
|
|
168
|
+
private async agent(
|
|
169
|
+
kind: 'work' | 'review',
|
|
170
|
+
cwd: string,
|
|
171
|
+
prompt: string,
|
|
172
|
+
): Promise<string | Verdict | undefined> {
|
|
173
|
+
const task: AgentTask = {
|
|
174
|
+
cwd,
|
|
175
|
+
prompt,
|
|
176
|
+
signal: this.controller.signal,
|
|
177
|
+
onTool: (label) => this.update({ tools: this.progress.tools + 1, lastTool: label }),
|
|
178
|
+
};
|
|
179
|
+
const result = kind === 'work' ? await this.ports.work(task) : await this.ports.review(task);
|
|
180
|
+
this.controller.signal.throwIfAborted();
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async handle(issue: Issue, saved: IssueState | undefined): Promise<Outcome> {
|
|
185
|
+
const { ports, config } = this;
|
|
186
|
+
const number = issue.number;
|
|
187
|
+
this.update({
|
|
188
|
+
issue: number,
|
|
189
|
+
title: issue.title,
|
|
190
|
+
stage: 'prepare',
|
|
191
|
+
tools: 0,
|
|
192
|
+
lastTool: undefined,
|
|
193
|
+
issueStartedAt: Date.now(),
|
|
194
|
+
});
|
|
195
|
+
let state: IssueState = saved ?? {
|
|
196
|
+
issue: number,
|
|
197
|
+
branch: String(number),
|
|
198
|
+
phase: 'implement',
|
|
199
|
+
ciFixes: 0,
|
|
200
|
+
round: 0,
|
|
201
|
+
earlier: [],
|
|
202
|
+
};
|
|
203
|
+
const save = async (change: Partial<IssueState>) => {
|
|
204
|
+
state = { ...state, ...change };
|
|
205
|
+
await ports.writeState(state);
|
|
206
|
+
};
|
|
207
|
+
const needsHuman = async (reason: string): Promise<Outcome> => {
|
|
208
|
+
await ports.setLabel(number, LABELS.needsHuman, [LABELS.inProgress]);
|
|
209
|
+
await ports.commentOnIssue(number, `next-issue stopped: ${reason}`);
|
|
210
|
+
await ports.dropState(number);
|
|
211
|
+
return 'needs-human';
|
|
212
|
+
};
|
|
213
|
+
if (saved === undefined) {
|
|
214
|
+
await ports.setLabel(number, LABELS.inProgress, [LABELS.ready]);
|
|
215
|
+
await ports.writeState(state);
|
|
216
|
+
}
|
|
217
|
+
const comments = await ports.comments(number);
|
|
218
|
+
const cwd = await ports.worktree(number, state.branch);
|
|
219
|
+
const pr = () => {
|
|
220
|
+
if (state.pr === undefined) {
|
|
221
|
+
throw new Error(`the saved state of #${number} holds no pull request`);
|
|
222
|
+
}
|
|
223
|
+
return state.pr;
|
|
224
|
+
};
|
|
225
|
+
for (;;) {
|
|
226
|
+
switch (state.phase) {
|
|
227
|
+
case 'implement': {
|
|
228
|
+
this.stage('implement');
|
|
229
|
+
const reply = await this.agent('work', cwd, implementPrompt(issue, comments));
|
|
230
|
+
const title = parseCommitSubject(reply, `feat: ${issue.title}`);
|
|
231
|
+
if (!(await ports.commit(cwd, title))) {
|
|
232
|
+
return needsHuman('the implementer made no change');
|
|
233
|
+
}
|
|
234
|
+
await save({ phase: 'publish', title });
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
case 'publish': {
|
|
238
|
+
this.stage('pull request');
|
|
239
|
+
await ports.push(cwd, state.branch);
|
|
240
|
+
const title = state.title ?? `feat: ${issue.title}`;
|
|
241
|
+
await save({
|
|
242
|
+
phase: 'checks',
|
|
243
|
+
pr: await ports.openPr(state.branch, title, `Closes #${number}`),
|
|
244
|
+
});
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case 'checks': {
|
|
248
|
+
this.stage('checks');
|
|
249
|
+
const result = await ports.checks(pr(), cwd, this.controller.signal);
|
|
250
|
+
if (result === 'pass') {
|
|
251
|
+
await save({ phase: 'review' });
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
if (result === 'timeout') {
|
|
255
|
+
return needsHuman('the checks did not finish in time');
|
|
256
|
+
}
|
|
257
|
+
if (state.ciFixes >= config.maxCiFixes) {
|
|
258
|
+
return needsHuman(`the checks still fail after ${config.maxCiFixes} fixes`);
|
|
259
|
+
}
|
|
260
|
+
this.stage(`fix checks ${state.ciFixes + 1}/${config.maxCiFixes}`);
|
|
261
|
+
const logs = cap(await ports.failedLogs(pr()), config.logMaxChars);
|
|
262
|
+
const reply = await this.agent(
|
|
263
|
+
'work',
|
|
264
|
+
cwd,
|
|
265
|
+
fixPrompt(issue, 'The checks failed.', logs, []),
|
|
266
|
+
);
|
|
267
|
+
if (!(await ports.commit(cwd, parseCommitSubject(reply, 'fix: make the checks pass')))) {
|
|
268
|
+
return needsHuman(
|
|
269
|
+
`the fixer made no change: ${parseUnrelated(reply) ?? 'no reason given'}`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
await ports.push(cwd, state.branch);
|
|
273
|
+
await save({ ciFixes: state.ciFixes + 1 });
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
case 'review': {
|
|
277
|
+
const round = state.round + 1;
|
|
278
|
+
const last = round >= config.maxReviewRounds;
|
|
279
|
+
this.stage(`review ${round}/${config.maxReviewRounds}`);
|
|
280
|
+
const diff = cap(await ports.diff(cwd), config.diffMaxChars);
|
|
281
|
+
const verdict = await this.agent(
|
|
282
|
+
'review',
|
|
283
|
+
cwd,
|
|
284
|
+
reviewPrompt(issue, comments, diff, state.earlier),
|
|
285
|
+
);
|
|
286
|
+
await ports.discard(cwd);
|
|
287
|
+
if (verdict === undefined) {
|
|
288
|
+
return needsHuman('the reviewer gave no verdict');
|
|
289
|
+
}
|
|
290
|
+
const open = last
|
|
291
|
+
? verdict.findings.filter((finding) => finding.severity === 'blocking')
|
|
292
|
+
: verdict.findings;
|
|
293
|
+
await ports.commentOnPr(pr(), formatVerdict(verdict, open));
|
|
294
|
+
if (open.length === 0) {
|
|
295
|
+
await save({ phase: 'merge', round });
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
if (last) {
|
|
299
|
+
return needsHuman('blocking review findings are left after the last round');
|
|
300
|
+
}
|
|
301
|
+
this.stage(`fix review ${round}/${config.maxReviewRounds}`);
|
|
302
|
+
const findings = formatFindings(open);
|
|
303
|
+
const reply = await this.agent(
|
|
304
|
+
'work',
|
|
305
|
+
cwd,
|
|
306
|
+
fixPrompt(issue, 'The reviewer found problems.', findings, state.earlier),
|
|
307
|
+
);
|
|
308
|
+
const committed = await ports.commit(
|
|
309
|
+
cwd,
|
|
310
|
+
parseCommitSubject(reply, 'fix: address the review'),
|
|
311
|
+
);
|
|
312
|
+
const dispute = committed
|
|
313
|
+
? ''
|
|
314
|
+
: `\nThe fixer disputed every finding of this round: ${parseUnrelated(reply) ?? 'no reason given'}`;
|
|
315
|
+
const earlier = [...state.earlier, `Round ${round}:\n${findings}${dispute}`];
|
|
316
|
+
if (committed) {
|
|
317
|
+
await ports.push(cwd, state.branch);
|
|
318
|
+
}
|
|
319
|
+
await save({ phase: committed ? 'checks' : 'review', round, earlier });
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
case 'merge': {
|
|
323
|
+
this.stage('merge');
|
|
324
|
+
if (!(await ports.merge(pr(), this.controller.signal))) {
|
|
325
|
+
return needsHuman(`pull request #${pr()} could not be merged`);
|
|
326
|
+
}
|
|
327
|
+
await ports.setLabel(number, LABELS.done, [LABELS.inProgress]);
|
|
328
|
+
await ports.cleanup(number, state.branch, cwd);
|
|
329
|
+
await ports.dropState(number);
|
|
330
|
+
return 'merged';
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { WORKTREE_DIR } from './git.ts';
|
|
4
|
+
import type { IssueState } from './runner.ts';
|
|
5
|
+
|
|
6
|
+
function dir(root: string): string {
|
|
7
|
+
return join(root, WORKTREE_DIR);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function file(root: string, issue: number): string {
|
|
11
|
+
return join(dir(root), `${issue}.json`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function readState(root: string, issue: number): Promise<IssueState | undefined> {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(await readFile(file(root, issue), 'utf8')) as IssueState;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`the state of #${issue} cannot be read: ${(error as Error).message}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function writeState(root: string, state: IssueState): Promise<void> {
|
|
26
|
+
await mkdir(dir(root), { recursive: true });
|
|
27
|
+
await writeFile(file(root, state.issue), `${JSON.stringify(state, null, 2)}\n`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function dropState(root: string, issue: number): Promise<void> {
|
|
31
|
+
await rm(file(root, issue), { force: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function savedIssues(root: string): Promise<number[]> {
|
|
35
|
+
const names = await readdir(dir(root)).catch(() => []);
|
|
36
|
+
return names.flatMap((name) => {
|
|
37
|
+
const match = /^(\d+)\.json$/.exec(name);
|
|
38
|
+
return match === null ? [] : [Number(match[1])];
|
|
39
|
+
});
|
|
40
|
+
}
|