@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/ledger.ts
CHANGED
|
@@ -130,8 +130,22 @@ export type VerdictRow = RowCommon & {
|
|
|
130
130
|
evidenceRefs: string[];
|
|
131
131
|
};
|
|
132
132
|
export type HandoffRow = RowCommon & { type: "handoff"; note: string | null; next: string };
|
|
133
|
+
/** A lead's standing order for every worker of a fanout ("no new dependencies"). */
|
|
134
|
+
export type StandingRow = RowCommon & { type: "standing"; fanout: string; what: string };
|
|
135
|
+
/** Ends one standing order (`target`), or every one recorded before it (null). */
|
|
136
|
+
export type StandingClearedRow = RowCommon & {
|
|
137
|
+
type: "standing.cleared";
|
|
138
|
+
fanout: string;
|
|
139
|
+
target: string | null;
|
|
140
|
+
};
|
|
133
141
|
|
|
134
|
-
export type LedgerRow =
|
|
142
|
+
export type LedgerRow =
|
|
143
|
+
| DecisionRow
|
|
144
|
+
| RulingRow
|
|
145
|
+
| VerdictRow
|
|
146
|
+
| HandoffRow
|
|
147
|
+
| StandingRow
|
|
148
|
+
| StandingClearedRow;
|
|
135
149
|
/** A row as read back: any known or future type, plus its position. */
|
|
136
150
|
export type ReadRow = RowCommon &
|
|
137
151
|
Record<string, unknown> & {
|
|
@@ -747,6 +761,8 @@ export type RejectReason =
|
|
|
747
761
|
| "no_session"
|
|
748
762
|
| "author_session"
|
|
749
763
|
| "failing_verdict"
|
|
764
|
+
/** The only passing current verdict is `type-check-only`, which proves no behavior. */
|
|
765
|
+
| "type_check_only"
|
|
750
766
|
| "no_verdict";
|
|
751
767
|
|
|
752
768
|
export type VerdictEntry = {
|
|
@@ -769,7 +785,10 @@ export type VerdictCheck = {
|
|
|
769
785
|
patchId: string | null;
|
|
770
786
|
/** Newest current verdict (else newest verdict): does it still apply? */
|
|
771
787
|
current: { basis: VerdictBasis; verdict: ReadRow | null };
|
|
772
|
-
/**
|
|
788
|
+
/**
|
|
789
|
+
* What merge gates read: an accepted strong (`verified`/`tests-verified`)
|
|
790
|
+
* verdict and no current independent failure. `type-check-only` never counts.
|
|
791
|
+
*/
|
|
773
792
|
accepted: { accepted: boolean; verdict: ReadRow | null; reasons: RejectReason[] };
|
|
774
793
|
review: ReviewLabel;
|
|
775
794
|
/** The newest current, strong, self verdict when `review` is self-reviewed. */
|
|
@@ -829,10 +848,17 @@ const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[]
|
|
|
829
848
|
/**
|
|
830
849
|
* The effective verdict of one kind, walking rows in order: a self verdict
|
|
831
850
|
* never displaces an independent one, and an independent failed/blocked
|
|
832
|
-
* verdict sticks until an independent verdict from a
|
|
833
|
-
* its own session's supersede) replaces it.
|
|
851
|
+
* verdict sticks, for the code it judged, until an independent verdict from a
|
|
852
|
+
* different session (or its own session's supersede) replaces it. A verdict
|
|
853
|
+
* from the same session on different code (`sameCode` false: a real change,
|
|
854
|
+
* not a carry or restack of the failed code) is a re-review of the fix and
|
|
855
|
+
* replaces it.
|
|
834
856
|
*/
|
|
835
|
-
function effectiveOf(
|
|
857
|
+
function effectiveOf(
|
|
858
|
+
rows: readonly ReadRow[],
|
|
859
|
+
authors: Set<string>,
|
|
860
|
+
sameCode: (failed: ReadRow, row: ReadRow) => boolean,
|
|
861
|
+
): ReadRow | null {
|
|
836
862
|
let effective: ReadRow | null = null;
|
|
837
863
|
for (const row of rows) {
|
|
838
864
|
if (!effective) {
|
|
@@ -845,7 +871,8 @@ function effectiveOf(rows: readonly ReadRow[], authors: Set<string>): ReadRow |
|
|
|
845
871
|
if (
|
|
846
872
|
effIndependent &&
|
|
847
873
|
FAILING.has(String(effective.result)) &&
|
|
848
|
-
row.actor.session === effective.actor.session
|
|
874
|
+
row.actor.session === effective.actor.session &&
|
|
875
|
+
sameCode(effective, row)
|
|
849
876
|
)
|
|
850
877
|
continue;
|
|
851
878
|
effective = row;
|
|
@@ -899,15 +926,24 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
899
926
|
const head = branchHead(cwd, branch);
|
|
900
927
|
const fallbackBase = defaultBase(cwd);
|
|
901
928
|
const keyCache = new Map<string, { patchId: string | null; diffHash: string | null }>();
|
|
902
|
-
const
|
|
903
|
-
if (!base || !
|
|
904
|
-
|
|
929
|
+
const keysAt = (base: string | null, at: string | null) => {
|
|
930
|
+
if (!base || !at) return { patchId: null, diffHash: null };
|
|
931
|
+
const cacheKey = `${base}\0${at}`;
|
|
932
|
+
let key = keyCache.get(cacheKey);
|
|
905
933
|
if (!key) {
|
|
906
|
-
key = { patchId: patchId(cwd, base,
|
|
907
|
-
keyCache.set(
|
|
934
|
+
key = { patchId: patchId(cwd, base, at), diffHash: diffHash(cwd, base, at) };
|
|
935
|
+
keyCache.set(cacheKey, key);
|
|
908
936
|
}
|
|
909
937
|
return key;
|
|
910
938
|
};
|
|
939
|
+
const keyFor = (base: string | null) => keysAt(base, head);
|
|
940
|
+
// A newer row is judged under the failure's own base, so a row recorded
|
|
941
|
+
// with a different --base cannot make unchanged code look changed.
|
|
942
|
+
const sameCode = (failed: ReadRow, row: ReadRow): boolean =>
|
|
943
|
+
verdictBasis(failed, {
|
|
944
|
+
head: row.head,
|
|
945
|
+
...keysAt(failed.base ?? fallbackBase, row.head),
|
|
946
|
+
}) !== "stale" || restackCarries(rows, branch, failed.head, row.head);
|
|
911
947
|
const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head });
|
|
912
948
|
const byKind = new Map<string, ReadRow[]>();
|
|
913
949
|
for (const row of rows)
|
|
@@ -917,7 +953,7 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
917
953
|
}
|
|
918
954
|
const verdicts: VerdictEntry[] = [];
|
|
919
955
|
for (const [kind, list] of byKind) {
|
|
920
|
-
const row = effectiveOf(list, authors);
|
|
956
|
+
const row = effectiveOf(list, authors, sameCode);
|
|
921
957
|
if (!row) continue;
|
|
922
958
|
let basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
|
|
923
959
|
if (basis === "stale" && restackCarries(rows, branch, row.head, head)) basis = "carried";
|
|
@@ -943,7 +979,10 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
943
979
|
const failing = verdicts.find(
|
|
944
980
|
(entry) => entry.current && entry.independent && FAILING.has(String(entry.verdict.result)),
|
|
945
981
|
);
|
|
946
|
-
const newestAccepted =
|
|
982
|
+
const newestAccepted =
|
|
983
|
+
verdicts.findLast(
|
|
984
|
+
(entry) => entry.accepted && STRONG_RESULTS.has(String(entry.verdict.result)),
|
|
985
|
+
) ?? null;
|
|
947
986
|
const accepted =
|
|
948
987
|
newestAccepted && !failing
|
|
949
988
|
? { accepted: true, verdict: newestAccepted.verdict, reasons: [] }
|
|
@@ -952,7 +991,11 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
952
991
|
verdict: failing?.verdict ?? null,
|
|
953
992
|
reasons: failing
|
|
954
993
|
? (["failing_verdict"] as RejectReason[])
|
|
955
|
-
:
|
|
994
|
+
: !newestCurrent
|
|
995
|
+
? (["no_verdict"] as RejectReason[])
|
|
996
|
+
: newestCurrent.reasons.length
|
|
997
|
+
? newestCurrent.reasons
|
|
998
|
+
: (["type_check_only"] as RejectReason[]),
|
|
956
999
|
};
|
|
957
1000
|
const selfEntry = failing
|
|
958
1001
|
? undefined
|
|
@@ -1076,6 +1119,142 @@ export function recordRuling(
|
|
|
1076
1119
|
);
|
|
1077
1120
|
}
|
|
1078
1121
|
|
|
1122
|
+
/**
|
|
1123
|
+
* May this row speak for the fanout's lead? With recorded leads (the current
|
|
1124
|
+
* one and any it took over from) only their rows count; a plan from before
|
|
1125
|
+
* leads (null) takes any session.
|
|
1126
|
+
*/
|
|
1127
|
+
const fromLead = (row: ReadRow, leads: readonly string[] | null): boolean =>
|
|
1128
|
+
leads === null || (row.actor.session !== null && leads.includes(row.actor.session));
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* The standing orders in force for `fanout`, oldest first (D17: odd rows are
|
|
1132
|
+
* skipped). Given the plan's lead session, orders and clears recorded by any
|
|
1133
|
+
* other session are ignored, so a worker can neither add nor clear one.
|
|
1134
|
+
*/
|
|
1135
|
+
export function activeStanding(
|
|
1136
|
+
rows: readonly ReadRow[],
|
|
1137
|
+
fanout: string,
|
|
1138
|
+
leads: readonly string[] | null = null,
|
|
1139
|
+
): ReadRow[] {
|
|
1140
|
+
const active: ReadRow[] = [];
|
|
1141
|
+
for (const row of rows) {
|
|
1142
|
+
if (row.fanout !== fanout || !fromLead(row, leads)) continue;
|
|
1143
|
+
if (row.type === "standing" && typeof row.what === "string" && row.what.trim()) {
|
|
1144
|
+
if (!row.superseded) active.push(row);
|
|
1145
|
+
} else if (row.type === "standing.cleared") {
|
|
1146
|
+
const target = str(row.target);
|
|
1147
|
+
if (target === null) active.length = 0;
|
|
1148
|
+
else {
|
|
1149
|
+
const index = active.findIndex((order) => order.id === target);
|
|
1150
|
+
if (index >= 0) active.splice(index, 1);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return active;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* A worker or verifier session by the ids workit hands out: `fanout brief`'s
|
|
1159
|
+
* `<lead>-w-<slice>[+try<n>]`, a verifier's `<lead>-v<n>`, the Claude Code
|
|
1160
|
+
* hook's `<lead>:<agent id>` and `--as` ids (`<session>:<role>:<hex>`). Host
|
|
1161
|
+
* session ids carry no colon. Used where no recorded lead decides: plans that
|
|
1162
|
+
* predate `leadSession`, and `fanout plan --take-lead`.
|
|
1163
|
+
*/
|
|
1164
|
+
export const isDelegateSession = (session: string | null): boolean =>
|
|
1165
|
+
session !== null &&
|
|
1166
|
+
(/-w-[a-z0-9._-]+(?:\+try\d)?$/u.test(session) ||
|
|
1167
|
+
/-v\d+$/u.test(session) ||
|
|
1168
|
+
session.includes(":"));
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Standing orders come from the fanout's lead: the session that first made
|
|
1172
|
+
* the plan (`lead`). A plan without one falls back to refusing the session
|
|
1173
|
+
* ids handed to workers and verifiers.
|
|
1174
|
+
*/
|
|
1175
|
+
function leadOnly(context: RecordContext, lead: string | null): LedgerResult<void> {
|
|
1176
|
+
const session = context.actor.session;
|
|
1177
|
+
if (lead !== null ? session === lead : !isDelegateSession(session))
|
|
1178
|
+
return { ok: true, value: undefined };
|
|
1179
|
+
return err(
|
|
1180
|
+
"blocked",
|
|
1181
|
+
lead !== null
|
|
1182
|
+
? `session ${session ?? "(none)"} is not the fanout's lead (${lead}): standing orders come from the lead`
|
|
1183
|
+
: `session ${session} is a worker or verifier: standing orders come from the lead`,
|
|
1184
|
+
lead !== null
|
|
1185
|
+
? `the lead in a new session? workit fanout plan <plan.json> --take-lead from this session; a worker or verifier reports the order to the lead instead`
|
|
1186
|
+
: "a worker or verifier reports the order to the lead instead",
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
export function recordStanding(
|
|
1191
|
+
context: RecordContext,
|
|
1192
|
+
input: { fanout: string; lead: string | null; what?: string },
|
|
1193
|
+
): LedgerResult<StandingRow> {
|
|
1194
|
+
const what = required(input.what, "<order>");
|
|
1195
|
+
if (!what.ok) return what;
|
|
1196
|
+
// One order per line of every brief: a newline or control character would
|
|
1197
|
+
// let an order forge other brief fields.
|
|
1198
|
+
if (/\p{Cc}/u.test(what.value))
|
|
1199
|
+
return err(
|
|
1200
|
+
"invalid_input",
|
|
1201
|
+
"a standing order is one line: no newlines or control characters",
|
|
1202
|
+
'add each order separately: workit ledger standing add "<order>"',
|
|
1203
|
+
);
|
|
1204
|
+
const lead = leadOnly(context, input.lead);
|
|
1205
|
+
if (!lead.ok) return lead;
|
|
1206
|
+
const link = checkSupersede(context, "standing");
|
|
1207
|
+
if (!link.ok) return link;
|
|
1208
|
+
return appendRow<StandingRow>(
|
|
1209
|
+
context.cwd,
|
|
1210
|
+
{
|
|
1211
|
+
...common(context, noteKey(context)),
|
|
1212
|
+
type: "standing",
|
|
1213
|
+
fanout: input.fanout,
|
|
1214
|
+
what: what.value,
|
|
1215
|
+
},
|
|
1216
|
+
{ now: context.now },
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/** Clear one standing order by id (it must be in force), or all of them; lead only. */
|
|
1221
|
+
export function clearStanding(
|
|
1222
|
+
context: RecordContext,
|
|
1223
|
+
input: {
|
|
1224
|
+
fanout: string;
|
|
1225
|
+
lead: string | null;
|
|
1226
|
+
leads?: readonly string[] | null;
|
|
1227
|
+
target: string | null;
|
|
1228
|
+
},
|
|
1229
|
+
): LedgerResult<StandingClearedRow> {
|
|
1230
|
+
const lead = leadOnly(context, input.lead);
|
|
1231
|
+
if (!lead.ok) return lead;
|
|
1232
|
+
if (input.target !== null) {
|
|
1233
|
+
const ledger = readLedger(context.cwd);
|
|
1234
|
+
if (!ledger.ok) return ledger;
|
|
1235
|
+
if (
|
|
1236
|
+
!activeStanding(ledger.value.rows, input.fanout, input.leads ?? null).some(
|
|
1237
|
+
(row) => row.id === input.target,
|
|
1238
|
+
)
|
|
1239
|
+
)
|
|
1240
|
+
return err(
|
|
1241
|
+
"not_found",
|
|
1242
|
+
`no standing order ${input.target} in force for fanout ${input.fanout}`,
|
|
1243
|
+
`workit ledger standing list --fanout ${input.fanout}`,
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
return appendRow<StandingClearedRow>(
|
|
1247
|
+
context.cwd,
|
|
1248
|
+
{
|
|
1249
|
+
...common(context, noteKey(context)),
|
|
1250
|
+
type: "standing.cleared",
|
|
1251
|
+
fanout: input.fanout,
|
|
1252
|
+
target: input.target,
|
|
1253
|
+
},
|
|
1254
|
+
{ now: context.now },
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1079
1258
|
/**
|
|
1080
1259
|
* Record an agent-asserted verdict on a branch's committed head. Refused on a
|
|
1081
1260
|
* dirty worktree (the head would not be what was judged) and, for an author
|
|
@@ -1194,7 +1373,7 @@ export type RowSummary = {
|
|
|
1194
1373
|
};
|
|
1195
1374
|
|
|
1196
1375
|
/** Row types that count only when an observing verb wrote them. */
|
|
1197
|
-
const OBSERVED_ONLY: ReadonlySet<string> = new Set(["check", ...PR_ROW_TYPES]);
|
|
1376
|
+
const OBSERVED_ONLY: ReadonlySet<string> = new Set(["check", "merge.unverified", ...PR_ROW_TYPES]);
|
|
1198
1377
|
|
|
1199
1378
|
export function rowLabels(row: ReadRow): RowSummary["labels"] {
|
|
1200
1379
|
const labels: RowSummary["labels"] = [];
|
|
@@ -1221,6 +1400,12 @@ export function summarizeRow(row: ReadRow): RowSummary {
|
|
|
1221
1400
|
case "handoff":
|
|
1222
1401
|
summary = `next: ${text("next")}${text("note") ? ` (note: ${text("note")})` : ""}`;
|
|
1223
1402
|
break;
|
|
1403
|
+
case "standing":
|
|
1404
|
+
summary = `${text("what")} (fanout ${text("fanout")})`;
|
|
1405
|
+
break;
|
|
1406
|
+
case "standing.cleared":
|
|
1407
|
+
summary = `cleared ${text("target") || "every standing order"} (fanout ${text("fanout")})`;
|
|
1408
|
+
break;
|
|
1224
1409
|
case "commit.recorded":
|
|
1225
1410
|
summary = `${(row.head ?? "?").slice(0, 12)} ${text("subject")}${row.actor.session ? "" : " (no session)"}`;
|
|
1226
1411
|
break;
|
|
@@ -1234,6 +1419,9 @@ export function summarizeRow(row: ReadRow): RowSummary {
|
|
|
1234
1419
|
case "pr.merged":
|
|
1235
1420
|
summary = `#${row.pr ?? "?"} ${text("method")} at ${(row.head ?? "?").slice(0, 12)}${text("mergeSha") ? ` -> ${text("mergeSha").slice(0, 12)}` : ""}`;
|
|
1236
1421
|
break;
|
|
1422
|
+
case "merge.unverified":
|
|
1423
|
+
summary = `#${row.pr ?? "?"} unverified merge requested at ${(row.head ?? "?").slice(0, 12)} (reason: ${text("reason")})`;
|
|
1424
|
+
break;
|
|
1237
1425
|
case "delivery.verified":
|
|
1238
1426
|
summary = `${text("expect")} delivered at ${(row.head ?? "?").slice(0, 12)}`;
|
|
1239
1427
|
break;
|
package/src/stack.ts
CHANGED
|
@@ -996,6 +996,7 @@ export type StopReason =
|
|
|
996
996
|
| "not_ready"
|
|
997
997
|
| "no_verdict"
|
|
998
998
|
| "needs_verdict"
|
|
999
|
+
| "failed_verdict"
|
|
999
1000
|
| "grant_required"
|
|
1000
1001
|
| "max_reached"
|
|
1001
1002
|
| "merge_refused"
|
|
@@ -1096,8 +1097,9 @@ const verdictOf = (cwd: string, branch: string, rows: readonly ReadRow[]) => {
|
|
|
1096
1097
|
/**
|
|
1097
1098
|
* Does the root-most open PR qualify to land? Order: a PR exists and is
|
|
1098
1099
|
* open, it targets the trunk, the forge says READY, an accepted verdict
|
|
1099
|
-
* covers the head (unless
|
|
1100
|
-
* merging. `ready` says everything but the grant holds
|
|
1100
|
+
* covers the head (unless `bypass`: `--unverified` under `merge: true`), and
|
|
1101
|
+
* the grant allows merging. `ready` says everything but the grant holds
|
|
1102
|
+
* ("verified, ready").
|
|
1101
1103
|
*/
|
|
1102
1104
|
export function qualify(
|
|
1103
1105
|
ctx: Pick<Ctx, "cwd" | "resolved">,
|
|
@@ -1108,6 +1110,8 @@ export function qualify(
|
|
|
1108
1110
|
grant: GrantDecision,
|
|
1109
1111
|
/** Why the PR number no longer belongs to this branch (null: it does). */
|
|
1110
1112
|
binding: string | null = null,
|
|
1113
|
+
/** `stack land --unverified`: skip the verdict where the grant allows it. */
|
|
1114
|
+
bypass = false,
|
|
1111
1115
|
): Qualification {
|
|
1112
1116
|
const label = entry.pr ? noun(ctx.resolved, entry.pr) : entry.branch;
|
|
1113
1117
|
const stop = (reason: StopReason, detail: string, unblock: string, ready = false) => ({
|
|
@@ -1149,12 +1153,19 @@ export function qualify(
|
|
|
1149
1153
|
? `workit ci wait --pr ${entry.pr}`
|
|
1150
1154
|
: `workit pr status --pr ${entry.pr}`,
|
|
1151
1155
|
);
|
|
1152
|
-
const
|
|
1153
|
-
|
|
1156
|
+
const allowUnverified = grant.allowed && grant.allowUnverified;
|
|
1157
|
+
// The bypass covers a missing verdict, never a rejection.
|
|
1158
|
+
if (bypass && allowUnverified && verdict.reasons.includes("failing_verdict"))
|
|
1159
|
+
return stop(
|
|
1160
|
+
"failed_verdict",
|
|
1161
|
+
`${label} has a current independent failed verdict for ${doc.head.sha.slice(0, 12)}; --unverified never lands over a rejection`,
|
|
1162
|
+
"a new independent verdict on the head supersedes it",
|
|
1163
|
+
);
|
|
1164
|
+
if (!(bypass && allowUnverified) && !verdict.accepted)
|
|
1154
1165
|
return stop(
|
|
1155
1166
|
verdict.reasons.includes("no_verdict") ? "no_verdict" : "needs_verdict",
|
|
1156
1167
|
`${label} has no accepted independent verdict for ${doc.head.sha.slice(0, 12)} (${verdict.reasons.join(", ") || "none"})`,
|
|
1157
|
-
`an independent session verifies ${entry.branch} and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${entry.branch}`,
|
|
1168
|
+
`an independent session verifies ${entry.branch} and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${entry.branch}${allowUnverified ? `; or, only if the user asks to land unverified: workit stack land --unverified --reason "<why>"` : ""}`,
|
|
1158
1169
|
);
|
|
1159
1170
|
if (!grant.allowed)
|
|
1160
1171
|
return stop(
|
|
@@ -1701,6 +1712,8 @@ export type LandOptions = {
|
|
|
1701
1712
|
dryRun: boolean;
|
|
1702
1713
|
max: number | null;
|
|
1703
1714
|
method: MergeMethod;
|
|
1715
|
+
/** Land without verdicts (`merge: true` only); each bypass is recorded. */
|
|
1716
|
+
unverified?: { reason: string } | null;
|
|
1704
1717
|
/** How long to wait for CI on a PR that was just restacked. */
|
|
1705
1718
|
timeoutMs: number;
|
|
1706
1719
|
intervalMs: number;
|
|
@@ -1709,7 +1722,8 @@ export type LandOptions = {
|
|
|
1709
1722
|
export type LandOutcome = {
|
|
1710
1723
|
name: string;
|
|
1711
1724
|
dryRun: boolean;
|
|
1712
|
-
|
|
1725
|
+
/** `unverified`: the ledger row of an `--unverified` bypass, else null. */
|
|
1726
|
+
landed: Array<{ pr: number; branch: string; mergeSha: string | null; unverified: string | null }>;
|
|
1713
1727
|
/** Dry run: the PRs that qualify now, in order (later ones assume a clean restack). */
|
|
1714
1728
|
wouldLand: Array<{ pr: number; branch: string }>;
|
|
1715
1729
|
stoppedAt: {
|
|
@@ -1778,6 +1792,23 @@ export async function landStack(
|
|
|
1778
1792
|
});
|
|
1779
1793
|
const mismatch = repoMismatch(ctx, stack);
|
|
1780
1794
|
if (mismatch) return failWith(mismatch);
|
|
1795
|
+
const bypass = options.unverified ?? null;
|
|
1796
|
+
if (bypass) {
|
|
1797
|
+
const grant = requireGrant(ctx.cwd, "merge");
|
|
1798
|
+
if (!grant.allowed)
|
|
1799
|
+
return failWith(
|
|
1800
|
+
stackFail("blocked", grant.error, grant.unblock, { reason: "grant_required" }),
|
|
1801
|
+
);
|
|
1802
|
+
if (!grant.allowUnverified)
|
|
1803
|
+
return failWith(
|
|
1804
|
+
stackFail(
|
|
1805
|
+
"blocked",
|
|
1806
|
+
`unverified_refused: workspace ${grant.workspace ? `"${grant.workspace}"` : "(none)"} grants merge: "verified", which never lands without an accepted independent verdict`,
|
|
1807
|
+
"a non-author session verifies each head (workit-review), then land without --unverified",
|
|
1808
|
+
{ reason: "unverified_refused" },
|
|
1809
|
+
),
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1781
1812
|
|
|
1782
1813
|
// Catch up first: a parent merged elsewhere leaves the rest to restack.
|
|
1783
1814
|
if (!options.dryRun) {
|
|
@@ -1847,6 +1878,7 @@ export async function landStack(
|
|
|
1847
1878
|
verdict,
|
|
1848
1879
|
grant,
|
|
1849
1880
|
doc ? prBinding(ctx, entry, doc) : null,
|
|
1881
|
+
bypass !== null,
|
|
1850
1882
|
);
|
|
1851
1883
|
if (!q.ok) {
|
|
1852
1884
|
outcome.stoppedAt = {
|
|
@@ -1874,6 +1906,7 @@ export async function landStack(
|
|
|
1874
1906
|
deleteBranch: false,
|
|
1875
1907
|
actor: ctx.actor,
|
|
1876
1908
|
expect: { base: stack.trunk, branch: entry.branch },
|
|
1909
|
+
...(bypass && !verdict.accepted ? { unverified: bypass } : {}),
|
|
1877
1910
|
},
|
|
1878
1911
|
ctx.sleep,
|
|
1879
1912
|
);
|
|
@@ -1891,7 +1924,12 @@ export async function landStack(
|
|
|
1891
1924
|
}
|
|
1892
1925
|
return failWith(stackFail(merged.code, merged.error, merged.unblock));
|
|
1893
1926
|
}
|
|
1894
|
-
outcome.landed.push({
|
|
1927
|
+
outcome.landed.push({
|
|
1928
|
+
pr,
|
|
1929
|
+
branch: entry.branch,
|
|
1930
|
+
mergeSha: merged.data.mergeSha,
|
|
1931
|
+
unverified: merged.data.unverified?.recorded ?? null,
|
|
1932
|
+
});
|
|
1895
1933
|
entry.merged = { pr, mergeSha: merged.data.mergeSha, at: new Date().toISOString() };
|
|
1896
1934
|
const written = writeStack(ctx.cwd, stack);
|
|
1897
1935
|
if (!written.ok) return failWith(written);
|