@brainervirus/workit-core 2.4.0 → 2.5.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.
@@ -0,0 +1,450 @@
1
+ // `workit pr create` and `workit pr merge` (design §2.1 S11): forge writes with
2
+ // a SHA bound before the call and verified after it.
3
+ //
4
+ // create: the branch must already be pushed (remote tip == local head); that
5
+ // SHA is bound, an open PR/MR for the branch is reused (`created: false`),
6
+ // and success means the forge reports the bound SHA as the PR head. The
7
+ // `pr.created` ledger row is what `ledger check --pr` resolves through (S13).
8
+ //
9
+ // merge: three independent gates, all required:
10
+ // 1. the S10 status document reads READY (required checks green, no
11
+ // conflicts, threads, review or draft blockers);
12
+ // 2. an accepted S13 verdict for that exact head (fresh or carried), unless
13
+ // the workspace grants `merge: true`;
14
+ // 3. the `merge` grant (autonomy.ts `requireGrant`; S16 fills the defaults).
15
+ // The merge call carries the head SHA (GitHub `sha=`, GitLab `sha=`), so a
16
+ // head that moves after the gates refuses instead of merging unverified code.
17
+ // --delete-branch deletes with a lease on that same SHA.
18
+ import { requireGrant } from "../autonomy";
19
+ import { isProtectedTarget } from "../core/branch";
20
+ import { vcsConfig } from "../core/vcs-config";
21
+ import { deleteRemoteBranch } from "../git/ops";
22
+ import { spawnSync } from "node:child_process";
23
+ import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from "../git/rev";
24
+ import {
25
+ appendObserved,
26
+ checkVerdicts,
27
+ readLedger,
28
+ type LedgerActor,
29
+ type VerdictCheck,
30
+ } from "../ledger";
31
+ import { buildStatusDoc, selectPr, type PrStatusDoc } from "./report";
32
+ import type { ResolvedForge } from "./resolve";
33
+ import { failure, success, type ForgeResult, type MergeMethod, type PrRef } from "./types";
34
+
35
+ export type Sleep = (ms: number) => Promise<void>;
36
+
37
+ /** Post-verification retries: the forge may take a moment to show a new head or merge. */
38
+ const VERIFY_DELAYS_MS = [1_000, 2_000, 4_000];
39
+
40
+ const noun = (resolved: ResolvedForge): string =>
41
+ resolved.forge.kind === "github" ? "pull request" : "merge request";
42
+
43
+ const grantBlocked = (decision: Extract<ReturnType<typeof requireGrant>, { allowed: false }>) =>
44
+ failure("blocked", decision.error, decision.unblock);
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // create
48
+
49
+ export type CreateInput = {
50
+ base: string;
51
+ title: string;
52
+ body: string;
53
+ draft: boolean;
54
+ actor: LedgerActor;
55
+ };
56
+
57
+ export type CreateOutcome = {
58
+ number: number;
59
+ url: string;
60
+ branch: string;
61
+ base: string;
62
+ head: string;
63
+ created: boolean;
64
+ draft: boolean;
65
+ recorded: { id: string } | { error: string };
66
+ };
67
+
68
+ /**
69
+ * `--fill`: the title and body from the branch's commits on top of the base
70
+ * (fetched first). One commit gives its subject and body; several give the
71
+ * oldest subject and a list of every subject.
72
+ */
73
+ export function fillFromCommits(
74
+ cwd: string,
75
+ resolved: ResolvedForge,
76
+ base: string,
77
+ ): ForgeResult<{ title: string; body: string }> {
78
+ const tracking = `refs/remotes/${resolved.baseRemote}/${base}`;
79
+ if (!base.startsWith("-"))
80
+ fetchRefs(cwd, resolved.baseRemote, [`+refs/heads/${base}:${tracking}`]);
81
+ const from = resolveRef(cwd, tracking) ?? resolveRef(cwd, `refs/heads/${base}`);
82
+ if (!from)
83
+ return failure(
84
+ "not_found",
85
+ `base ${base} is not available locally`,
86
+ `git fetch ${resolved.baseRemote} ${base}`,
87
+ );
88
+ const log = spawnSync(
89
+ "git",
90
+ ["log", "--reverse", "--no-merges", "--format=%s%x1f%b%x1e", `${from}..HEAD`],
91
+ {
92
+ cwd,
93
+ encoding: "utf8",
94
+ timeout: GIT_TIMEOUTS.local,
95
+ killSignal: "SIGKILL",
96
+ },
97
+ );
98
+ const commits = (log.status === 0 ? log.stdout : "")
99
+ .split("\x1e")
100
+ .map((entry) => entry.trim())
101
+ .filter(Boolean)
102
+ .map((entry) => {
103
+ const [subject = "", body = ""] = entry.split("\x1f");
104
+ return { subject: subject.trim(), body: body.trim() };
105
+ });
106
+ if (commits.length === 0)
107
+ return failure(
108
+ "invalid_input",
109
+ `no commits on top of ${base}; nothing to describe`,
110
+ "pass --title <t>",
111
+ );
112
+ if (commits.length === 1) return success({ title: commits[0].subject, body: commits[0].body });
113
+ return success({
114
+ title: commits[0].subject,
115
+ body: commits.map((commit) => `- ${commit.subject}`).join("\n"),
116
+ });
117
+ }
118
+
119
+ /** The pushed head of the current branch, bound before any forge write. */
120
+ export function boundSource(
121
+ cwd: string,
122
+ resolved: ResolvedForge,
123
+ ): ForgeResult<{ branch: string; sha: string }> {
124
+ const branch = currentBranch(cwd);
125
+ if (!branch) return failure("invalid_input", "HEAD is detached; check out the PR branch");
126
+ const sha = resolveRef(cwd, `refs/heads/${branch}`);
127
+ if (!sha) return failure("not_found", `branch ${branch} has no commit`);
128
+ const remote = remoteRefTip(cwd, resolved.remote, `refs/heads/${branch}`);
129
+ if (!remote.ok) return failure(remote.code, remote.error);
130
+ if (remote.sha !== sha)
131
+ return failure(
132
+ "blocked",
133
+ remote.sha === null
134
+ ? `not_pushed: ${branch} is not on ${resolved.remote}`
135
+ : `not_pushed: ${resolved.remote}/${branch} is at ${remote.sha.slice(0, 12)}, local ${branch} is at ${sha.slice(0, 12)}`,
136
+ "workit git push",
137
+ );
138
+ return success({ branch, sha });
139
+ }
140
+
141
+ async function verifiedHead(
142
+ resolved: ResolvedForge,
143
+ branch: string,
144
+ sha: string,
145
+ first: PrRef,
146
+ sleep: Sleep,
147
+ ): Promise<ForgeResult<PrRef>> {
148
+ let ref = first;
149
+ for (const delay of [0, ...VERIFY_DELAYS_MS]) {
150
+ if (ref.headSha === sha) return success(ref);
151
+ if (delay) await sleep(delay);
152
+ const found = resolved.forge.findPr(branch, {
153
+ owner: resolved.headRepo.split("/")[0] ?? null,
154
+ projectId: resolved.headProjectId,
155
+ sha,
156
+ });
157
+ if (!found.ok) return found;
158
+ if (found.data && found.data.number === first.number) ref = found.data;
159
+ }
160
+ return ref.headSha === sha
161
+ ? success(ref)
162
+ : failure(
163
+ "failed",
164
+ `head_unverified: ${noun(resolved)} #${first.number} reports head ${ref.headSha?.slice(0, 12) ?? "(none)"}, not the pushed ${sha.slice(0, 12)}`,
165
+ `workit pr status --pr ${first.number}`,
166
+ );
167
+ }
168
+
169
+ export async function createPullRequest(
170
+ cwd: string,
171
+ resolved: ResolvedForge,
172
+ input: CreateInput,
173
+ sleep: Sleep,
174
+ ): Promise<ForgeResult<CreateOutcome>> {
175
+ const grant = requireGrant(cwd, "pr");
176
+ if (!grant.allowed) return grantBlocked(grant);
177
+ const source = boundSource(cwd, resolved);
178
+ if (!source.ok) return source;
179
+ const { branch, sha } = source.data;
180
+ if (branch === input.base)
181
+ return failure(
182
+ "invalid_input",
183
+ `the PR source and base are both ${branch}`,
184
+ "pass --base <branch>",
185
+ );
186
+
187
+ const existing = resolved.forge.findPr(branch, {
188
+ owner: resolved.headRepo.split("/")[0] ?? null,
189
+ projectId: resolved.headProjectId,
190
+ sha,
191
+ });
192
+ if (!existing.ok) return existing;
193
+ let created = false;
194
+ let ref: PrRef;
195
+ if (existing.data && existing.data.state === "open") ref = existing.data;
196
+ else {
197
+ const made = resolved.forge.createPr({
198
+ head: branch,
199
+ headRepo: resolved.headRepo,
200
+ headProjectId: resolved.headProjectId,
201
+ base: input.base,
202
+ title: input.title,
203
+ body: input.body,
204
+ draft: input.draft,
205
+ });
206
+ if (!made.ok) return made;
207
+ ref = made.data;
208
+ created = true;
209
+ }
210
+ const verified = await verifiedHead(resolved, branch, sha, ref, sleep);
211
+ if (!verified.ok) return verified;
212
+ const row = appendObserved(cwd, {
213
+ type: "pr.created",
214
+ actor: input.actor,
215
+ branch,
216
+ head: sha,
217
+ pr: ref.number,
218
+ base: input.base,
219
+ url: ref.url,
220
+ forge: resolved.forge.kind,
221
+ repo: resolved.forge.repo,
222
+ created,
223
+ });
224
+ return success({
225
+ number: ref.number,
226
+ url: ref.url,
227
+ branch,
228
+ base: input.base,
229
+ head: sha,
230
+ created,
231
+ draft: input.draft,
232
+ recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
233
+ });
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // merge
238
+
239
+ export type MergeInput = {
240
+ pr: number | null;
241
+ method: MergeMethod;
242
+ deleteBranch: boolean;
243
+ actor: LedgerActor;
244
+ };
245
+
246
+ export type MergeOutcome = {
247
+ number: number;
248
+ url: string;
249
+ branch: string;
250
+ base: string;
251
+ head: string;
252
+ method: MergeMethod;
253
+ mergeSha: string | null;
254
+ verdict: { required: boolean; accepted: boolean; verdictId: string | null };
255
+ grant: { source: "autonomy" | "host_authority" };
256
+ deletedBranch: boolean | { error: string };
257
+ recorded: { id: string } | { error: string };
258
+ };
259
+
260
+ /** Why `pr merge` refused, for the envelope `data` (agents branch on `reason`). */
261
+ export type MergeRefusal = {
262
+ reason:
263
+ | "grant_required"
264
+ | "not_ready"
265
+ | "needs_verdict"
266
+ | "head_mismatch"
267
+ | "already_merged"
268
+ | "closed"
269
+ | "protected_branch";
270
+ next?: string;
271
+ blockers?: string[];
272
+ verdict?: Pick<VerdictCheck, "head" | "accepted" | "authors">;
273
+ };
274
+
275
+ export type MergeResult =
276
+ | { ok: true; data: MergeOutcome }
277
+ | {
278
+ ok: false;
279
+ code: "blocked" | "failed" | "unavailable" | "not_found" | "busy" | "invalid_input";
280
+ error: string;
281
+ unblock?: string;
282
+ refusal?: MergeRefusal;
283
+ };
284
+
285
+ const refuse = (error: string, unblock: string, refusal: MergeRefusal): MergeResult => ({
286
+ ok: false,
287
+ code: "blocked",
288
+ error,
289
+ unblock,
290
+ refusal,
291
+ });
292
+
293
+ const NEXT_HINTS: Record<string, string> = {
294
+ RESOLVE_CONFLICTS: "rebase onto the base, resolve, then workit git push --force-with-lease",
295
+ REBASE: "rebase onto the base, then workit git push --force-with-lease",
296
+ RESOLVE_THREADS: "address and resolve the open review threads (workit pr status lists them)",
297
+ FIX_CI:
298
+ "fix the failing checks (workit pr status shows the log tails), push, then workit ci wait",
299
+ WAITING_CI: "workit ci wait",
300
+ ADDRESS_REVIEW: "address the requested changes",
301
+ REVIEW: "get the required review",
302
+ MARK_READY: "mark the PR ready for review",
303
+ IN_MERGE_QUEUE: "the PR is already in the merge queue",
304
+ NOT_MERGEABLE: "workit pr status # the forge reports it cannot merge yet",
305
+ };
306
+
307
+ export async function mergePullRequest(
308
+ cwd: string,
309
+ resolved: ResolvedForge,
310
+ input: MergeInput,
311
+ sleep: Sleep,
312
+ ): Promise<MergeResult> {
313
+ const grant = requireGrant(cwd, "merge");
314
+ if (!grant.allowed) return refuse(grant.error, grant.unblock, { reason: "grant_required" });
315
+
316
+ const number = selectPr(cwd, resolved, { pr: input.pr });
317
+ if (!number.ok) return number;
318
+ const status = resolved.forge.prStatus(number.data);
319
+ if (!status.ok) return status;
320
+ const built = buildStatusDoc(cwd, resolved, status.data, { logLines: 0, behind: false });
321
+ if (!built.ok) return built;
322
+ const doc: PrStatusDoc = built.data;
323
+ const label = `${resolved.forge.kind === "github" ? "PR #" : "MR !"}${doc.number}`;
324
+ if (doc.state === "merged")
325
+ return refuse(
326
+ `already_merged: ${label} is already merged`,
327
+ `workit verify-delivery merged --pr ${doc.number}`,
328
+ {
329
+ reason: "already_merged",
330
+ },
331
+ );
332
+ if (doc.state === "closed")
333
+ return refuse(`closed: ${label} is closed`, "reopen it on the forge first", {
334
+ reason: "closed",
335
+ });
336
+ if (doc.next !== "READY")
337
+ return refuse(
338
+ `not_ready: ${label} is ${doc.next} (${doc.blockers.join(", ") || doc.next.toLowerCase()})`,
339
+ NEXT_HINTS[doc.next] ?? "workit pr status",
340
+ { reason: "not_ready", next: doc.next, blockers: doc.blockers },
341
+ );
342
+
343
+ const branch = doc.head.branch;
344
+ const head = doc.head.sha;
345
+ // --delete-branch never deletes a protected branch, the base, or the
346
+ // default target (a develop -> main release PR must keep develop). Refused
347
+ // before merging, so the caller decides without a half-done delivery.
348
+ if (input.deleteBranch) {
349
+ const resolvedVcs = vcsConfig("resolve", cwd);
350
+ const defaultTarget =
351
+ resolvedVcs.ok === false ? null : String(resolvedVcs.defaultTargetBranch ?? "") || null;
352
+ const lower = branch.toLowerCase();
353
+ const reason = isProtectedTarget(cwd, branch)
354
+ ? "is protected by the workspace branch policy"
355
+ : lower === doc.base.toLowerCase()
356
+ ? "is the PR base"
357
+ : defaultTarget !== null && lower === defaultTarget.toLowerCase()
358
+ ? "is the default target branch"
359
+ : null;
360
+ if (reason)
361
+ return refuse(
362
+ `protected_branch: --delete-branch would delete ${branch}, which ${reason}; workit never deletes it`,
363
+ `workit pr merge${input.pr ? ` --pr ${doc.number}` : ""} --method ${input.method} # without --delete-branch`,
364
+ { reason: "protected_branch" },
365
+ );
366
+ }
367
+ let verdict: MergeOutcome["verdict"] = {
368
+ required: grant.requireVerdict,
369
+ accepted: false,
370
+ verdictId: null,
371
+ };
372
+ if (grant.requireVerdict) {
373
+ const ledger = readLedger(cwd);
374
+ if (!ledger.ok) return ledger;
375
+ const check = checkVerdicts(cwd, branch, ledger.value.rows);
376
+ const summary = { head: check.head, accepted: check.accepted, authors: check.authors };
377
+ if (check.head !== head)
378
+ return refuse(
379
+ `head_mismatch: the verdict check reads ${branch} at ${check.head?.slice(0, 12) ?? "(missing)"} but ${label} is at ${head.slice(0, 12)}`,
380
+ `git fetch && git switch ${branch} && git merge --ff-only @{u} # then re-verify that head`,
381
+ { reason: "head_mismatch", verdict: summary },
382
+ );
383
+ if (!check.accepted.accepted)
384
+ return refuse(
385
+ `NEEDS_VERDICT: ${label} has no accepted independent verdict for ${head.slice(0, 12)} (${check.accepted.reasons.join(", ")})`,
386
+ `an independent session verifies the head and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${branch}`,
387
+ { reason: "needs_verdict", verdict: summary },
388
+ );
389
+ verdict = {
390
+ required: true,
391
+ accepted: true,
392
+ verdictId: typeof check.accepted.verdict?.id === "string" ? check.accepted.verdict.id : null,
393
+ };
394
+ }
395
+
396
+ const merged = resolved.forge.merge(doc.number, { sha: head, method: input.method });
397
+ if (!merged.ok) return merged;
398
+ // Post-verify: the forge must report the PR merged.
399
+ let state = "open";
400
+ for (const delay of [0, ...VERIFY_DELAYS_MS]) {
401
+ if (delay) await sleep(delay);
402
+ const after = resolved.forge.prStatus(doc.number);
403
+ if (after.ok) state = after.data.state;
404
+ if (state === "merged") break;
405
+ }
406
+ if (state !== "merged")
407
+ return {
408
+ ok: false,
409
+ code: "failed",
410
+ error: `merge_unverified: the forge accepted the merge of ${label} but still reports it ${state}`,
411
+ unblock: `workit verify-delivery merged --pr ${doc.number}`,
412
+ };
413
+
414
+ let deletedBranch: MergeOutcome["deletedBranch"] = false;
415
+ if (input.deleteBranch) {
416
+ const deleted = deleteRemoteBranch(cwd, resolved.remote, branch, head);
417
+ // The forge may delete merged branches itself; gone is deleted.
418
+ const gone = !deleted.ok && remoteRefTip(cwd, resolved.remote, `refs/heads/${branch}`);
419
+ deletedBranch =
420
+ deleted.ok || (gone && gone.ok && gone.sha === null) ? true : { error: deleted.error };
421
+ }
422
+ const row = appendObserved(cwd, {
423
+ type: "pr.merged",
424
+ actor: input.actor,
425
+ branch,
426
+ head,
427
+ pr: doc.number,
428
+ base: doc.base,
429
+ mergeSha: merged.data.mergeSha,
430
+ method: input.method,
431
+ verdictId: verdict.verdictId,
432
+ grant: grant.source,
433
+ });
434
+ return {
435
+ ok: true,
436
+ data: {
437
+ number: doc.number,
438
+ url: doc.url,
439
+ branch,
440
+ base: doc.base,
441
+ head,
442
+ method: input.method,
443
+ mergeSha: merged.data.mergeSha,
444
+ verdict,
445
+ grant: { source: grant.source },
446
+ deletedBranch,
447
+ recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
448
+ },
449
+ };
450
+ }
@@ -257,7 +257,7 @@ export function selectPr(
257
257
  return failure(
258
258
  "not_found",
259
259
  `no ${noun} for branch ${branch} from ${resolved.headRepo} in ${resolved.forge.repo}`,
260
- "push the branch and open one (workit pr create, S11), or pass --pr <n>",
260
+ "workit git push && workit pr create --fill # or pass --pr <n>",
261
261
  );
262
262
  }
263
263
  return success(found.data.number);
@@ -119,6 +119,27 @@ export type Identity = {
119
119
  note?: string;
120
120
  };
121
121
 
122
+ export type MergeMethod = "squash" | "merge" | "rebase";
123
+
124
+ /** What `workit pr create` asks the forge for (S11). */
125
+ export type CreatePrInput = {
126
+ /** Source branch in the push (head) repository. */
127
+ head: string;
128
+ /** owner/name (GitHub) or group/project (GitLab) of the push repository. */
129
+ headRepo: string;
130
+ /** GitLab: the push project id (MRs from a fork are opened from it). */
131
+ headProjectId: number | null;
132
+ base: string;
133
+ title: string;
134
+ body: string;
135
+ draft: boolean;
136
+ };
137
+
138
+ export type MergeResult = {
139
+ /** The merge (or squash/rebase tip) commit the forge reported, when it did. */
140
+ mergeSha: string | null;
141
+ };
142
+
122
143
  /** A repository as the forge sees it: id and the repo it was forked from. */
123
144
  export type RepoInfo = { id: number | null; parent: string | null };
124
145
 
@@ -133,4 +154,13 @@ export interface Forge {
133
154
  prStatus(n: number): ForgeResult<ForgePrStatus>;
134
155
  jobLogTail(job: JobRef, lines: number): ForgeResult<string[]>;
135
156
  rerun(target: RerunTarget): ForgeResult<void>;
157
+ /** Open a PR/MR; `headSha` is the head the forge reports right after creation. */
158
+ createPr(input: CreatePrInput): ForgeResult<PrRef>;
159
+ /**
160
+ * Merge with the head guard: the forge refuses (`blocked`, head_moved) when
161
+ * the PR head is no longer `sha`.
162
+ */
163
+ merge(n: number, options: { sha: string; method: MergeMethod }): ForgeResult<MergeResult>;
164
+ /** Retarget a PR/MR to another base branch (S12 stacks). */
165
+ updateBase(n: number, base: string): ForgeResult<void>;
136
166
  }