@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/src/github.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
import { must, run } from './exec.ts';
|
|
3
|
+
import type { ChecksResult, Issue } from './runner.ts';
|
|
4
|
+
|
|
5
|
+
export type Repo = { root: string; slug: string };
|
|
6
|
+
|
|
7
|
+
type Check = { name: string; bucket: string; link: string };
|
|
8
|
+
|
|
9
|
+
const POLL_MS = 15_000;
|
|
10
|
+
const GRACE_MS = 60_000;
|
|
11
|
+
const READ_TRIES = 3;
|
|
12
|
+
const TRUSTED = ['OWNER', 'MEMBER', 'COLLABORATOR'];
|
|
13
|
+
|
|
14
|
+
function gh(repo: Repo, args: string[], input?: string): Promise<string> {
|
|
15
|
+
return must('gh', [...args, '--repo', repo.slug], {
|
|
16
|
+
cwd: repo.root,
|
|
17
|
+
...(input === undefined ? {} : { input }),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function defaultBranch(repo: Repo): Promise<string> {
|
|
22
|
+
return must(
|
|
23
|
+
'gh',
|
|
24
|
+
['repo', 'view', repo.slug, '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name'],
|
|
25
|
+
{
|
|
26
|
+
cwd: repo.root,
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function openIssues(repo: Repo): Promise<Issue[]> {
|
|
32
|
+
const raw = await gh(repo, [
|
|
33
|
+
'issue',
|
|
34
|
+
'list',
|
|
35
|
+
'--state',
|
|
36
|
+
'open',
|
|
37
|
+
'--limit',
|
|
38
|
+
'200',
|
|
39
|
+
'--json',
|
|
40
|
+
'number,title,body,labels,createdAt',
|
|
41
|
+
]);
|
|
42
|
+
const parsed = JSON.parse(raw) as Array<{
|
|
43
|
+
number: number;
|
|
44
|
+
title: string;
|
|
45
|
+
body: string | null;
|
|
46
|
+
createdAt: string;
|
|
47
|
+
labels: Array<{ name: string }>;
|
|
48
|
+
}>;
|
|
49
|
+
return parsed
|
|
50
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
51
|
+
.map((item) => ({
|
|
52
|
+
number: item.number,
|
|
53
|
+
title: item.title,
|
|
54
|
+
body: item.body ?? '',
|
|
55
|
+
labels: item.labels.map((label) => label.name),
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function comments(repo: Repo, issue: number): Promise<string[]> {
|
|
60
|
+
const raw = await gh(repo, ['issue', 'view', String(issue), '--json', 'comments']);
|
|
61
|
+
const parsed = JSON.parse(raw) as {
|
|
62
|
+
comments: Array<{ author: { login: string }; authorAssociation: string; body: string }>;
|
|
63
|
+
};
|
|
64
|
+
return parsed.comments
|
|
65
|
+
.filter((comment) => TRUSTED.includes(comment.authorAssociation))
|
|
66
|
+
.map((comment) => `@${comment.author.login}: ${comment.body}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function setLabel(
|
|
70
|
+
repo: Repo,
|
|
71
|
+
issue: number,
|
|
72
|
+
add: string,
|
|
73
|
+
remove: string[],
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
await run('gh', ['label', 'create', add, '--repo', repo.slug], { cwd: repo.root });
|
|
76
|
+
const raw = await gh(repo, [
|
|
77
|
+
'issue',
|
|
78
|
+
'view',
|
|
79
|
+
String(issue),
|
|
80
|
+
'--json',
|
|
81
|
+
'labels',
|
|
82
|
+
'--jq',
|
|
83
|
+
'.labels[].name',
|
|
84
|
+
]);
|
|
85
|
+
const current = raw.split('\n');
|
|
86
|
+
const args = ['issue', 'edit', String(issue), '--add-label', add];
|
|
87
|
+
for (const name of remove.filter((item) => current.includes(item))) {
|
|
88
|
+
args.push('--remove-label', name);
|
|
89
|
+
}
|
|
90
|
+
await gh(repo, args);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function commentOnIssue(repo: Repo, issue: number, body: string): Promise<void> {
|
|
94
|
+
await gh(repo, ['issue', 'comment', String(issue), '--body-file', '-'], body);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function commentOnPr(repo: Repo, pr: number, body: string): Promise<void> {
|
|
98
|
+
await gh(repo, ['pr', 'comment', String(pr), '--body-file', '-'], body);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function openPr(
|
|
102
|
+
repo: Repo,
|
|
103
|
+
branch: string,
|
|
104
|
+
base: string,
|
|
105
|
+
title: string,
|
|
106
|
+
body: string,
|
|
107
|
+
): Promise<number> {
|
|
108
|
+
const found = await gh(repo, [
|
|
109
|
+
'pr',
|
|
110
|
+
'list',
|
|
111
|
+
'--head',
|
|
112
|
+
branch,
|
|
113
|
+
'--state',
|
|
114
|
+
'open',
|
|
115
|
+
'--json',
|
|
116
|
+
'number',
|
|
117
|
+
'--jq',
|
|
118
|
+
'.[0].number',
|
|
119
|
+
]);
|
|
120
|
+
if (found.length > 0) {
|
|
121
|
+
return Number(found);
|
|
122
|
+
}
|
|
123
|
+
const created = await gh(repo, [
|
|
124
|
+
'pr',
|
|
125
|
+
'create',
|
|
126
|
+
'--head',
|
|
127
|
+
branch,
|
|
128
|
+
'--base',
|
|
129
|
+
base,
|
|
130
|
+
'--title',
|
|
131
|
+
title,
|
|
132
|
+
'--body',
|
|
133
|
+
body,
|
|
134
|
+
]);
|
|
135
|
+
const match = /\/pull\/(\d+)/.exec(created);
|
|
136
|
+
if (match === null) {
|
|
137
|
+
throw new Error(`cannot read the pull request number from: ${created}`);
|
|
138
|
+
}
|
|
139
|
+
return Number(match[1]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function readChecks(repo: Repo, pr: number): Promise<Check[]> {
|
|
143
|
+
const result = await run(
|
|
144
|
+
'gh',
|
|
145
|
+
['pr', 'checks', String(pr), '--json', 'name,bucket,link', '--repo', repo.slug],
|
|
146
|
+
{ cwd: repo.root },
|
|
147
|
+
);
|
|
148
|
+
if (result.stdout.trim().length === 0) {
|
|
149
|
+
if (/no checks reported/i.test(result.stderr)) {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
throw new Error(`gh pr checks ${pr} failed with code ${result.code}: ${result.stderr.trim()}`);
|
|
153
|
+
}
|
|
154
|
+
return JSON.parse(result.stdout) as Check[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function checks(
|
|
158
|
+
repo: Repo,
|
|
159
|
+
pr: number,
|
|
160
|
+
headSha: string,
|
|
161
|
+
timeoutMs: number,
|
|
162
|
+
signal: AbortSignal,
|
|
163
|
+
): Promise<ChecksResult> {
|
|
164
|
+
const deadline = Date.now() + timeoutMs;
|
|
165
|
+
let shownAt: number | undefined;
|
|
166
|
+
let failures = 0;
|
|
167
|
+
while (Date.now() < deadline) {
|
|
168
|
+
try {
|
|
169
|
+
const prHead = await gh(repo, [
|
|
170
|
+
'pr',
|
|
171
|
+
'view',
|
|
172
|
+
String(pr),
|
|
173
|
+
'--json',
|
|
174
|
+
'headRefOid',
|
|
175
|
+
'--jq',
|
|
176
|
+
'.headRefOid',
|
|
177
|
+
]);
|
|
178
|
+
if (prHead === headSha) {
|
|
179
|
+
shownAt ??= Date.now();
|
|
180
|
+
const list = await readChecks(repo, pr);
|
|
181
|
+
if (list.some((check) => check.bucket === 'fail' || check.bucket === 'cancel')) {
|
|
182
|
+
return 'fail';
|
|
183
|
+
}
|
|
184
|
+
if (list.length > 0 && list.every((check) => check.bucket !== 'pending')) {
|
|
185
|
+
return 'pass';
|
|
186
|
+
}
|
|
187
|
+
if (list.length === 0 && Date.now() - shownAt >= GRACE_MS) {
|
|
188
|
+
return 'pass';
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
failures = 0;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
failures += 1;
|
|
194
|
+
if (failures >= READ_TRIES) {
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
await sleep(Math.max(0, Math.min(POLL_MS, deadline - Date.now())), undefined, { signal });
|
|
199
|
+
}
|
|
200
|
+
return 'timeout';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function failedLogs(repo: Repo, pr: number, maxChars: number): Promise<string> {
|
|
204
|
+
const failed = (await readChecks(repo, pr)).filter(
|
|
205
|
+
(check) => check.bucket === 'fail' || check.bucket === 'cancel',
|
|
206
|
+
);
|
|
207
|
+
const share = Math.max(1, Math.floor(maxChars / Math.max(1, failed.length)));
|
|
208
|
+
const parts: string[] = [];
|
|
209
|
+
for (const check of failed) {
|
|
210
|
+
parts.push(`## ${check.name}`);
|
|
211
|
+
const runId = /\/actions\/runs\/(\d+)/.exec(check.link)?.[1];
|
|
212
|
+
if (runId !== undefined) {
|
|
213
|
+
const logs = await run('gh', ['run', 'view', runId, '--log-failed', '--repo', repo.slug], {
|
|
214
|
+
cwd: repo.root,
|
|
215
|
+
});
|
|
216
|
+
parts.push((logs.stdout || logs.stderr).slice(-share));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return parts.length > 0 ? parts.join('\n\n') : 'The checks failed, but gh lists no failed check.';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function merge(
|
|
223
|
+
repo: Repo,
|
|
224
|
+
pr: number,
|
|
225
|
+
timeoutMs: number,
|
|
226
|
+
signal: AbortSignal,
|
|
227
|
+
): Promise<boolean> {
|
|
228
|
+
const result = await run('gh', ['pr', 'merge', String(pr), '--squash', '--repo', repo.slug], {
|
|
229
|
+
cwd: repo.root,
|
|
230
|
+
});
|
|
231
|
+
if (result.code !== 0) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
const deadline = Date.now() + timeoutMs;
|
|
235
|
+
for (;;) {
|
|
236
|
+
const state = await gh(repo, ['pr', 'view', String(pr), '--json', 'state', '--jq', '.state']);
|
|
237
|
+
if (state === 'MERGED') {
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
if (state === 'CLOSED' || Date.now() >= deadline) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
await sleep(Math.min(POLL_MS, deadline - Date.now()), undefined, { signal });
|
|
244
|
+
}
|
|
245
|
+
}
|
package/src/ports.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as agent from './agent.ts';
|
|
2
|
+
import type { Config } from './config.ts';
|
|
3
|
+
import * as git from './git.ts';
|
|
4
|
+
import * as github from './github.ts';
|
|
5
|
+
import type { Ports } from './runner.ts';
|
|
6
|
+
import * as state from './state.ts';
|
|
7
|
+
|
|
8
|
+
export async function createPorts(root: string, config: Config): Promise<Ports> {
|
|
9
|
+
const repo: github.Repo = { root, slug: await git.remoteSlug(root, config.remote) };
|
|
10
|
+
const base = await github.defaultBranch(repo);
|
|
11
|
+
const timeoutMs = config.checkTimeoutMinutes * 60_000;
|
|
12
|
+
return {
|
|
13
|
+
openIssues: () => github.openIssues(repo),
|
|
14
|
+
comments: (issue) => github.comments(repo, issue),
|
|
15
|
+
setLabel: (issue, add, remove) => github.setLabel(repo, issue, add, remove),
|
|
16
|
+
commentOnIssue: (issue, body) => github.commentOnIssue(repo, issue, body),
|
|
17
|
+
worktree: (issue, branch) => git.worktree(root, config.remote, base, issue, branch),
|
|
18
|
+
commit: (cwd, message) => git.commit(cwd, message),
|
|
19
|
+
push: (cwd, branch) => git.push(cwd, config.remote, branch),
|
|
20
|
+
discard: (cwd) => git.discard(cwd),
|
|
21
|
+
diff: (cwd) => git.diff(cwd, config.remote, base),
|
|
22
|
+
openPr: (branch, title, body) => github.openPr(repo, branch, base, title, body),
|
|
23
|
+
checks: async (pr, cwd, signal) =>
|
|
24
|
+
github.checks(repo, pr, await git.head(cwd), timeoutMs, signal),
|
|
25
|
+
failedLogs: (pr) => github.failedLogs(repo, pr, config.logMaxChars),
|
|
26
|
+
commentOnPr: (pr, body) => github.commentOnPr(repo, pr, body),
|
|
27
|
+
merge: (pr, signal) => github.merge(repo, pr, timeoutMs, signal),
|
|
28
|
+
cleanup: (_issue, branch, cwd) => git.cleanup(root, config.remote, branch, cwd),
|
|
29
|
+
work: agent.work,
|
|
30
|
+
review: agent.review,
|
|
31
|
+
readState: (issue) => state.readState(root, issue),
|
|
32
|
+
writeState: (value) => state.writeState(root, value),
|
|
33
|
+
dropState: (issue) => state.dropState(root, issue),
|
|
34
|
+
savedIssues: () => state.savedIssues(root),
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Finding, Issue, Verdict } from './runner.ts';
|
|
2
|
+
|
|
3
|
+
const COMMIT_RULES = [
|
|
4
|
+
'Do not run git, gh or any push command. The harness commits for you.',
|
|
5
|
+
'End your final message with one line in the form `commit: <conventional commit subject>`.',
|
|
6
|
+
].join('\n');
|
|
7
|
+
|
|
8
|
+
function issueBlock(issue: Issue, comments: string[]): string {
|
|
9
|
+
return [
|
|
10
|
+
`# Issue #${issue.number}: ${issue.title}`,
|
|
11
|
+
issue.body.length > 0 ? issue.body : '(no description)',
|
|
12
|
+
comments.length > 0 ? `## Comments\n${comments.join('\n\n')}` : '',
|
|
13
|
+
]
|
|
14
|
+
.filter((part) => part.length > 0)
|
|
15
|
+
.join('\n\n');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function implementPrompt(issue: Issue, comments: string[]): string {
|
|
19
|
+
return [
|
|
20
|
+
'You are the implementer. Solve the issue below in this worktree.',
|
|
21
|
+
issueBlock(issue, comments),
|
|
22
|
+
'## Rules',
|
|
23
|
+
'Read the repository conventions first. Keep the change minimal and focused on the issue.',
|
|
24
|
+
'Add or update tests when the repository has a test suite.',
|
|
25
|
+
COMMIT_RULES,
|
|
26
|
+
].join('\n\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function reviewPrompt(
|
|
30
|
+
issue: Issue,
|
|
31
|
+
comments: string[],
|
|
32
|
+
diff: string,
|
|
33
|
+
earlier: string[],
|
|
34
|
+
): string {
|
|
35
|
+
const scope =
|
|
36
|
+
earlier.length === 0
|
|
37
|
+
? 'Check correctness, scope, repository conventions and test cover.'
|
|
38
|
+
: [
|
|
39
|
+
'This is a later round. Judge only whether the earlier findings below are fixed and whether the new commits broke something.',
|
|
40
|
+
'Do not raise a new point of taste. Withdraw a finding when the fixer disputed it correctly.',
|
|
41
|
+
`## Earlier rounds\n${earlier.join('\n\n')}`,
|
|
42
|
+
].join('\n');
|
|
43
|
+
return [
|
|
44
|
+
'You are the reviewer. Judge whether the diff solves the issue correctly.',
|
|
45
|
+
issueBlock(issue, comments),
|
|
46
|
+
'## Diff',
|
|
47
|
+
'```diff',
|
|
48
|
+
diff,
|
|
49
|
+
'```',
|
|
50
|
+
'## Rules',
|
|
51
|
+
'Read files and run commands to prove what you say: the tests, the type check or the linter.',
|
|
52
|
+
'Do not change a file. Do not write with git or gh. The harness discards each change that you make.',
|
|
53
|
+
scope,
|
|
54
|
+
'Mark a finding blocking only for a wrong result, a missing part of the issue, a regression or a broken convention.',
|
|
55
|
+
'Finish by calling the submit_verdict tool once with a short summary and one entry per finding.',
|
|
56
|
+
].join('\n\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function fixPrompt(issue: Issue, reason: string, detail: string, earlier: string[]): string {
|
|
60
|
+
return [
|
|
61
|
+
`You are the fixer. Correct the work for issue #${issue.number}: ${issue.title}.`,
|
|
62
|
+
`## Reason\n${reason}`,
|
|
63
|
+
`## Detail\n${detail}`,
|
|
64
|
+
earlier.length > 0
|
|
65
|
+
? `## Earlier rounds\n${earlier.join('\n\n')}\n\nKeep those earlier fixes.`
|
|
66
|
+
: '',
|
|
67
|
+
'## Rules',
|
|
68
|
+
'Fix the cause, not the symptom. Keep the change minimal. Do not weaken, skip or delete a test to make it pass.',
|
|
69
|
+
'If the problem is not caused by this change, or every finding is wrong, change nothing and end your final message with one line in the form `unrelated: <one sentence on why>`.',
|
|
70
|
+
COMMIT_RULES,
|
|
71
|
+
]
|
|
72
|
+
.filter((part) => part.length > 0)
|
|
73
|
+
.join('\n\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function lastLine(text: string, key: string): string | undefined {
|
|
77
|
+
const match = [...text.matchAll(new RegExp(`^${key}:\\s*(.+)$`, 'gim'))].at(-1);
|
|
78
|
+
const value = match?.[1]
|
|
79
|
+
?.trim()
|
|
80
|
+
.replace(/^`+|`+$/g, '')
|
|
81
|
+
.trim();
|
|
82
|
+
return value !== undefined && value.length > 0 ? value : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseCommitSubject(text: string, fallback: string): string {
|
|
86
|
+
return lastLine(text, 'commit') ?? fallback;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseUnrelated(text: string): string | undefined {
|
|
90
|
+
return lastLine(text, 'unrelated');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function cap(text: string, maxChars: number): string {
|
|
94
|
+
return text.length <= maxChars
|
|
95
|
+
? text
|
|
96
|
+
: `${text.slice(0, maxChars)}\n... cut after ${maxChars} characters.`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function formatVerdict(verdict: Verdict, open: Finding[]): string {
|
|
100
|
+
const head = open.length === 0 ? 'Review: approved' : 'Review: changes requested';
|
|
101
|
+
const lines = verdict.findings.map((finding) => `- **${finding.severity}** ${finding.detail}`);
|
|
102
|
+
return [`### ${head}`, verdict.summary, lines.join('\n')]
|
|
103
|
+
.filter((part) => part.length > 0)
|
|
104
|
+
.join('\n\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function formatFindings(findings: Finding[]): string {
|
|
108
|
+
return findings.map((finding) => `- ${finding.detail}`).join('\n');
|
|
109
|
+
}
|