@ashulab/carrier 0.0.0-stage → 0.1.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 +265 -2
- package/dist/chat/claude.js +367 -0
- package/dist/chat/codex.js +375 -0
- package/dist/chat/config.js +61 -0
- package/dist/chat/db.js +9 -0
- package/dist/chat/fake.js +203 -0
- package/dist/chat/folder.js +56 -0
- package/dist/chat/git.js +27 -0
- package/dist/chat/group.js +28 -0
- package/dist/chat/health.js +49 -0
- package/dist/chat/host.js +123 -0
- package/dist/chat/idle.js +20 -0
- package/dist/chat/migrations/0000_lame_quasar.sql +15 -0
- package/dist/chat/migrations/0001_quiet_penance.sql +23 -0
- package/dist/chat/migrations/0002_slim_ezekiel_stane.sql +1 -0
- package/dist/chat/migrations/0003_naive_iron_lad.sql +5 -0
- package/dist/chat/migrations/0004_skinny_dakota_north.sql +1 -0
- package/dist/chat/migrations/0005_tidy_strong_guy.sql +1 -0
- package/dist/chat/migrations/meta/0000_snapshot.json +121 -0
- package/dist/chat/migrations/meta/0001_snapshot.json +277 -0
- package/dist/chat/migrations/meta/0002_snapshot.json +284 -0
- package/dist/chat/migrations/meta/0003_snapshot.json +320 -0
- package/dist/chat/migrations/meta/0004_snapshot.json +327 -0
- package/dist/chat/migrations/meta/0005_snapshot.json +334 -0
- package/dist/chat/migrations/meta/_journal.json +48 -0
- package/dist/chat/names.js +97 -0
- package/dist/chat/plan.js +21 -0
- package/dist/chat/projects.js +242 -0
- package/dist/chat/runner.js +6 -0
- package/dist/chat/schema.js +48 -0
- package/dist/chat/server.js +202 -0
- package/dist/chat/sessions.js +766 -0
- package/dist/chat/sweep.js +64 -0
- package/dist/chat/transcript.js +31 -0
- package/dist/chat/words.js +20 -0
- package/dist/chat/workspace.js +134 -0
- package/dist/chat/worktree.js +142 -0
- package/dist/config/default-path.js +7 -0
- package/dist/config/engine-places.js +9 -0
- package/dist/config/json-schema.js +15 -0
- package/dist/config/load.js +48 -0
- package/dist/config/resolve.js +41 -0
- package/dist/config/schema.js +225 -0
- package/dist/control/epic.js +219 -0
- package/dist/control/events.js +48 -0
- package/dist/control/files.js +262 -0
- package/dist/control/replies.js +1 -0
- package/dist/control/server.js +237 -0
- package/dist/control/sessions.js +68 -0
- package/dist/doctor/doctor.js +255 -0
- package/dist/doctor/fixes.js +35 -0
- package/dist/execution/agent-runner.js +1 -0
- package/dist/execution/caller-result.js +61 -0
- package/dist/execution/events.js +19 -0
- package/dist/execution/failure.js +3 -0
- package/dist/execution/fake.js +82 -0
- package/dist/execution/git-workspaces.js +136 -0
- package/dist/execution/import-result.js +23 -0
- package/dist/execution/workspaces.js +1 -0
- package/dist/format/duration.js +12 -0
- package/dist/harnesses/agy.js +131 -0
- package/dist/harnesses/claude.js +246 -0
- package/dist/harnesses/codex.js +118 -0
- package/dist/harnesses/common.js +59 -0
- package/dist/harnesses/credentials.js +38 -0
- package/dist/harnesses/host-runner.js +225 -0
- package/dist/harnesses/local.js +25 -0
- package/dist/harnesses/names.js +9 -0
- package/dist/harnesses/replay-events.js +57 -0
- package/dist/integrations/available-repos.js +29 -0
- package/dist/integrations/forge-cwd.js +27 -0
- package/dist/integrations/github-graphql.js +216 -0
- package/dist/integrations/github.js +231 -0
- package/dist/logging/logger.js +53 -0
- package/dist/logging/redact.js +21 -0
- package/dist/orchestration/blockers.js +25 -0
- package/dist/orchestration/changes-slices.js +5 -0
- package/dist/orchestration/clock.js +7 -0
- package/dist/orchestration/engine.js +1068 -0
- package/dist/orchestration/fake.js +152 -0
- package/dist/orchestration/final-pr.js +15 -0
- package/dist/orchestration/github.js +31 -0
- package/dist/orchestration/reserve.js +32 -0
- package/dist/orchestration/resume-refused.js +7 -0
- package/dist/orchestration/retention.js +145 -0
- package/dist/orchestration/retry.js +16 -0
- package/dist/prompts/build-prompt.js +13 -0
- package/dist/prompts/fix.js +21 -0
- package/dist/prompts/intro.js +21 -0
- package/dist/prompts/invoke.js +2 -0
- package/dist/prompts/persona.js +12 -0
- package/dist/prompts/result.js +32 -0
- package/dist/prompts/review.js +15 -0
- package/dist/prompts/ship.js +8 -0
- package/dist/status/snapshot.js +92 -0
- package/dist/status/types.js +1 -0
- package/dist/storage/caller-results.js +7 -0
- package/dist/storage/db.js +8 -0
- package/dist/storage/epic-review-threads.js +19 -0
- package/dist/storage/epic-slices.js +17 -0
- package/dist/storage/events.js +52 -0
- package/dist/storage/executions.js +31 -0
- package/dist/storage/lock.js +21 -0
- package/dist/storage/migrations/0000_nosy_demogoblin.sql +5 -0
- package/dist/storage/migrations/0001_worried_grey_gargoyle.sql +3 -0
- package/dist/storage/migrations/0002_tough_squirrel_girl.sql +5 -0
- package/dist/storage/migrations/0003_loose_maria_hill.sql +4 -0
- package/dist/storage/migrations/0004_long_cerise.sql +18 -0
- package/dist/storage/migrations/0005_review_result.sql +2 -0
- package/dist/storage/migrations/0006_melted_menace.sql +10 -0
- package/dist/storage/migrations/0007_powerful_bloodscream.sql +2 -0
- package/dist/storage/migrations/0008_panel_state.sql +4 -0
- package/dist/storage/migrations/0009_workable_tiger_shark.sql +5 -0
- package/dist/storage/migrations/0010_nasty_blue_marvel.sql +4 -0
- package/dist/storage/migrations/0011_steep_lila_cheney.sql +13 -0
- package/dist/storage/migrations/0012_talented_big_bertha.sql +1 -0
- package/dist/storage/migrations/0013_third_mulholland_black.sql +17 -0
- package/dist/storage/migrations/0014_perfect_marten_broadcloak.sql +20 -0
- package/dist/storage/migrations/0015_long_bloodaxe.sql +1 -0
- package/dist/storage/migrations/0016_neat_true_believers.sql +5 -0
- package/dist/storage/migrations/0017_previous_swarm.sql +6 -0
- package/dist/storage/migrations/0018_opposite_plazm.sql +2 -0
- package/dist/storage/migrations/0019_hesitant_triton.sql +3 -0
- package/dist/storage/migrations/meta/0000_snapshot.json +49 -0
- package/dist/storage/migrations/meta/0001_snapshot.json +74 -0
- package/dist/storage/migrations/meta/0002_snapshot.json +105 -0
- package/dist/storage/migrations/meta/0003_snapshot.json +134 -0
- package/dist/storage/migrations/meta/0004_snapshot.json +257 -0
- package/dist/storage/migrations/meta/0005_snapshot.json +271 -0
- package/dist/storage/migrations/meta/0006_snapshot.json +337 -0
- package/dist/storage/migrations/meta/0007_snapshot.json +351 -0
- package/dist/storage/migrations/meta/0008_snapshot.json +379 -0
- package/dist/storage/migrations/meta/0009_snapshot.json +414 -0
- package/dist/storage/migrations/meta/0010_snapshot.json +442 -0
- package/dist/storage/migrations/meta/0011_snapshot.json +531 -0
- package/dist/storage/migrations/meta/0012_snapshot.json +538 -0
- package/dist/storage/migrations/meta/0013_snapshot.json +655 -0
- package/dist/storage/migrations/meta/0014_snapshot.json +772 -0
- package/dist/storage/migrations/meta/0015_snapshot.json +779 -0
- package/dist/storage/migrations/meta/0016_snapshot.json +793 -0
- package/dist/storage/migrations/meta/0017_snapshot.json +835 -0
- package/dist/storage/migrations/meta/0018_snapshot.json +849 -0
- package/dist/storage/migrations/meta/0019_snapshot.json +870 -0
- package/dist/storage/migrations/meta/_journal.json +146 -0
- package/dist/storage/schema.js +43 -0
- package/dist/terminal/cli/args.js +91 -0
- package/dist/terminal/cli/cli-error.js +11 -0
- package/dist/terminal/cli/commands/chat-host.js +18 -0
- package/dist/terminal/cli/commands/chat.js +321 -0
- package/dist/terminal/cli/commands/control.js +46 -0
- package/dist/terminal/cli/commands/doctor.js +15 -0
- package/dist/terminal/cli/commands/init.js +43 -0
- package/dist/terminal/cli/commands/restart.js +92 -0
- package/dist/terminal/cli/commands/run.js +20 -0
- package/dist/terminal/cli/commands/schema.js +4 -0
- package/dist/terminal/cli/commands/start.js +21 -0
- package/dist/terminal/cli/commands/status.js +61 -0
- package/dist/terminal/cli/commands/watch.js +112 -0
- package/dist/terminal/cli/compose.js +163 -0
- package/dist/terminal/cli/config-file.js +24 -0
- package/dist/terminal/cli/control-client.js +94 -0
- package/dist/terminal/cli/detached.js +41 -0
- package/dist/terminal/cli/interrupt.js +39 -0
- package/dist/terminal/cli/main.js +85 -0
- package/dist/terminal/cli/open-url.js +9 -0
- package/dist/terminal/dashboard/dashboard.js +204 -0
- package/dist/terminal/dashboard/history.js +38 -0
- package/dist/terminal/dashboard/interactive.js +60 -0
- package/dist/terminal/dashboard/keys.js +22 -0
- package/dist/terminal/dashboard/logs.js +85 -0
- package/dist/terminal/dashboard/panel.js +62 -0
- package/dist/terminal/dashboard/plain.js +32 -0
- package/dist/terminal/dashboard/printable.js +2 -0
- package/dist/terminal/dashboard/ui-state.js +110 -0
- package/dist/terminal/prompts/clack.js +121 -0
- package/dist/terminal/prompts/types.js +3 -0
- package/dist/terminal/wizard/build-config.js +66 -0
- package/dist/terminal/wizard/deps.js +94 -0
- package/dist/terminal/wizard/flags.js +1 -0
- package/dist/terminal/wizard/forge-registry.js +23 -0
- package/dist/terminal/wizard/prior.js +20 -0
- package/dist/terminal/wizard/questions/agent.js +61 -0
- package/dist/terminal/wizard/questions/identities.js +28 -0
- package/dist/terminal/wizard/questions/identity.js +30 -0
- package/dist/terminal/wizard/questions/options.js +93 -0
- package/dist/terminal/wizard/questions/out.js +16 -0
- package/dist/terminal/wizard/questions/persona.js +47 -0
- package/dist/terminal/wizard/questions/repos.js +61 -0
- package/dist/terminal/wizard/summary.js +31 -0
- package/dist/terminal/wizard/testing.js +106 -0
- package/dist/terminal/wizard/wizard.js +172 -0
- package/package.json +46 -3
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { freemem, loadavg } from 'node:os';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { failureDetail } from '../execution/failure.js';
|
|
7
|
+
import { createEventState } from '../execution/events.js';
|
|
8
|
+
import { claudeAdapter } from './claude.js';
|
|
9
|
+
import { agyAdapter } from './agy.js';
|
|
10
|
+
import { codexAdapter } from './codex.js';
|
|
11
|
+
import { resolveBinary } from './common.js';
|
|
12
|
+
const exec = promisify(execFile);
|
|
13
|
+
const markerRe = /carrier-exec-([\w-]+)/;
|
|
14
|
+
/** Pids of live processes carrying an execution marker, found in `ps` so another carrier process can see them. */
|
|
15
|
+
async function listMarked() {
|
|
16
|
+
const { stdout } = await exec('ps', ['-axo', 'pid=,command='], { maxBuffer: 64 * 1024 * 1024 });
|
|
17
|
+
const found = new Map();
|
|
18
|
+
for (const line of stdout.split('\n')) {
|
|
19
|
+
const match = markerRe.exec(line);
|
|
20
|
+
if (match)
|
|
21
|
+
found.set(match[1], Number(line.trim().split(/\s+/)[0]));
|
|
22
|
+
}
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
// The agent leads its own process group (detached), so the signal reaches the whole group.
|
|
26
|
+
function signal(pid, sig) {
|
|
27
|
+
try {
|
|
28
|
+
process.kill(-pid, sig);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, sig);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// already gone
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const spawnError = (message) => ({
|
|
40
|
+
exit: 'failed',
|
|
41
|
+
failure: { kind: 'spawn_error', detail: failureDetail(message), exitCode: null, signal: null, hostFreeMemMb: null, hostLoad: null },
|
|
42
|
+
});
|
|
43
|
+
// A failed process: what the exit code and the signal say, and the host at that moment. What the adapter read from the
|
|
44
|
+
// agent's own result names the cause and wins over the symptom; the exit code and the signal are recorded either way.
|
|
45
|
+
function processFailure(code, signal, adapterFailure) {
|
|
46
|
+
if (adapterFailure)
|
|
47
|
+
return { kind: adapterFailure.kind, detail: failureDetail(adapterFailure.detail), exitCode: code, signal, hostFreeMemMb: null, hostLoad: null };
|
|
48
|
+
const kind = signal ? 'killed' : code ? 'crashed' : 'unknown';
|
|
49
|
+
const detail = signal ?? (code ? `exit code ${code}` : 'the process ended without an exit code');
|
|
50
|
+
return { kind, detail, exitCode: code, signal, hostFreeMemMb: Math.round(freemem() / 1048576), hostLoad: loadavg()[0] };
|
|
51
|
+
}
|
|
52
|
+
const alive = (pid) => {
|
|
53
|
+
try {
|
|
54
|
+
process.kill(pid, 0);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
// SIGTERM first (Claude cleans its children up on it), SIGKILL if it has not exited after the grace period.
|
|
62
|
+
async function terminate(pid, graceMs) {
|
|
63
|
+
signal(pid, 'SIGTERM');
|
|
64
|
+
const deadline = Date.now() + graceMs;
|
|
65
|
+
while (alive(pid) && Date.now() < deadline)
|
|
66
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
67
|
+
if (alive(pid))
|
|
68
|
+
signal(pid, 'SIGKILL');
|
|
69
|
+
}
|
|
70
|
+
export class HostAgentRunner {
|
|
71
|
+
runs = new Map();
|
|
72
|
+
options;
|
|
73
|
+
constructor(options) {
|
|
74
|
+
this.options = options;
|
|
75
|
+
}
|
|
76
|
+
run(task) {
|
|
77
|
+
const sessions = [];
|
|
78
|
+
const outputs = [];
|
|
79
|
+
const eventSinks = [];
|
|
80
|
+
let sessionId;
|
|
81
|
+
let reason;
|
|
82
|
+
let pid;
|
|
83
|
+
const emit = (stream, chunk) => outputs.forEach((cb) => cb(stream, chunk));
|
|
84
|
+
const graceMs = this.options.graceMs ?? 5000;
|
|
85
|
+
let finished = false;
|
|
86
|
+
// Before the spawn there is nothing to kill: start() checks `reason` instead.
|
|
87
|
+
const kill = async () => {
|
|
88
|
+
if (pid !== undefined && !finished)
|
|
89
|
+
await terminate(pid, graceMs);
|
|
90
|
+
};
|
|
91
|
+
const start = async () => {
|
|
92
|
+
const adapter = this.options.adapters?.[task.harness] ?? { claude: claudeAdapter, codex: codexAdapter, agy: agyAdapter }[task.harness];
|
|
93
|
+
let credentials;
|
|
94
|
+
try {
|
|
95
|
+
credentials = await this.options.credentials(task);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
// Without a token the run cannot start; retrying does not help, so it is an auth failure, not a technical one.
|
|
99
|
+
emit('stderr', `carrier: no GitHub token for profile "${task.profile}": ${e instanceof Error ? e.message : String(e)}\n`);
|
|
100
|
+
return { exit: 'auth_failed' };
|
|
101
|
+
}
|
|
102
|
+
if (reason)
|
|
103
|
+
return { exit: reason };
|
|
104
|
+
let command;
|
|
105
|
+
try {
|
|
106
|
+
const binary = this.options.binaries?.[task.harness] ?? resolveBinary(task.harness);
|
|
107
|
+
command = adapter.command(task, { binary, baseEnv: process.env, ...credentials });
|
|
108
|
+
mkdirSync(dirname(task.resultPath), { recursive: true });
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
112
|
+
emit('stderr', `carrier: cannot build the ${task.harness} command: ${message}\n`);
|
|
113
|
+
return spawnError(`cannot build the ${task.harness} command: ${message}`);
|
|
114
|
+
}
|
|
115
|
+
const child = spawn(command.bin, command.args, { cwd: command.cwd, env: command.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
116
|
+
pid = child.pid;
|
|
117
|
+
let authFailed = false;
|
|
118
|
+
let sessionLost = false;
|
|
119
|
+
let adapterFailure;
|
|
120
|
+
const eventState = createEventState();
|
|
121
|
+
// The events of the lines of one chunk, to be reported together; only stdout carries the stream.
|
|
122
|
+
let batch = [];
|
|
123
|
+
const readLine = (line, stream) => {
|
|
124
|
+
if (stream === 'stdout')
|
|
125
|
+
batch.push(...(adapter.parseEvents?.(line, eventState) ?? []));
|
|
126
|
+
const signalOfLine = adapter.parseLine(line);
|
|
127
|
+
if (signalOfLine?.session && !sessionId) {
|
|
128
|
+
sessionId = signalOfLine.session;
|
|
129
|
+
sessions.forEach((cb) => cb(sessionId));
|
|
130
|
+
}
|
|
131
|
+
authFailed ||= signalOfLine?.authFailed === true;
|
|
132
|
+
sessionLost ||= signalOfLine?.sessionUnavailable === true;
|
|
133
|
+
adapterFailure ??= signalOfLine?.failure;
|
|
134
|
+
};
|
|
135
|
+
const reportEvents = () => {
|
|
136
|
+
if (batch.length > 0)
|
|
137
|
+
eventSinks.forEach((cb) => cb(batch));
|
|
138
|
+
};
|
|
139
|
+
// Some errors (Codex's lost session) only show on stderr, so both streams go through the adapter.
|
|
140
|
+
const lineReader = (stream) => {
|
|
141
|
+
let pending = '';
|
|
142
|
+
return {
|
|
143
|
+
push: (chunk) => {
|
|
144
|
+
emit(stream, chunk);
|
|
145
|
+
const lines = (pending + chunk).split('\n');
|
|
146
|
+
pending = lines.pop();
|
|
147
|
+
batch = [];
|
|
148
|
+
lines.forEach((line) => readLine(line, stream));
|
|
149
|
+
reportEvents();
|
|
150
|
+
},
|
|
151
|
+
flush: () => {
|
|
152
|
+
if (!pending)
|
|
153
|
+
return;
|
|
154
|
+
batch = [];
|
|
155
|
+
readLine(pending, stream);
|
|
156
|
+
reportEvents();
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
const out = lineReader('stdout');
|
|
161
|
+
const err = lineReader('stderr');
|
|
162
|
+
child.stdout.setEncoding('utf8').on('data', out.push);
|
|
163
|
+
child.stderr.setEncoding('utf8').on('data', err.push);
|
|
164
|
+
const timer = task.timeoutMs === null ? undefined : setTimeout(() => {
|
|
165
|
+
reason ??= 'timed_out';
|
|
166
|
+
void kill();
|
|
167
|
+
}, task.timeoutMs);
|
|
168
|
+
let startError;
|
|
169
|
+
const [code, killedBy] = await new Promise((resolve) => {
|
|
170
|
+
child.on('error', (e) => {
|
|
171
|
+
emit('stderr', `carrier: cannot start ${command.bin}: ${e.message}\n`);
|
|
172
|
+
startError = e.message;
|
|
173
|
+
resolve([null, null]);
|
|
174
|
+
});
|
|
175
|
+
child.on('close', (c, sig) => resolve([c, sig]));
|
|
176
|
+
});
|
|
177
|
+
finished = true;
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
out.flush();
|
|
180
|
+
err.flush();
|
|
181
|
+
if (reason)
|
|
182
|
+
return { exit: reason, sessionId };
|
|
183
|
+
if (authFailed)
|
|
184
|
+
return { exit: 'auth_failed', sessionId };
|
|
185
|
+
if (sessionLost)
|
|
186
|
+
return { exit: 'session_unavailable', sessionId };
|
|
187
|
+
if (startError !== undefined)
|
|
188
|
+
return { ...spawnError(`cannot start ${command.bin}: ${startError}`), sessionId };
|
|
189
|
+
if (code === 0)
|
|
190
|
+
return { exit: 'ok', sessionId };
|
|
191
|
+
return { exit: 'failed', sessionId, failure: processFailure(code, killedBy, adapterFailure) };
|
|
192
|
+
};
|
|
193
|
+
const done = start().finally(() => this.runs.delete(task.executionId));
|
|
194
|
+
const handle = {
|
|
195
|
+
onSession: (cb) => {
|
|
196
|
+
sessions.push(cb);
|
|
197
|
+
if (sessionId)
|
|
198
|
+
cb(sessionId);
|
|
199
|
+
},
|
|
200
|
+
onOutput: (cb) => void outputs.push(cb),
|
|
201
|
+
onEvent: (cb) => void eventSinks.push(cb),
|
|
202
|
+
done,
|
|
203
|
+
stop: async () => {
|
|
204
|
+
reason ??= 'stopped';
|
|
205
|
+
await kill();
|
|
206
|
+
await done;
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
this.runs.set(task.executionId, handle);
|
|
210
|
+
return handle;
|
|
211
|
+
}
|
|
212
|
+
async listActive() {
|
|
213
|
+
return [...new Set([...this.runs.keys(), ...(await listMarked()).keys()])];
|
|
214
|
+
}
|
|
215
|
+
async stop(executionId) {
|
|
216
|
+
const handle = this.runs.get(executionId);
|
|
217
|
+
if (handle)
|
|
218
|
+
return handle.stop();
|
|
219
|
+
// An orphan from a previous carrier process: no handle, only its pid.
|
|
220
|
+
const pid = (await listMarked()).get(executionId);
|
|
221
|
+
if (pid === undefined)
|
|
222
|
+
return;
|
|
223
|
+
await terminate(pid, this.options.graceMs ?? 5000);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const localKinds = ['lmstudio', 'ollama'];
|
|
2
|
+
export const localLabel = { lmstudio: 'LM Studio', ollama: 'Ollama' };
|
|
3
|
+
// ponytail: default ports only; a server on another port needs a base-URL setting when someone runs one.
|
|
4
|
+
export const localUrl = { lmstudio: 'http://localhost:1234', ollama: 'http://localhost:11434' };
|
|
5
|
+
/** The environment that points the Claude CLI at a local server, which speaks the Anthropic API on its root URL. */
|
|
6
|
+
export const claudeLocalEnv = (local) => ({
|
|
7
|
+
ANTHROPIC_BASE_URL: localUrl[local],
|
|
8
|
+
ANTHROPIC_AUTH_TOKEN: local, // any non-empty value: the local server does not check it
|
|
9
|
+
ANTHROPIC_API_KEY: '', // a real key in the user's environment must not be sent to a local server
|
|
10
|
+
});
|
|
11
|
+
/** The chat models the server has. Throws when it does not answer, which is how callers tell "not running". */
|
|
12
|
+
export async function listLocalModels(local, fetchJson = defaultFetch) {
|
|
13
|
+
if (local === 'ollama') {
|
|
14
|
+
const body = (await fetchJson(`${localUrl.ollama}/api/tags`));
|
|
15
|
+
return (body.models ?? []).flatMap((m) => (m.name ? [m.name] : []));
|
|
16
|
+
}
|
|
17
|
+
const body = (await fetchJson(`${localUrl.lmstudio}/v1/models`));
|
|
18
|
+
return (body.data ?? []).flatMap((m) => (m.id && !/embed/i.test(m.id) ? [m.id] : [])); // LM Studio lists embedding models too
|
|
19
|
+
}
|
|
20
|
+
async function defaultFetch(url) {
|
|
21
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(2000) });
|
|
22
|
+
if (!response.ok)
|
|
23
|
+
throw new Error(`${url} answered HTTP ${response.status}`);
|
|
24
|
+
return response.json();
|
|
25
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The agent CLIs Carrier can run; each is also the name of its binary. */
|
|
2
|
+
export const harnessNames = ['claude', 'codex', 'agy'];
|
|
3
|
+
/** The environment variable that points each CLI at its config folder (what an option's `configDir` sets). Antigravity has none. */
|
|
4
|
+
export const configDirVariable = { claude: 'CLAUDE_CONFIG_DIR', codex: 'CODEX_HOME' };
|
|
5
|
+
export const harnessLabel = { claude: 'Claude Code', codex: 'Codex', agy: 'Antigravity' };
|
|
6
|
+
/** Who serves the model when the config names no provider. */
|
|
7
|
+
export const nativeProvider = { claude: 'Anthropic', codex: 'OpenAI', agy: 'Antigravity' };
|
|
8
|
+
/** The local servers a harness can be pointed at. Antigravity has no setting for it (docs/research/antigravity.md). */
|
|
9
|
+
export const localProvidersOf = { claude: ['lmstudio', 'ollama'], codex: ['lmstudio', 'ollama'], agy: [] };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Feeds the stdout.log of an old Claude execution through the adapter (ADR 0006): prints its events as JSON lines,
|
|
2
|
+
// or, with --db and --execution, stores them for an execution that ran before events existed.
|
|
3
|
+
// pnpm replay:events <stdout.log> [--db <carrier.db> --execution <id>]
|
|
4
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { eq } from 'drizzle-orm';
|
|
7
|
+
import { createEventState } from '../execution/events.js';
|
|
8
|
+
import { openDatabase } from '../storage/db.js';
|
|
9
|
+
import { executionEvents, recordEvents } from '../storage/events.js';
|
|
10
|
+
import { executions } from '../storage/executions.js';
|
|
11
|
+
import { parseClaudeEvents } from './claude.js';
|
|
12
|
+
export function replayEvents(log) {
|
|
13
|
+
const state = createEventState();
|
|
14
|
+
return log.split('\n').flatMap((line) => parseClaudeEvents(line, state));
|
|
15
|
+
}
|
|
16
|
+
class UsageError extends Error {
|
|
17
|
+
}
|
|
18
|
+
function run(argv, out) {
|
|
19
|
+
const { values, positionals } = parseArgs({ args: argv, options: { db: { type: 'string' }, execution: { type: 'string' } }, allowPositionals: true });
|
|
20
|
+
const [file, ...extra] = positionals;
|
|
21
|
+
if (file === undefined || extra.length > 0 || (values.db === undefined) !== (values.execution === undefined)) {
|
|
22
|
+
throw new UsageError('usage: replay-events <stdout.log> [--db <carrier.db> --execution <id>]');
|
|
23
|
+
}
|
|
24
|
+
if (!existsSync(file))
|
|
25
|
+
throw new Error(`cannot read ${file}: no such file`);
|
|
26
|
+
const events = replayEvents(readFileSync(file, 'utf8'));
|
|
27
|
+
if (values.db === undefined || values.execution === undefined) {
|
|
28
|
+
for (const event of events)
|
|
29
|
+
out(JSON.stringify(event));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!existsSync(values.db))
|
|
33
|
+
throw new Error(`cannot open ${values.db}: no such file`); // openDatabase would create an empty one
|
|
34
|
+
const db = openDatabase(values.db);
|
|
35
|
+
try {
|
|
36
|
+
if (!db.select().from(executions).where(eq(executions.executionId, values.execution)).get())
|
|
37
|
+
throw new Error(`no execution ${values.execution} in ${values.db}`);
|
|
38
|
+
if (db.select().from(executionEvents).where(eq(executionEvents.executionId, values.execution)).limit(1).get()) {
|
|
39
|
+
throw new Error(`execution ${values.execution} already has events: nothing was stored`);
|
|
40
|
+
}
|
|
41
|
+
// The log has no time for most lines: the time the log was last written is the closest there is.
|
|
42
|
+
recordEvents(db, values.execution, events, statSync(file).mtimeMs);
|
|
43
|
+
out(`stored ${events.length} events for ${values.execution}`);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
db.$client.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (import.meta.main) {
|
|
50
|
+
try {
|
|
51
|
+
run(process.argv.slice(2), (line) => console.log(line));
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
console.error(`replay-events: ${e instanceof Error ? e.message : String(e)}`);
|
|
55
|
+
process.exit(e instanceof UsageError ? 2 : 1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { repoNamePattern } from '../config/schema.js';
|
|
4
|
+
export const pageSize = 30;
|
|
5
|
+
/**
|
|
6
|
+
* One page of the repos the gh login can reach, newest push first: the raw count of the page on the first line, then one repo per
|
|
7
|
+
* line (tab separated), archived and disabled left out. `push` (the engine's picker, the default) keeps what the login can push to;
|
|
8
|
+
* `read` (the chat) also takes the repos it is only a collaborator on, and keeps every one it can read.
|
|
9
|
+
*/
|
|
10
|
+
export const ghReposArgs = (page, scope = 'push') => [
|
|
11
|
+
'api',
|
|
12
|
+
`user/repos?affiliation=${scope === 'push' ? 'owner,organization_member' : 'owner,collaborator,organization_member'}&sort=pushed&per_page=${pageSize}&page=${page}`,
|
|
13
|
+
'--jq',
|
|
14
|
+
`(length | tostring), (.[] | select(.archived or .disabled | not) | ${scope === 'push' ? 'select(.permissions.push) | ' : ''}[.full_name, .private, .pushed_at // ""] | @tsv)`,
|
|
15
|
+
];
|
|
16
|
+
/** `hasMore` follows the raw page (it came back full), not what survived the filter. A line that does not fit (a name that is not `owner/name`) is dropped. */
|
|
17
|
+
export function parseRepos(output) {
|
|
18
|
+
const [count, ...lines] = output.split('\n');
|
|
19
|
+
const repos = lines.flatMap((line) => {
|
|
20
|
+
const [name = '', isPrivate, pushedAt] = line.split('\t');
|
|
21
|
+
return repoNamePattern.test(name) ? [{ name, private: isPrivate === 'true', pushedAt: pushedAt || null }] : [];
|
|
22
|
+
});
|
|
23
|
+
return { repos, hasMore: Number(count) === pageSize };
|
|
24
|
+
}
|
|
25
|
+
/** One page, without blocking the engine; throws when gh is missing, not logged in, offline or past `timeoutMs`. */
|
|
26
|
+
export async function listAvailableRepos(page, timeoutMs, scope = 'push') {
|
|
27
|
+
const { stdout } = await promisify(execFile)('gh', ghReposArgs(page, scope), { encoding: 'utf8', timeout: timeoutMs });
|
|
28
|
+
return parseRepos(stdout);
|
|
29
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { repoNamePattern } from '../config/schema.js';
|
|
5
|
+
/**
|
|
6
|
+
* Where to run `gh` / Forge for a repo. Forge picks the App installation from the `origin` of the cwd, so it must
|
|
7
|
+
* be a repo under the right owner: the managed clone (`<repos>/<owner>/<name>`) once it exists, and before the
|
|
8
|
+
* first clone (the first poll comes first) a stub repo whose only content is that `origin`.
|
|
9
|
+
*/
|
|
10
|
+
export function forgeCwd(places, remoteUrl = (repo) => `https://github.com/${repo}.git`) {
|
|
11
|
+
return (repo) => {
|
|
12
|
+
if (!repoNamePattern.test(repo))
|
|
13
|
+
throw new Error(`invalid repo "${repo}", expected owner/name`);
|
|
14
|
+
const clone = join(places.repos, repo);
|
|
15
|
+
if (existsSync(join(clone, '.git')))
|
|
16
|
+
return clone;
|
|
17
|
+
const stub = join(places.state, 'forge-cwd', repo);
|
|
18
|
+
if (!existsSync(join(stub, '.git'))) {
|
|
19
|
+
mkdirSync(stub, { recursive: true });
|
|
20
|
+
execFileSync('git', ['init', '-q'], { cwd: stub });
|
|
21
|
+
}
|
|
22
|
+
// Separate step: a process killed between init and here leaves a stub that the next call completes.
|
|
23
|
+
if (spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: stub }).status !== 0)
|
|
24
|
+
execFileSync('git', ['remote', 'add', 'origin', remoteUrl(repo)], { cwd: stub });
|
|
25
|
+
return stub;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { redact } from '../logging/redact.js';
|
|
3
|
+
// What the GraphQL answers look like, as recorded in docs/research/macos-app-github-graphql.md.
|
|
4
|
+
// The queries live in that note; these schemas decode what they return.
|
|
5
|
+
/** Slices read per epic; more than this and the reply says it is partial. */
|
|
6
|
+
export const SLICE_LIMIT = 100;
|
|
7
|
+
/** Review threads read per pull request; more than this and the reply says it is partial. */
|
|
8
|
+
export const THREAD_LIMIT = 100;
|
|
9
|
+
/** Longest first line of a thread's opening comment that is kept. */
|
|
10
|
+
const FIRST_LINE_MAX = 160;
|
|
11
|
+
const rateLimit = z.object({ cost: z.int().positive(), remaining: z.int().nonnegative(), limit: z.int().positive(), resetAt: z.string() });
|
|
12
|
+
const slice = z.object({
|
|
13
|
+
number: z.int().positive(),
|
|
14
|
+
title: z.string(),
|
|
15
|
+
state: z.enum(['OPEN', 'CLOSED']),
|
|
16
|
+
stateReason: z.enum(['COMPLETED', 'NOT_PLANNED', 'REOPENED', 'DUPLICATE']).nullable(),
|
|
17
|
+
closedAt: z.string().nullable(),
|
|
18
|
+
assignees: z.object({ nodes: z.array(z.object({ login: z.string() })) }),
|
|
19
|
+
// Closed blockers are listed too: a slice waits only while one of them is OPEN.
|
|
20
|
+
blockedBy: z.object({
|
|
21
|
+
nodes: z.array(z.object({ number: z.int().positive(), state: z.enum(['OPEN', 'CLOSED']), repository: z.object({ nameWithOwner: z.string() }) })),
|
|
22
|
+
}),
|
|
23
|
+
// Added after the spike: what mentions the slice. Absent in the recorded answers, so it defaults to nothing.
|
|
24
|
+
timelineItems: z.object({ nodes: z.array(z.object({ source: z.object({ __typename: z.string(), number: z.int().positive().optional(), headRefName: z.string().optional(), state: z.enum(['OPEN', 'CLOSED', 'MERGED']).optional(), isDraft: z.boolean().optional(), mergedAt: z.string().nullable().optional() }) })) }).default({ nodes: [] }),
|
|
25
|
+
});
|
|
26
|
+
export const epicSlicesResponse = z.object({
|
|
27
|
+
data: z.object({
|
|
28
|
+
rateLimit,
|
|
29
|
+
repository: z.object({
|
|
30
|
+
issue: z.object({
|
|
31
|
+
number: z.int().positive(),
|
|
32
|
+
title: z.string(),
|
|
33
|
+
state: z.enum(['OPEN', 'CLOSED']),
|
|
34
|
+
labels: z.object({ nodes: z.array(z.object({ name: z.string() })) }),
|
|
35
|
+
subIssuesSummary: z.object({ total: z.int().nonnegative(), completed: z.int().nonnegative(), percentCompleted: z.int().nonnegative() }),
|
|
36
|
+
subIssues: z.object({ pageInfo: z.object({ hasNextPage: z.boolean() }), nodes: z.array(slice) }),
|
|
37
|
+
}),
|
|
38
|
+
}),
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
const checkCountsByState = z.array(z.object({ state: z.string(), count: z.int().nonnegative() }));
|
|
42
|
+
/** Null when the commit has no checks at all: that is "no checks", never "pending". */
|
|
43
|
+
const statusCheckRollup = z
|
|
44
|
+
.object({
|
|
45
|
+
state: z.enum(['SUCCESS', 'FAILURE', 'ERROR', 'PENDING', 'EXPECTED']),
|
|
46
|
+
contexts: z.object({
|
|
47
|
+
checkRunCount: z.int().nonnegative(),
|
|
48
|
+
statusContextCount: z.int().nonnegative(),
|
|
49
|
+
checkRunCountsByState: checkCountsByState,
|
|
50
|
+
statusContextCountsByState: checkCountsByState,
|
|
51
|
+
}),
|
|
52
|
+
})
|
|
53
|
+
.nullable();
|
|
54
|
+
const reviewThread = z.object({
|
|
55
|
+
id: z.string(),
|
|
56
|
+
isResolved: z.boolean(),
|
|
57
|
+
isOutdated: z.boolean(),
|
|
58
|
+
path: z.string(),
|
|
59
|
+
line: z.int().nullable(), // null when the thread is outdated
|
|
60
|
+
comments: z.object({
|
|
61
|
+
totalCount: z.int().nonnegative(),
|
|
62
|
+
nodes: z.array(z.object({ url: z.string(), body: z.string(), author: z.object({ login: z.string(), __typename: z.string() }).nullable() })).min(1),
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
export const pullRequestResponse = z.object({
|
|
66
|
+
data: z.object({
|
|
67
|
+
rateLimit,
|
|
68
|
+
repository: z.object({
|
|
69
|
+
pullRequest: z.object({
|
|
70
|
+
number: z.int().positive(),
|
|
71
|
+
url: z.string(),
|
|
72
|
+
isDraft: z.boolean(),
|
|
73
|
+
state: z.enum(['OPEN', 'CLOSED', 'MERGED']),
|
|
74
|
+
baseRefName: z.string(),
|
|
75
|
+
headRefName: z.string(),
|
|
76
|
+
headRefOid: z.string(),
|
|
77
|
+
mergeable: z.enum(['MERGEABLE', 'CONFLICTING', 'UNKNOWN']),
|
|
78
|
+
mergeStateStatus: z.enum(['CLEAN', 'DIRTY', 'BLOCKED', 'BEHIND', 'UNSTABLE', 'HAS_HOOKS', 'UNKNOWN']),
|
|
79
|
+
reviewDecision: z.enum(['APPROVED', 'CHANGES_REQUESTED', 'REVIEW_REQUIRED']).nullable(),
|
|
80
|
+
additions: z.int().nonnegative(),
|
|
81
|
+
deletions: z.int().nonnegative(),
|
|
82
|
+
changedFiles: z.int().nonnegative(),
|
|
83
|
+
commits: z.object({ totalCount: z.int().nonnegative() }),
|
|
84
|
+
lastCommit: z.object({ nodes: z.array(z.object({ commit: z.object({ statusCheckRollup }) })) }),
|
|
85
|
+
// Added after the spike: absent in the recorded answers, so it defaults to no threads.
|
|
86
|
+
reviewThreads: z.object({ pageInfo: z.object({ hasNextPage: z.boolean() }), nodes: z.array(reviewThread) }).default({ pageInfo: { hasNextPage: false }, nodes: [] }),
|
|
87
|
+
}),
|
|
88
|
+
}),
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
const checkContext = z.discriminatedUnion('__typename', [
|
|
92
|
+
z.object({ __typename: z.literal('CheckRun'), name: z.string(), status: z.string(), conclusion: z.string().nullable() }),
|
|
93
|
+
z.object({ __typename: z.literal('StatusContext'), context: z.string(), state: z.string() }),
|
|
94
|
+
]);
|
|
95
|
+
export const failingCheckResponse = z.object({
|
|
96
|
+
data: z.object({
|
|
97
|
+
rateLimit,
|
|
98
|
+
repository: z.object({
|
|
99
|
+
pullRequest: z.object({
|
|
100
|
+
number: z.int().positive(),
|
|
101
|
+
state: z.enum(['OPEN', 'CLOSED', 'MERGED']),
|
|
102
|
+
mergeStateStatus: z.string(),
|
|
103
|
+
lastCommit: z.object({
|
|
104
|
+
nodes: z.array(z.object({
|
|
105
|
+
commit: z.object({
|
|
106
|
+
statusCheckRollup: z
|
|
107
|
+
.object({
|
|
108
|
+
state: z.string(),
|
|
109
|
+
contexts: z.object({
|
|
110
|
+
checkRunCount: z.int().nonnegative(),
|
|
111
|
+
statusContextCount: z.int().nonnegative(),
|
|
112
|
+
pageInfo: z.object({ hasNextPage: z.boolean() }),
|
|
113
|
+
nodes: z.array(checkContext),
|
|
114
|
+
}),
|
|
115
|
+
})
|
|
116
|
+
.nullable(),
|
|
117
|
+
}),
|
|
118
|
+
})),
|
|
119
|
+
}),
|
|
120
|
+
}),
|
|
121
|
+
}),
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
/** The epic's slices with their blockers and the pull requests that mention them; the schemas above decode its answer. Cost 2 for 52 slices. */
|
|
125
|
+
export const epicSlicesQuery = `query($owner: String!, $name: String!, $number: Int!) {
|
|
126
|
+
rateLimit { cost remaining limit resetAt }
|
|
127
|
+
repository(owner: $owner, name: $name) {
|
|
128
|
+
issue(number: $number) {
|
|
129
|
+
number title state
|
|
130
|
+
labels(first: 20) { nodes { name } }
|
|
131
|
+
subIssuesSummary { total completed percentCompleted }
|
|
132
|
+
subIssues(first: ${SLICE_LIMIT}) {
|
|
133
|
+
pageInfo { hasNextPage }
|
|
134
|
+
nodes {
|
|
135
|
+
number title state stateReason closedAt
|
|
136
|
+
assignees(first: 3) { nodes { login } }
|
|
137
|
+
blockedBy(first: 20) { nodes { number state repository { nameWithOwner } } }
|
|
138
|
+
timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT]) { nodes { ... on CrossReferencedEvent { source { __typename ... on PullRequest { number headRefName state isDraft mergedAt } } } } }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}`;
|
|
144
|
+
/**
|
|
145
|
+
* Closed as completed is merged (`closed`), closed any other way is skipped; open and assigned or with a pull request is working,
|
|
146
|
+
* open with an open blocker is waiting, otherwise ready to start. A closed blocker does not hold a slice back.
|
|
147
|
+
*/
|
|
148
|
+
export function sliceOf(s) {
|
|
149
|
+
const blockedBy = s.blockedBy.nodes.filter((b) => b.state === 'OPEN').map((b) => b.number);
|
|
150
|
+
// The slice's own pull request is the newest mention whose branch is `<feature>/<slice>-<name>`; the timeline also lists pull requests that only cite it (a sibling's, the final one).
|
|
151
|
+
const own = s.timelineItems.nodes.findLast(({ source }) => source.__typename === 'PullRequest' && source.headRefName?.split('/').pop()?.startsWith(`${s.number}-`))?.source;
|
|
152
|
+
const prNumber = own?.number ?? null;
|
|
153
|
+
const prState = own?.state === undefined ? null : own.state === 'MERGED' ? 'merged' : own.state === 'CLOSED' ? 'closed' : own.isDraft ? 'draft' : 'open';
|
|
154
|
+
const prMergedAt = own?.mergedAt ? Date.parse(own.mergedAt) : null;
|
|
155
|
+
const assignee = s.assignees.nodes[0]?.login ?? null;
|
|
156
|
+
let state;
|
|
157
|
+
if (s.state === 'CLOSED')
|
|
158
|
+
state = s.stateReason === 'NOT_PLANNED' || s.stateReason === 'DUPLICATE' ? 'skipped' : 'closed';
|
|
159
|
+
else if (assignee !== null || prNumber !== null)
|
|
160
|
+
state = 'in_progress';
|
|
161
|
+
else
|
|
162
|
+
state = blockedBy.length > 0 ? 'waiting' : 'ready';
|
|
163
|
+
return { number: s.number, title: s.title, state, blockedBy, assignee, prNumber, prState, prMergedAt, closedAt: s.closedAt === null ? null : Date.parse(s.closedAt) };
|
|
164
|
+
}
|
|
165
|
+
/** A pull request with its checks, merge state, review decision and first hundred review threads; the schema above decodes its answer. Cost 1. */
|
|
166
|
+
export const pullRequestQuery = `query($owner: String!, $name: String!, $number: Int!) {
|
|
167
|
+
rateLimit { cost remaining limit resetAt }
|
|
168
|
+
repository(owner: $owner, name: $name) {
|
|
169
|
+
pullRequest(number: $number) {
|
|
170
|
+
number url isDraft state baseRefName headRefName headRefOid
|
|
171
|
+
mergeable mergeStateStatus reviewDecision
|
|
172
|
+
additions deletions changedFiles commits { totalCount }
|
|
173
|
+
lastCommit: commits(last: 1) { nodes { commit { statusCheckRollup { state
|
|
174
|
+
contexts(first: 1) { checkRunCount statusContextCount
|
|
175
|
+
checkRunCountsByState { state count } statusContextCountsByState { state count } } } } } }
|
|
176
|
+
reviewThreads(first: ${THREAD_LIMIT}) { totalCount pageInfo { hasNextPage }
|
|
177
|
+
nodes { id isResolved isOutdated path line
|
|
178
|
+
comments(first: 1) { totalCount nodes { url body author { login __typename } } } } }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}`;
|
|
182
|
+
const failingStates = new Set(['ACTION_REQUIRED', 'CANCELLED', 'FAILURE', 'STARTUP_FAILURE', 'STALE', 'TIMED_OUT', 'ERROR']);
|
|
183
|
+
const passingStates = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED', 'COMPLETED']);
|
|
184
|
+
/** Check runs and status contexts counted together: a state that is neither passing nor failing is still running or waiting. A commit with no rollup has no checks (null), never pending. */
|
|
185
|
+
export function pullRequestOf(response) {
|
|
186
|
+
const pr = response.data.repository.pullRequest;
|
|
187
|
+
const rollup = pr.lastCommit.nodes[0]?.commit.statusCheckRollup ?? null;
|
|
188
|
+
let checks = null;
|
|
189
|
+
if (rollup !== null) {
|
|
190
|
+
checks = { passing: 0, failing: 0, pending: 0 };
|
|
191
|
+
for (const { state, count } of [...rollup.contexts.checkRunCountsByState, ...rollup.contexts.statusContextCountsByState]) {
|
|
192
|
+
checks[failingStates.has(state) ? 'failing' : passingStates.has(state) ? 'passing' : 'pending'] += count;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { number: pr.number, url: pr.url, draft: pr.isDraft, mergeState: pr.mergeStateStatus, reviewDecision: pr.reviewDecision, checks };
|
|
196
|
+
}
|
|
197
|
+
/** The first line of the opening comment is redacted before it is cut, so a token is never left half there. */
|
|
198
|
+
export function threadsOf(response) {
|
|
199
|
+
const { nodes, pageInfo } = response.data.repository.pullRequest.reviewThreads;
|
|
200
|
+
const threads = nodes.map((t) => {
|
|
201
|
+
const first = t.comments.nodes[0];
|
|
202
|
+
return {
|
|
203
|
+
id: t.id,
|
|
204
|
+
path: t.path,
|
|
205
|
+
line: t.line,
|
|
206
|
+
resolved: t.isResolved,
|
|
207
|
+
outdated: t.isOutdated,
|
|
208
|
+
author: first.author?.login ?? null,
|
|
209
|
+
bot: first.author?.__typename === 'Bot',
|
|
210
|
+
firstLine: [...redact(first.body.split(/\r?\n/, 1)[0])].slice(0, FIRST_LINE_MAX).join(''),
|
|
211
|
+
commentCount: t.comments.totalCount,
|
|
212
|
+
url: first.url,
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
return { threads, truncated: pageInfo.hasNextPage };
|
|
216
|
+
}
|