@ravshansbox/pi-next-issue 1.0.2 → 1.0.4
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 +2 -2
- package/package.json +1 -1
- package/src/github.ts +34 -11
- package/src/ports.ts +3 -1
- package/src/runner.ts +10 -1
- package/src/state.ts +82 -1
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ The run does not belong to a session: it goes on after `/new`, `/resume`, a fork
|
|
|
22
22
|
|
|
23
23
|
## What it does
|
|
24
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:
|
|
25
|
+
Each loop fetches the 200 oldest open issues with the `status:todo` label and none of `status:in-progress`, `status:done`, `status:needs-human` or `status:blocked`, and the open issues with saved state that it can read. It 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
26
|
|
|
27
27
|
1. Swap `status:todo` for `status:in-progress`.
|
|
28
28
|
2. Add a git worktree at `.worktrees/<n>` on the `<n>` branch, from the remote default branch.
|
|
@@ -39,7 +39,7 @@ The implementer and the reviewer read the issue title, the body and the comments
|
|
|
39
39
|
|
|
40
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
41
|
|
|
42
|
-
The state of each issue is saved in `.worktrees/<n>.json`. The extension adds `/.worktrees/` to `.git/info/exclude`.
|
|
42
|
+
The state of each issue is saved in `.worktrees/<n>.json`. A run holds `.worktrees/run.lock`, which keeps the process id, so that only one pi at a time works on a repository. A second run fails at once. A lock whose process is no longer alive is taken over. If the operating system gives that process id to another process, the lock stays; remove `.worktrees/run.lock` by hand when no run is going. The extension adds `/.worktrees/` to `.git/info/exclude`.
|
|
43
43
|
|
|
44
44
|
## Config
|
|
45
45
|
|
package/package.json
CHANGED
package/src/github.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
2
|
import { must, run } from './exec.ts';
|
|
3
|
-
import type
|
|
3
|
+
import { LABELS, STOP_LABELS, type ChecksResult, type Issue } from './runner.ts';
|
|
4
4
|
|
|
5
5
|
export type Repo = { root: string; slug: string };
|
|
6
6
|
|
|
@@ -28,25 +28,48 @@ export async function defaultBranch(repo: Repo): Promise<string> {
|
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
type RawIssue = {
|
|
32
|
+
number: number;
|
|
33
|
+
title: string;
|
|
34
|
+
body: string | null;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
labels: Array<{ name: string }>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const ISSUE_FIELDS = 'number,title,body,labels,createdAt';
|
|
40
|
+
|
|
41
|
+
export async function openIssues(repo: Repo, saved: number[]): Promise<Issue[]> {
|
|
32
42
|
const raw = await gh(repo, [
|
|
33
43
|
'issue',
|
|
34
44
|
'list',
|
|
35
45
|
'--state',
|
|
36
46
|
'open',
|
|
47
|
+
'--label',
|
|
48
|
+
LABELS.ready,
|
|
49
|
+
'--search',
|
|
50
|
+
['sort:created-asc', ...STOP_LABELS.map((label) => `-label:${label}`)].join(' '),
|
|
37
51
|
'--limit',
|
|
38
52
|
'200',
|
|
39
53
|
'--json',
|
|
40
|
-
|
|
54
|
+
ISSUE_FIELDS,
|
|
41
55
|
]);
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
const listed = JSON.parse(raw) as RawIssue[];
|
|
57
|
+
const known = new Set(listed.map((item) => item.number));
|
|
58
|
+
for (const number of saved.filter((item) => !known.has(item))) {
|
|
59
|
+
const view = await run(
|
|
60
|
+
'gh',
|
|
61
|
+
['issue', 'view', String(number), '--json', `${ISSUE_FIELDS},state`, '--repo', repo.slug],
|
|
62
|
+
{ cwd: repo.root },
|
|
63
|
+
);
|
|
64
|
+
if (view.code !== 0) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const item = JSON.parse(view.stdout) as RawIssue & { state: string };
|
|
68
|
+
if (item.state === 'OPEN') {
|
|
69
|
+
listed.push(item);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return listed
|
|
50
73
|
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
51
74
|
.map((item) => ({
|
|
52
75
|
number: item.number,
|
package/src/ports.ts
CHANGED
|
@@ -10,7 +10,7 @@ export async function createPorts(root: string, config: Config): Promise<Ports>
|
|
|
10
10
|
const base = await github.defaultBranch(repo);
|
|
11
11
|
const timeoutMs = config.checkTimeoutMinutes * 60_000;
|
|
12
12
|
return {
|
|
13
|
-
openIssues: () => github.openIssues(repo),
|
|
13
|
+
openIssues: async () => github.openIssues(repo, await state.savedIssues(root)),
|
|
14
14
|
comments: (issue) => github.comments(repo, issue),
|
|
15
15
|
setLabel: (issue, add, remove) => github.setLabel(repo, issue, add, remove),
|
|
16
16
|
commentOnIssue: (issue, body) => github.commentOnIssue(repo, issue, body),
|
|
@@ -32,5 +32,7 @@ export async function createPorts(root: string, config: Config): Promise<Ports>
|
|
|
32
32
|
writeState: (value) => state.writeState(root, value),
|
|
33
33
|
dropState: (issue) => state.dropState(root, issue),
|
|
34
34
|
savedIssues: () => state.savedIssues(root),
|
|
35
|
+
lock: () => state.lock(root),
|
|
36
|
+
unlock: () => state.unlock(root),
|
|
35
37
|
};
|
|
36
38
|
}
|
package/src/runner.ts
CHANGED
|
@@ -60,6 +60,8 @@ export type Ports = {
|
|
|
60
60
|
writeState(state: IssueState): Promise<void>;
|
|
61
61
|
dropState(issue: number): Promise<void>;
|
|
62
62
|
savedIssues(): Promise<number[]>;
|
|
63
|
+
lock(): Promise<void>;
|
|
64
|
+
unlock(): Promise<void>;
|
|
63
65
|
};
|
|
64
66
|
|
|
65
67
|
export type Outcome = 'merged' | 'needs-human';
|
|
@@ -86,7 +88,7 @@ export const LABELS = {
|
|
|
86
88
|
blocked: 'status:blocked',
|
|
87
89
|
};
|
|
88
90
|
|
|
89
|
-
const STOP_LABELS = [LABELS.inProgress, LABELS.done, LABELS.needsHuman, LABELS.blocked];
|
|
91
|
+
export const STOP_LABELS = [LABELS.inProgress, LABELS.done, LABELS.needsHuman, LABELS.blocked];
|
|
90
92
|
|
|
91
93
|
function ready(issue: Issue): boolean {
|
|
92
94
|
return (
|
|
@@ -123,7 +125,10 @@ export class Runner {
|
|
|
123
125
|
async run(): Promise<void> {
|
|
124
126
|
this.update({ status: 'running' });
|
|
125
127
|
const seen = new Set<number>();
|
|
128
|
+
let locked = false;
|
|
126
129
|
try {
|
|
130
|
+
await this.ports.lock();
|
|
131
|
+
locked = true;
|
|
127
132
|
for (;;) {
|
|
128
133
|
this.controller.signal.throwIfAborted();
|
|
129
134
|
const issues = await this.ports.openIssues();
|
|
@@ -148,6 +153,10 @@ export class Runner {
|
|
|
148
153
|
error: error instanceof Error ? error.message : String(error),
|
|
149
154
|
});
|
|
150
155
|
}
|
|
156
|
+
} finally {
|
|
157
|
+
if (locked) {
|
|
158
|
+
await this.ports.unlock().catch(() => {});
|
|
159
|
+
}
|
|
151
160
|
}
|
|
152
161
|
}
|
|
153
162
|
|
package/src/state.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { link, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { WORKTREE_DIR } from './git.ts';
|
|
4
4
|
import type { IssueState } from './runner.ts';
|
|
@@ -38,3 +38,84 @@ export async function savedIssues(root: string): Promise<number[]> {
|
|
|
38
38
|
return match === null ? [] : [Number(match[1])];
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
function lockFile(root: string): string {
|
|
43
|
+
return join(dir(root), 'run.lock');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function alive(pid: number): boolean {
|
|
47
|
+
try {
|
|
48
|
+
process.kill(pid, 0);
|
|
49
|
+
return true;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function holder(path: string): Promise<number> {
|
|
56
|
+
return Number((await readFile(path, 'utf8').catch(() => '')).trim());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function busy(pid: number, cause: unknown): Error {
|
|
60
|
+
return new Error(`another run works on this repository (pid ${pid})`, { cause });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function place(root: string): Promise<void> {
|
|
64
|
+
const draft = `${lockFile(root)}.${process.pid}.draft`;
|
|
65
|
+
await writeFile(draft, `${process.pid}\n`);
|
|
66
|
+
try {
|
|
67
|
+
await link(draft, lockFile(root));
|
|
68
|
+
} finally {
|
|
69
|
+
await rm(draft, { force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function claim(root: string): Promise<void> {
|
|
74
|
+
try {
|
|
75
|
+
await place(root);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
78
|
+
throw busy(await holder(lockFile(root)), error);
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function lock(root: string): Promise<void> {
|
|
85
|
+
await mkdir(dir(root), { recursive: true });
|
|
86
|
+
try {
|
|
87
|
+
await place(root);
|
|
88
|
+
return;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
const pid = await holder(lockFile(root));
|
|
94
|
+
if (Number.isInteger(pid) && pid > 0 && alive(pid)) {
|
|
95
|
+
throw busy(pid, error);
|
|
96
|
+
}
|
|
97
|
+
const stale = `${lockFile(root)}.${process.pid}`;
|
|
98
|
+
try {
|
|
99
|
+
await rename(lockFile(root), stale);
|
|
100
|
+
} catch (renameError) {
|
|
101
|
+
if ((renameError as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
102
|
+
throw renameError;
|
|
103
|
+
}
|
|
104
|
+
return claim(root);
|
|
105
|
+
}
|
|
106
|
+
const moved = await holder(stale);
|
|
107
|
+
if (!Object.is(moved, pid)) {
|
|
108
|
+
await link(stale, lockFile(root)).catch(() => undefined);
|
|
109
|
+
await rm(stale, { force: true });
|
|
110
|
+
throw busy(moved, error);
|
|
111
|
+
}
|
|
112
|
+
await rm(stale, { force: true });
|
|
113
|
+
}
|
|
114
|
+
await claim(root);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function unlock(root: string): Promise<void> {
|
|
118
|
+
if ((await holder(lockFile(root))) === process.pid) {
|
|
119
|
+
await rm(lockFile(root), { force: true });
|
|
120
|
+
}
|
|
121
|
+
}
|