@ravshansbox/pi-next-issue 1.2.0 → 2.3.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 +12 -36
- package/index.ts +101 -76
- package/package.json +11 -7
- package/src/git.ts +1 -122
- package/src/worker.ts +241 -0
- package/src/agent.ts +0 -105
- package/src/config.ts +0 -69
- package/src/github.ts +0 -294
- package/src/ports.ts +0 -43
- package/src/prompts.ts +0 -109
- package/src/runner.ts +0 -358
- package/src/state.ts +0 -121
package/src/worker.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { closeSync, openSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import WebSocket from 'ws';
|
|
5
|
+
|
|
6
|
+
export type RunStatus = 'running' | 'stopping' | 'finished' | 'stopped' | 'failed';
|
|
7
|
+
|
|
8
|
+
export type Handled = { issue: number; outcome: 'done' | 'needs-human' | 'error' };
|
|
9
|
+
|
|
10
|
+
export type Progress = {
|
|
11
|
+
status: RunStatus;
|
|
12
|
+
issue?: number;
|
|
13
|
+
title?: string;
|
|
14
|
+
stage?: string;
|
|
15
|
+
tools: number;
|
|
16
|
+
lastTool?: string;
|
|
17
|
+
issueStartedAt?: number;
|
|
18
|
+
handled: Handled[];
|
|
19
|
+
error?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ClientMessage =
|
|
23
|
+
| { type: 'hello'; version: string }
|
|
24
|
+
| { type: 'start'; root: string; merge: boolean }
|
|
25
|
+
| { type: 'stop'; root: string };
|
|
26
|
+
|
|
27
|
+
export type WorkerMessage =
|
|
28
|
+
| { type: 'hello'; version: string }
|
|
29
|
+
| { type: 'runs'; runs: Record<string, Progress> }
|
|
30
|
+
| { type: 'error'; root: string; message: string };
|
|
31
|
+
|
|
32
|
+
export type WorkerEvent =
|
|
33
|
+
| { type: 'runs'; runs: Record<string, Progress> }
|
|
34
|
+
| { type: 'error'; root: string; message: string };
|
|
35
|
+
|
|
36
|
+
export type WorkerOptions = {
|
|
37
|
+
socketPath: string;
|
|
38
|
+
version: string;
|
|
39
|
+
spawnWorker: () => void;
|
|
40
|
+
startTimeoutMs?: number;
|
|
41
|
+
reconnectMs?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const START_TIMEOUT_MS = 120_000;
|
|
45
|
+
const RECONNECT_MS = 5_000;
|
|
46
|
+
const POLL_MS = 200;
|
|
47
|
+
|
|
48
|
+
export function isNewer(candidate: string, current: string): boolean {
|
|
49
|
+
const left = candidate.split('.').map(Number);
|
|
50
|
+
const right = current.split('.').map(Number);
|
|
51
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
52
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
53
|
+
if (difference !== 0) {
|
|
54
|
+
return difference > 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function spawnWorker(agentDir: string): void {
|
|
61
|
+
const log = openSync(join(agentDir, 'next-issue-worker.log'), 'a');
|
|
62
|
+
try {
|
|
63
|
+
const child = spawn('npx', ['-y', 'next-issue@latest', 'serve'], {
|
|
64
|
+
detached: true,
|
|
65
|
+
stdio: ['ignore', log, log],
|
|
66
|
+
});
|
|
67
|
+
child.on('error', () => {});
|
|
68
|
+
child.unref();
|
|
69
|
+
} finally {
|
|
70
|
+
closeSync(log);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function delay(ms: number): Promise<void> {
|
|
75
|
+
return new Promise((resolve) => setTimeout(resolve, ms).unref?.());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class WorkerClient {
|
|
79
|
+
runs: Record<string, Progress> = {};
|
|
80
|
+
private socket: WebSocket | undefined;
|
|
81
|
+
private workerVersion: string | undefined;
|
|
82
|
+
private connecting: Promise<void> | undefined;
|
|
83
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
84
|
+
private readonly listeners = new Set<(event: WorkerEvent) => void>();
|
|
85
|
+
private readonly options: WorkerOptions;
|
|
86
|
+
|
|
87
|
+
constructor(options: WorkerOptions) {
|
|
88
|
+
this.options = options;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get connected(): boolean {
|
|
92
|
+
return this.socket?.readyState === WebSocket.OPEN;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
subscribe(listener: (event: WorkerEvent) => void): () => void {
|
|
96
|
+
this.listeners.add(listener);
|
|
97
|
+
return () => this.listeners.delete(listener);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
send(message: ClientMessage): void {
|
|
101
|
+
this.socket?.send(JSON.stringify(message));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async attach(): Promise<void> {
|
|
105
|
+
if (this.connected) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await this.open().catch(() => this.scheduleReconnect());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async ensure(): Promise<void> {
|
|
112
|
+
if (!this.connected) {
|
|
113
|
+
this.connecting ??= this.start().finally(() => {
|
|
114
|
+
this.connecting = undefined;
|
|
115
|
+
});
|
|
116
|
+
await this.connecting;
|
|
117
|
+
}
|
|
118
|
+
if (this.workerVersion !== undefined && isNewer(this.options.version, this.workerVersion)) {
|
|
119
|
+
const busy = Object.values(this.runs).some(
|
|
120
|
+
(run) => run.status === 'running' || run.status === 'stopping',
|
|
121
|
+
);
|
|
122
|
+
if (!busy) {
|
|
123
|
+
await this.waitForClose();
|
|
124
|
+
this.connecting ??= this.start().finally(() => {
|
|
125
|
+
this.connecting = undefined;
|
|
126
|
+
});
|
|
127
|
+
await this.connecting;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
close(): void {
|
|
133
|
+
clearTimeout(this.reconnectTimer);
|
|
134
|
+
this.reconnectTimer = undefined;
|
|
135
|
+
this.listeners.clear();
|
|
136
|
+
const socket = this.socket;
|
|
137
|
+
this.socket = undefined;
|
|
138
|
+
socket?.terminate();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async start(): Promise<void> {
|
|
142
|
+
try {
|
|
143
|
+
await this.open();
|
|
144
|
+
return;
|
|
145
|
+
} catch {
|
|
146
|
+
this.options.spawnWorker();
|
|
147
|
+
}
|
|
148
|
+
const deadline = Date.now() + (this.options.startTimeoutMs ?? START_TIMEOUT_MS);
|
|
149
|
+
for (;;) {
|
|
150
|
+
await delay(POLL_MS);
|
|
151
|
+
try {
|
|
152
|
+
await this.open();
|
|
153
|
+
return;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (Date.now() >= deadline) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`the next-issue worker did not start: ${error instanceof Error ? error.message : String(error)}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private open(): Promise<void> {
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
const socket = new WebSocket(`ws+unix://${this.options.socketPath}:/`);
|
|
167
|
+
let hello = false;
|
|
168
|
+
socket.once('error', (error) => {
|
|
169
|
+
if (!hello) {
|
|
170
|
+
reject(error);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
socket.on('message', (data) => {
|
|
174
|
+
let message: WorkerMessage;
|
|
175
|
+
try {
|
|
176
|
+
message = JSON.parse(String(data)) as WorkerMessage;
|
|
177
|
+
} catch {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (message.type === 'hello') {
|
|
181
|
+
this.workerVersion = message.version;
|
|
182
|
+
} else if (message.type === 'runs') {
|
|
183
|
+
this.runs = message.runs;
|
|
184
|
+
this.emit({ type: 'runs', runs: message.runs });
|
|
185
|
+
if (!hello) {
|
|
186
|
+
hello = true;
|
|
187
|
+
resolve();
|
|
188
|
+
}
|
|
189
|
+
} else if (message.type === 'error') {
|
|
190
|
+
this.emit(message);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
socket.once('open', () => {
|
|
194
|
+
this.socket = socket;
|
|
195
|
+
socket.send(JSON.stringify({ type: 'hello', version: this.options.version }));
|
|
196
|
+
});
|
|
197
|
+
socket.once('close', () => {
|
|
198
|
+
if (this.socket === socket) {
|
|
199
|
+
this.socket = undefined;
|
|
200
|
+
this.scheduleReconnect();
|
|
201
|
+
}
|
|
202
|
+
if (!hello) {
|
|
203
|
+
reject(new Error('the worker closed the connection'));
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private waitForClose(): Promise<void> {
|
|
210
|
+
const socket = this.socket;
|
|
211
|
+
if (socket === undefined || socket.readyState === WebSocket.CLOSED) {
|
|
212
|
+
return Promise.resolve();
|
|
213
|
+
}
|
|
214
|
+
return new Promise((resolve) => {
|
|
215
|
+
const timer = setTimeout(resolve, this.options.startTimeoutMs ?? START_TIMEOUT_MS);
|
|
216
|
+
socket.once('close', () => {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
resolve();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private scheduleReconnect(): void {
|
|
224
|
+
if (this.listeners.size === 0 || this.reconnectTimer !== undefined) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
this.reconnectTimer = setTimeout(() => {
|
|
228
|
+
this.reconnectTimer = undefined;
|
|
229
|
+
if (!this.connected && this.connecting === undefined) {
|
|
230
|
+
void this.attach();
|
|
231
|
+
}
|
|
232
|
+
}, this.options.reconnectMs ?? RECONNECT_MS);
|
|
233
|
+
this.reconnectTimer.unref?.();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private emit(event: WorkerEvent): void {
|
|
237
|
+
for (const listener of this.listeners) {
|
|
238
|
+
listener(event);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
package/src/agent.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
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/github.ts
DELETED
|
@@ -1,294 +0,0 @@
|
|
|
1
|
-
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
-
import { must, run } from './exec.ts';
|
|
3
|
-
import { LABELS, STOP_LABELS, type ChecksResult, type Issue, type MergeResult } 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
|
-
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(
|
|
42
|
-
repo: Repo,
|
|
43
|
-
saved: number[],
|
|
44
|
-
): Promise<{ open: Issue[]; closed: number[] }> {
|
|
45
|
-
const raw = await gh(repo, [
|
|
46
|
-
'issue',
|
|
47
|
-
'list',
|
|
48
|
-
'--state',
|
|
49
|
-
'open',
|
|
50
|
-
'--label',
|
|
51
|
-
LABELS.ready,
|
|
52
|
-
'--search',
|
|
53
|
-
['sort:created-asc', ...STOP_LABELS.map((label) => `-label:${label}`)].join(' '),
|
|
54
|
-
'--limit',
|
|
55
|
-
'200',
|
|
56
|
-
'--json',
|
|
57
|
-
ISSUE_FIELDS,
|
|
58
|
-
]);
|
|
59
|
-
const listed = JSON.parse(raw) as RawIssue[];
|
|
60
|
-
const known = new Set(listed.map((item) => item.number));
|
|
61
|
-
const closed: number[] = [];
|
|
62
|
-
for (const number of saved.filter((item) => !known.has(item))) {
|
|
63
|
-
const view = await run(
|
|
64
|
-
'gh',
|
|
65
|
-
['issue', 'view', String(number), '--json', `${ISSUE_FIELDS},state`, '--repo', repo.slug],
|
|
66
|
-
{ cwd: repo.root },
|
|
67
|
-
);
|
|
68
|
-
if (view.code !== 0) {
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
const item = JSON.parse(view.stdout) as RawIssue & { state: string };
|
|
72
|
-
if (item.state === 'OPEN') {
|
|
73
|
-
listed.push(item);
|
|
74
|
-
} else {
|
|
75
|
-
closed.push(number);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
const open = listed
|
|
79
|
-
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
80
|
-
.map((item) => ({
|
|
81
|
-
number: item.number,
|
|
82
|
-
title: item.title,
|
|
83
|
-
body: item.body ?? '',
|
|
84
|
-
labels: item.labels.map((label) => label.name),
|
|
85
|
-
}));
|
|
86
|
-
return { open, closed };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export async function comments(repo: Repo, issue: number): Promise<string[]> {
|
|
90
|
-
const raw = await gh(repo, ['issue', 'view', String(issue), '--json', 'comments']);
|
|
91
|
-
const parsed = JSON.parse(raw) as {
|
|
92
|
-
comments: Array<{ author: { login: string }; authorAssociation: string; body: string }>;
|
|
93
|
-
};
|
|
94
|
-
return parsed.comments
|
|
95
|
-
.filter((comment) => TRUSTED.includes(comment.authorAssociation))
|
|
96
|
-
.map((comment) => `@${comment.author.login}: ${comment.body}`);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export async function setLabel(
|
|
100
|
-
repo: Repo,
|
|
101
|
-
issue: number,
|
|
102
|
-
add: string,
|
|
103
|
-
remove: string[],
|
|
104
|
-
): Promise<void> {
|
|
105
|
-
await run('gh', ['label', 'create', add, '--repo', repo.slug], { cwd: repo.root });
|
|
106
|
-
const raw = await gh(repo, [
|
|
107
|
-
'issue',
|
|
108
|
-
'view',
|
|
109
|
-
String(issue),
|
|
110
|
-
'--json',
|
|
111
|
-
'labels',
|
|
112
|
-
'--jq',
|
|
113
|
-
'.labels[].name',
|
|
114
|
-
]);
|
|
115
|
-
const current = raw.split('\n');
|
|
116
|
-
const args = ['issue', 'edit', String(issue), '--add-label', add];
|
|
117
|
-
for (const name of remove.filter((item) => current.includes(item))) {
|
|
118
|
-
args.push('--remove-label', name);
|
|
119
|
-
}
|
|
120
|
-
await gh(repo, args);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export async function commentOnIssue(repo: Repo, issue: number, body: string): Promise<void> {
|
|
124
|
-
await gh(repo, ['issue', 'comment', String(issue), '--body-file', '-'], body);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export async function commentOnPr(repo: Repo, pr: number, body: string): Promise<void> {
|
|
128
|
-
await gh(repo, ['pr', 'comment', String(pr), '--body-file', '-'], body);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export async function openPr(
|
|
132
|
-
repo: Repo,
|
|
133
|
-
branch: string,
|
|
134
|
-
base: string,
|
|
135
|
-
title: string,
|
|
136
|
-
body: string,
|
|
137
|
-
): Promise<number> {
|
|
138
|
-
const found = await gh(repo, [
|
|
139
|
-
'pr',
|
|
140
|
-
'list',
|
|
141
|
-
'--head',
|
|
142
|
-
branch,
|
|
143
|
-
'--state',
|
|
144
|
-
'open',
|
|
145
|
-
'--json',
|
|
146
|
-
'number',
|
|
147
|
-
'--jq',
|
|
148
|
-
'.[0].number',
|
|
149
|
-
]);
|
|
150
|
-
if (found.length > 0) {
|
|
151
|
-
return Number(found);
|
|
152
|
-
}
|
|
153
|
-
const created = await gh(repo, [
|
|
154
|
-
'pr',
|
|
155
|
-
'create',
|
|
156
|
-
'--head',
|
|
157
|
-
branch,
|
|
158
|
-
'--base',
|
|
159
|
-
base,
|
|
160
|
-
'--title',
|
|
161
|
-
title,
|
|
162
|
-
'--body',
|
|
163
|
-
body,
|
|
164
|
-
]);
|
|
165
|
-
const match = /\/pull\/(\d+)/.exec(created);
|
|
166
|
-
if (match === null) {
|
|
167
|
-
throw new Error(`cannot read the pull request number from: ${created}`);
|
|
168
|
-
}
|
|
169
|
-
return Number(match[1]);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async function readChecks(repo: Repo, pr: number): Promise<Check[]> {
|
|
173
|
-
const result = await run(
|
|
174
|
-
'gh',
|
|
175
|
-
['pr', 'checks', String(pr), '--json', 'name,bucket,link', '--repo', repo.slug],
|
|
176
|
-
{ cwd: repo.root },
|
|
177
|
-
);
|
|
178
|
-
if (result.stdout.trim().length === 0) {
|
|
179
|
-
if (/no checks reported/i.test(result.stderr)) {
|
|
180
|
-
return [];
|
|
181
|
-
}
|
|
182
|
-
throw new Error(`gh pr checks ${pr} failed with code ${result.code}: ${result.stderr.trim()}`);
|
|
183
|
-
}
|
|
184
|
-
return JSON.parse(result.stdout) as Check[];
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export async function checks(
|
|
188
|
-
repo: Repo,
|
|
189
|
-
pr: number,
|
|
190
|
-
headSha: string,
|
|
191
|
-
timeoutMs: number,
|
|
192
|
-
signal: AbortSignal,
|
|
193
|
-
): Promise<ChecksResult> {
|
|
194
|
-
const deadline = Date.now() + timeoutMs;
|
|
195
|
-
let shownAt: number | undefined;
|
|
196
|
-
let failures = 0;
|
|
197
|
-
while (Date.now() < deadline) {
|
|
198
|
-
try {
|
|
199
|
-
const prHead = await gh(repo, [
|
|
200
|
-
'pr',
|
|
201
|
-
'view',
|
|
202
|
-
String(pr),
|
|
203
|
-
'--json',
|
|
204
|
-
'headRefOid',
|
|
205
|
-
'--jq',
|
|
206
|
-
'.headRefOid',
|
|
207
|
-
]);
|
|
208
|
-
if (prHead === headSha) {
|
|
209
|
-
shownAt ??= Date.now();
|
|
210
|
-
const list = await readChecks(repo, pr);
|
|
211
|
-
if (list.some((check) => check.bucket === 'fail' || check.bucket === 'cancel')) {
|
|
212
|
-
return 'fail';
|
|
213
|
-
}
|
|
214
|
-
if (list.length > 0 && list.every((check) => check.bucket !== 'pending')) {
|
|
215
|
-
return 'pass';
|
|
216
|
-
}
|
|
217
|
-
if (list.length === 0 && Date.now() - shownAt >= GRACE_MS) {
|
|
218
|
-
return 'pass';
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
failures = 0;
|
|
222
|
-
} catch (error) {
|
|
223
|
-
failures += 1;
|
|
224
|
-
if (failures >= READ_TRIES) {
|
|
225
|
-
throw error;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
await sleep(Math.max(0, Math.min(POLL_MS, deadline - Date.now())), undefined, { signal });
|
|
229
|
-
}
|
|
230
|
-
return 'timeout';
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export async function failedLogs(repo: Repo, pr: number, maxChars: number): Promise<string> {
|
|
234
|
-
const failed = (await readChecks(repo, pr)).filter(
|
|
235
|
-
(check) => check.bucket === 'fail' || check.bucket === 'cancel',
|
|
236
|
-
);
|
|
237
|
-
const groups = new Map<string, { names: string[]; runId: string | undefined }>();
|
|
238
|
-
for (const check of failed) {
|
|
239
|
-
const runId = /\/actions\/runs\/(\d+)/.exec(check.link)?.[1];
|
|
240
|
-
const key = runId ?? `check:${check.name}`;
|
|
241
|
-
const group = groups.get(key) ?? { names: [], runId };
|
|
242
|
-
group.names.push(check.name);
|
|
243
|
-
groups.set(key, group);
|
|
244
|
-
}
|
|
245
|
-
const share = Math.max(1, Math.floor(maxChars / Math.max(1, groups.size)));
|
|
246
|
-
const parts: string[] = [];
|
|
247
|
-
for (const group of groups.values()) {
|
|
248
|
-
parts.push(`## ${group.names.join(', ')}`);
|
|
249
|
-
if (group.runId !== undefined) {
|
|
250
|
-
const logs = await run(
|
|
251
|
-
'gh',
|
|
252
|
-
['run', 'view', group.runId, '--log-failed', '--repo', repo.slug],
|
|
253
|
-
{ cwd: repo.root },
|
|
254
|
-
);
|
|
255
|
-
parts.push((logs.stdout || logs.stderr).slice(-share));
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
return parts.length > 0 ? parts.join('\n\n') : 'The checks failed, but gh lists no failed check.';
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export async function merge(
|
|
262
|
-
repo: Repo,
|
|
263
|
-
pr: number,
|
|
264
|
-
timeoutMs: number,
|
|
265
|
-
signal: AbortSignal,
|
|
266
|
-
): Promise<MergeResult> {
|
|
267
|
-
const before = await gh(repo, ['pr', 'view', String(pr), '--json', 'state', '--jq', '.state']);
|
|
268
|
-
if (before === 'MERGED') {
|
|
269
|
-
return { merged: true };
|
|
270
|
-
}
|
|
271
|
-
const result = await run('gh', ['pr', 'merge', String(pr), '--squash', '--repo', repo.slug], {
|
|
272
|
-
cwd: repo.root,
|
|
273
|
-
});
|
|
274
|
-
if (result.code !== 0) {
|
|
275
|
-
return {
|
|
276
|
-
merged: false,
|
|
277
|
-
reason: result.stderr.trim().slice(-2000) || `gh pr merge failed with code ${result.code}`,
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
const deadline = Date.now() + timeoutMs;
|
|
281
|
-
for (;;) {
|
|
282
|
-
const state = await gh(repo, ['pr', 'view', String(pr), '--json', 'state', '--jq', '.state']);
|
|
283
|
-
if (state === 'MERGED') {
|
|
284
|
-
return { merged: true };
|
|
285
|
-
}
|
|
286
|
-
if (state === 'CLOSED') {
|
|
287
|
-
return { merged: false, reason: 'the pull request was closed' };
|
|
288
|
-
}
|
|
289
|
-
if (Date.now() >= deadline) {
|
|
290
|
-
return { merged: false, reason: 'the merge did not finish in time' };
|
|
291
|
-
}
|
|
292
|
-
await sleep(Math.max(0, Math.min(POLL_MS, deadline - Date.now())), undefined, { signal });
|
|
293
|
-
}
|
|
294
|
-
}
|