opencode-ship 0.2.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/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/assets/agents/delivery-reviewer.md +80 -0
- package/assets/agents/delivery-verifier.md +61 -0
- package/assets/skills/delivery-workflow/SKILL.md +62 -0
- package/assets/skills/planning-research-checkpoint/SKILL.md +37 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2484 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +1851 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.js +15060 -0
- package/docs/adr/0001-opencode-ship-redesign.md +168 -0
- package/package.json +62 -0
- package/schema/project-adapter.example.json +50 -0
- package/schema/project-adapter.schema.json +132 -0
- package/schema/project-opencode-shim.json +42 -0
- package/schema/ship-config.schema.json +136 -0
- package/schema/ship-lock.schema.json +112 -0
package/dist/core.js
ADDED
|
@@ -0,0 +1,1851 @@
|
|
|
1
|
+
// opencode-ship/core v0.2.0
|
|
2
|
+
|
|
3
|
+
// src/adapter.js
|
|
4
|
+
import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
var ADAPTER_CONTRACT_VERSION = 1;
|
|
9
|
+
var LOCK_FILENAME = "delivery.lock.json";
|
|
10
|
+
var ADAPTER_FILENAME = "delivery.json";
|
|
11
|
+
var KNOWN_KEYS = /* @__PURE__ */ new Set([
|
|
12
|
+
"contractVersion",
|
|
13
|
+
"repository",
|
|
14
|
+
"forge",
|
|
15
|
+
"worktree",
|
|
16
|
+
"verification",
|
|
17
|
+
"review",
|
|
18
|
+
"ci",
|
|
19
|
+
"ready",
|
|
20
|
+
"merge",
|
|
21
|
+
"cleanup"
|
|
22
|
+
]);
|
|
23
|
+
var KNOWN_REPOSITORY_KEYS = /* @__PURE__ */ new Set(["remote", "defaultBranch"]);
|
|
24
|
+
var KNOWN_FORGE_KEYS = /* @__PURE__ */ new Set([
|
|
25
|
+
"driver",
|
|
26
|
+
"issueRequired",
|
|
27
|
+
"draftAfterFirstCommit",
|
|
28
|
+
"issueClosingSyntax"
|
|
29
|
+
]);
|
|
30
|
+
var KNOWN_WORKTREE_KEYS = /* @__PURE__ */ new Set(["root", "branchTemplate", "bootstrap"]);
|
|
31
|
+
var KNOWN_VERIFICATION_KEYS = /* @__PURE__ */ new Set([
|
|
32
|
+
"commands",
|
|
33
|
+
"requireCleanDiffAfter",
|
|
34
|
+
"invalidateOnHeadChange"
|
|
35
|
+
]);
|
|
36
|
+
var KNOWN_REVIEW_KEYS = /* @__PURE__ */ new Set(["agent", "required", "invalidateOnHeadChange"]);
|
|
37
|
+
var KNOWN_CI_KEYS = /* @__PURE__ */ new Set(["driver", "requiredChecks", "wait", "flakyRetry"]);
|
|
38
|
+
var KNOWN_READY_KEYS = /* @__PURE__ */ new Set(["requires", "stopAfterReady"]);
|
|
39
|
+
var KNOWN_MERGE_KEYS = /* @__PURE__ */ new Set(["strategy", "policy", "requireFreshGates"]);
|
|
40
|
+
var KNOWN_CLEANUP_KEYS = /* @__PURE__ */ new Set(["when", "requires"]);
|
|
41
|
+
function issuesFor(prefix, allowed, value) {
|
|
42
|
+
const issues = [];
|
|
43
|
+
for (const k of Object.keys(value)) {
|
|
44
|
+
if (!allowed.has(k)) issues.push(`${prefix}.${k} is not a recognised field`);
|
|
45
|
+
}
|
|
46
|
+
return issues;
|
|
47
|
+
}
|
|
48
|
+
function isStringArrayOfArrays(v) {
|
|
49
|
+
return Array.isArray(v) && v.every((row) => Array.isArray(row) && row.every((s) => typeof s === "string"));
|
|
50
|
+
}
|
|
51
|
+
function isStringArray(v) {
|
|
52
|
+
return Array.isArray(v) && v.every((s) => typeof s === "string");
|
|
53
|
+
}
|
|
54
|
+
function validateAdapter(value) {
|
|
55
|
+
const issues = [];
|
|
56
|
+
if (!value || typeof value !== "object") {
|
|
57
|
+
return { ok: false, issues: ["root must be an object"] };
|
|
58
|
+
}
|
|
59
|
+
const obj = value;
|
|
60
|
+
for (const k of Object.keys(obj)) {
|
|
61
|
+
if (!KNOWN_KEYS.has(k)) issues.push(`root.${k} is not a recognised field`);
|
|
62
|
+
}
|
|
63
|
+
if (obj.contractVersion !== 1) issues.push("contractVersion must be the literal 1");
|
|
64
|
+
if (obj.repository !== void 0) {
|
|
65
|
+
const r = obj.repository;
|
|
66
|
+
issues.push(...issuesFor("repository", KNOWN_REPOSITORY_KEYS, r));
|
|
67
|
+
if (r.defaultBranch !== void 0) {
|
|
68
|
+
const db = r.defaultBranch;
|
|
69
|
+
if (db.discover !== void 0 && typeof db.discover !== "boolean")
|
|
70
|
+
issues.push("repository.defaultBranch.discover must be boolean");
|
|
71
|
+
if (db.name !== void 0 && typeof db.name !== "string")
|
|
72
|
+
issues.push("repository.defaultBranch.name must be string");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (obj.forge !== void 0) {
|
|
76
|
+
const f = obj.forge;
|
|
77
|
+
issues.push(...issuesFor("forge", KNOWN_FORGE_KEYS, f));
|
|
78
|
+
if (f.driver !== void 0 && f.driver !== "github")
|
|
79
|
+
issues.push("forge.driver must be 'github'");
|
|
80
|
+
if (f.issueRequired !== void 0 && typeof f.issueRequired !== "boolean")
|
|
81
|
+
issues.push("forge.issueRequired must be boolean");
|
|
82
|
+
if (f.draftAfterFirstCommit !== void 0 && typeof f.draftAfterFirstCommit !== "boolean")
|
|
83
|
+
issues.push("forge.draftAfterFirstCommit must be boolean");
|
|
84
|
+
if (f.issueClosingSyntax !== void 0 && typeof f.issueClosingSyntax !== "boolean")
|
|
85
|
+
issues.push("forge.issueClosingSyntax must be boolean");
|
|
86
|
+
}
|
|
87
|
+
if (obj.worktree !== void 0) {
|
|
88
|
+
const w = obj.worktree;
|
|
89
|
+
issues.push(...issuesFor("worktree", KNOWN_WORKTREE_KEYS, w));
|
|
90
|
+
if (w.root !== void 0 && typeof w.root !== "string")
|
|
91
|
+
issues.push("worktree.root must be string");
|
|
92
|
+
if (w.branchTemplate !== void 0 && typeof w.branchTemplate !== "string")
|
|
93
|
+
issues.push("worktree.branchTemplate must be string");
|
|
94
|
+
if (w.bootstrap !== void 0 && !isStringArrayOfArrays(w.bootstrap))
|
|
95
|
+
issues.push("worktree.bootstrap must be an array of argv arrays");
|
|
96
|
+
}
|
|
97
|
+
if (obj.verification !== void 0) {
|
|
98
|
+
const v = obj.verification;
|
|
99
|
+
issues.push(...issuesFor("verification", KNOWN_VERIFICATION_KEYS, v));
|
|
100
|
+
if (v.commands !== void 0) {
|
|
101
|
+
if (!Array.isArray(v.commands)) issues.push("verification.commands must be an array");
|
|
102
|
+
else {
|
|
103
|
+
for (let i = 0; i < v.commands.length; i++) {
|
|
104
|
+
const cmd = v.commands[i];
|
|
105
|
+
if (typeof cmd.id !== "string")
|
|
106
|
+
issues.push(`verification.commands[${i}].id must be string`);
|
|
107
|
+
if (!Array.isArray(cmd.argv) || !cmd.argv.every((s) => typeof s === "string"))
|
|
108
|
+
issues.push(`verification.commands[${i}].argv must be string[]`);
|
|
109
|
+
if (cmd.timeoutMs !== void 0 && typeof cmd.timeoutMs !== "number")
|
|
110
|
+
issues.push(`verification.commands[${i}].timeoutMs must be number`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (v.requireCleanDiffAfter !== void 0 && typeof v.requireCleanDiffAfter !== "boolean")
|
|
115
|
+
issues.push("verification.requireCleanDiffAfter must be boolean");
|
|
116
|
+
if (v.invalidateOnHeadChange !== void 0 && typeof v.invalidateOnHeadChange !== "boolean")
|
|
117
|
+
issues.push("verification.invalidateOnHeadChange must be boolean");
|
|
118
|
+
}
|
|
119
|
+
if (obj.review !== void 0) {
|
|
120
|
+
const r = obj.review;
|
|
121
|
+
issues.push(...issuesFor("review", KNOWN_REVIEW_KEYS, r));
|
|
122
|
+
if (r.agent !== void 0 && typeof r.agent !== "string")
|
|
123
|
+
issues.push("review.agent must be string");
|
|
124
|
+
if (r.required !== void 0 && typeof r.required !== "boolean")
|
|
125
|
+
issues.push("review.required must be boolean");
|
|
126
|
+
if (r.invalidateOnHeadChange !== void 0 && typeof r.invalidateOnHeadChange !== "boolean")
|
|
127
|
+
issues.push("review.invalidateOnHeadChange must be boolean");
|
|
128
|
+
}
|
|
129
|
+
if (obj.ci !== void 0) {
|
|
130
|
+
const c = obj.ci;
|
|
131
|
+
issues.push(...issuesFor("ci", KNOWN_CI_KEYS, c));
|
|
132
|
+
if (c.driver !== void 0 && c.driver !== "github-status-checks")
|
|
133
|
+
issues.push("ci.driver must be 'github-status-checks'");
|
|
134
|
+
if (c.requiredChecks !== void 0 && !isStringArray(c.requiredChecks))
|
|
135
|
+
issues.push("ci.requiredChecks must be string[]");
|
|
136
|
+
if (c.wait !== void 0 && typeof c.wait !== "boolean")
|
|
137
|
+
issues.push("ci.wait must be boolean");
|
|
138
|
+
if (c.flakyRetry !== void 0 && c.flakyRetry !== 0 && c.flakyRetry !== 1)
|
|
139
|
+
issues.push("ci.flakyRetry must be 0 or 1");
|
|
140
|
+
}
|
|
141
|
+
if (obj.ready !== void 0) {
|
|
142
|
+
const r = obj.ready;
|
|
143
|
+
issues.push(...issuesFor("ready", KNOWN_READY_KEYS, r));
|
|
144
|
+
if (r.requires !== void 0) {
|
|
145
|
+
const set = /* @__PURE__ */ new Set(["review", "local-verification", "remote-ci"]);
|
|
146
|
+
const arr = r.requires;
|
|
147
|
+
if (!Array.isArray(arr) || !arr.every((x) => typeof x === "string" && set.has(x))) {
|
|
148
|
+
issues.push("ready.requires must be one of review|local-verification|remote-ci");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (r.stopAfterReady !== void 0 && typeof r.stopAfterReady !== "boolean")
|
|
152
|
+
issues.push("ready.stopAfterReady must be boolean");
|
|
153
|
+
}
|
|
154
|
+
if (obj.merge !== void 0) {
|
|
155
|
+
const m = obj.merge;
|
|
156
|
+
issues.push(...issuesFor("merge", KNOWN_MERGE_KEYS, m));
|
|
157
|
+
if (m.strategy !== void 0 && m.strategy !== "squash")
|
|
158
|
+
issues.push("merge.strategy must be 'squash'");
|
|
159
|
+
if (m.policy !== void 0 && m.policy !== "explicit-user-request-only")
|
|
160
|
+
issues.push("merge.policy must be 'explicit-user-request-only'");
|
|
161
|
+
if (m.requireFreshGates !== void 0 && typeof m.requireFreshGates !== "boolean")
|
|
162
|
+
issues.push("merge.requireFreshGates must be boolean");
|
|
163
|
+
}
|
|
164
|
+
if (obj.cleanup !== void 0) {
|
|
165
|
+
const c = obj.cleanup;
|
|
166
|
+
issues.push(...issuesFor("cleanup", KNOWN_CLEANUP_KEYS, c));
|
|
167
|
+
if (c.when !== void 0 && c.when !== "next-task")
|
|
168
|
+
issues.push("cleanup.when must be 'next-task'");
|
|
169
|
+
if (c.requires !== void 0) {
|
|
170
|
+
const set = /* @__PURE__ */ new Set(["pr-merged", "worktree-clean", "no-unpublished-commits"]);
|
|
171
|
+
const arr = c.requires;
|
|
172
|
+
if (!Array.isArray(arr) || !arr.every((x) => typeof x === "string" && set.has(x))) {
|
|
173
|
+
issues.push(
|
|
174
|
+
"cleanup.requires must be one of pr-merged|worktree-clean|no-unpublished-commits"
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (issues.length > 0) return { ok: false, issues };
|
|
180
|
+
return { ok: true, adapter: obj };
|
|
181
|
+
}
|
|
182
|
+
async function loadAdapter(repoRoot) {
|
|
183
|
+
const path = resolve(repoRoot, ".opencode", ADAPTER_FILENAME);
|
|
184
|
+
let raw;
|
|
185
|
+
try {
|
|
186
|
+
raw = await readFile(path, "utf8");
|
|
187
|
+
} catch {
|
|
188
|
+
return { ok: false, error: { kind: "missing", path } };
|
|
189
|
+
}
|
|
190
|
+
let parsed;
|
|
191
|
+
try {
|
|
192
|
+
parsed = JSON.parse(raw);
|
|
193
|
+
} catch (e) {
|
|
194
|
+
return { ok: false, error: { kind: "parse", path, message: e.message } };
|
|
195
|
+
}
|
|
196
|
+
const v = validateAdapter(parsed);
|
|
197
|
+
if (!v.ok) return { ok: false, error: { kind: "contract", path, issues: v.issues } };
|
|
198
|
+
const sha256 = createHash("sha256").update(raw).digest("hex");
|
|
199
|
+
return { ok: true, adapter: v.adapter, path, sha256 };
|
|
200
|
+
}
|
|
201
|
+
async function writeLock(repoRoot, adapterSha256) {
|
|
202
|
+
const lockPath = resolve(repoRoot, ".opencode", LOCK_FILENAME);
|
|
203
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
204
|
+
const lock = {
|
|
205
|
+
contractVersion: 1,
|
|
206
|
+
adapterSha256,
|
|
207
|
+
writtenAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
208
|
+
};
|
|
209
|
+
const tmp = `${lockPath}.tmp`;
|
|
210
|
+
await writeFile(tmp, JSON.stringify(lock, null, 2) + "\n", "utf8");
|
|
211
|
+
await rename(tmp, lockPath);
|
|
212
|
+
return lockPath;
|
|
213
|
+
}
|
|
214
|
+
async function readLock(repoRoot) {
|
|
215
|
+
const lockPath = resolve(repoRoot, ".opencode", LOCK_FILENAME);
|
|
216
|
+
try {
|
|
217
|
+
const raw = await readFile(lockPath, "utf8");
|
|
218
|
+
const parsed = JSON.parse(raw);
|
|
219
|
+
if (parsed.contractVersion !== 1) return null;
|
|
220
|
+
return parsed;
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function findOpencodeDir(start) {
|
|
226
|
+
let cur = resolve(start);
|
|
227
|
+
while (true) {
|
|
228
|
+
const candidate = join(cur, ".opencode");
|
|
229
|
+
if (existsSync(join(candidate, ADAPTER_FILENAME))) return candidate;
|
|
230
|
+
const parent = dirname(cur);
|
|
231
|
+
if (parent === cur) return null;
|
|
232
|
+
cur = parent;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// src/state/lifecycle.js
|
|
237
|
+
var STATES = [
|
|
238
|
+
"issue-linked",
|
|
239
|
+
"worktree-created",
|
|
240
|
+
"draft-open",
|
|
241
|
+
"validating",
|
|
242
|
+
"ready",
|
|
243
|
+
"merged",
|
|
244
|
+
"cleanup-pending",
|
|
245
|
+
"cleaned",
|
|
246
|
+
"failed",
|
|
247
|
+
"aborted"
|
|
248
|
+
];
|
|
249
|
+
var TERMINAL = /* @__PURE__ */ new Set(["cleaned", "aborted"]);
|
|
250
|
+
var NEXT = {
|
|
251
|
+
"issue-linked": ["issue-linked", "worktree-created", "aborted", "failed"],
|
|
252
|
+
"worktree-created": ["worktree-created", "draft-open", "validating", "aborted", "failed"],
|
|
253
|
+
"draft-open": ["draft-open", "validating", "aborted", "failed"],
|
|
254
|
+
"validating": ["validating", "ready", "draft-open", "aborted", "failed"],
|
|
255
|
+
"ready": ["ready", "merged", "validating", "aborted", "failed"],
|
|
256
|
+
"merged": ["merged", "cleanup-pending", "aborted", "failed"],
|
|
257
|
+
"cleanup-pending": ["cleanup-pending", "cleaned", "aborted", "failed"],
|
|
258
|
+
"cleaned": ["cleaned"],
|
|
259
|
+
"failed": ["failed", "aborted"],
|
|
260
|
+
"aborted": ["aborted"]
|
|
261
|
+
};
|
|
262
|
+
function transition(m, to, opts) {
|
|
263
|
+
opts = opts ?? {};
|
|
264
|
+
if (!m || typeof m !== "object") {
|
|
265
|
+
return { ok: false, from: void 0, attempted: to, reason: "manifest is missing" };
|
|
266
|
+
}
|
|
267
|
+
if (!STATES.includes(m.state)) {
|
|
268
|
+
return { ok: false, from: m.state, attempted: to, reason: `manifest state ${m.state} is not recognised` };
|
|
269
|
+
}
|
|
270
|
+
if (!STATES.includes(to)) {
|
|
271
|
+
return { ok: false, from: m.state, attempted: to, reason: `target state ${to} is not recognised` };
|
|
272
|
+
}
|
|
273
|
+
const allowed = NEXT[m.state];
|
|
274
|
+
if (!allowed.includes(to)) {
|
|
275
|
+
return { ok: false, from: m.state, attempted: to, reason: `transition from ${m.state} to ${to} is not permitted` };
|
|
276
|
+
}
|
|
277
|
+
const now = (opts.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
278
|
+
const at = now.getTime();
|
|
279
|
+
const entry = { from: m.state, to, at };
|
|
280
|
+
if (opts.reason !== void 0) entry.reason = opts.reason;
|
|
281
|
+
const next = {
|
|
282
|
+
...m,
|
|
283
|
+
state: to,
|
|
284
|
+
transitionLog: [...m.transitionLog, entry],
|
|
285
|
+
updatedAt: now.toISOString()
|
|
286
|
+
};
|
|
287
|
+
if (to === "failed") {
|
|
288
|
+
next.fatalReason = opts.reason ?? "unspecified";
|
|
289
|
+
}
|
|
290
|
+
return { ok: true, from: m.state, to, at, reason: opts.reason };
|
|
291
|
+
}
|
|
292
|
+
function createManifest(input) {
|
|
293
|
+
const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
294
|
+
return {
|
|
295
|
+
schemaVersion: 1,
|
|
296
|
+
taskId: input.taskId,
|
|
297
|
+
repoIdentity: input.repoIdentity,
|
|
298
|
+
issueNumber: input.issueNumber,
|
|
299
|
+
prNumber: input.prNumber ?? null,
|
|
300
|
+
baseBranch: input.baseBranch,
|
|
301
|
+
baseSha: input.baseSha,
|
|
302
|
+
branch: input.branch,
|
|
303
|
+
worktreePath: input.worktreePath ?? null,
|
|
304
|
+
lastPrHeadSha: input.lastPrHeadSha ?? null,
|
|
305
|
+
lastReviewerSha: input.lastReviewerSha ?? null,
|
|
306
|
+
lastVerifierSha: input.lastVerifierSha ?? null,
|
|
307
|
+
owner: input.owner,
|
|
308
|
+
state: "issue-linked",
|
|
309
|
+
transitionLog: [],
|
|
310
|
+
createdAt: now.toISOString(),
|
|
311
|
+
updatedAt: now.toISOString()
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function canTransition(from, to) {
|
|
315
|
+
return NEXT[from]?.includes(to) === true;
|
|
316
|
+
}
|
|
317
|
+
function isTerminal(s) {
|
|
318
|
+
return TERMINAL.has(s);
|
|
319
|
+
}
|
|
320
|
+
function mustRerunReview(previousSha, currentSha) {
|
|
321
|
+
return previousSha !== currentSha;
|
|
322
|
+
}
|
|
323
|
+
function mustRerunVerifier(previousSha, currentSha) {
|
|
324
|
+
return previousSha !== currentSha;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// src/state/manifest-store.js
|
|
328
|
+
import { readFile as readFile2, writeFile as writeFile2, rename as rename2, mkdir as mkdir2, readdir, unlink } from "node:fs/promises";
|
|
329
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
|
|
330
|
+
import { spawn } from "node:child_process";
|
|
331
|
+
async function runGitCommonDir(repoRoot) {
|
|
332
|
+
return new Promise((res, rej) => {
|
|
333
|
+
const proc = spawn(
|
|
334
|
+
"git",
|
|
335
|
+
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
336
|
+
{ cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"], shell: false }
|
|
337
|
+
);
|
|
338
|
+
let out = "";
|
|
339
|
+
proc.stdout.on("data", (d) => out += d.toString());
|
|
340
|
+
proc.on("error", rej);
|
|
341
|
+
proc.on("close", (code) => {
|
|
342
|
+
if (code !== 0) {
|
|
343
|
+
rej(new Error(`git rev-parse --git-common-dir failed with ${code}`));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const trimmed = out.trim();
|
|
347
|
+
if (!trimmed) {
|
|
348
|
+
rej(new Error("git rev-parse returned an empty path"));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
res(resolve2(repoRoot, trimmed));
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
function manifestPath(commonDir, taskId) {
|
|
356
|
+
return join2(commonDir, "opencode-delivery", "manifests", `${taskId}.json`);
|
|
357
|
+
}
|
|
358
|
+
async function writeManifest(repoRoot, manifest) {
|
|
359
|
+
const commonDir = await runGitCommonDir(repoRoot);
|
|
360
|
+
const path = manifestPath(commonDir, manifest.taskId);
|
|
361
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
362
|
+
const tmp = `${path}.tmp`;
|
|
363
|
+
await writeFile2(tmp, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
364
|
+
await rename2(tmp, path);
|
|
365
|
+
return resolve2(path);
|
|
366
|
+
}
|
|
367
|
+
async function readManifest(repoRoot, taskId) {
|
|
368
|
+
const commonDir = await runGitCommonDir(repoRoot);
|
|
369
|
+
const path = manifestPath(commonDir, taskId);
|
|
370
|
+
try {
|
|
371
|
+
const raw = await readFile2(path, "utf8");
|
|
372
|
+
return JSON.parse(raw);
|
|
373
|
+
} catch {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async function listManifests(repoRoot) {
|
|
378
|
+
const commonDir = await runGitCommonDir(repoRoot);
|
|
379
|
+
const dir = join2(commonDir, "opencode-delivery", "manifests");
|
|
380
|
+
let names;
|
|
381
|
+
try {
|
|
382
|
+
names = await readdir(dir);
|
|
383
|
+
} catch {
|
|
384
|
+
return [];
|
|
385
|
+
}
|
|
386
|
+
const out = [];
|
|
387
|
+
for (const name of names) {
|
|
388
|
+
if (!name.endsWith(".json")) continue;
|
|
389
|
+
try {
|
|
390
|
+
const raw = await readFile2(join2(dir, name), "utf8");
|
|
391
|
+
out.push(JSON.parse(raw));
|
|
392
|
+
} catch {
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
async function deleteManifest(repoRoot, taskId) {
|
|
398
|
+
const commonDir = await runGitCommonDir(repoRoot);
|
|
399
|
+
const path = manifestPath(commonDir, taskId);
|
|
400
|
+
try {
|
|
401
|
+
await unlink(path);
|
|
402
|
+
} catch {
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/drivers/git.js
|
|
407
|
+
import { spawnSync } from "node:child_process";
|
|
408
|
+
import { resolve as resolve3 } from "node:path";
|
|
409
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
410
|
+
var WorktreeRecord = {
|
|
411
|
+
path: "",
|
|
412
|
+
branch: "",
|
|
413
|
+
head: ""
|
|
414
|
+
};
|
|
415
|
+
function runGit(args, cwd) {
|
|
416
|
+
return spawnSync("git", args, {
|
|
417
|
+
cwd,
|
|
418
|
+
encoding: "utf8",
|
|
419
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
420
|
+
env: process.env
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
function isInsideWorktree(cwd) {
|
|
424
|
+
const r = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
425
|
+
return r.status === 0 && r.stdout.trim() === "true";
|
|
426
|
+
}
|
|
427
|
+
function isMainCheckout(cwd) {
|
|
428
|
+
const common = runGit(["rev-parse", "--git-common-dir"], cwd);
|
|
429
|
+
const dir = runGit(["rev-parse", "--git-dir"], cwd);
|
|
430
|
+
if (common.status !== 0 || dir.status !== 0) return false;
|
|
431
|
+
return common.stdout.trim() === dir.stdout.trim();
|
|
432
|
+
}
|
|
433
|
+
function listWorktrees(cwd) {
|
|
434
|
+
const r = runGit(["worktree", "list", "--porcelain"], cwd);
|
|
435
|
+
if (r.status !== 0) return [];
|
|
436
|
+
const records = [];
|
|
437
|
+
let cur = {};
|
|
438
|
+
for (const line of r.stdout.split("\n")) {
|
|
439
|
+
if (line.startsWith("worktree ")) {
|
|
440
|
+
if (cur.path && cur.branch && cur.head) {
|
|
441
|
+
records.push({ path: cur.path, branch: cur.branch, head: cur.head });
|
|
442
|
+
}
|
|
443
|
+
cur = { path: line.slice("worktree ".length).trim() };
|
|
444
|
+
} else if (line.startsWith("HEAD ")) {
|
|
445
|
+
cur.head = line.slice("HEAD ".length).trim();
|
|
446
|
+
} else if (line.startsWith("branch ")) {
|
|
447
|
+
cur.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "");
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (cur.path && cur.branch && cur.head) {
|
|
451
|
+
records.push({ path: cur.path, branch: cur.branch, head: cur.head });
|
|
452
|
+
}
|
|
453
|
+
return records;
|
|
454
|
+
}
|
|
455
|
+
function isWorktreeClean(cwd) {
|
|
456
|
+
const r = runGit(["status", "--porcelain"], cwd);
|
|
457
|
+
if (r.status !== 0) return false;
|
|
458
|
+
return r.stdout.trim().length === 0;
|
|
459
|
+
}
|
|
460
|
+
function isRebaseInProgress(cwd) {
|
|
461
|
+
const merge = runGit(["rev-parse", "--git-path", "rebase-merge"], cwd);
|
|
462
|
+
const apply = runGit(["rev-parse", "--git-path", "rebase-apply"], cwd);
|
|
463
|
+
const mergeExists = merge.status === 0 && safeExists(resolve3(cwd, merge.stdout.trim()));
|
|
464
|
+
const applyExists = apply.status === 0 && safeExists(resolve3(cwd, apply.stdout.trim()));
|
|
465
|
+
return mergeExists || applyExists;
|
|
466
|
+
}
|
|
467
|
+
function safeExists(p) {
|
|
468
|
+
try {
|
|
469
|
+
return existsSync2(p);
|
|
470
|
+
} catch {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function currentBranch(cwd) {
|
|
475
|
+
const r = runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
476
|
+
if (r.status !== 0) return null;
|
|
477
|
+
const out = r.stdout.trim();
|
|
478
|
+
if (out === "HEAD") return null;
|
|
479
|
+
return out;
|
|
480
|
+
}
|
|
481
|
+
function revParse(ref, cwd) {
|
|
482
|
+
const r = runGit(["rev-parse", "--verify", ref], cwd);
|
|
483
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
484
|
+
}
|
|
485
|
+
function fetchBranch(remote, branch, cwd) {
|
|
486
|
+
const r = runGit(["fetch", remote, branch], cwd);
|
|
487
|
+
return { status: r.status ?? -1, stderr: r.stderr ?? "" };
|
|
488
|
+
}
|
|
489
|
+
function remoteExists(remote, cwd) {
|
|
490
|
+
const r = runGit(["remote", "get-url", remote], cwd);
|
|
491
|
+
return r.status === 0;
|
|
492
|
+
}
|
|
493
|
+
function createWorktree(opts) {
|
|
494
|
+
const args = ["worktree", "add", "-b", opts.branch, opts.worktreePath, opts.base];
|
|
495
|
+
const r = runGit(args, opts.cwd);
|
|
496
|
+
return { status: r.status ?? -1, stderr: r.stderr ?? "" };
|
|
497
|
+
}
|
|
498
|
+
function createWorktreeFromLocal(opts) {
|
|
499
|
+
const args = ["worktree", "add", "-b", opts.branch, opts.worktreePath, opts.base];
|
|
500
|
+
const r = runGit(args, opts.cwd);
|
|
501
|
+
return { status: r.status ?? -1, stderr: r.stderr ?? "" };
|
|
502
|
+
}
|
|
503
|
+
function worktreeExists(cwd, path) {
|
|
504
|
+
return listWorktrees(cwd).some((w) => w.path === path);
|
|
505
|
+
}
|
|
506
|
+
function branchExistsLocally(branch, cwd) {
|
|
507
|
+
const r = runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], cwd);
|
|
508
|
+
return r.status === 0;
|
|
509
|
+
}
|
|
510
|
+
function branchExistsRemotely(remote, branch, cwd) {
|
|
511
|
+
const r = runGit(["ls-remote", "--heads", remote, branch], cwd);
|
|
512
|
+
return r.status === 0 && r.stdout.includes(`refs/heads/${branch}`);
|
|
513
|
+
}
|
|
514
|
+
function mergeIntoFeature(branch, base, cwd) {
|
|
515
|
+
const r = runGit(["merge", `--into-name=${branch}`, base], cwd);
|
|
516
|
+
return { status: r.status ?? -1, stderr: r.stderr ?? "" };
|
|
517
|
+
}
|
|
518
|
+
function currentHead(cwd) {
|
|
519
|
+
const r = runGit(["rev-parse", "HEAD"], cwd);
|
|
520
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
521
|
+
}
|
|
522
|
+
function push(remote, branch, cwd) {
|
|
523
|
+
const r = runGit(["push", remote, branch], cwd);
|
|
524
|
+
return { status: r.status ?? -1, stderr: r.stderr ?? "" };
|
|
525
|
+
}
|
|
526
|
+
function pushForceDisabled(_remote, _branch, cwd) {
|
|
527
|
+
return { status: 1, stderr: "force-push is not permitted by the delivery driver" };
|
|
528
|
+
}
|
|
529
|
+
function defaultBranch(cwd) {
|
|
530
|
+
const r = runGit(["rev-parse", "--abbrev-ref", "HEAD", "@{u}"], cwd);
|
|
531
|
+
if (r.status !== 0) {
|
|
532
|
+
const head = runGit(["symbolic-ref", "--short", "HEAD"], cwd);
|
|
533
|
+
return head.status === 0 ? head.stdout.trim() : null;
|
|
534
|
+
}
|
|
535
|
+
const out = r.stdout.trim();
|
|
536
|
+
const slash = out.indexOf("/");
|
|
537
|
+
return slash >= 0 ? out.slice(slash + 1) : out;
|
|
538
|
+
}
|
|
539
|
+
function mergeBaseRemoteHead(remote, branch, cwd) {
|
|
540
|
+
const r = runGit(["rev-parse", "--verify", `${remote}/${branch}`], cwd);
|
|
541
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// src/drivers/gh-cli.js
|
|
545
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
546
|
+
|
|
547
|
+
// src/drivers/github.js
|
|
548
|
+
function parseRepoSlug(slug) {
|
|
549
|
+
const slash = slug.indexOf("/");
|
|
550
|
+
if (slash <= 0 || slash === slug.length - 1) return null;
|
|
551
|
+
return { owner: slug.slice(0, slash), name: slug.slice(slash + 1) };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// src/drivers/gh-cli.js
|
|
555
|
+
function defaultRunner(cwd, env) {
|
|
556
|
+
return (args) => new Promise((resolve7, reject) => {
|
|
557
|
+
const proc = spawn2("gh", args, {
|
|
558
|
+
cwd,
|
|
559
|
+
env,
|
|
560
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
561
|
+
shell: false
|
|
562
|
+
});
|
|
563
|
+
let stdout = "";
|
|
564
|
+
let stderr = "";
|
|
565
|
+
proc.stdout.on("data", (d) => stdout += d.toString());
|
|
566
|
+
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
567
|
+
proc.on("error", reject);
|
|
568
|
+
proc.on("close", (status) => resolve7({ status: status ?? -1, stdout, stderr }));
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
function viewFields() {
|
|
572
|
+
return [
|
|
573
|
+
"number",
|
|
574
|
+
"url",
|
|
575
|
+
"baseRefName",
|
|
576
|
+
"headRefName",
|
|
577
|
+
"headRefOid",
|
|
578
|
+
"isDraft",
|
|
579
|
+
"mergeable",
|
|
580
|
+
"mergeStateStatus",
|
|
581
|
+
"state",
|
|
582
|
+
"mergedAt"
|
|
583
|
+
].join(",");
|
|
584
|
+
}
|
|
585
|
+
function pullRequestSummaryFromView(fields) {
|
|
586
|
+
const merged = fields.state === "MERGED" || fields.merged === true || typeof fields.mergedAt === "string" && fields.mergedAt.length > 0;
|
|
587
|
+
return {
|
|
588
|
+
number: fields.number,
|
|
589
|
+
url: fields.url,
|
|
590
|
+
baseRefName: fields.baseRefName,
|
|
591
|
+
headRefName: fields.headRefName,
|
|
592
|
+
headSha: fields.headRefOid,
|
|
593
|
+
draft: Boolean(fields.isDraft),
|
|
594
|
+
mergeable: fields.mergeable ?? "UNKNOWN",
|
|
595
|
+
mergeStateStatus: fields.mergeStateStatus ?? "UNKNOWN",
|
|
596
|
+
state: fields.state ?? "UNKNOWN",
|
|
597
|
+
merged: Boolean(merged),
|
|
598
|
+
mergedAt: fields.mergedAt ?? null
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
async function ghJson(run, args) {
|
|
602
|
+
const r = await run(args);
|
|
603
|
+
if (r.status !== 0) {
|
|
604
|
+
throw new Error(`gh ${args.join(" ")} failed: ${r.stderr.trim() || "(no stderr)"}`);
|
|
605
|
+
}
|
|
606
|
+
if (!r.stdout.trim()) {
|
|
607
|
+
throw new Error(`gh ${args.join(" ")} returned empty stdout`);
|
|
608
|
+
}
|
|
609
|
+
return JSON.parse(r.stdout);
|
|
610
|
+
}
|
|
611
|
+
function createGhDriver(opts = {}) {
|
|
612
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
613
|
+
const env = opts.env ?? process.env;
|
|
614
|
+
const run = opts.runner ?? defaultRunner(cwd, env);
|
|
615
|
+
return {
|
|
616
|
+
async ensureIssue({ repo, title, body, labels }) {
|
|
617
|
+
const repoSlug = parseRepoSlug(repo);
|
|
618
|
+
if (!repoSlug) throw new Error(`ensureIssue: invalid repo slug ${repo}`);
|
|
619
|
+
const list = await run([
|
|
620
|
+
"issue",
|
|
621
|
+
"list",
|
|
622
|
+
"--repo",
|
|
623
|
+
repo,
|
|
624
|
+
"--search",
|
|
625
|
+
title,
|
|
626
|
+
"--state",
|
|
627
|
+
"open",
|
|
628
|
+
"--json",
|
|
629
|
+
"number,title,state,url",
|
|
630
|
+
"--limit",
|
|
631
|
+
"20"
|
|
632
|
+
]);
|
|
633
|
+
if (list.status === 0 && list.stdout.trim()) {
|
|
634
|
+
const issues = JSON.parse(list.stdout);
|
|
635
|
+
const exact = issues.find(
|
|
636
|
+
(i) => i.title?.trim() === title.trim() && i.state === "OPEN"
|
|
637
|
+
);
|
|
638
|
+
if (exact) {
|
|
639
|
+
return {
|
|
640
|
+
summary: {
|
|
641
|
+
number: exact.number,
|
|
642
|
+
url: exact.url,
|
|
643
|
+
state: "OPEN",
|
|
644
|
+
pullRequest: null
|
|
645
|
+
},
|
|
646
|
+
created: false
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const createArgs = ["issue", "create", "--repo", repo, "--title", title, "--body", body];
|
|
651
|
+
for (const label of labels ?? []) {
|
|
652
|
+
createArgs.push("--label", label);
|
|
653
|
+
}
|
|
654
|
+
const created = await run(createArgs);
|
|
655
|
+
if (created.status !== 0) {
|
|
656
|
+
throw new Error(`gh issue create failed: ${created.stderr.trim() || "(no stderr)"}`);
|
|
657
|
+
}
|
|
658
|
+
const url = (created.stdout.trim().split("\n").pop() ?? "").trim();
|
|
659
|
+
const m = url.match(/\/issues\/(\d+)/);
|
|
660
|
+
const number = m && m[1] ? parseInt(m[1], 10) : -1;
|
|
661
|
+
return {
|
|
662
|
+
summary: { number, url, state: "OPEN", pullRequest: null },
|
|
663
|
+
created: true
|
|
664
|
+
};
|
|
665
|
+
},
|
|
666
|
+
async openDraftPullRequest({ repo, head, base, title, body, issueNumber }) {
|
|
667
|
+
if (!parseRepoSlug(repo)) throw new Error(`openDraftPullRequest: invalid repo slug ${repo}`);
|
|
668
|
+
if (typeof issueNumber !== "number") throw new Error("openDraftPullRequest: issueNumber is required");
|
|
669
|
+
const issueBody = body.includes(`Closes #${issueNumber}`) ? body : `${body}
|
|
670
|
+
|
|
671
|
+
Closes #${issueNumber}`;
|
|
672
|
+
const args = [
|
|
673
|
+
"pr",
|
|
674
|
+
"create",
|
|
675
|
+
"--repo",
|
|
676
|
+
repo,
|
|
677
|
+
"--draft",
|
|
678
|
+
"--base",
|
|
679
|
+
base,
|
|
680
|
+
"--head",
|
|
681
|
+
head,
|
|
682
|
+
"--title",
|
|
683
|
+
title,
|
|
684
|
+
"--body",
|
|
685
|
+
issueBody
|
|
686
|
+
];
|
|
687
|
+
const r = await run(args);
|
|
688
|
+
if (r.status !== 0) {
|
|
689
|
+
throw new Error(`gh pr create failed: ${r.stderr.trim() || "(no stderr)"}`);
|
|
690
|
+
}
|
|
691
|
+
const url = (r.stdout.trim().split("\n").pop() ?? "").trim();
|
|
692
|
+
const m = url.match(/\/pull\/(\d+)/);
|
|
693
|
+
const number = m && m[1] ? parseInt(m[1], 10) : -1;
|
|
694
|
+
const fields = await ghJson(run, [
|
|
695
|
+
"pr",
|
|
696
|
+
"view",
|
|
697
|
+
String(number),
|
|
698
|
+
"--repo",
|
|
699
|
+
repo,
|
|
700
|
+
"--json",
|
|
701
|
+
viewFields()
|
|
702
|
+
]);
|
|
703
|
+
return pullRequestSummaryFromView(fields);
|
|
704
|
+
},
|
|
705
|
+
async updatePullRequestBody({ repo, number, body }) {
|
|
706
|
+
if (typeof number !== "number") throw new Error("updatePullRequestBody: number is required");
|
|
707
|
+
const r = await run(["pr", "edit", String(number), "--repo", repo, "--body", body]);
|
|
708
|
+
if (r.status !== 0) throw new Error(`gh pr edit failed: ${r.stderr.trim() || "(no stderr)"}`);
|
|
709
|
+
},
|
|
710
|
+
async markReady({ repo, number }) {
|
|
711
|
+
if (typeof number !== "number") throw new Error("markReady: number is required");
|
|
712
|
+
const r = await run(["pr", "ready", String(number), "--repo", repo]);
|
|
713
|
+
if (r.status !== 0) throw new Error(`gh pr ready failed: ${r.stderr.trim() || "(no stderr)"}`);
|
|
714
|
+
},
|
|
715
|
+
async mergePullRequest({ repo, number, subject }) {
|
|
716
|
+
if (typeof number !== "number") throw new Error("mergePullRequest: number is required");
|
|
717
|
+
const args = [
|
|
718
|
+
"pr",
|
|
719
|
+
"merge",
|
|
720
|
+
String(number),
|
|
721
|
+
"--repo",
|
|
722
|
+
repo,
|
|
723
|
+
"--squash",
|
|
724
|
+
"--subject",
|
|
725
|
+
subject
|
|
726
|
+
];
|
|
727
|
+
const r = await run(args);
|
|
728
|
+
if (r.status !== 0) {
|
|
729
|
+
throw new Error(`gh pr merge failed: ${r.stderr.trim() || "(no stderr)"}`);
|
|
730
|
+
}
|
|
731
|
+
const fields = await ghJson(run, [
|
|
732
|
+
"pr",
|
|
733
|
+
"view",
|
|
734
|
+
String(number),
|
|
735
|
+
"--repo",
|
|
736
|
+
repo,
|
|
737
|
+
"--json",
|
|
738
|
+
viewFields()
|
|
739
|
+
]);
|
|
740
|
+
return pullRequestSummaryFromView(fields);
|
|
741
|
+
},
|
|
742
|
+
async readPullRequest({ repo, number }) {
|
|
743
|
+
if (typeof number !== "number") throw new Error("readPullRequest: number is required");
|
|
744
|
+
const fields = await ghJson(run, [
|
|
745
|
+
"pr",
|
|
746
|
+
"view",
|
|
747
|
+
String(number),
|
|
748
|
+
"--repo",
|
|
749
|
+
repo,
|
|
750
|
+
"--json",
|
|
751
|
+
viewFields()
|
|
752
|
+
]);
|
|
753
|
+
return pullRequestSummaryFromView(fields);
|
|
754
|
+
},
|
|
755
|
+
async readChecks({ repo, sha, number, branch, required }) {
|
|
756
|
+
const target = typeof number === "number" && Number.isFinite(number) ? String(number) : typeof branch === "string" && branch.length > 0 ? branch : typeof sha === "string" && sha.length > 0 ? String(sha) : null;
|
|
757
|
+
if (target === null) {
|
|
758
|
+
throw new Error("readChecks requires either a number, branch, or sha");
|
|
759
|
+
}
|
|
760
|
+
const r = await run([
|
|
761
|
+
"pr",
|
|
762
|
+
"checks",
|
|
763
|
+
target,
|
|
764
|
+
"--repo",
|
|
765
|
+
repo,
|
|
766
|
+
"--json",
|
|
767
|
+
"name,state,bucket"
|
|
768
|
+
]);
|
|
769
|
+
if (r.status !== 0) {
|
|
770
|
+
if (/no checks reported/i.test(r.stderr ?? "")) {
|
|
771
|
+
return [];
|
|
772
|
+
}
|
|
773
|
+
throw new Error(`gh pr checks failed: ${r.stderr.trim() || "(no stderr)"}`);
|
|
774
|
+
}
|
|
775
|
+
const all = r.stdout.trim() ? JSON.parse(r.stdout) : [];
|
|
776
|
+
const out = [];
|
|
777
|
+
for (const requiredName of required ?? []) {
|
|
778
|
+
const match = all.find((c) => c.name === requiredName);
|
|
779
|
+
if (!match) {
|
|
780
|
+
out.push({ name: requiredName, state: "pending", bucket: "pending" });
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
out.push({ name: match.name, state: match.state, bucket: match.bucket });
|
|
784
|
+
}
|
|
785
|
+
return out;
|
|
786
|
+
},
|
|
787
|
+
async comment({ repo, number, body }) {
|
|
788
|
+
if (typeof number !== "number") throw new Error("comment: number is required");
|
|
789
|
+
const r = await run(["issue", "comment", String(number), "--repo", repo, "--body", body]);
|
|
790
|
+
if (r.status !== 0) throw new Error(`gh issue comment failed: ${r.stderr.trim() || "(no stderr)"}`);
|
|
791
|
+
},
|
|
792
|
+
async refreshHead({ repo, number }) {
|
|
793
|
+
if (typeof number !== "number") throw new Error("refreshHead: number is required");
|
|
794
|
+
const fields = await ghJson(run, [
|
|
795
|
+
"pr",
|
|
796
|
+
"view",
|
|
797
|
+
String(number),
|
|
798
|
+
"--repo",
|
|
799
|
+
repo,
|
|
800
|
+
"--json",
|
|
801
|
+
"headRefOid"
|
|
802
|
+
]);
|
|
803
|
+
return fields.headRefOid;
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
function createGhStub(responses) {
|
|
808
|
+
const queue = responses.map((r) => ({ ...r }));
|
|
809
|
+
const runner = async (args) => {
|
|
810
|
+
const head = args[0] ?? "";
|
|
811
|
+
const idx = queue.findIndex((entry) => entry.match(args));
|
|
812
|
+
if (idx === -1) {
|
|
813
|
+
return {
|
|
814
|
+
status: 1,
|
|
815
|
+
stdout: "",
|
|
816
|
+
stderr: `gh stub: no response queued for ${head}`
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
const next = queue[idx];
|
|
820
|
+
queue.splice(idx, 1);
|
|
821
|
+
return { status: next.status ?? 0, stdout: next.stdout ?? "", stderr: next.stderr ?? "" };
|
|
822
|
+
};
|
|
823
|
+
return { driver: createGhDriver({ runner }), queue };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// src/recovery.js
|
|
827
|
+
async function scanRecovery(repoRoot) {
|
|
828
|
+
const manifests = await listManifests(repoRoot);
|
|
829
|
+
const report = {
|
|
830
|
+
total: manifests.length,
|
|
831
|
+
pendingCleanup: 0,
|
|
832
|
+
orphanWorktrees: 0,
|
|
833
|
+
cleaned: 0,
|
|
834
|
+
notes: []
|
|
835
|
+
};
|
|
836
|
+
for (const m of manifests) {
|
|
837
|
+
if (m.state === "cleanup-pending") report.pendingCleanup += 1;
|
|
838
|
+
if (m.state === "cleaned") report.cleaned += 1;
|
|
839
|
+
}
|
|
840
|
+
for (const wt of listWorktrees(repoRoot)) {
|
|
841
|
+
const note = `worktree ${wt.path} branch=${wt.branch} head=${wt.head}`;
|
|
842
|
+
if (!manifests.some((m) => m.worktreePath === wt.path)) {
|
|
843
|
+
report.orphanWorktrees += 1;
|
|
844
|
+
report.notes.push(`orphan ${note}`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return report;
|
|
848
|
+
}
|
|
849
|
+
async function removeManifestIfSafe(repoRoot, taskId) {
|
|
850
|
+
const m = await readManifest(repoRoot, taskId);
|
|
851
|
+
if (!m) return false;
|
|
852
|
+
if (m.state !== "cleaned") return false;
|
|
853
|
+
await deleteManifest(repoRoot, taskId);
|
|
854
|
+
return true;
|
|
855
|
+
}
|
|
856
|
+
function wouldCleanupBeSafe(args) {
|
|
857
|
+
return Boolean(
|
|
858
|
+
args.prMerged && args.worktreeClean && !args.rebaseInProgress && args.headMatchesPr && args.baseMatches
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
function recoverManifestAfterCrash(manifest) {
|
|
862
|
+
return manifest;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// src/doctor.js
|
|
866
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
867
|
+
import { resolve as resolve4 } from "node:path";
|
|
868
|
+
function runVersion(argv) {
|
|
869
|
+
const r = spawnSync2(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
870
|
+
if (r.status !== 0) return null;
|
|
871
|
+
return r.stdout.trim();
|
|
872
|
+
}
|
|
873
|
+
async function doctor(repoRoot, packageVersion) {
|
|
874
|
+
const adapter = await loadAdapter(repoRoot);
|
|
875
|
+
const lock = await readLock(repoRoot);
|
|
876
|
+
const checks = [];
|
|
877
|
+
checks.push({
|
|
878
|
+
name: "node>=20",
|
|
879
|
+
ok: /^v(2[0-9]|[3-9]\d)/.test(process.version),
|
|
880
|
+
detail: process.version
|
|
881
|
+
});
|
|
882
|
+
const git = runVersion(["git", "--version"]);
|
|
883
|
+
checks.push({
|
|
884
|
+
name: "git installed",
|
|
885
|
+
ok: git !== null,
|
|
886
|
+
detail: git ?? "git not on PATH"
|
|
887
|
+
});
|
|
888
|
+
const gh = runVersion(["gh", "--version"]);
|
|
889
|
+
checks.push({
|
|
890
|
+
name: "gh installed",
|
|
891
|
+
ok: gh !== null,
|
|
892
|
+
detail: gh ?? "gh CLI not on PATH"
|
|
893
|
+
});
|
|
894
|
+
checks.push({
|
|
895
|
+
name: `adapter contract v${ADAPTER_CONTRACT_VERSION}`,
|
|
896
|
+
ok: adapter.ok,
|
|
897
|
+
detail: adapter.ok ? `loaded from ${adapter.path}` : adapter.error.kind
|
|
898
|
+
});
|
|
899
|
+
if (adapter.ok && lock) {
|
|
900
|
+
checks.push({
|
|
901
|
+
name: "lock sha matches adapter",
|
|
902
|
+
ok: lock.adapterSha256 === adapter.sha256,
|
|
903
|
+
detail: lock.adapterSha256 === adapter.sha256 ? "match" : "drift"
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
checks.push({
|
|
907
|
+
name: "package version pinned",
|
|
908
|
+
ok: packageVersion !== null,
|
|
909
|
+
detail: packageVersion ?? "missing"
|
|
910
|
+
});
|
|
911
|
+
return {
|
|
912
|
+
contractVersion: 1,
|
|
913
|
+
adapterPath: adapter.ok ? adapter.path : null,
|
|
914
|
+
adapterSha256: adapter.ok ? adapter.sha256 : null,
|
|
915
|
+
lockPath: lock ? resolve4(repoRoot, ".opencode", "delivery.lock.json") : null,
|
|
916
|
+
lockSha256: lock ? lock.adapterSha256 : null,
|
|
917
|
+
packageVersion,
|
|
918
|
+
nodeVersion: process.version,
|
|
919
|
+
ghVersion: gh,
|
|
920
|
+
gitVersion: git,
|
|
921
|
+
checks
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// src/gates.js
|
|
926
|
+
var CHECK_BUCKETS = /* @__PURE__ */ new Map([
|
|
927
|
+
["pass", "pass"],
|
|
928
|
+
["fail", "fail"],
|
|
929
|
+
["pending", "pending"],
|
|
930
|
+
["skip", "skip"],
|
|
931
|
+
["neutral", "neutral"]
|
|
932
|
+
]);
|
|
933
|
+
function bucketFor(check) {
|
|
934
|
+
if (!check) return "pending";
|
|
935
|
+
if (CHECK_BUCKETS.has(check.bucket)) return check.bucket;
|
|
936
|
+
if (check.state === "success") return "pass";
|
|
937
|
+
if (check.state === "failure") return "fail";
|
|
938
|
+
return "pending";
|
|
939
|
+
}
|
|
940
|
+
function gateSnapshot({ manifest, prHead, checks }) {
|
|
941
|
+
const required = manifest.adapter?.ci?.requiredChecks ?? [];
|
|
942
|
+
const observed = checks ?? [];
|
|
943
|
+
const missing = [];
|
|
944
|
+
const failing = [];
|
|
945
|
+
const pending = [];
|
|
946
|
+
for (const name of required) {
|
|
947
|
+
const match = observed.find((c) => c.name === name);
|
|
948
|
+
if (!match) {
|
|
949
|
+
missing.push(name);
|
|
950
|
+
pending.push(name);
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
const bucket = bucketFor(match);
|
|
954
|
+
if (bucket === "fail") failing.push(name);
|
|
955
|
+
else if (bucket === "pending") pending.push(name);
|
|
956
|
+
}
|
|
957
|
+
return {
|
|
958
|
+
prHead: prHead ?? null,
|
|
959
|
+
reviewerSha: manifest?.lastReviewerSha ?? null,
|
|
960
|
+
verifierSha: manifest?.lastVerifierSha ?? null,
|
|
961
|
+
checks: observed,
|
|
962
|
+
missingChecks: missing,
|
|
963
|
+
failingChecks: failing,
|
|
964
|
+
pendingChecks: pending
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
function checkGates({ manifest, prHead, checks, requires }) {
|
|
968
|
+
const snap = gateSnapshot({ manifest, prHead, checks });
|
|
969
|
+
const need = new Set(requires ?? ["review", "local-verification", "remote-ci"]);
|
|
970
|
+
if (need.has("review")) {
|
|
971
|
+
if (!manifest?.lastReviewerSha) return { ok: false, reason: "missing-review", snapshot: snap };
|
|
972
|
+
if (manifest.lastReviewerSha !== prHead) {
|
|
973
|
+
return { ok: false, reason: "head-changed-after-review", snapshot: snap };
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
if (need.has("local-verification")) {
|
|
977
|
+
if (!manifest?.lastVerifierSha) return { ok: false, reason: "missing-verifier", snapshot: snap };
|
|
978
|
+
if (manifest.lastVerifierSha !== prHead) {
|
|
979
|
+
return { ok: false, reason: "head-changed-after-verifier", snapshot: snap };
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
if (need.has("remote-ci")) {
|
|
983
|
+
if (snap.missingChecks.length > 0) {
|
|
984
|
+
return { ok: false, reason: "ci-missing", snapshot: snap };
|
|
985
|
+
}
|
|
986
|
+
if (snap.failingChecks.length > 0) {
|
|
987
|
+
return { ok: false, reason: "ci-failing", snapshot: snap };
|
|
988
|
+
}
|
|
989
|
+
if (snap.pendingChecks.length > 0) {
|
|
990
|
+
return { ok: false, reason: "ci-pending", snapshot: snap };
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return { ok: true, snapshot: snap };
|
|
994
|
+
}
|
|
995
|
+
function gateFailureEnvelope(result) {
|
|
996
|
+
switch (result.reason) {
|
|
997
|
+
case "missing-review":
|
|
998
|
+
return { kind: "missing-gate", gate: "review" };
|
|
999
|
+
case "missing-verifier":
|
|
1000
|
+
return { kind: "missing-gate", gate: "local-verification" };
|
|
1001
|
+
case "head-changed-after-review":
|
|
1002
|
+
return {
|
|
1003
|
+
kind: "head-changed-after-review",
|
|
1004
|
+
headSha: result.snapshot.prHead ?? "",
|
|
1005
|
+
reviewSha: result.snapshot.reviewerSha ?? ""
|
|
1006
|
+
};
|
|
1007
|
+
case "head-changed-after-verifier":
|
|
1008
|
+
return {
|
|
1009
|
+
kind: "head-changed-after-verifier",
|
|
1010
|
+
headSha: result.snapshot.prHead ?? "",
|
|
1011
|
+
verifierSha: result.snapshot.verifierSha ?? ""
|
|
1012
|
+
};
|
|
1013
|
+
case "ci-missing":
|
|
1014
|
+
return { kind: "ci-missing", missing: result.snapshot.missingChecks };
|
|
1015
|
+
case "ci-failing":
|
|
1016
|
+
return { kind: "ci-failing", failing: result.snapshot.failingChecks };
|
|
1017
|
+
case "ci-pending":
|
|
1018
|
+
return { kind: "ci-pending", pending: result.snapshot.pendingChecks };
|
|
1019
|
+
default:
|
|
1020
|
+
return { kind: "gate-failed", reason: result.reason };
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// src/tools/delivery-inspect.js
|
|
1025
|
+
function createInspectTool(deps) {
|
|
1026
|
+
return async function inspect(input) {
|
|
1027
|
+
const manifest = await readManifest(deps.repoRoot, input.taskId);
|
|
1028
|
+
const doc = await doctor(deps.repoRoot, deps.packageVersion);
|
|
1029
|
+
return {
|
|
1030
|
+
contractVersion: 1,
|
|
1031
|
+
manifest: manifest ?? null,
|
|
1032
|
+
doctor: doc
|
|
1033
|
+
};
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// src/tools/delivery-issue.js
|
|
1038
|
+
function createIssueTool(deps) {
|
|
1039
|
+
return async function issue(input) {
|
|
1040
|
+
if (!input.taskId) return { kind: "missing-input", field: "taskId" };
|
|
1041
|
+
if (!input.title) return { kind: "missing-input", field: "title" };
|
|
1042
|
+
if (!input.baseBranch) return { kind: "missing-input", field: "baseBranch" };
|
|
1043
|
+
if (!input.branch) return { kind: "missing-input", field: "branch" };
|
|
1044
|
+
const existing = await readManifest(deps.repoRoot, input.taskId);
|
|
1045
|
+
if (existing) {
|
|
1046
|
+
return {
|
|
1047
|
+
contractVersion: 1,
|
|
1048
|
+
created: false,
|
|
1049
|
+
issueNumber: existing.issueNumber,
|
|
1050
|
+
issueUrl: `https://github.com/${deps.repoSlug}/issues/${existing.issueNumber}`,
|
|
1051
|
+
manifestPath: "preserved",
|
|
1052
|
+
preserved: true
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
const ensured = await deps.driver.ensureIssue({
|
|
1056
|
+
repo: deps.repoSlug,
|
|
1057
|
+
title: input.title,
|
|
1058
|
+
body: input.body ?? "",
|
|
1059
|
+
labels: input.labels ?? []
|
|
1060
|
+
});
|
|
1061
|
+
const m = createManifest({
|
|
1062
|
+
taskId: input.taskId,
|
|
1063
|
+
repoIdentity: deps.repoSlug,
|
|
1064
|
+
issueNumber: ensured.summary.number,
|
|
1065
|
+
baseBranch: input.baseBranch,
|
|
1066
|
+
baseSha: input.baseSha ?? "0000000000000000000000000000000000000000",
|
|
1067
|
+
branch: input.branch,
|
|
1068
|
+
owner: deps.owner,
|
|
1069
|
+
prNumber: null,
|
|
1070
|
+
lastPrHeadSha: null,
|
|
1071
|
+
lastReviewerSha: null,
|
|
1072
|
+
lastVerifierSha: null
|
|
1073
|
+
});
|
|
1074
|
+
const t = transition(m, "issue-linked", {
|
|
1075
|
+
reason: ensured.created ? "issue just created" : "issue reused"
|
|
1076
|
+
});
|
|
1077
|
+
if (!t.ok) {
|
|
1078
|
+
return { kind: "lifecycle", reason: t.reason };
|
|
1079
|
+
}
|
|
1080
|
+
const next = {
|
|
1081
|
+
...m,
|
|
1082
|
+
state: t.to,
|
|
1083
|
+
transitionLog: [
|
|
1084
|
+
...m.transitionLog,
|
|
1085
|
+
{ from: t.from, to: t.to, at: t.at, reason: t.reason }
|
|
1086
|
+
],
|
|
1087
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1088
|
+
};
|
|
1089
|
+
const path = await writeManifest(deps.repoRoot, next);
|
|
1090
|
+
return {
|
|
1091
|
+
contractVersion: 1,
|
|
1092
|
+
created: ensured.created,
|
|
1093
|
+
issueNumber: ensured.summary.number,
|
|
1094
|
+
issueUrl: ensured.summary.url,
|
|
1095
|
+
manifestPath: path
|
|
1096
|
+
};
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// src/tools/delivery-worktree.js
|
|
1101
|
+
import { resolve as resolve5 } from "node:path";
|
|
1102
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
1103
|
+
function runBootstrap(args, cwd) {
|
|
1104
|
+
return new Promise((resolveP, rejectP) => {
|
|
1105
|
+
const proc = spawn3(args[0], args.slice(1), {
|
|
1106
|
+
cwd,
|
|
1107
|
+
env: process.env,
|
|
1108
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1109
|
+
shell: false
|
|
1110
|
+
});
|
|
1111
|
+
let stderr = "";
|
|
1112
|
+
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
1113
|
+
proc.on("error", rejectP);
|
|
1114
|
+
proc.on("close", (code) => {
|
|
1115
|
+
if (code !== 0) {
|
|
1116
|
+
rejectP(new Error(`bootstrap ${args.join(" ")} failed (exit ${code}): ${stderr.trim()}`));
|
|
1117
|
+
} else {
|
|
1118
|
+
resolveP();
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
function isPathContained(repoRoot, worktreeRoot, candidatePath) {
|
|
1124
|
+
const rootAbs = resolve5(repoRoot, worktreeRoot);
|
|
1125
|
+
const normalized = resolve5(candidatePath);
|
|
1126
|
+
if (normalized !== rootAbs && !normalized.startsWith(rootAbs + "/")) {
|
|
1127
|
+
return false;
|
|
1128
|
+
}
|
|
1129
|
+
return true;
|
|
1130
|
+
}
|
|
1131
|
+
async function markBootstrapFailed(repoRoot, manifest, error, argv) {
|
|
1132
|
+
const failed = {
|
|
1133
|
+
...manifest,
|
|
1134
|
+
state: "cleanup-pending",
|
|
1135
|
+
fatalReason: `bootstrap failed: ${error.message}`,
|
|
1136
|
+
transitionLog: [
|
|
1137
|
+
...manifest.transitionLog,
|
|
1138
|
+
{
|
|
1139
|
+
from: manifest.state,
|
|
1140
|
+
to: "cleanup-pending",
|
|
1141
|
+
at: Date.now(),
|
|
1142
|
+
reason: `bootstrap failed: ${error.message}`
|
|
1143
|
+
}
|
|
1144
|
+
],
|
|
1145
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1146
|
+
};
|
|
1147
|
+
await writeManifest(repoRoot, failed);
|
|
1148
|
+
}
|
|
1149
|
+
function createWorktreeTool(deps) {
|
|
1150
|
+
return async function worktree(input) {
|
|
1151
|
+
const m = await readManifest(deps.repoRoot, input.taskId);
|
|
1152
|
+
if (!m) return { kind: "missing-manifest", taskId: input.taskId };
|
|
1153
|
+
if (m.state !== "issue-linked" && m.state !== "worktree-created") {
|
|
1154
|
+
return { kind: "manifest-state", state: m.state };
|
|
1155
|
+
}
|
|
1156
|
+
if (!input.branch) return { kind: "missing-input", field: "branch" };
|
|
1157
|
+
if (!input.worktreeRelativePath) {
|
|
1158
|
+
return { kind: "missing-input", field: "worktreeRelativePath" };
|
|
1159
|
+
}
|
|
1160
|
+
const worktreeRoot = deps.adapter?.worktree?.root ?? ".worktrees";
|
|
1161
|
+
const worktreePath = resolve5(deps.repoRoot, input.worktreeRelativePath);
|
|
1162
|
+
if (!isPathContained(deps.repoRoot, worktreeRoot, worktreePath)) {
|
|
1163
|
+
return {
|
|
1164
|
+
kind: "path-escape",
|
|
1165
|
+
resolvedPath: worktreePath,
|
|
1166
|
+
expectedRoot: resolve5(deps.repoRoot, worktreeRoot)
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
const remote = deps.remote ?? "origin";
|
|
1170
|
+
const hasRemote = remoteExists(remote, deps.repoRoot);
|
|
1171
|
+
if (hasRemote) {
|
|
1172
|
+
const fetched = fetchBranch(remote, m.baseBranch, deps.repoRoot);
|
|
1173
|
+
if (fetched.status !== 0) {
|
|
1174
|
+
return { kind: "remote-fetch", stderr: fetched.stderr };
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if (branchExistsLocally(input.branch, deps.repoRoot)) {
|
|
1178
|
+
return { kind: "branch-exists-locally", branch: input.branch };
|
|
1179
|
+
}
|
|
1180
|
+
if (branchExistsRemotely(remote, input.branch, deps.repoRoot)) {
|
|
1181
|
+
return { kind: "branch-exists-remotely", branch: input.branch };
|
|
1182
|
+
}
|
|
1183
|
+
if (worktreeExists(deps.repoRoot, worktreePath)) {
|
|
1184
|
+
return { kind: "worktree-exists" };
|
|
1185
|
+
}
|
|
1186
|
+
const baseRef = hasRemote ? `${remote}/${m.baseBranch}` : m.baseBranch;
|
|
1187
|
+
const created = createWorktree({
|
|
1188
|
+
cwd: deps.repoRoot,
|
|
1189
|
+
branch: input.branch,
|
|
1190
|
+
worktreePath,
|
|
1191
|
+
base: baseRef
|
|
1192
|
+
});
|
|
1193
|
+
if (created.status !== 0) {
|
|
1194
|
+
return { kind: "create-failed", stderr: created.stderr };
|
|
1195
|
+
}
|
|
1196
|
+
const head = currentHead(worktreePath);
|
|
1197
|
+
if (!head) {
|
|
1198
|
+
return { kind: "create-failed", stderr: "no HEAD after worktree create" };
|
|
1199
|
+
}
|
|
1200
|
+
const bootstrap = deps.adapter?.worktree?.bootstrap ?? [];
|
|
1201
|
+
for (const argv of bootstrap) {
|
|
1202
|
+
if (!Array.isArray(argv) || argv.length === 0) {
|
|
1203
|
+
return { kind: "bootstrap-invalid", bootstrap };
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
await runBootstrap(argv, worktreePath);
|
|
1207
|
+
} catch (e) {
|
|
1208
|
+
await markBootstrapFailed(
|
|
1209
|
+
deps.repoRoot,
|
|
1210
|
+
{
|
|
1211
|
+
...m,
|
|
1212
|
+
worktreePath,
|
|
1213
|
+
branch: input.branch,
|
|
1214
|
+
baseSha: m.baseSha
|
|
1215
|
+
},
|
|
1216
|
+
e,
|
|
1217
|
+
argv
|
|
1218
|
+
);
|
|
1219
|
+
return { kind: "bootstrap-failed", stderr: e.message, argv };
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
const baseSha = mergeBaseRemoteHead(remote, m.baseBranch, deps.repoRoot) ?? m.baseSha ?? null;
|
|
1223
|
+
if (!baseSha) return { kind: "missing-base-sha" };
|
|
1224
|
+
const t = transition(
|
|
1225
|
+
{ ...m, worktreePath, branch: input.branch, baseSha },
|
|
1226
|
+
"worktree-created",
|
|
1227
|
+
{ reason: "worktree created" }
|
|
1228
|
+
);
|
|
1229
|
+
if (!t.ok) return { kind: "lifecycle", reason: t.reason };
|
|
1230
|
+
const next = {
|
|
1231
|
+
...m,
|
|
1232
|
+
worktreePath,
|
|
1233
|
+
branch: input.branch,
|
|
1234
|
+
baseSha,
|
|
1235
|
+
state: t.to,
|
|
1236
|
+
transitionLog: [
|
|
1237
|
+
...m.transitionLog,
|
|
1238
|
+
{ from: t.from, to: t.to, at: t.at, reason: t.reason }
|
|
1239
|
+
],
|
|
1240
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1241
|
+
};
|
|
1242
|
+
const path = await writeManifest(deps.repoRoot, next);
|
|
1243
|
+
return {
|
|
1244
|
+
contractVersion: 1,
|
|
1245
|
+
branch: input.branch,
|
|
1246
|
+
worktreePath,
|
|
1247
|
+
headSha: head,
|
|
1248
|
+
manifestPath: path
|
|
1249
|
+
};
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// src/tools/delivery-verify.js
|
|
1254
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
1255
|
+
function runCommand(argv, cwd, timeoutMs) {
|
|
1256
|
+
return new Promise((resolveP, rejectP) => {
|
|
1257
|
+
const proc = spawn4(argv[0], argv.slice(1), {
|
|
1258
|
+
cwd,
|
|
1259
|
+
env: process.env,
|
|
1260
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1261
|
+
shell: false
|
|
1262
|
+
});
|
|
1263
|
+
const stdoutChunks = [];
|
|
1264
|
+
const stderrChunks = [];
|
|
1265
|
+
let killed = false;
|
|
1266
|
+
const timer = setTimeout(() => {
|
|
1267
|
+
killed = true;
|
|
1268
|
+
proc.kill("SIGKILL");
|
|
1269
|
+
}, timeoutMs);
|
|
1270
|
+
proc.stdout.on("data", (d) => stdoutChunks.push(d.toString()));
|
|
1271
|
+
proc.stderr.on("data", (d) => stderrChunks.push(d.toString()));
|
|
1272
|
+
proc.on("error", (err) => {
|
|
1273
|
+
clearTimeout(timer);
|
|
1274
|
+
rejectP(err);
|
|
1275
|
+
});
|
|
1276
|
+
proc.on("close", (code) => {
|
|
1277
|
+
clearTimeout(timer);
|
|
1278
|
+
resolveP({
|
|
1279
|
+
status: killed ? -1 : code ?? -1,
|
|
1280
|
+
stdout: stdoutChunks.join(""),
|
|
1281
|
+
stderr: stderrChunks.join("")
|
|
1282
|
+
});
|
|
1283
|
+
});
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
function createVerifyTool(deps) {
|
|
1287
|
+
return async function verify(input) {
|
|
1288
|
+
const m = await readManifest(deps.repoRoot, input.taskId);
|
|
1289
|
+
if (!m) return { kind: "missing-manifest", taskId: input.taskId };
|
|
1290
|
+
const commands = deps.adapter?.verification?.commands ?? [];
|
|
1291
|
+
if (commands.length === 0) return { kind: "no-commands" };
|
|
1292
|
+
const cmd = input.commandId ? commands.find((c) => c.id === input.commandId) : commands[0];
|
|
1293
|
+
if (!cmd) return { kind: "command-not-found", commandId: input.commandId ?? commands[0]?.id };
|
|
1294
|
+
if (!m.worktreePath) {
|
|
1295
|
+
return { kind: "manifest-state", state: m.state, reason: "no worktree" };
|
|
1296
|
+
}
|
|
1297
|
+
if (m.state !== "worktree-created" && m.state !== "draft-open" && m.state !== "validating" && m.state !== "ready") {
|
|
1298
|
+
return { kind: "manifest-state", state: m.state };
|
|
1299
|
+
}
|
|
1300
|
+
if (deps.adapter?.verification?.requireCleanDiffAfter) {
|
|
1301
|
+
if (!isWorktreeClean(m.worktreePath)) {
|
|
1302
|
+
return { kind: "worktree-dirty" };
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
const head = currentHead(m.worktreePath);
|
|
1306
|
+
if (!head) return { kind: "no-head" };
|
|
1307
|
+
const timeoutMs = cmd.timeoutMs ?? 18e5;
|
|
1308
|
+
const result = await runCommand(cmd.argv, m.worktreePath, timeoutMs);
|
|
1309
|
+
const stdoutTail = result.stdout.slice(-2e3);
|
|
1310
|
+
const stderrTail = result.stderr.slice(-2e3);
|
|
1311
|
+
if (result.status !== 0) {
|
|
1312
|
+
return {
|
|
1313
|
+
kind: "verify-failed",
|
|
1314
|
+
commandId: cmd.id,
|
|
1315
|
+
status: result.status,
|
|
1316
|
+
stdoutTail,
|
|
1317
|
+
stderrTail,
|
|
1318
|
+
headSha: head
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
const t = transition(
|
|
1322
|
+
{ ...m, lastVerifierSha: head },
|
|
1323
|
+
"validating",
|
|
1324
|
+
{ reason: `verify ok (${cmd.id})` }
|
|
1325
|
+
);
|
|
1326
|
+
if (!t.ok) return { kind: "lifecycle", reason: t.reason };
|
|
1327
|
+
const next = {
|
|
1328
|
+
...m,
|
|
1329
|
+
lastVerifierSha: head,
|
|
1330
|
+
state: t.to,
|
|
1331
|
+
transitionLog: [
|
|
1332
|
+
...m.transitionLog,
|
|
1333
|
+
{ from: t.from, to: t.to, at: t.at, reason: t.reason }
|
|
1334
|
+
],
|
|
1335
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1336
|
+
};
|
|
1337
|
+
const manifestPath2 = await writeManifest(deps.repoRoot, next);
|
|
1338
|
+
return {
|
|
1339
|
+
contractVersion: 1,
|
|
1340
|
+
commandId: cmd.id,
|
|
1341
|
+
status: 0,
|
|
1342
|
+
stdoutTail,
|
|
1343
|
+
stderrTail,
|
|
1344
|
+
headSha: head,
|
|
1345
|
+
manifestPath: manifestPath2
|
|
1346
|
+
};
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// src/tools/delivery-review.js
|
|
1351
|
+
function createReviewTool(deps) {
|
|
1352
|
+
return async function review(input) {
|
|
1353
|
+
const m = await readManifest(deps.repoRoot, input.taskId);
|
|
1354
|
+
if (!m) return { kind: "missing-manifest", taskId: input.taskId };
|
|
1355
|
+
if (m.prNumber === null) return { kind: "missing-pr" };
|
|
1356
|
+
if (m.state !== "worktree-created" && m.state !== "draft-open" && m.state !== "validating" && m.state !== "ready") {
|
|
1357
|
+
return { kind: "manifest-state", state: m.state };
|
|
1358
|
+
}
|
|
1359
|
+
const prHead = await deps.driver.refreshHead({
|
|
1360
|
+
repo: deps.repoSlug,
|
|
1361
|
+
number: m.prNumber
|
|
1362
|
+
});
|
|
1363
|
+
if (input.status !== "pass") {
|
|
1364
|
+
return {
|
|
1365
|
+
kind: "review-not-pass",
|
|
1366
|
+
status: input.status,
|
|
1367
|
+
headSha: prHead,
|
|
1368
|
+
recordedReviewerSha: m.lastReviewerSha ?? null
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
if (!input.headSha) {
|
|
1372
|
+
return {
|
|
1373
|
+
kind: "missing-head-sha",
|
|
1374
|
+
prHeadSha: prHead
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
if (input.headSha !== prHead) {
|
|
1378
|
+
return {
|
|
1379
|
+
kind: "head-mismatch",
|
|
1380
|
+
reviewSha: input.headSha,
|
|
1381
|
+
prHeadSha: prHead
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
const next = {
|
|
1385
|
+
...m,
|
|
1386
|
+
lastReviewerSha: prHead,
|
|
1387
|
+
transitionLog: [
|
|
1388
|
+
...m.transitionLog,
|
|
1389
|
+
{
|
|
1390
|
+
from: m.state,
|
|
1391
|
+
to: m.state,
|
|
1392
|
+
at: Date.now(),
|
|
1393
|
+
reason: `reviewer pass at ${prHead.slice(0, 7)}`
|
|
1394
|
+
}
|
|
1395
|
+
],
|
|
1396
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1397
|
+
};
|
|
1398
|
+
const path = await writeManifest(deps.repoRoot, next);
|
|
1399
|
+
return {
|
|
1400
|
+
contractVersion: 1,
|
|
1401
|
+
pr: m.prNumber,
|
|
1402
|
+
reviewerSha: prHead,
|
|
1403
|
+
manifestPath: path
|
|
1404
|
+
};
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// src/tools/delivery-pr.js
|
|
1409
|
+
function preserveClosingReference(existingBody, issueNumber) {
|
|
1410
|
+
if (!existingBody) return null;
|
|
1411
|
+
const match = existingBody.match(/Closes\s+#(\d+)/i);
|
|
1412
|
+
if (match) {
|
|
1413
|
+
if (match[1] === String(issueNumber)) return existingBody;
|
|
1414
|
+
return existingBody.replace(/Closes\s+#\d+/i, `Closes #${issueNumber}`);
|
|
1415
|
+
}
|
|
1416
|
+
return `${existingBody}
|
|
1417
|
+
|
|
1418
|
+
Closes #${issueNumber}`;
|
|
1419
|
+
}
|
|
1420
|
+
function createPrTool(deps) {
|
|
1421
|
+
return async function pr(input) {
|
|
1422
|
+
const m = await readManifest(deps.repoRoot, input.taskId);
|
|
1423
|
+
if (!m) return { kind: "missing-manifest", taskId: input.taskId };
|
|
1424
|
+
if (m.state !== "worktree-created" && m.state !== "draft-open") {
|
|
1425
|
+
return { kind: "manifest-state", state: m.state };
|
|
1426
|
+
}
|
|
1427
|
+
if (m.prNumber === null) {
|
|
1428
|
+
const opened = await deps.driver.openDraftPullRequest({
|
|
1429
|
+
repo: deps.repoSlug,
|
|
1430
|
+
head: m.branch,
|
|
1431
|
+
base: m.baseBranch,
|
|
1432
|
+
title: input.title,
|
|
1433
|
+
body: input.body,
|
|
1434
|
+
issueNumber: m.issueNumber
|
|
1435
|
+
});
|
|
1436
|
+
const t = transition(m, "draft-open", { reason: "draft opened" });
|
|
1437
|
+
if (!t.ok) return { kind: "lifecycle", reason: t.reason };
|
|
1438
|
+
const next2 = {
|
|
1439
|
+
...m,
|
|
1440
|
+
prNumber: opened.number,
|
|
1441
|
+
lastPrHeadSha: opened.headSha,
|
|
1442
|
+
state: t.to,
|
|
1443
|
+
transitionLog: [
|
|
1444
|
+
...m.transitionLog,
|
|
1445
|
+
{ from: t.from, to: t.to, at: t.at, reason: t.reason }
|
|
1446
|
+
],
|
|
1447
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1448
|
+
};
|
|
1449
|
+
const path2 = await writeManifest(deps.repoRoot, next2);
|
|
1450
|
+
return { contractVersion: 1, pr: opened, manifestPath: path2 };
|
|
1451
|
+
}
|
|
1452
|
+
const existingPr = await deps.driver.readPullRequest({
|
|
1453
|
+
repo: deps.repoSlug,
|
|
1454
|
+
number: m.prNumber
|
|
1455
|
+
});
|
|
1456
|
+
const mergedBody = preserveClosingReference(
|
|
1457
|
+
input.body,
|
|
1458
|
+
m.issueNumber
|
|
1459
|
+
) ?? input.body;
|
|
1460
|
+
await deps.driver.updatePullRequestBody({
|
|
1461
|
+
repo: deps.repoSlug,
|
|
1462
|
+
number: m.prNumber,
|
|
1463
|
+
body: mergedBody
|
|
1464
|
+
});
|
|
1465
|
+
const refreshed = typeof existingPr?.headSha === "string" && existingPr.headSha ? existingPr.headSha : await deps.driver.refreshHead({ repo: deps.repoSlug, number: m.prNumber });
|
|
1466
|
+
const next = {
|
|
1467
|
+
...m,
|
|
1468
|
+
lastPrHeadSha: refreshed,
|
|
1469
|
+
transitionLog: [
|
|
1470
|
+
...m.transitionLog,
|
|
1471
|
+
{
|
|
1472
|
+
from: m.state,
|
|
1473
|
+
to: m.state,
|
|
1474
|
+
at: Date.now(),
|
|
1475
|
+
reason: `pr body updated (head ${refreshed.slice(0, 7)})`
|
|
1476
|
+
}
|
|
1477
|
+
],
|
|
1478
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1479
|
+
};
|
|
1480
|
+
const path = await writeManifest(deps.repoRoot, next);
|
|
1481
|
+
return {
|
|
1482
|
+
contractVersion: 1,
|
|
1483
|
+
pr: {
|
|
1484
|
+
number: m.prNumber,
|
|
1485
|
+
url: existingPr?.url ?? "",
|
|
1486
|
+
baseRefName: existingPr?.baseRefName ?? m.baseBranch,
|
|
1487
|
+
headRefName: existingPr?.headRefName ?? m.branch,
|
|
1488
|
+
headSha: refreshed,
|
|
1489
|
+
draft: existingPr?.draft ?? true,
|
|
1490
|
+
mergeable: existingPr?.mergeable ?? "UNKNOWN",
|
|
1491
|
+
mergeStateStatus: existingPr?.mergeStateStatus ?? "UNKNOWN",
|
|
1492
|
+
merged: existingPr?.merged ?? false,
|
|
1493
|
+
mergedAt: existingPr?.mergedAt ?? null
|
|
1494
|
+
},
|
|
1495
|
+
manifestPath: path
|
|
1496
|
+
};
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// src/tools/delivery-ready.js
|
|
1501
|
+
function createReadyTool(deps) {
|
|
1502
|
+
return async function ready(input) {
|
|
1503
|
+
const m = await readManifest(deps.repoRoot, input.taskId);
|
|
1504
|
+
if (!m) return { kind: "missing-manifest", taskId: input.taskId };
|
|
1505
|
+
if (m.prNumber === null) return { kind: "missing-pr" };
|
|
1506
|
+
const prHead = await deps.driver.refreshHead({
|
|
1507
|
+
repo: deps.repoSlug,
|
|
1508
|
+
number: m.prNumber
|
|
1509
|
+
});
|
|
1510
|
+
const required = deps.adapter?.ready?.requires ?? [
|
|
1511
|
+
"review",
|
|
1512
|
+
"local-verification",
|
|
1513
|
+
"remote-ci"
|
|
1514
|
+
];
|
|
1515
|
+
const ciDriverAvailable = Boolean(deps.adapter?.ci?.driver);
|
|
1516
|
+
const checks = ciDriverAvailable ? await deps.driver.readChecks({
|
|
1517
|
+
repo: deps.repoSlug,
|
|
1518
|
+
number: m.prNumber,
|
|
1519
|
+
branch: m.branch,
|
|
1520
|
+
required: deps.adapter?.ci?.requiredChecks ?? []
|
|
1521
|
+
}) : [];
|
|
1522
|
+
const result = checkGates({
|
|
1523
|
+
manifest: { ...m, adapter: deps.adapter },
|
|
1524
|
+
prHead,
|
|
1525
|
+
checks,
|
|
1526
|
+
requires: required
|
|
1527
|
+
});
|
|
1528
|
+
if (!result.ok) {
|
|
1529
|
+
return gateFailureEnvelope(result);
|
|
1530
|
+
}
|
|
1531
|
+
await deps.driver.markReady({
|
|
1532
|
+
repo: deps.repoSlug,
|
|
1533
|
+
number: m.prNumber
|
|
1534
|
+
});
|
|
1535
|
+
const t = transition(m, "ready", { reason: "all gates fresh" });
|
|
1536
|
+
if (!t.ok) return { kind: "lifecycle", reason: t.reason };
|
|
1537
|
+
const next = {
|
|
1538
|
+
...m,
|
|
1539
|
+
lastPrHeadSha: prHead,
|
|
1540
|
+
state: t.to,
|
|
1541
|
+
transitionLog: [
|
|
1542
|
+
...m.transitionLog,
|
|
1543
|
+
{ from: t.from, to: t.to, at: t.at, reason: t.reason }
|
|
1544
|
+
],
|
|
1545
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1546
|
+
};
|
|
1547
|
+
const path = await writeManifest(deps.repoRoot, next);
|
|
1548
|
+
return { contractVersion: 1, manifestPath: path, pr: m.prNumber };
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// src/tools/delivery-merge.js
|
|
1553
|
+
function createMergeTool(deps) {
|
|
1554
|
+
return async function merge(input) {
|
|
1555
|
+
const m = await readManifest(deps.repoRoot, input.taskId);
|
|
1556
|
+
if (!m) return { kind: "missing-manifest", taskId: input.taskId };
|
|
1557
|
+
if (m.prNumber === null) return { kind: "missing-pr" };
|
|
1558
|
+
if (m.state !== "ready") {
|
|
1559
|
+
return { kind: "not-ready", state: m.state };
|
|
1560
|
+
}
|
|
1561
|
+
const pr = await deps.driver.readPullRequest({
|
|
1562
|
+
repo: deps.repoSlug,
|
|
1563
|
+
number: m.prNumber
|
|
1564
|
+
});
|
|
1565
|
+
if (pr.baseRefName !== m.baseBranch) {
|
|
1566
|
+
return { kind: "wrong-base", base: pr.baseRefName };
|
|
1567
|
+
}
|
|
1568
|
+
const freshGates = deps.adapter?.merge?.requireFreshGates !== false;
|
|
1569
|
+
if (freshGates) {
|
|
1570
|
+
const required = deps.adapter?.ready?.requires ?? [
|
|
1571
|
+
"review",
|
|
1572
|
+
"local-verification",
|
|
1573
|
+
"remote-ci"
|
|
1574
|
+
];
|
|
1575
|
+
const ciDriverAvailable = Boolean(deps.adapter?.ci?.driver);
|
|
1576
|
+
const checks = ciDriverAvailable ? await deps.driver.readChecks({
|
|
1577
|
+
repo: deps.repoSlug,
|
|
1578
|
+
number: m.prNumber,
|
|
1579
|
+
branch: m.branch,
|
|
1580
|
+
required: deps.adapter?.ci?.requiredChecks ?? []
|
|
1581
|
+
}) : [];
|
|
1582
|
+
const result = checkGates({
|
|
1583
|
+
manifest: { ...m, adapter: deps.adapter },
|
|
1584
|
+
prHead: pr.headSha,
|
|
1585
|
+
checks,
|
|
1586
|
+
requires: required
|
|
1587
|
+
});
|
|
1588
|
+
if (!result.ok) return gateFailureEnvelope(result);
|
|
1589
|
+
}
|
|
1590
|
+
if (pr.headSha !== (m.lastPrHeadSha ?? pr.headSha)) {
|
|
1591
|
+
return {
|
|
1592
|
+
kind: "head-changed",
|
|
1593
|
+
headSha: pr.headSha,
|
|
1594
|
+
manifestSha: m.lastPrHeadSha ?? ""
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
if (pr.draft) return { kind: "not-mergeable", reason: "PR is still draft" };
|
|
1598
|
+
if (pr.mergeable !== "MERGEABLE") {
|
|
1599
|
+
return { kind: "not-mergeable", reason: `mergeable=${pr.mergeable}` };
|
|
1600
|
+
}
|
|
1601
|
+
const merged = await deps.driver.mergePullRequest({
|
|
1602
|
+
repo: deps.repoSlug,
|
|
1603
|
+
number: m.prNumber,
|
|
1604
|
+
subject: input.subject
|
|
1605
|
+
});
|
|
1606
|
+
const t = transition(
|
|
1607
|
+
{ ...m, lastPrHeadSha: merged.headSha },
|
|
1608
|
+
"merged",
|
|
1609
|
+
{ reason: `squash merged as ${input.subject}` }
|
|
1610
|
+
);
|
|
1611
|
+
if (!t.ok) return { kind: "lifecycle", reason: t.reason };
|
|
1612
|
+
const next = {
|
|
1613
|
+
...m,
|
|
1614
|
+
lastPrHeadSha: merged.headSha,
|
|
1615
|
+
state: t.to,
|
|
1616
|
+
transitionLog: [
|
|
1617
|
+
...m.transitionLog,
|
|
1618
|
+
{ from: t.from, to: t.to, at: t.at, reason: t.reason }
|
|
1619
|
+
],
|
|
1620
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1621
|
+
};
|
|
1622
|
+
const path = await writeManifest(deps.repoRoot, next);
|
|
1623
|
+
return { kind: "merge", contractVersion: 1, manifestPath: path, pr: m.prNumber, taskId: m.taskId };
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// src/tools/delivery-cleanup.js
|
|
1628
|
+
import { resolve as resolve6 } from "node:path";
|
|
1629
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
1630
|
+
function safeRemoveWorktree(repoRoot, path) {
|
|
1631
|
+
const r = spawnSync3("git", ["worktree", "remove", path], {
|
|
1632
|
+
cwd: repoRoot,
|
|
1633
|
+
encoding: "utf8",
|
|
1634
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1635
|
+
env: process.env
|
|
1636
|
+
});
|
|
1637
|
+
return { status: r.status ?? -1, stderr: r.stderr ?? "" };
|
|
1638
|
+
}
|
|
1639
|
+
function casDeleteBranch(repoRoot, branch, expectedSha) {
|
|
1640
|
+
const args = ["update-ref", "-d"];
|
|
1641
|
+
if (expectedSha && /^[0-9a-f]{7,}$/i.test(expectedSha)) {
|
|
1642
|
+
args.push(`refs/heads/${branch}`, expectedSha);
|
|
1643
|
+
} else {
|
|
1644
|
+
args.push(`refs/heads/${branch}`);
|
|
1645
|
+
}
|
|
1646
|
+
const r = spawnSync3("git", args, {
|
|
1647
|
+
cwd: repoRoot,
|
|
1648
|
+
encoding: "utf8",
|
|
1649
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1650
|
+
env: process.env
|
|
1651
|
+
});
|
|
1652
|
+
return { status: r.status ?? -1, stderr: r.stderr ?? "" };
|
|
1653
|
+
}
|
|
1654
|
+
function branchStillExists(repoRoot, branch) {
|
|
1655
|
+
const r = spawnSync3(
|
|
1656
|
+
"git",
|
|
1657
|
+
["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
|
|
1658
|
+
{ cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: process.env }
|
|
1659
|
+
);
|
|
1660
|
+
return r.status === 0;
|
|
1661
|
+
}
|
|
1662
|
+
function remoteBranchGone(repoRoot, branch, remote) {
|
|
1663
|
+
const r = spawnSync3("git", ["ls-remote", "--heads", remote, branch], {
|
|
1664
|
+
cwd: repoRoot,
|
|
1665
|
+
encoding: "utf8",
|
|
1666
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1667
|
+
env: process.env
|
|
1668
|
+
});
|
|
1669
|
+
return r.status === 0 && !r.stdout.includes(`refs/heads/${branch}`);
|
|
1670
|
+
}
|
|
1671
|
+
function aheadOfRemote(repoRoot, branch, remote) {
|
|
1672
|
+
const r = spawnSync3(
|
|
1673
|
+
"git",
|
|
1674
|
+
["rev-list", "--count", `${remote}/${branch}..${branch}`],
|
|
1675
|
+
{ cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: process.env }
|
|
1676
|
+
);
|
|
1677
|
+
if (r.status !== 0) return null;
|
|
1678
|
+
const n = parseInt(r.stdout.trim(), 10);
|
|
1679
|
+
return Number.isFinite(n) ? n : null;
|
|
1680
|
+
}
|
|
1681
|
+
function createCleanupTool(deps) {
|
|
1682
|
+
return async function cleanup(input) {
|
|
1683
|
+
const m = await readManifest(deps.repoRoot, input.taskId);
|
|
1684
|
+
if (!m) return { kind: "missing-manifest", taskId: input.taskId };
|
|
1685
|
+
if (m.state !== "merged" && m.state !== "cleanup-pending") {
|
|
1686
|
+
return { kind: "manifest-state", state: m.state };
|
|
1687
|
+
}
|
|
1688
|
+
if (!m.worktreePath) return { kind: "missing-worktree-path" };
|
|
1689
|
+
const wtPath = resolve6(m.worktreePath);
|
|
1690
|
+
const mainCwd = resolve6(deps.repoRoot);
|
|
1691
|
+
if (wtPath === mainCwd) return { kind: "current-checkout", worktreePath: wtPath };
|
|
1692
|
+
if (!isWorktreeClean(wtPath)) return { kind: "dirty-worktree" };
|
|
1693
|
+
if (isRebaseInProgress(wtPath)) return { kind: "rebase-in-progress" };
|
|
1694
|
+
const head = currentHead(wtPath);
|
|
1695
|
+
if (!head || m.lastPrHeadSha && head !== m.lastPrHeadSha) {
|
|
1696
|
+
return {
|
|
1697
|
+
kind: "head-mismatch",
|
|
1698
|
+
headSha: head ?? "",
|
|
1699
|
+
manifestSha: m.lastPrHeadSha ?? ""
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
const isBootstrapRecovery = m.state === "cleanup-pending" && m.prNumber === null;
|
|
1703
|
+
if (!isBootstrapRecovery && m.prNumber === null) {
|
|
1704
|
+
return { kind: "missing-pr" };
|
|
1705
|
+
}
|
|
1706
|
+
let prHeadSha = head;
|
|
1707
|
+
let prMerged = true;
|
|
1708
|
+
if (!isBootstrapRecovery) {
|
|
1709
|
+
const pr = await deps.driver.readPullRequest({
|
|
1710
|
+
repo: deps.repoSlug,
|
|
1711
|
+
number: m.prNumber
|
|
1712
|
+
});
|
|
1713
|
+
if (!pr.merged) {
|
|
1714
|
+
return {
|
|
1715
|
+
kind: "unmerged",
|
|
1716
|
+
headSha: pr.headSha,
|
|
1717
|
+
manifestSha: m.lastPrHeadSha ?? ""
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
if (pr.baseRefName !== m.baseBranch) {
|
|
1721
|
+
return { kind: "base-mismatch", manifestBase: m.baseBranch, prBase: pr.baseRefName };
|
|
1722
|
+
}
|
|
1723
|
+
prHeadSha = pr.headSha;
|
|
1724
|
+
prMerged = pr.merged;
|
|
1725
|
+
}
|
|
1726
|
+
if (!isBootstrapRecovery && prHeadSha && prHeadSha !== head) {
|
|
1727
|
+
return {
|
|
1728
|
+
kind: "head-mismatch",
|
|
1729
|
+
headSha: head,
|
|
1730
|
+
manifestSha: prHeadSha
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
const remote = deps.remote ?? "origin";
|
|
1734
|
+
const remoteGone = isBootstrapRecovery ? true : remoteBranchGone(wtPath, m.branch, remote);
|
|
1735
|
+
const ahead = remoteGone ? null : aheadOfRemote(wtPath, m.branch, remote);
|
|
1736
|
+
if (!remoteGone && ahead !== null && ahead > 0) {
|
|
1737
|
+
return {
|
|
1738
|
+
kind: "has-unpublished-commits",
|
|
1739
|
+
ahead,
|
|
1740
|
+
branch: m.branch,
|
|
1741
|
+
remote
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
const removed = safeRemoveWorktree(deps.repoRoot, wtPath);
|
|
1745
|
+
if (removed.status !== 0) {
|
|
1746
|
+
return { kind: "remove-failed", stderr: removed.stderr };
|
|
1747
|
+
}
|
|
1748
|
+
const expectedSha = m.lastPrHeadSha ?? head ?? null;
|
|
1749
|
+
const branchResult = casDeleteBranch(deps.repoRoot, m.branch, expectedSha);
|
|
1750
|
+
if (branchResult.status !== 0 && branchStillExists(deps.repoRoot, m.branch)) {
|
|
1751
|
+
return { kind: "branch-delete-failed", stderr: branchResult.stderr };
|
|
1752
|
+
}
|
|
1753
|
+
const tCleanup = transition(m, "cleanup-pending", { reason: "worktree removed" });
|
|
1754
|
+
const candidate = tCleanup.ok ? {
|
|
1755
|
+
...m,
|
|
1756
|
+
state: tCleanup.to,
|
|
1757
|
+
transitionLog: [
|
|
1758
|
+
...m.transitionLog,
|
|
1759
|
+
{ from: tCleanup.from, to: tCleanup.to, at: tCleanup.at, reason: tCleanup.reason }
|
|
1760
|
+
],
|
|
1761
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1762
|
+
} : m;
|
|
1763
|
+
const tCleaned = transition(candidate, "cleaned", { reason: "manifest sealed" });
|
|
1764
|
+
if (tCleaned.ok) {
|
|
1765
|
+
const sealed = {
|
|
1766
|
+
...candidate,
|
|
1767
|
+
state: tCleaned.to,
|
|
1768
|
+
transitionLog: [
|
|
1769
|
+
...candidate.transitionLog,
|
|
1770
|
+
{ from: tCleaned.from, to: tCleaned.to, at: tCleaned.at, reason: tCleaned.reason }
|
|
1771
|
+
],
|
|
1772
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1773
|
+
};
|
|
1774
|
+
await writeManifest(deps.repoRoot, sealed);
|
|
1775
|
+
await deleteManifest(deps.repoRoot, input.taskId);
|
|
1776
|
+
}
|
|
1777
|
+
return {
|
|
1778
|
+
contractVersion: 1,
|
|
1779
|
+
manifestPath: null,
|
|
1780
|
+
removedPath: wtPath,
|
|
1781
|
+
bootstrapRecovery: isBootstrapRecovery
|
|
1782
|
+
};
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// src/index.js
|
|
1787
|
+
var PACKAGE_VERSION = "0.1.3";
|
|
1788
|
+
export {
|
|
1789
|
+
ADAPTER_CONTRACT_VERSION,
|
|
1790
|
+
ADAPTER_FILENAME,
|
|
1791
|
+
LOCK_FILENAME,
|
|
1792
|
+
PACKAGE_VERSION,
|
|
1793
|
+
STATES,
|
|
1794
|
+
WorktreeRecord,
|
|
1795
|
+
branchExistsLocally,
|
|
1796
|
+
branchExistsRemotely,
|
|
1797
|
+
bucketFor,
|
|
1798
|
+
canTransition,
|
|
1799
|
+
checkGates,
|
|
1800
|
+
createCleanupTool,
|
|
1801
|
+
createGhDriver,
|
|
1802
|
+
createGhStub,
|
|
1803
|
+
createInspectTool,
|
|
1804
|
+
createIssueTool,
|
|
1805
|
+
createManifest,
|
|
1806
|
+
createMergeTool,
|
|
1807
|
+
createPrTool,
|
|
1808
|
+
createReadyTool,
|
|
1809
|
+
createReviewTool,
|
|
1810
|
+
createVerifyTool,
|
|
1811
|
+
createWorktree,
|
|
1812
|
+
createWorktreeFromLocal,
|
|
1813
|
+
createWorktreeTool,
|
|
1814
|
+
currentBranch,
|
|
1815
|
+
currentHead,
|
|
1816
|
+
defaultBranch,
|
|
1817
|
+
deleteManifest,
|
|
1818
|
+
doctor,
|
|
1819
|
+
fetchBranch,
|
|
1820
|
+
findOpencodeDir,
|
|
1821
|
+
gateFailureEnvelope,
|
|
1822
|
+
gateSnapshot,
|
|
1823
|
+
isInsideWorktree,
|
|
1824
|
+
isMainCheckout,
|
|
1825
|
+
isRebaseInProgress,
|
|
1826
|
+
isTerminal,
|
|
1827
|
+
isWorktreeClean,
|
|
1828
|
+
listManifests,
|
|
1829
|
+
listWorktrees,
|
|
1830
|
+
loadAdapter,
|
|
1831
|
+
mergeBaseRemoteHead,
|
|
1832
|
+
mergeIntoFeature,
|
|
1833
|
+
mustRerunReview,
|
|
1834
|
+
mustRerunVerifier,
|
|
1835
|
+
parseRepoSlug,
|
|
1836
|
+
push,
|
|
1837
|
+
pushForceDisabled,
|
|
1838
|
+
readLock,
|
|
1839
|
+
readManifest,
|
|
1840
|
+
recoverManifestAfterCrash,
|
|
1841
|
+
remoteExists,
|
|
1842
|
+
removeManifestIfSafe,
|
|
1843
|
+
revParse,
|
|
1844
|
+
scanRecovery,
|
|
1845
|
+
transition,
|
|
1846
|
+
validateAdapter,
|
|
1847
|
+
worktreeExists,
|
|
1848
|
+
wouldCleanupBeSafe,
|
|
1849
|
+
writeLock,
|
|
1850
|
+
writeManifest
|
|
1851
|
+
};
|