@brainervirus/workit-core 8.10.0 → 8.12.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.
@@ -16,16 +16,20 @@
16
16
  // The merge call carries the head SHA (GitHub `sha=`, GitLab `sha=`), so a
17
17
  // head that moves after the gates refuses instead of merging unverified code.
18
18
  // --delete-branch deletes with a lease on that same SHA.
19
+ // A squash with --subject/--body, or in a workspace with `trailers: false`,
20
+ // sends its own message (the PR title and the branch's commits by default,
21
+ // on stdin) with `Workit-Session:` lines dropped from each trailer block.
19
22
  import {
20
23
  requireGrant,
21
24
  resolveAutonomy,
22
25
  type AutonomySource,
23
26
  type DefaultEndpoint,
24
27
  } from "../autonomy";
25
- import { isProtectedTarget } from "../core/branch";
28
+ import { branchKindOf, flowRoute, gitFlowOf, isProtectedTarget } from "../core/branch";
26
29
  import { vcsConfig } from "../core/vcs-config";
30
+ import { workspaceTrailers } from "../core/workspaces";
27
31
  import { workspaceReleaseTracks } from "../core/release-tracks";
28
- import { deleteRemoteBranch } from "../git/ops";
32
+ import { deleteRemoteBranch, stripSessionTrailers } from "../git/ops";
29
33
  import { spawnSync } from "node:child_process";
30
34
  import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from "../git/rev";
31
35
  import {
@@ -211,6 +215,7 @@ async function verifiedHead(
211
215
  sha: string,
212
216
  first: PrRef,
213
217
  sleep: Sleep,
218
+ base: string,
214
219
  ): Promise<ForgeResult<PrRef>> {
215
220
  let ref = first;
216
221
  for (const delay of [0, ...VERIFY_DELAYS_MS]) {
@@ -220,6 +225,7 @@ async function verifiedHead(
220
225
  owner: resolved.headRepo.split("/")[0] ?? null,
221
226
  projectId: resolved.headProjectId,
222
227
  sha,
228
+ base,
223
229
  });
224
230
  if (!found.ok) return found;
225
231
  if (found.data && found.data.number === first.number) ref = found.data;
@@ -258,10 +264,13 @@ export async function createPullRequest(
258
264
  "pass --base <branch>",
259
265
  );
260
266
 
267
+ // Head AND base: a release branch with an open PR into main still gets
268
+ // its back-merge PR into develop.
261
269
  const existing = resolved.forge.findPr(branch, {
262
270
  owner: resolved.headRepo.split("/")[0] ?? null,
263
271
  projectId: resolved.headProjectId,
264
272
  sha,
273
+ base: input.base,
265
274
  });
266
275
  if (!existing.ok) return existing;
267
276
  let created = false;
@@ -281,7 +290,7 @@ export async function createPullRequest(
281
290
  ref = made.data;
282
291
  created = true;
283
292
  }
284
- const verified = await verifiedHead(resolved, branch, sha, ref, sleep);
293
+ const verified = await verifiedHead(resolved, branch, sha, ref, sleep, input.base);
285
294
  if (!verified.ok) return verified;
286
295
  const row = appendObserved(cwd, {
287
296
  type: "pr.created",
@@ -338,6 +347,9 @@ export type MergeInput = {
338
347
  expect?: { base: string; branch: string };
339
348
  /** Merge without a verdict (`merge: true` only); the reason is recorded. */
340
349
  unverified?: { reason: string };
350
+ /** Squash commit subject and body (`--subject`, `--body`); unset parts use the defaults. */
351
+ subject?: string;
352
+ body?: string;
341
353
  };
342
354
 
343
355
  export type MergeOutcome = {
@@ -366,13 +378,26 @@ export type MergeOutcome = {
366
378
  /**
367
379
  * The merge-back a merge into `base` owes: the track whose production branch
368
380
  * `base` is, by ownership alone (no --track, no WORKFLOW_RELEASE_TRACK, no
369
- * history). Best effort: a config problem never stops the pr.merged record.
381
+ * history); without tracks, a git-flow release/* or hotfix/* landing on the
382
+ * production branch owes develop. Best effort: a config problem never stops
383
+ * the pr.merged record.
370
384
  */
371
- const mergeBackFor = (cwd: string, base: string): { track: string | null; branches: string[] } => {
385
+ const mergeBackFor = (
386
+ cwd: string,
387
+ base: string,
388
+ head: string,
389
+ ): { track: string | null; branches: string[] } => {
372
390
  try {
373
- const owners = workspaceReleaseTracks(cwd).tracks.filter(
374
- (track) => track.productionBranch === base,
375
- );
391
+ const read = workspaceReleaseTracks(cwd);
392
+ if (!read.tracks.length) {
393
+ const flow = gitFlowOf(cwd);
394
+ const kind = branchKindOf(head);
395
+ const route = flow && kind ? flowRoute(flow, kind) : null;
396
+ return route?.mergeBack && route.target === base
397
+ ? { track: "git-flow", branches: [route.mergeBack] }
398
+ : { track: null, branches: [] };
399
+ }
400
+ const owners = read.tracks.filter((track) => track.productionBranch === base);
376
401
  if (owners.length !== 1) return { track: null, branches: [] };
377
402
  return {
378
403
  track: owners[0].name,
@@ -451,6 +476,90 @@ export function nextHint(next: string, number: number): string | null {
451
476
  }
452
477
  }
453
478
 
479
+ /**
480
+ * The squash message workit sends, or null to leave the forge default: only a
481
+ * squash with --subject/--body or in a workspace with `trailers: false`.
482
+ * Defaults: the PR title (GitHub appends `(#n)` like its own default) and the
483
+ * branch's commits as GitHub lists them; Workit-Session lines never survive.
484
+ */
485
+ function squashMessageFor(
486
+ cwd: string,
487
+ resolved: ResolvedForge,
488
+ input: MergeInput,
489
+ doc: PrStatusDoc,
490
+ ): ForgeResult<{ subject: string; body: string }> | null {
491
+ if (input.method !== "squash") return null;
492
+ if (input.subject === undefined && input.body === undefined && workspaceTrailers(cwd).enabled)
493
+ return null;
494
+ let subject = input.subject?.trim() ?? "";
495
+ if (!subject) {
496
+ const meta = resolved.forge.prMeta(doc.number);
497
+ if (!meta.ok) return meta;
498
+ subject =
499
+ resolved.forge.kind === "github" ? `${meta.data.title} (#${doc.number})` : meta.data.title;
500
+ }
501
+ // An explicit body is kept byte for byte but for its trailer block.
502
+ const body =
503
+ input.body === undefined
504
+ ? squashCommitList(cwd, resolved, doc.base, doc.head.sha)
505
+ : stripSessionTrailers(input.body, { subject: false });
506
+ return success({ subject, body });
507
+ }
508
+
509
+ /** Bounds of the generated squash body: a forge message, not an archive. */
510
+ const SQUASH_LIST_MAX_COMMITS = 100;
511
+ const SQUASH_LIST_MAX_CHARS = 60_000;
512
+
513
+ /**
514
+ * `* subject` and body of each non-merge commit on `base..head`, oldest
515
+ * first, from the local clone ("" when absent); each commit loses its
516
+ * Workit-Session trailers, and past the bounds the rest is counted.
517
+ */
518
+ function squashCommitList(
519
+ cwd: string,
520
+ resolved: ResolvedForge,
521
+ base: string,
522
+ head: string,
523
+ ): string {
524
+ const from =
525
+ resolveRef(cwd, `refs/remotes/${resolved.baseRemote}/${base}`) ??
526
+ resolveRef(cwd, `refs/heads/${base}`);
527
+ if (!from) return "";
528
+ const log = spawnSync(
529
+ "git",
530
+ ["log", "--reverse", "--no-merges", "--format=%B%x1e", `${from}..${head}`],
531
+ {
532
+ cwd,
533
+ encoding: "utf8",
534
+ timeout: GIT_TIMEOUTS.local,
535
+ killSignal: "SIGKILL",
536
+ maxBuffer: 64 * 1024 * 1024,
537
+ },
538
+ );
539
+ if (log.status !== 0) return "";
540
+ const entries = log.stdout
541
+ .split("\x1e")
542
+ .map((message) => stripSessionTrailers(message.replace(/^\n+/u, ""), { subject: true }))
543
+ .filter((message) => message.trim())
544
+ .map((message) => {
545
+ const [subject = "", ...rest] = message.trimEnd().split("\n");
546
+ const body = rest.join("\n").replace(/^\n+/u, "");
547
+ return body ? `* ${subject}\n\n${body}` : `* ${subject}`;
548
+ });
549
+ const listed: string[] = [];
550
+ let size = 0;
551
+ for (const entry of entries) {
552
+ if (listed.length >= SQUASH_LIST_MAX_COMMITS || size + entry.length > SQUASH_LIST_MAX_CHARS)
553
+ break;
554
+ listed.push(entry);
555
+ size += entry.length + 2;
556
+ }
557
+ const more = entries.length - listed.length;
558
+ return [...listed, ...(more ? [`… and ${more} more commit${more === 1 ? "" : "s"}`] : [])].join(
559
+ "\n\n",
560
+ );
561
+ }
562
+
454
563
  export async function mergePullRequest(
455
564
  cwd: string,
456
565
  resolved: ResolvedForge,
@@ -578,7 +687,13 @@ export async function mergePullRequest(
578
687
  { reason: "needs_verdict", verdict: summary },
579
688
  );
580
689
 
581
- const merged = resolved.forge.merge(doc.number, { sha: head, method: input.method });
690
+ const squashMessage = squashMessageFor(cwd, resolved, input, doc);
691
+ if (squashMessage && !squashMessage.ok) return squashMessage;
692
+ const merged = resolved.forge.merge(doc.number, {
693
+ sha: head,
694
+ method: input.method,
695
+ ...(squashMessage ? { squashMessage: squashMessage.data } : {}),
696
+ });
582
697
  if (!merged.ok) return merged;
583
698
  // Post-verify: the forge must report the PR merged.
584
699
  let state = "open";
@@ -604,7 +719,7 @@ export async function mergePullRequest(
604
719
  deletedBranch =
605
720
  deleted.ok || (gone && gone.ok && gone.sha === null) ? true : { error: deleted.error };
606
721
  }
607
- const mergeBack = mergeBackFor(cwd, doc.base);
722
+ const mergeBack = mergeBackFor(cwd, doc.base, branch);
608
723
  const row = appendObserved(cwd, {
609
724
  type: "pr.merged",
610
725
  actor: input.actor,
@@ -42,6 +42,11 @@ export type PrMatch = {
42
42
  projectId: number | null;
43
43
  /** A PR whose head is exactly this commit is ours whatever its owner. */
44
44
  sha: string | null;
45
+ /**
46
+ * Only a PR into this base: one head may have PRs into several bases (a
47
+ * git-flow release into main and its back-merge into develop).
48
+ */
49
+ base?: string | null;
45
50
  };
46
51
 
47
52
  /** One CI check (GitHub check run / commit status, GitLab job or bridge). */
@@ -135,6 +140,13 @@ export type CreatePrInput = {
135
140
  draft: boolean;
136
141
  };
137
142
 
143
+ export type MergeOptions = {
144
+ sha: string;
145
+ method: MergeMethod;
146
+ /** Squash only: the commit message the forge writes instead of its default. */
147
+ squashMessage?: { subject: string; body: string };
148
+ };
149
+
138
150
  export type MergeResult = {
139
151
  /** The merge (or squash/rebase tip) commit the forge reported, when it did. */
140
152
  mergeSha: string | null;
@@ -182,7 +194,7 @@ export interface Forge {
182
194
  * Merge with the head guard: the forge refuses (`blocked`, head_moved) when
183
195
  * the PR head is no longer `sha`.
184
196
  */
185
- merge(n: number, options: { sha: string; method: MergeMethod }): ForgeResult<MergeResult>;
197
+ merge(n: number, options: MergeOptions): ForgeResult<MergeResult>;
186
198
  /** Retarget a PR/MR to another base branch (S12 stacks). */
187
199
  updateBase(n: number, base: string): ForgeResult<void>;
188
200
  /** Title, draft flag, base and state of one PR/MR. */