@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.
- package/package.json +1 -1
- package/skills/workit-fanout/references/brief.md +23 -8
- package/skills/workit-fanout/references/orchestration.md +9 -4
- package/src/autonomy.ts +6 -2
- package/src/check-config.ts +3 -1
- package/src/checks.ts +14 -1
- package/src/core/branch-policy.ts +8 -1
- package/src/core/branch.ts +270 -3
- package/src/core/methods.ts +8 -6
- package/src/core/resources.ts +747 -0
- package/src/core/route-intent.ts +155 -27
- package/src/core/workspaces.ts +46 -3
- package/src/fanout-brief.ts +66 -13
- package/src/forge/exec.ts +8 -2
- package/src/forge/github.ts +27 -6
- package/src/forge/gitlab.ts +29 -5
- package/src/forge/pr-ops.ts +125 -10
- package/src/forge/types.ts +13 -1
- package/src/git/ops.ts +360 -10
- package/src/git/session-hook.ts +68 -0
- package/src/hooks/handle.ts +13 -6
- package/src/hooks/policy.ts +11 -5
- package/src/hooks/raw-git.ts +138 -10
package/src/forge/pr-ops.ts
CHANGED
|
@@ -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)
|
|
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 = (
|
|
385
|
+
const mergeBackFor = (
|
|
386
|
+
cwd: string,
|
|
387
|
+
base: string,
|
|
388
|
+
head: string,
|
|
389
|
+
): { track: string | null; branches: string[] } => {
|
|
372
390
|
try {
|
|
373
|
-
const
|
|
374
|
-
|
|
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
|
|
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,
|
package/src/forge/types.ts
CHANGED
|
@@ -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:
|
|
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. */
|