@brainervirus/workit-core 7.6.0 → 8.0.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/SKILL.md +46 -46
- package/skills/workit-fanout/references/brief.md +15 -8
- package/skills/workit-fanout/references/orchestration.md +117 -0
- package/skills/workit-ship/SKILL.md +2 -2
- package/src/autonomy.ts +8 -6
- package/src/core/pr-create.ts +0 -232
- package/src/fanout-brief.ts +178 -0
- package/src/fanout-check.ts +2 -1
- package/src/fanout-landed.ts +41 -16
- package/src/fanout-status.ts +7 -3
- package/src/fanout-worktree.ts +89 -19
- package/src/fanout.ts +148 -3
- package/src/forge/pr-ops.ts +89 -28
- package/src/ledger.ts +203 -15
- package/src/stack.ts +45 -7
package/src/fanout.ts
CHANGED
|
@@ -18,12 +18,33 @@
|
|
|
18
18
|
// deterministic suggestion: an owner for known shared files (lockfiles,
|
|
19
19
|
// manifests, barrels, CI config), else a dependency that serializes them.
|
|
20
20
|
//
|
|
21
|
+
// Each slice's `hash` covers what it is (goal, acceptance, verify, forbidden,
|
|
22
|
+
// scope, owns, branch, base, dependencies), not how it is briefed (tier,
|
|
23
|
+
// timebox, context) or where its worktree goes. A re-plan that changes a
|
|
24
|
+
// slice starts a new run for that slice alone: its `hashSince` moves to now,
|
|
25
|
+
// and only ledger rows recorded under its hash (worktree rows carry
|
|
26
|
+
// `sliceHash`) or since then link a gone branch's merged PR to it or count as
|
|
27
|
+
// its worktree. Liveness still reads every row. Its siblings keep their runs.
|
|
28
|
+
//
|
|
29
|
+
// `fanIn: "integration"` is the one-PR mode: the trunk is an integration
|
|
30
|
+
// branch (never origin's default branch), workers merge its tip into their
|
|
31
|
+
// branch before reporting, and the lead fast-forwards it.
|
|
32
|
+
//
|
|
21
33
|
// The CLI never spawns, waits or wakes anything: it only records and checks.
|
|
22
34
|
import { spawnSync } from "node:child_process";
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
23
36
|
import fs from "node:fs";
|
|
24
37
|
import path from "node:path";
|
|
25
38
|
import { GIT_TIMEOUTS, gitCommonDir, pushRemoteName, resolveRef } from "./git/rev";
|
|
26
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
activeStanding,
|
|
41
|
+
appendObserved,
|
|
42
|
+
isDelegateSession,
|
|
43
|
+
defaultBase,
|
|
44
|
+
readLedger,
|
|
45
|
+
storeRoot,
|
|
46
|
+
type LedgerActor,
|
|
47
|
+
} from "./ledger";
|
|
27
48
|
import { stackFileName } from "./stack";
|
|
28
49
|
|
|
29
50
|
export const FANOUT_VERSION = 1;
|
|
@@ -48,15 +69,28 @@ export type Slice = {
|
|
|
48
69
|
forbidden: string[];
|
|
49
70
|
context: string | null;
|
|
50
71
|
timebox: string | null;
|
|
72
|
+
/** sliceHash of this definition: which run of the slice a ledger row belongs to. */
|
|
73
|
+
hash: string;
|
|
74
|
+
/** When a slice with this hash was first planned: older rows are not its run. */
|
|
75
|
+
hashSince: string;
|
|
51
76
|
};
|
|
52
77
|
|
|
78
|
+
export const FAN_IN_MODES = ["prs", "integration"] as const;
|
|
79
|
+
export type FanInMode = (typeof FAN_IN_MODES)[number];
|
|
80
|
+
|
|
53
81
|
export type FanoutFile = {
|
|
54
82
|
v: number;
|
|
55
83
|
name: string;
|
|
56
84
|
trunk: string;
|
|
85
|
+
/** prs: one PR per slice (default); integration: slices fast-forward one integration branch. */
|
|
86
|
+
fanIn: FanInMode;
|
|
57
87
|
/** Extra shared-file globs on top of DEFAULT_SHARED. */
|
|
58
88
|
shared: string[];
|
|
59
89
|
slices: Slice[];
|
|
90
|
+
/** The lead: the session that made the plan or took it over (`--take-lead`). */
|
|
91
|
+
leadSession: string | null;
|
|
92
|
+
/** Leads it took over from: their standing orders stay in force until cleared. */
|
|
93
|
+
formerLeads: string[];
|
|
60
94
|
createdAt: string;
|
|
61
95
|
updatedAt: string;
|
|
62
96
|
};
|
|
@@ -308,6 +342,9 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
|
|
|
308
342
|
if ("error" in normalized) problems.push(`shared "${entry}" ${normalized.error}`);
|
|
309
343
|
else shared.push(normalized.pattern);
|
|
310
344
|
}
|
|
345
|
+
const fanIn = raw.fanIn ?? "prs";
|
|
346
|
+
if (!(FAN_IN_MODES as readonly unknown[]).includes(fanIn))
|
|
347
|
+
problems.push(`fanIn must be one of ${FAN_IN_MODES.join(", ")}`);
|
|
311
348
|
if (!Array.isArray(raw.slices) || raw.slices.length === 0)
|
|
312
349
|
return { ok: false, problems: [...problems, "slices: list at least one slice"] };
|
|
313
350
|
|
|
@@ -388,6 +425,8 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
|
|
|
388
425
|
forbidden,
|
|
389
426
|
context: optional("context"),
|
|
390
427
|
timebox: optional("timebox"),
|
|
428
|
+
hash: "",
|
|
429
|
+
hashSince: "",
|
|
391
430
|
});
|
|
392
431
|
});
|
|
393
432
|
|
|
@@ -419,8 +458,11 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
|
|
|
419
458
|
v: FANOUT_VERSION,
|
|
420
459
|
name: planName,
|
|
421
460
|
trunk,
|
|
461
|
+
fanIn: fanIn as FanInMode,
|
|
422
462
|
shared,
|
|
423
463
|
slices,
|
|
464
|
+
leadSession: null,
|
|
465
|
+
formerLeads: [],
|
|
424
466
|
createdAt: "",
|
|
425
467
|
updatedAt: "",
|
|
426
468
|
},
|
|
@@ -728,6 +770,29 @@ export function worktreeRoot(cwd: string): string {
|
|
|
728
770
|
return path.join(path.dirname(main), `${path.basename(main)}-wt`);
|
|
729
771
|
}
|
|
730
772
|
|
|
773
|
+
/** Whose standing orders count: the lead and those it took over from; null before leads. */
|
|
774
|
+
export const planLeads = (
|
|
775
|
+
plan: Pick<FanoutFile, "leadSession" | "formerLeads">,
|
|
776
|
+
): string[] | null => (plan.leadSession ? [plan.leadSession, ...plan.formerLeads] : null);
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* sha256 (16 hex) of the slice's definition. Brief-only fields (tier,
|
|
780
|
+
* timebox, context) and the worktree path are left out, so fixing a typo in
|
|
781
|
+
* them never re-dispatches landed work.
|
|
782
|
+
*/
|
|
783
|
+
export function sliceHash(slice: Slice): string {
|
|
784
|
+
const {
|
|
785
|
+
worktree: _worktree,
|
|
786
|
+
hash: _hash,
|
|
787
|
+
hashSince: _since,
|
|
788
|
+
tier: _tier,
|
|
789
|
+
timebox: _timebox,
|
|
790
|
+
context: _context,
|
|
791
|
+
...definition
|
|
792
|
+
} = slice;
|
|
793
|
+
return createHash("sha256").update(JSON.stringify(definition)).digest("hex").slice(0, 16);
|
|
794
|
+
}
|
|
795
|
+
|
|
731
796
|
// ---------------------------------------------------------------------------
|
|
732
797
|
// store
|
|
733
798
|
|
|
@@ -777,8 +842,12 @@ function parseStored(raw: unknown): FanoutFile | null {
|
|
|
777
842
|
forbidden: list(item.forbidden),
|
|
778
843
|
context: typeof item.context === "string" ? item.context : null,
|
|
779
844
|
timebox: typeof item.timebox === "string" ? item.timebox : null,
|
|
845
|
+
hash: text(item.hash),
|
|
846
|
+
// A plan written before slice hashes: its run began at createdAt.
|
|
847
|
+
hashSince: text(item.hashSince) || text(raw.createdAt),
|
|
780
848
|
});
|
|
781
849
|
}
|
|
850
|
+
for (const slice of slices) slice.hash ||= sliceHash(slice);
|
|
782
851
|
const shared = globs(raw.shared);
|
|
783
852
|
const ids = new Set(slices.map((slice) => slice.id));
|
|
784
853
|
if (
|
|
@@ -788,13 +857,18 @@ function parseStored(raw: unknown): FanoutFile | null {
|
|
|
788
857
|
findCycle(slices)
|
|
789
858
|
)
|
|
790
859
|
return null;
|
|
860
|
+
const fanIn = raw.fanIn === "integration" ? "integration" : "prs";
|
|
861
|
+
const createdAt = text(raw.createdAt);
|
|
791
862
|
return {
|
|
792
863
|
v: typeof raw.v === "number" ? raw.v : FANOUT_VERSION,
|
|
793
864
|
name: raw.name,
|
|
794
865
|
trunk: raw.trunk,
|
|
866
|
+
fanIn,
|
|
795
867
|
shared,
|
|
796
868
|
slices,
|
|
797
|
-
|
|
869
|
+
leadSession: typeof raw.leadSession === "string" && raw.leadSession ? raw.leadSession : null,
|
|
870
|
+
formerLeads: list(raw.formerLeads),
|
|
871
|
+
createdAt,
|
|
798
872
|
updatedAt: text(raw.updatedAt),
|
|
799
873
|
};
|
|
800
874
|
}
|
|
@@ -890,6 +964,7 @@ function writeFanout(cwd: string, plan: FanoutFile): FanoutResult<string> {
|
|
|
890
964
|
export type PlanOutcome = {
|
|
891
965
|
name: string;
|
|
892
966
|
trunk: string;
|
|
967
|
+
fanIn: FanInMode;
|
|
893
968
|
file: string;
|
|
894
969
|
created: boolean;
|
|
895
970
|
slices: Array<
|
|
@@ -899,6 +974,8 @@ export type PlanOutcome = {
|
|
|
899
974
|
waves: string[][];
|
|
900
975
|
landingOrder: string[];
|
|
901
976
|
notes: string[];
|
|
977
|
+
/** A new plan under a name that still has standing orders in force (an earlier run's?). */
|
|
978
|
+
warnings: string[];
|
|
902
979
|
};
|
|
903
980
|
|
|
904
981
|
/** Above this many slices in one wave, the lead queues the rest (4-6 in flight). */
|
|
@@ -906,11 +983,31 @@ export const IN_FLIGHT_CAP = 6;
|
|
|
906
983
|
|
|
907
984
|
export function planFanout(
|
|
908
985
|
cwd: string,
|
|
909
|
-
input: {
|
|
986
|
+
input: {
|
|
987
|
+
raw: unknown;
|
|
988
|
+
name: string | null;
|
|
989
|
+
trunk: string | null;
|
|
990
|
+
actor: LedgerActor;
|
|
991
|
+
/** Record this session as the lead (a lead resuming in a new session). */
|
|
992
|
+
takeLead?: boolean;
|
|
993
|
+
},
|
|
910
994
|
now: Date = new Date(),
|
|
911
995
|
): FanoutResult<PlanOutcome> {
|
|
912
996
|
const dir = fanoutsDir(cwd);
|
|
913
997
|
if (!dir.ok) return dir;
|
|
998
|
+
const session = input.actor.session;
|
|
999
|
+
if (input.takeLead && !session)
|
|
1000
|
+
return fanoutFail(
|
|
1001
|
+
"blocked",
|
|
1002
|
+
"--take-lead needs a session: WORKIT_SESSION_ID is not set",
|
|
1003
|
+
"export WORKIT_SESSION_ID=<your session id>, then plan again with --take-lead",
|
|
1004
|
+
);
|
|
1005
|
+
if (input.takeLead && isDelegateSession(session))
|
|
1006
|
+
return fanoutFail(
|
|
1007
|
+
"blocked",
|
|
1008
|
+
`session ${session} is a worker or verifier id; only a lead takes the lead`,
|
|
1009
|
+
"run --take-lead from the lead's own session",
|
|
1010
|
+
);
|
|
914
1011
|
const parsed = parsePlan(
|
|
915
1012
|
input.trunk && isRecord(input.raw) ? { ...input.raw, trunk: input.trunk } : input.raw,
|
|
916
1013
|
{
|
|
@@ -928,6 +1025,12 @@ export function planFanout(
|
|
|
928
1025
|
{ problems: parsed.problems },
|
|
929
1026
|
);
|
|
930
1027
|
const plan = parsed.plan;
|
|
1028
|
+
if (plan.fanIn === "integration" && plan.trunk === defaultTrunk(cwd))
|
|
1029
|
+
return fanoutFail(
|
|
1030
|
+
"invalid_input",
|
|
1031
|
+
`fanIn "integration" needs an integration branch as the trunk, not ${plan.trunk}`,
|
|
1032
|
+
`workit git branch <integration-branch> --base ${plan.trunk}, then plan with --trunk <integration-branch>`,
|
|
1033
|
+
);
|
|
931
1034
|
const badBranches = plan.slices
|
|
932
1035
|
.filter((slice) => !gitRun(cwd, ["check-ref-format", "--branch", slice.branch]).ok)
|
|
933
1036
|
.map((slice) => `slice ${slice.id}: ${slice.branch} is not a valid branch name`);
|
|
@@ -975,20 +1078,60 @@ export function planFanout(
|
|
|
975
1078
|
const existing = readFanout(cwd, plan.name);
|
|
976
1079
|
const previous = existing.ok ? existing.data : null;
|
|
977
1080
|
plan.createdAt = previous?.createdAt || now.toISOString();
|
|
1081
|
+
// A re-plan keeps the lead (and a plan from before leads stays without one)
|
|
1082
|
+
// unless this session takes it over.
|
|
1083
|
+
plan.leadSession = input.takeLead ? session : previous ? previous.leadSession : session;
|
|
1084
|
+
plan.formerLeads = [
|
|
1085
|
+
...new Set([
|
|
1086
|
+
...(previous?.formerLeads ?? []),
|
|
1087
|
+
...(previous?.leadSession && previous.leadSession !== plan.leadSession
|
|
1088
|
+
? [previous.leadSession]
|
|
1089
|
+
: []),
|
|
1090
|
+
]),
|
|
1091
|
+
].filter((former) => former !== plan.leadSession);
|
|
978
1092
|
plan.updatedAt = now.toISOString();
|
|
1093
|
+
// Per slice: the same definition continues its run; a changed one starts anew.
|
|
1094
|
+
for (const slice of plan.slices) {
|
|
1095
|
+
slice.hash = sliceHash(slice);
|
|
1096
|
+
const before = previous?.slices.find((old) => old.id === slice.id);
|
|
1097
|
+
slice.hashSince =
|
|
1098
|
+
before && before.hash === slice.hash ? before.hashSince || plan.updatedAt : plan.updatedAt;
|
|
1099
|
+
}
|
|
979
1100
|
const written = writeFanout(cwd, plan);
|
|
980
1101
|
if (!written.ok) return written;
|
|
1102
|
+
if (previous && plan.leadSession !== previous.leadSession)
|
|
1103
|
+
appendObserved(cwd, {
|
|
1104
|
+
type: "fanout.lead.changed",
|
|
1105
|
+
actor: input.actor,
|
|
1106
|
+
fanout: plan.name,
|
|
1107
|
+
from: previous.leadSession,
|
|
1108
|
+
to: plan.leadSession,
|
|
1109
|
+
});
|
|
981
1110
|
appendObserved(cwd, {
|
|
982
1111
|
type: "fanout.planned",
|
|
983
1112
|
actor: input.actor,
|
|
984
1113
|
fanout: plan.name,
|
|
985
1114
|
trunk: plan.trunk,
|
|
1115
|
+
fanIn: plan.fanIn,
|
|
986
1116
|
slices: plan.slices.length,
|
|
987
1117
|
ids: plan.slices.slice(0, 20).map((slice) => slice.id),
|
|
988
1118
|
});
|
|
989
1119
|
|
|
990
1120
|
const grouped = waves(plan.slices);
|
|
991
1121
|
const notes: string[] = [];
|
|
1122
|
+
const warnings: string[] = [];
|
|
1123
|
+
if (!input.takeLead && previous?.leadSession && session && session !== previous.leadSession)
|
|
1124
|
+
notes.push(
|
|
1125
|
+
`the lead stays ${previous.leadSession}; this session is ${session}. Leading from this session now? workit fanout plan <plan.json> --take-lead`,
|
|
1126
|
+
);
|
|
1127
|
+
if (previous === null) {
|
|
1128
|
+
const ledger = readLedger(cwd);
|
|
1129
|
+
const inForce = ledger.ok ? activeStanding(ledger.value.rows, plan.name, planLeads(plan)) : [];
|
|
1130
|
+
if (inForce.length)
|
|
1131
|
+
warnings.push(
|
|
1132
|
+
`${inForce.length} standing order${inForce.length === 1 ? " is" : "s are"} already in force for fanout ${plan.name}, and every brief will carry ${inForce.length === 1 ? "it" : "them"}: ${inForce.map((row) => `"${String(row.what)}"`).join("; ")}. From an earlier run? workit ledger standing clear --fanout ${plan.name}`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
992
1135
|
if (!ref)
|
|
993
1136
|
notes.push(
|
|
994
1137
|
`trunk ${plan.trunk} does not resolve here; only sample paths of each glob were compared`,
|
|
@@ -1009,6 +1152,7 @@ export function planFanout(
|
|
|
1009
1152
|
data: {
|
|
1010
1153
|
name: plan.name,
|
|
1011
1154
|
trunk: plan.trunk,
|
|
1155
|
+
fanIn: plan.fanIn,
|
|
1012
1156
|
file: written.data,
|
|
1013
1157
|
created: previous === null,
|
|
1014
1158
|
slices: plan.slices.map(({ id, branch, base, worktree, tier, dependsOn, scope, owns }) => ({
|
|
@@ -1025,6 +1169,7 @@ export function planFanout(
|
|
|
1025
1169
|
waves: grouped,
|
|
1026
1170
|
landingOrder: landingOrder(plan.slices),
|
|
1027
1171
|
notes,
|
|
1172
|
+
warnings,
|
|
1028
1173
|
},
|
|
1029
1174
|
};
|
|
1030
1175
|
}
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -9,13 +9,19 @@
|
|
|
9
9
|
// merge: three independent gates, all required:
|
|
10
10
|
// 1. the S10 status document reads READY (required checks green, no
|
|
11
11
|
// conflicts, threads, review or draft blockers);
|
|
12
|
-
// 2. an accepted S13 verdict for that exact head (fresh or carried)
|
|
13
|
-
//
|
|
12
|
+
// 2. an accepted S13 verdict for that exact head (fresh or carried). Under
|
|
13
|
+
// `merge: true` only, `--unverified --reason` bypasses it, and the bypass
|
|
14
|
+
// is recorded (`merge.unverified`) before the merge call;
|
|
14
15
|
// 3. the `merge` grant (autonomy.ts `requireGrant`; S16 fills the defaults).
|
|
15
16
|
// The merge call carries the head SHA (GitHub `sha=`, GitLab `sha=`), so a
|
|
16
17
|
// head that moves after the gates refuses instead of merging unverified code.
|
|
17
18
|
// --delete-branch deletes with a lease on that same SHA.
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
requireGrant,
|
|
21
|
+
resolveAutonomy,
|
|
22
|
+
type AutonomySource,
|
|
23
|
+
type DefaultEndpoint,
|
|
24
|
+
} from "../autonomy";
|
|
19
25
|
import { isProtectedTarget } from "../core/branch";
|
|
20
26
|
import { vcsConfig } from "../core/vcs-config";
|
|
21
27
|
import { workspaceReleaseTracks } from "../core/release-tracks";
|
|
@@ -64,6 +70,20 @@ export type CreateOutcome = {
|
|
|
64
70
|
created: boolean;
|
|
65
71
|
draft: boolean;
|
|
66
72
|
recorded: { id: string } | { error: string };
|
|
73
|
+
/** The review step and what the effective endpoint does after it. */
|
|
74
|
+
next: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const AFTER_REVIEW: Record<DefaultEndpoint, string> = {
|
|
78
|
+
commit: "stop here; review comes when the user asks",
|
|
79
|
+
pr: "stop here; review comes when the user asks",
|
|
80
|
+
green: "babysit it to merge-ready, never merging",
|
|
81
|
+
merged: "babysit it, then land it after verification",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const createNext = (cwd: string): string => {
|
|
85
|
+
const endpoint = resolveAutonomy(cwd).effectiveEndpoint;
|
|
86
|
+
return `a non-author verifies the head (workit-review); the endpoint is ${endpoint}, so ${AFTER_REVIEW[endpoint]}`;
|
|
67
87
|
};
|
|
68
88
|
|
|
69
89
|
/**
|
|
@@ -231,6 +251,7 @@ export async function createPullRequest(
|
|
|
231
251
|
created,
|
|
232
252
|
draft: input.draft,
|
|
233
253
|
recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
|
|
254
|
+
next: createNext(cwd),
|
|
234
255
|
});
|
|
235
256
|
}
|
|
236
257
|
|
|
@@ -247,6 +268,8 @@ export type MergeInput = {
|
|
|
247
268
|
* comes from this branch, re-checked right before the merge call.
|
|
248
269
|
*/
|
|
249
270
|
expect?: { base: string; branch: string };
|
|
271
|
+
/** Merge without a verdict (`merge: true` only); the reason is recorded. */
|
|
272
|
+
unverified?: { reason: string };
|
|
250
273
|
};
|
|
251
274
|
|
|
252
275
|
export type MergeOutcome = {
|
|
@@ -258,6 +281,8 @@ export type MergeOutcome = {
|
|
|
258
281
|
method: MergeMethod;
|
|
259
282
|
mergeSha: string | null;
|
|
260
283
|
verdict: { required: boolean; accepted: boolean; verdictId: string | null };
|
|
284
|
+
/** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
|
|
285
|
+
unverified: { reason: string; recorded: string } | null;
|
|
261
286
|
grant: { source: AutonomySource };
|
|
262
287
|
deletedBranch: boolean | { error: string };
|
|
263
288
|
recorded: { id: string } | { error: string };
|
|
@@ -294,6 +319,8 @@ const mergeBackFor = (cwd: string, base: string): { track: string | null; branch
|
|
|
294
319
|
export type MergeRefusal = {
|
|
295
320
|
reason:
|
|
296
321
|
| "grant_required"
|
|
322
|
+
| "unverified_refused"
|
|
323
|
+
| "failed_verdict"
|
|
297
324
|
| "not_ready"
|
|
298
325
|
| "needs_verdict"
|
|
299
326
|
| "head_mismatch"
|
|
@@ -346,6 +373,15 @@ export async function mergePullRequest(
|
|
|
346
373
|
): Promise<MergeResult> {
|
|
347
374
|
const grant = requireGrant(cwd, "merge");
|
|
348
375
|
if (!grant.allowed) return refuse(grant.error, grant.unblock, { reason: "grant_required" });
|
|
376
|
+
const bypass = input.unverified ?? null;
|
|
377
|
+
if (bypass && !bypass.reason.trim())
|
|
378
|
+
return failure("invalid_input", "--unverified needs --reason <why the user asked for it>");
|
|
379
|
+
if (bypass && !grant.allowUnverified)
|
|
380
|
+
return refuse(
|
|
381
|
+
`unverified_refused: workspace ${grant.workspace ? `"${grant.workspace}"` : "(none)"} grants merge: "verified", which never merges without an accepted independent verdict`,
|
|
382
|
+
"a non-author session verifies the head (workit-review), then merge without --unverified",
|
|
383
|
+
{ reason: "unverified_refused" },
|
|
384
|
+
);
|
|
349
385
|
|
|
350
386
|
const number = selectPr(cwd, resolved, { pr: input.pr });
|
|
351
387
|
if (!number.ok) return number;
|
|
@@ -404,34 +440,57 @@ export async function mergePullRequest(
|
|
|
404
440
|
{ reason: "protected_branch" },
|
|
405
441
|
);
|
|
406
442
|
}
|
|
443
|
+
const ledger = readLedger(cwd);
|
|
444
|
+
if (!ledger.ok) return ledger;
|
|
445
|
+
const check = checkVerdicts(cwd, branch, ledger.value.rows);
|
|
446
|
+
const summary = { head: check.head, accepted: check.accepted, authors: check.authors };
|
|
447
|
+
const covered = check.head === head && check.accepted.accepted;
|
|
407
448
|
let verdict: MergeOutcome["verdict"] = {
|
|
408
|
-
required:
|
|
409
|
-
accepted:
|
|
410
|
-
verdictId:
|
|
449
|
+
required: true,
|
|
450
|
+
accepted: covered,
|
|
451
|
+
verdictId:
|
|
452
|
+
covered && typeof check.accepted.verdict?.id === "string" ? check.accepted.verdict.id : null,
|
|
411
453
|
};
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
454
|
+
let unverified: MergeOutcome["unverified"] = null;
|
|
455
|
+
// The bypass covers a missing verdict, never a rejection.
|
|
456
|
+
if (!covered && bypass && check.accepted.reasons.includes("failing_verdict"))
|
|
457
|
+
return refuse(
|
|
458
|
+
`failed_verdict: ${label} has a current independent failed verdict${typeof check.accepted.verdict?.id === "string" ? ` (${check.accepted.verdict.id})` : ""}; --unverified never merges over a rejection`,
|
|
459
|
+
"a new independent verdict on the head supersedes it",
|
|
460
|
+
{ reason: "failed_verdict", verdict: summary },
|
|
461
|
+
);
|
|
462
|
+
if (!covered && bypass) {
|
|
463
|
+
// Recorded before the merge call: a bypass that cannot be audited never merges.
|
|
464
|
+
const reason = bypass.reason.trim();
|
|
465
|
+
const recorded = appendObserved(cwd, {
|
|
466
|
+
type: "merge.unverified",
|
|
467
|
+
actor: input.actor,
|
|
468
|
+
branch,
|
|
469
|
+
head,
|
|
470
|
+
pr: doc.number,
|
|
471
|
+
base: doc.base,
|
|
472
|
+
reason,
|
|
473
|
+
grant: grant.source,
|
|
474
|
+
});
|
|
475
|
+
if (!recorded.ok)
|
|
476
|
+
return failure(
|
|
477
|
+
"failed",
|
|
478
|
+
`unverified_unrecorded: the --unverified bypass could not be recorded (${recorded.error}); nothing was merged`,
|
|
428
479
|
);
|
|
429
|
-
verdict = {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
480
|
+
verdict = { required: false, accepted: false, verdictId: null };
|
|
481
|
+
unverified = { reason, recorded: String(recorded.value.id) };
|
|
482
|
+
} else if (check.head !== head)
|
|
483
|
+
return refuse(
|
|
484
|
+
`head_mismatch: the verdict check reads ${branch} at ${check.head?.slice(0, 12) ?? "(missing)"} but ${label} is at ${head.slice(0, 12)}`,
|
|
485
|
+
`git fetch && git switch ${branch} && git merge --ff-only @{u} # then re-verify that head`,
|
|
486
|
+
{ reason: "head_mismatch", verdict: summary },
|
|
487
|
+
);
|
|
488
|
+
else if (!covered)
|
|
489
|
+
return refuse(
|
|
490
|
+
`NEEDS_VERDICT: ${label} has no accepted independent verdict for ${head.slice(0, 12)} (${check.accepted.reasons.join(", ")})`,
|
|
491
|
+
`an independent session verifies the head and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${branch}${grant.allowUnverified ? `; or, only if the user asks to merge unverified: workit pr merge --unverified --reason "<why>"` : ""}`,
|
|
492
|
+
{ reason: "needs_verdict", verdict: summary },
|
|
493
|
+
);
|
|
435
494
|
|
|
436
495
|
const merged = resolved.forge.merge(doc.number, { sha: head, method: input.method });
|
|
437
496
|
if (!merged.ok) return merged;
|
|
@@ -470,6 +529,7 @@ export async function mergePullRequest(
|
|
|
470
529
|
mergeSha: merged.data.mergeSha,
|
|
471
530
|
method: input.method,
|
|
472
531
|
verdictId: verdict.verdictId,
|
|
532
|
+
...(unverified ? { unverified: unverified.recorded } : {}),
|
|
473
533
|
grant: grant.source,
|
|
474
534
|
});
|
|
475
535
|
return {
|
|
@@ -483,6 +543,7 @@ export async function mergePullRequest(
|
|
|
483
543
|
method: input.method,
|
|
484
544
|
mergeSha: merged.data.mergeSha,
|
|
485
545
|
verdict,
|
|
546
|
+
unverified,
|
|
486
547
|
grant: { source: grant.source },
|
|
487
548
|
deletedBranch,
|
|
488
549
|
recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
|