@ravshansbox/pi-next-issue 0.0.0-stage → 1.0.2
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 +54 -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 +245 -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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ravshan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-next-issue
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A [pi](https://github.com/earendil-works/pi) extension that works through the open GitHub issues of a repository in the background, while you keep using pi.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pi install npm:@ravshansbox/pi-next-issue
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It needs `git` and an authenticated [`gh`](https://cli.github.com/).
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
Run these in pi, inside a git repository:
|
|
14
|
+
|
|
15
|
+
- `/next-issue start` starts a run.
|
|
16
|
+
- `/next-issue stop` stops the run. The issue keeps its saved state, and the next run resumes it.
|
|
17
|
+
- `/next-issue status` shows where the run is.
|
|
18
|
+
|
|
19
|
+
The footer shows the issue, the stage, the number of tool calls and the time spent on the issue, for example `next-issue #12 · review 1/3 · 37 tools · 4m12s`. A line above the editor shows the last tool call. A note appears when an issue is merged or needs a human.
|
|
20
|
+
|
|
21
|
+
The run does not belong to a session: it goes on after `/new`, `/resume`, a fork or a reload, and the new session shows it. It stops when you quit pi.
|
|
22
|
+
|
|
23
|
+
## What it does
|
|
24
|
+
|
|
25
|
+
Each loop fetches the open issues and takes the oldest one with saved state, else the oldest one with the `status:todo` label and none of `status:in-progress`, `status:done`, `status:needs-human` or `status:blocked`. The run ends when no such issue is left, or at the first error. For each issue:
|
|
26
|
+
|
|
27
|
+
1. Swap `status:todo` for `status:in-progress`.
|
|
28
|
+
2. Add a git worktree at `.worktrees/<n>` on the `<n>` branch, from the remote default branch.
|
|
29
|
+
3. The implementer agent does the work. The extension commits it, with the subject from a `commit:` line in the reply of the agent, and pushes.
|
|
30
|
+
4. Open a pull request that closes the issue.
|
|
31
|
+
5. Wait for the checks. On a red build, the fixer agent gets the failed logs, up to `maxCiFixes` times.
|
|
32
|
+
6. The reviewer agent judges the diff and gives a verdict with `blocking` and `minor` findings, which goes on the pull request. Any change the reviewer makes is thrown away.
|
|
33
|
+
7. While review rounds are left, the fixer gets every finding, and the checks and the review run again. In the last round only a blocking finding stops the merge.
|
|
34
|
+
8. Squash-merge the pull request, wait until it is merged, set `status:done`, and remove the worktree, the local branch, the remote branch and the saved state.
|
|
35
|
+
|
|
36
|
+
An issue that cannot go on gets `status:needs-human` and a comment with the reason. Its worktree stays for you to look at.
|
|
37
|
+
|
|
38
|
+
The implementer and the reviewer read the issue title, the body and the comments. They get only comments by the owner, members and collaborators of the repository. The body is passed as it is, and its author can edit it after you add the label, so check the body before you add the label.
|
|
39
|
+
|
|
40
|
+
Each agent is a pi session in the worktree. It uses the model and thinking level from your pi settings, and loads all of your extensions except this one.
|
|
41
|
+
|
|
42
|
+
The state of each issue is saved in `.worktrees/<n>.json`. The extension adds `/.worktrees/` to `.git/info/exclude`.
|
|
43
|
+
|
|
44
|
+
## Config
|
|
45
|
+
|
|
46
|
+
An optional `.pi/next-issue.json` in the repository sets these fields:
|
|
47
|
+
|
|
48
|
+
| Field | Default | Meaning |
|
|
49
|
+
| --------------------- | ---------- | ------------------------------------------------- |
|
|
50
|
+
| `remote` | `"origin"` | The git remote to push to. |
|
|
51
|
+
| `maxCiFixes` | `3` | Fixes of red checks per issue. |
|
|
52
|
+
| `maxReviewRounds` | `3` | Review rounds per issue. |
|
|
53
|
+
| `checkTimeoutMinutes` | `30` | The longest wait for the checks, and for a merge. |
|
|
54
|
+
| `diffMaxChars` | `60000` | The longest diff that the reviewer gets. |
|
|
55
|
+
| `logMaxChars` | `20000` | The longest failed log that the fixer gets. |
|
package/index.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { loadConfig } from './src/config.ts';
|
|
3
|
+
import { repoRoot } from './src/git.ts';
|
|
4
|
+
import { createPorts } from './src/ports.ts';
|
|
5
|
+
import { type Progress, Runner } from './src/runner.ts';
|
|
6
|
+
|
|
7
|
+
type Launch = (cwd: string) => Promise<Runner>;
|
|
8
|
+
|
|
9
|
+
type Holder = { runner?: Runner };
|
|
10
|
+
|
|
11
|
+
const KEY = Symbol.for('pi-next-issue');
|
|
12
|
+
const STATUS_KEY = 'next-issue';
|
|
13
|
+
|
|
14
|
+
function holder(): Holder {
|
|
15
|
+
const store = globalThis as Record<symbol, Holder | undefined>;
|
|
16
|
+
store[KEY] ??= {};
|
|
17
|
+
return store[KEY];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function duration(ms: number): string {
|
|
21
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
22
|
+
const minutes = Math.floor(seconds / 60);
|
|
23
|
+
return minutes > 0 ? `${minutes}m${String(seconds % 60).padStart(2, '0')}s` : `${seconds}s`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function outcomes(progress: Progress): string {
|
|
27
|
+
const merged = progress.handled.filter((item) => item.outcome === 'merged').length;
|
|
28
|
+
const human = progress.handled.length - merged;
|
|
29
|
+
const parts = [`${merged} merged`];
|
|
30
|
+
if (human > 0) {
|
|
31
|
+
parts.push(`${human} needs a human`);
|
|
32
|
+
}
|
|
33
|
+
return parts.join(', ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatStatus(progress: Progress, now: number): string {
|
|
37
|
+
switch (progress.status) {
|
|
38
|
+
case 'idle':
|
|
39
|
+
return 'next-issue · starting';
|
|
40
|
+
case 'running': {
|
|
41
|
+
if (progress.issue === undefined) {
|
|
42
|
+
return 'next-issue · looking for issues';
|
|
43
|
+
}
|
|
44
|
+
const parts = [`next-issue #${progress.issue}`, progress.stage ?? 'prepare'];
|
|
45
|
+
parts.push(`${progress.tools} tools`);
|
|
46
|
+
if (progress.issueStartedAt !== undefined) {
|
|
47
|
+
parts.push(duration(now - progress.issueStartedAt));
|
|
48
|
+
}
|
|
49
|
+
return parts.join(' · ');
|
|
50
|
+
}
|
|
51
|
+
case 'stopping':
|
|
52
|
+
return `next-issue #${progress.issue ?? '?'} · stopping`;
|
|
53
|
+
case 'stopped':
|
|
54
|
+
return `next-issue · stopped: ${outcomes(progress)}`;
|
|
55
|
+
case 'finished':
|
|
56
|
+
return `next-issue · finished: ${outcomes(progress)}`;
|
|
57
|
+
case 'failed':
|
|
58
|
+
return `next-issue · failed: ${progress.error ?? 'unknown error'}`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const launchFromRepo: Launch = async (cwd) => {
|
|
63
|
+
const root = await repoRoot(cwd);
|
|
64
|
+
const config = await loadConfig(root);
|
|
65
|
+
return new Runner(await createPorts(root, config), config);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function createExtension(launch: Launch) {
|
|
69
|
+
return (pi: ExtensionAPI) => {
|
|
70
|
+
let ui: ExtensionContext['ui'] | undefined;
|
|
71
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
72
|
+
let unsubscribe: (() => void) | undefined;
|
|
73
|
+
let handled = 0;
|
|
74
|
+
let status: Progress['status'] | undefined;
|
|
75
|
+
|
|
76
|
+
const render = () => {
|
|
77
|
+
const runner = holder().runner;
|
|
78
|
+
if (ui === undefined || runner === undefined) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const progress = runner.progress;
|
|
82
|
+
ui.setStatus(STATUS_KEY, formatStatus(progress, Date.now()));
|
|
83
|
+
const active = progress.status === 'running' && progress.lastTool !== undefined;
|
|
84
|
+
ui.setWidget(STATUS_KEY, active ? [`↳ ${progress.lastTool}`] : undefined);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const onChange = (progress: Progress) => {
|
|
88
|
+
for (const item of progress.handled.slice(handled)) {
|
|
89
|
+
ui?.notify(
|
|
90
|
+
`next-issue: #${item.issue} ${item.outcome === 'merged' ? 'merged' : 'needs a human'}`,
|
|
91
|
+
item.outcome === 'merged' ? 'info' : 'warning',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
handled = progress.handled.length;
|
|
95
|
+
if (progress.status !== status) {
|
|
96
|
+
status = progress.status;
|
|
97
|
+
if (status === 'finished') {
|
|
98
|
+
ui?.notify('next-issue finished', 'info');
|
|
99
|
+
} else if (status === 'stopped') {
|
|
100
|
+
ui?.notify('next-issue stopped', 'info');
|
|
101
|
+
} else if (status === 'failed') {
|
|
102
|
+
ui?.notify(`next-issue failed: ${progress.error ?? 'unknown error'}`, 'error');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
render();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const follow = (runner: Runner) => {
|
|
109
|
+
unsubscribe?.();
|
|
110
|
+
handled = runner.progress.handled.length;
|
|
111
|
+
status = runner.progress.status;
|
|
112
|
+
unsubscribe = runner.subscribe(onChange);
|
|
113
|
+
render();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const detach = () => {
|
|
117
|
+
unsubscribe?.();
|
|
118
|
+
unsubscribe = undefined;
|
|
119
|
+
if (timer !== undefined) {
|
|
120
|
+
clearInterval(timer);
|
|
121
|
+
timer = undefined;
|
|
122
|
+
}
|
|
123
|
+
ui = undefined;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
127
|
+
if (!ctx.hasUI) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
ui = ctx.ui;
|
|
131
|
+
timer ??= setInterval(render, 1000);
|
|
132
|
+
timer.unref?.();
|
|
133
|
+
const runner = holder().runner;
|
|
134
|
+
if (runner !== undefined) {
|
|
135
|
+
follow(runner);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
pi.on('session_shutdown', async (event) => {
|
|
140
|
+
detach();
|
|
141
|
+
if (event.reason === 'quit') {
|
|
142
|
+
holder().runner?.stop();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
pi.registerCommand('next-issue', {
|
|
147
|
+
description: 'Work through the GitHub issues of this repository in the background',
|
|
148
|
+
getArgumentCompletions: (prefix) => {
|
|
149
|
+
const items = ['start', 'stop', 'status']
|
|
150
|
+
.filter((name) => name.startsWith(prefix))
|
|
151
|
+
.map((name) => ({ value: name, label: name }));
|
|
152
|
+
return items.length > 0 ? items : null;
|
|
153
|
+
},
|
|
154
|
+
handler: async (args, ctx) => {
|
|
155
|
+
const action = args.trim() || 'status';
|
|
156
|
+
const current = holder().runner;
|
|
157
|
+
const busy =
|
|
158
|
+
current?.progress.status === 'running' || current?.progress.status === 'stopping';
|
|
159
|
+
if (action === 'start') {
|
|
160
|
+
if (busy) {
|
|
161
|
+
ctx.ui.notify('next-issue is running already', 'warning');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
let runner: Runner;
|
|
165
|
+
try {
|
|
166
|
+
runner = await launch(ctx.cwd);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
ctx.ui.notify(
|
|
169
|
+
`next-issue cannot start: ${error instanceof Error ? error.message : String(error)}`,
|
|
170
|
+
'error',
|
|
171
|
+
);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
holder().runner = runner;
|
|
175
|
+
if (ctx.hasUI) {
|
|
176
|
+
ui = ctx.ui;
|
|
177
|
+
follow(runner);
|
|
178
|
+
}
|
|
179
|
+
void runner.run();
|
|
180
|
+
} else if (action === 'stop') {
|
|
181
|
+
if (!busy) {
|
|
182
|
+
ctx.ui.notify('next-issue is not running', 'info');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
current?.stop();
|
|
186
|
+
} else if (action === 'status') {
|
|
187
|
+
ctx.ui.notify(
|
|
188
|
+
current === undefined
|
|
189
|
+
? 'next-issue has not run yet'
|
|
190
|
+
: formatStatus(current.progress, Date.now()),
|
|
191
|
+
'info',
|
|
192
|
+
);
|
|
193
|
+
} else {
|
|
194
|
+
ctx.ui.notify('usage: /next-issue start | stop | status', 'warning');
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export default createExtension(launchFromRepo);
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ravshansbox/pi-next-issue",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Pi extension that works through GitHub issues in the background",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ravshansbox/pi-next-issue.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/ravshansbox/pi-next-issue#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ravshansbox/pi-next-issue/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package",
|
|
17
|
+
"pi-extension",
|
|
18
|
+
"github",
|
|
19
|
+
"issues"
|
|
20
|
+
],
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./index.ts"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"lint": "oxlint --deny-warnings",
|
|
29
|
+
"lint:fix": "oxlint --fix",
|
|
30
|
+
"format": "oxfmt",
|
|
31
|
+
"format:check": "oxfmt --check",
|
|
32
|
+
"test": "node --test \"test/*.test.ts\"",
|
|
33
|
+
"check": "npm run typecheck && npm run lint && npm run format:check && npm test"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
37
|
+
"typebox": "*"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^26.6.5",
|
|
41
|
+
"oxfmt": "^0.74.0",
|
|
42
|
+
"oxlint": "^1.89.0",
|
|
43
|
+
"typescript": "^7.0.2"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"index.ts",
|
|
47
|
+
"src"
|
|
48
|
+
],
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { dirname, sep } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import {
|
|
4
|
+
createAgentSession,
|
|
5
|
+
DefaultResourceLoader,
|
|
6
|
+
defineTool,
|
|
7
|
+
getAgentDir,
|
|
8
|
+
SessionManager,
|
|
9
|
+
type ToolDefinition,
|
|
10
|
+
} from '@earendil-works/pi-coding-agent';
|
|
11
|
+
import { Type } from 'typebox';
|
|
12
|
+
import type { AgentTask, Verdict } from './runner.ts';
|
|
13
|
+
|
|
14
|
+
const PACKAGE_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
15
|
+
|
|
16
|
+
export function isOwnExtension(path: string): boolean {
|
|
17
|
+
return path === PACKAGE_DIR || path.startsWith(PACKAGE_DIR + sep);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function toolLabel(name: string, args: unknown): string {
|
|
21
|
+
const fields = (typeof args === 'object' && args !== null ? args : {}) as Record<string, unknown>;
|
|
22
|
+
const detail = [fields['command'], fields['path'], fields['file_path'], fields['pattern']].find(
|
|
23
|
+
(value) => typeof value === 'string',
|
|
24
|
+
) as string | undefined;
|
|
25
|
+
const line = detail === undefined ? name : `${name} ${detail.split('\n')[0]}`;
|
|
26
|
+
return line.length > 80 ? `${line.slice(0, 79)}…` : line;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function runSession(task: AgentTask, customTools: ToolDefinition[]): Promise<string> {
|
|
30
|
+
const agentDir = getAgentDir();
|
|
31
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
32
|
+
cwd: task.cwd,
|
|
33
|
+
agentDir,
|
|
34
|
+
extensionsOverride: (base) => ({
|
|
35
|
+
...base,
|
|
36
|
+
extensions: base.extensions.filter((extension) => !isOwnExtension(extension.resolvedPath)),
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
39
|
+
await resourceLoader.reload();
|
|
40
|
+
const { session } = await createAgentSession({
|
|
41
|
+
cwd: task.cwd,
|
|
42
|
+
agentDir,
|
|
43
|
+
resourceLoader,
|
|
44
|
+
sessionManager: SessionManager.inMemory(task.cwd),
|
|
45
|
+
customTools,
|
|
46
|
+
});
|
|
47
|
+
const abort = () => void session.abort();
|
|
48
|
+
task.signal.addEventListener('abort', abort);
|
|
49
|
+
const unsubscribe = session.subscribe((event) => {
|
|
50
|
+
if (event.type === 'tool_execution_start') {
|
|
51
|
+
task.onTool(toolLabel(event.toolName, event.args));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
await session.bindExtensions({});
|
|
56
|
+
task.signal.throwIfAborted();
|
|
57
|
+
await session.prompt(task.prompt, { expandPromptTemplates: false });
|
|
58
|
+
task.signal.throwIfAborted();
|
|
59
|
+
const last = session.messages.findLast((message) => message.role === 'assistant');
|
|
60
|
+
if (last !== undefined && 'stopReason' in last && last.stopReason === 'error') {
|
|
61
|
+
throw new Error(`the agent failed: ${last.errorMessage ?? 'no reason given'}`);
|
|
62
|
+
}
|
|
63
|
+
return session.getLastAssistantText() ?? '';
|
|
64
|
+
} finally {
|
|
65
|
+
unsubscribe();
|
|
66
|
+
task.signal.removeEventListener('abort', abort);
|
|
67
|
+
session.dispose();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function work(task: AgentTask): Promise<string> {
|
|
72
|
+
return runSession(task, []);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function review(task: AgentTask): Promise<Verdict | undefined> {
|
|
76
|
+
let verdict: Verdict | undefined;
|
|
77
|
+
const submit = defineTool({
|
|
78
|
+
name: 'submit_verdict',
|
|
79
|
+
label: 'Submit verdict',
|
|
80
|
+
description: 'Give the result of the review. Call it once, as your last action.',
|
|
81
|
+
parameters: Type.Object({
|
|
82
|
+
summary: Type.String({ description: 'Short summary of the review' }),
|
|
83
|
+
findings: Type.Array(
|
|
84
|
+
Type.Object({
|
|
85
|
+
severity: Type.Union([Type.Literal('blocking'), Type.Literal('minor')], {
|
|
86
|
+
description:
|
|
87
|
+
'blocking for a wrong result, a missing part of the issue, a regression or a broken convention; minor for taste and style',
|
|
88
|
+
}),
|
|
89
|
+
detail: Type.String({ description: 'What is wrong and where' }),
|
|
90
|
+
}),
|
|
91
|
+
{ description: 'One entry per problem. Empty when nothing is wrong.' },
|
|
92
|
+
),
|
|
93
|
+
}),
|
|
94
|
+
async execute(_toolCallId, params) {
|
|
95
|
+
verdict = { summary: params.summary, findings: params.findings };
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: 'text', text: 'Verdict saved.' }],
|
|
98
|
+
details: verdict,
|
|
99
|
+
terminate: true,
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
await runSession(task, [submit as unknown as ToolDefinition]);
|
|
104
|
+
return verdict;
|
|
105
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export type Config = {
|
|
5
|
+
remote: string;
|
|
6
|
+
maxCiFixes: number;
|
|
7
|
+
maxReviewRounds: number;
|
|
8
|
+
checkTimeoutMinutes: number;
|
|
9
|
+
diffMaxChars: number;
|
|
10
|
+
logMaxChars: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const CONFIG_FILE = join('.pi', 'next-issue.json');
|
|
14
|
+
|
|
15
|
+
const DEFAULTS: Config = {
|
|
16
|
+
remote: 'origin',
|
|
17
|
+
maxCiFixes: 3,
|
|
18
|
+
maxReviewRounds: 3,
|
|
19
|
+
checkTimeoutMinutes: 30,
|
|
20
|
+
diffMaxChars: 60000,
|
|
21
|
+
logMaxChars: 20000,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const LEAST: Record<Exclude<keyof Config, 'remote'>, number> = {
|
|
25
|
+
maxCiFixes: 0,
|
|
26
|
+
maxReviewRounds: 1,
|
|
27
|
+
checkTimeoutMinutes: 1,
|
|
28
|
+
diffMaxChars: 1,
|
|
29
|
+
logMaxChars: 1,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function loadConfig(root: string): Promise<Config> {
|
|
33
|
+
let raw: string;
|
|
34
|
+
try {
|
|
35
|
+
raw = await readFile(join(root, CONFIG_FILE), 'utf8');
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
38
|
+
return { ...DEFAULTS };
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
let parsed: unknown;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(raw);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(`${CONFIG_FILE} is not valid JSON`);
|
|
47
|
+
}
|
|
48
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
49
|
+
throw new Error(`${CONFIG_FILE} must hold an object`);
|
|
50
|
+
}
|
|
51
|
+
const config: Config = { ...DEFAULTS };
|
|
52
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
53
|
+
if (key === 'remote') {
|
|
54
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
55
|
+
throw new Error(`${CONFIG_FILE}: remote must be text that is not empty`);
|
|
56
|
+
}
|
|
57
|
+
config.remote = value;
|
|
58
|
+
} else if (key in LEAST) {
|
|
59
|
+
const least = LEAST[key as keyof typeof LEAST];
|
|
60
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < least) {
|
|
61
|
+
throw new Error(`${CONFIG_FILE}: ${key} must be a whole number of ${least} or more`);
|
|
62
|
+
}
|
|
63
|
+
config[key as keyof typeof LEAST] = value;
|
|
64
|
+
} else {
|
|
65
|
+
throw new Error(`${CONFIG_FILE} holds an unknown field: ${key}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return config;
|
|
69
|
+
}
|
package/src/exec.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export type Result = { code: number; stdout: string; stderr: string };
|
|
4
|
+
|
|
5
|
+
export type RunOptions = { cwd: string; input?: string; signal?: AbortSignal };
|
|
6
|
+
|
|
7
|
+
export function run(command: string, args: string[], options: RunOptions): Promise<Result> {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const child = spawn(command, args, {
|
|
10
|
+
cwd: options.cwd,
|
|
11
|
+
stdio: [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
12
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
13
|
+
});
|
|
14
|
+
let stdout = '';
|
|
15
|
+
let stderr = '';
|
|
16
|
+
child.stdout?.setEncoding('utf8').on('data', (chunk: string) => (stdout += chunk));
|
|
17
|
+
child.stderr?.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk));
|
|
18
|
+
child.on('error', reject);
|
|
19
|
+
child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
20
|
+
if (options.input !== undefined) {
|
|
21
|
+
child.stdin?.on('error', () => {});
|
|
22
|
+
child.stdin?.end(options.input);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function must(command: string, args: string[], options: RunOptions): Promise<string> {
|
|
28
|
+
const result = await run(command, args, options);
|
|
29
|
+
if (result.code !== 0) {
|
|
30
|
+
const detail = (result.stderr.trim() || result.stdout.trim()).slice(-2000);
|
|
31
|
+
throw new Error(`${command} ${args.join(' ')} failed with code ${result.code}: ${detail}`);
|
|
32
|
+
}
|
|
33
|
+
return result.stdout.trim();
|
|
34
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { appendFile, readFile } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
import { must, run } from './exec.ts';
|
|
4
|
+
|
|
5
|
+
export const WORKTREE_DIR = '.worktrees';
|
|
6
|
+
|
|
7
|
+
export async function repoRoot(cwd: string): Promise<string> {
|
|
8
|
+
return must('git', ['rev-parse', '--show-toplevel'], { cwd });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseRemote(url: string): string {
|
|
12
|
+
const match = /[/:]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/.exec(url.trim());
|
|
13
|
+
if (match === null) {
|
|
14
|
+
throw new Error(`cannot read the owner and name from the git remote ${url}`);
|
|
15
|
+
}
|
|
16
|
+
return `${match[1]}/${match[2]}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function remoteSlug(root: string, remote: string): Promise<string> {
|
|
20
|
+
return parseRemote(await must('git', ['remote', 'get-url', remote], { cwd: root }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function ignoreWorktrees(root: string): Promise<void> {
|
|
24
|
+
const common = await must('git', ['rev-parse', '--git-common-dir'], { cwd: root });
|
|
25
|
+
const file = join(isAbsolute(common) ? common : join(root, common), 'info', 'exclude');
|
|
26
|
+
const current = await readFile(file, 'utf8').catch(() => '');
|
|
27
|
+
if (!current.split('\n').some((line) => line.trim() === `/${WORKTREE_DIR}/`)) {
|
|
28
|
+
const gap = current.length === 0 || current.endsWith('\n') ? '' : '\n';
|
|
29
|
+
await appendFile(file, `${gap}/${WORKTREE_DIR}/\n`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function findWorktree(root: string, branch: string): Promise<string | undefined> {
|
|
34
|
+
const list = await must('git', ['worktree', 'list', '--porcelain'], { cwd: root });
|
|
35
|
+
const entry = list
|
|
36
|
+
.split('\n\n')
|
|
37
|
+
.map((block) => block.split('\n'))
|
|
38
|
+
.find((lines) => lines.includes(`branch refs/heads/${branch}`));
|
|
39
|
+
return entry?.find((line) => line.startsWith('worktree '))?.slice('worktree '.length);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function hasRef(root: string, ref: string): Promise<boolean> {
|
|
43
|
+
return (await run('git', ['rev-parse', '--verify', '--quiet', ref], { cwd: root })).code === 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function worktree(
|
|
47
|
+
root: string,
|
|
48
|
+
remote: string,
|
|
49
|
+
base: string,
|
|
50
|
+
issue: number,
|
|
51
|
+
branch: string,
|
|
52
|
+
): Promise<string> {
|
|
53
|
+
await ignoreWorktrees(root);
|
|
54
|
+
await must('git', ['fetch', remote], { cwd: root });
|
|
55
|
+
await must('git', ['worktree', 'prune'], { cwd: root });
|
|
56
|
+
const existing = await findWorktree(root, branch);
|
|
57
|
+
if (existing !== undefined) {
|
|
58
|
+
return existing;
|
|
59
|
+
}
|
|
60
|
+
const path = join(root, WORKTREE_DIR, String(issue));
|
|
61
|
+
if (await hasRef(root, `refs/heads/${branch}`)) {
|
|
62
|
+
await must('git', ['worktree', 'add', path, branch], { cwd: root });
|
|
63
|
+
} else {
|
|
64
|
+
const upstream = `${remote}/${branch}`;
|
|
65
|
+
const start = (await hasRef(root, `refs/remotes/${upstream}`)) ? upstream : `${remote}/${base}`;
|
|
66
|
+
await must('git', ['worktree', 'add', '--no-track', '-b', branch, path, start], { cwd: root });
|
|
67
|
+
}
|
|
68
|
+
return path;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function commit(cwd: string, message: string): Promise<boolean> {
|
|
72
|
+
if ((await must('git', ['status', '--porcelain'], { cwd })).length === 0) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
await must('git', ['add', '-A'], { cwd });
|
|
76
|
+
await must('git', ['commit', '-m', message], { cwd });
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function push(cwd: string, remote: string, branch: string): Promise<void> {
|
|
81
|
+
await must('git', ['push', '-u', remote, `HEAD:refs/heads/${branch}`], { cwd });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function discard(cwd: string): Promise<void> {
|
|
85
|
+
await must('git', ['reset', '--hard', '@{upstream}'], { cwd });
|
|
86
|
+
await must('git', ['clean', '-fd'], { cwd });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function diff(cwd: string, remote: string, base: string): Promise<string> {
|
|
90
|
+
await must('git', ['fetch', remote, base], { cwd });
|
|
91
|
+
return must('git', ['diff', `${remote}/${base}...HEAD`], { cwd });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function head(cwd: string): Promise<string> {
|
|
95
|
+
return must('git', ['rev-parse', 'HEAD'], { cwd });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function cleanup(
|
|
99
|
+
root: string,
|
|
100
|
+
remote: string,
|
|
101
|
+
branch: string,
|
|
102
|
+
path: string,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
await run('git', ['worktree', 'remove', '--force', path], { cwd: root });
|
|
105
|
+
await run('git', ['branch', '-D', branch], { cwd: root });
|
|
106
|
+
await run('git', ['push', remote, '--delete', branch], { cwd: root });
|
|
107
|
+
}
|