@brainervirus/workit-core 7.7.0 → 8.0.1
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-ship/SKILL.md +2 -2
- package/src/autonomy.ts +8 -6
- package/src/core/pr-create.ts +0 -232
- package/src/forge/pr-ops.ts +89 -28
- package/src/ledger.ts +46 -14
- package/src/stack.ts +45 -7
package/package.json
CHANGED
|
@@ -51,8 +51,8 @@ the base keeps moving, or after 3 failed fix attempts on the same check.
|
|
|
51
51
|
flake|infra` per head. Real: reproduce with `workit check`, fix the root
|
|
52
52
|
cause, batch fixes into one push.
|
|
53
53
|
6. **Verified.** After the last push a non-author records a verdict
|
|
54
|
-
(workit-review). Land only when granted: `workit stack land` (
|
|
55
|
-
|
|
54
|
+
(workit-review). Land only when granted: `workit stack land` (verified run
|
|
55
|
+
from the root) or `workit pr merge`; `--unverified --reason` only if user-asked.
|
|
56
56
|
7. **Observe it landed:** `workit verify-delivery pr` or `merge`. After a failed
|
|
57
57
|
verdict or a check red 3+ times, at any endpoint: offer `/wk-retro` in one
|
|
58
58
|
line of the final or stop report; never pause the babysit loop for it.
|
package/src/autonomy.ts
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
// - Defaults (D4) when a kind is absent: push, pr and rerun are allowed;
|
|
18
18
|
// merge and release need an explicit grant. The default ceiling is a stack
|
|
19
19
|
// opened, CI green and independently verified: "verified, ready".
|
|
20
|
-
// -
|
|
21
|
-
//
|
|
20
|
+
// - Every merge needs an accepted independent verdict (S13). `merge: true`
|
|
21
|
+
// also allows an explicit, ledger-recorded `--unverified --reason` bypass;
|
|
22
|
+
// `merge: "verified"` never does.
|
|
22
23
|
// - A legacy `autoApprove: true | [classes]` maps once to grants (its `merge`
|
|
23
24
|
// class becomes `merge: "verified"`; `branch`/`commit` are local, dropped).
|
|
24
25
|
// - Explicitly configured grants require an account (design §2.0 Identity):
|
|
@@ -131,7 +132,8 @@ type EndpointContext = {
|
|
|
131
132
|
* delivery verbs make (requireGrant): without `push` or `pr` (or without the
|
|
132
133
|
* account explicit grants need) nothing reaches the forge, so anything above
|
|
133
134
|
* `commit` acts as `commit`; without `merge`, `merged` acts as `green`. The
|
|
134
|
-
* reason names the unblock.
|
|
135
|
+
* reason names the unblock. `merged` stays `merged` under either merge grant;
|
|
136
|
+
* landing still needs an accepted verdict (forge/pr-ops.ts).
|
|
135
137
|
*/
|
|
136
138
|
function effectiveEndpoint(
|
|
137
139
|
endpoint: DefaultEndpoint,
|
|
@@ -212,8 +214,8 @@ export type GrantDecision =
|
|
|
212
214
|
kind: GrantKind;
|
|
213
215
|
/** Where the permission came from. */
|
|
214
216
|
source: AutonomySource;
|
|
215
|
-
/**
|
|
216
|
-
|
|
217
|
+
/** `merge: true` only: `--unverified --reason` may bypass the verdict (S13). */
|
|
218
|
+
allowUnverified: boolean;
|
|
217
219
|
workspace: string | null;
|
|
218
220
|
}
|
|
219
221
|
| {
|
|
@@ -373,7 +375,7 @@ export function requireGrant(
|
|
|
373
375
|
allowed: true,
|
|
374
376
|
kind,
|
|
375
377
|
source: configured ? autonomy.source : "default",
|
|
376
|
-
|
|
378
|
+
allowUnverified: kind === "merge" && value === true,
|
|
377
379
|
workspace,
|
|
378
380
|
};
|
|
379
381
|
}
|
package/src/core/pr-create.ts
CHANGED
|
@@ -263,99 +263,6 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
|
|
|
263
263
|
(env.WORKIT_EXPECTED_SOURCE_COMMIT && env.WORKIT_EXPECTED_SOURCE_COMMIT !== sourceCommit)
|
|
264
264
|
)
|
|
265
265
|
return { error: "approved PR source branch or commit changed before execution" };
|
|
266
|
-
const mergeBaseResult =
|
|
267
|
-
policy.integration === "merge"
|
|
268
|
-
? spawnSync("git", ["rev-parse", "--verify", `refs/heads/${target}`], {
|
|
269
|
-
cwd: root,
|
|
270
|
-
encoding: "utf8",
|
|
271
|
-
})
|
|
272
|
-
: null;
|
|
273
|
-
const mergeBaseCommit =
|
|
274
|
-
mergeBaseResult?.status === 0 ? (mergeBaseResult.stdout ?? "").trim() : "";
|
|
275
|
-
if (
|
|
276
|
-
policy.integration === "merge" &&
|
|
277
|
-
((env.WORKIT_EXPECTED_REMOTE && !env.WORKIT_EXPECTED_MERGE_BASE_COMMIT) ||
|
|
278
|
-
!mergeBaseCommit ||
|
|
279
|
-
(env.WORKIT_EXPECTED_MERGE_BASE_COMMIT &&
|
|
280
|
-
env.WORKIT_EXPECTED_MERGE_BASE_COMMIT !== mergeBaseCommit))
|
|
281
|
-
)
|
|
282
|
-
return { error: "approved merge target branch changed before execution" };
|
|
283
|
-
|
|
284
|
-
if (policy.integration === "merge") {
|
|
285
|
-
const finish = (): Record<string, any> => {
|
|
286
|
-
if (!sourceCommit || !mergeBaseCommit)
|
|
287
|
-
return {
|
|
288
|
-
error: "merge failed",
|
|
289
|
-
mode: "merge",
|
|
290
|
-
targetBranch: target,
|
|
291
|
-
stderr: "source or target branch commit could not be resolved",
|
|
292
|
-
};
|
|
293
|
-
const merge = spawnSync("git", ["merge", "--no-ff", sourceCommit, "-m", title], {
|
|
294
|
-
cwd: root,
|
|
295
|
-
encoding: "utf8",
|
|
296
|
-
});
|
|
297
|
-
if (merge.status !== 0)
|
|
298
|
-
return {
|
|
299
|
-
error: "merge failed",
|
|
300
|
-
mode: "merge",
|
|
301
|
-
targetBranch: target,
|
|
302
|
-
stderr: (merge.stderr ?? "").slice(0, 800),
|
|
303
|
-
};
|
|
304
|
-
const mergedCommit = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
305
|
-
cwd: root,
|
|
306
|
-
encoding: "utf8",
|
|
307
|
-
});
|
|
308
|
-
if (mergedCommit.status !== 0)
|
|
309
|
-
return { error: "merge commit could not be resolved", mode: "merge" };
|
|
310
|
-
const updateTarget = spawnSync(
|
|
311
|
-
"git",
|
|
312
|
-
["update-ref", `refs/heads/${target}`, mergedCommit.stdout.trim(), mergeBaseCommit],
|
|
313
|
-
{ cwd: root, encoding: "utf8" },
|
|
314
|
-
);
|
|
315
|
-
if (updateTarget.status !== 0)
|
|
316
|
-
return { error: "merge target branch changed before update", mode: "merge" };
|
|
317
|
-
const push = spawnSync(
|
|
318
|
-
"git",
|
|
319
|
-
["push", targetUrl, `${mergedCommit.stdout.trim()}:refs/heads/${target}`],
|
|
320
|
-
{ cwd: root, encoding: "utf8", env: { ...process.env, PATH: process.env.PATH ?? "" } },
|
|
321
|
-
);
|
|
322
|
-
if (push.status !== 0)
|
|
323
|
-
return {
|
|
324
|
-
error: "push failed",
|
|
325
|
-
mode: "merge",
|
|
326
|
-
targetBranch: target,
|
|
327
|
-
stderr: (push.stderr ?? "").slice(0, 800),
|
|
328
|
-
};
|
|
329
|
-
return {
|
|
330
|
-
ok: true,
|
|
331
|
-
mode: "merge",
|
|
332
|
-
targetBranch: target,
|
|
333
|
-
merged: true,
|
|
334
|
-
pushed: true,
|
|
335
|
-
output: (push.stdout ?? "").trim(),
|
|
336
|
-
};
|
|
337
|
-
};
|
|
338
|
-
const co = spawnSync("git", ["checkout", "--detach", mergeBaseCommit], {
|
|
339
|
-
cwd: root,
|
|
340
|
-
encoding: "utf8",
|
|
341
|
-
});
|
|
342
|
-
if (co.status !== 0)
|
|
343
|
-
return {
|
|
344
|
-
error: `cannot checkout target ${target}`,
|
|
345
|
-
mode: "merge",
|
|
346
|
-
targetBranch: target,
|
|
347
|
-
stderr: (co.stderr ?? "").slice(0, 800),
|
|
348
|
-
};
|
|
349
|
-
// finish() returns error objects, it never throws, so the best-effort
|
|
350
|
-
// `git checkout branch` restore below runs on ALL outcomes (success,
|
|
351
|
-
// merge failure, and push failure) — the tree always returns to the
|
|
352
|
-
// feature branch. Deliberately not a try/finally: the restore is
|
|
353
|
-
// best-effort and its own failure is not actionable on this path.
|
|
354
|
-
const result = finish();
|
|
355
|
-
spawnSync("git", ["checkout", branch], { cwd: root, encoding: "utf8" }); // best-effort return
|
|
356
|
-
return result;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
266
|
const pr = (cfg.pr ?? {}) as Record<string, any>;
|
|
360
267
|
const body = env.WF_PR_BODY ?? "";
|
|
361
268
|
const draft = String(env.WF_PR_DRAFT ?? "false").toLowerCase() === "true";
|
|
@@ -510,144 +417,5 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
|
|
|
510
417
|
};
|
|
511
418
|
}
|
|
512
419
|
|
|
513
|
-
/** Merge the exact approved open PR/MR (squash + delete per pr settings). */
|
|
514
|
-
export function mergePr(
|
|
515
|
-
cwd: string,
|
|
516
|
-
opts: {
|
|
517
|
-
target: string;
|
|
518
|
-
source: string;
|
|
519
|
-
sourceCommit: string;
|
|
520
|
-
remote: string;
|
|
521
|
-
account: string;
|
|
522
|
-
apiHost: string;
|
|
523
|
-
},
|
|
524
|
-
): Record<string, any> {
|
|
525
|
-
const root = repoRoot(cwd);
|
|
526
|
-
const cfg = vcsConfig("load", root);
|
|
527
|
-
if (!cfg.ok) return { error: cfg.error ?? "vcs config missing" };
|
|
528
|
-
const provider = cfg.provider as string;
|
|
529
|
-
if (provider !== "gitlab" && provider !== "github")
|
|
530
|
-
return { error: `unsupported provider: ${provider}` };
|
|
531
|
-
const identity = vcsCliIdentity(root);
|
|
532
|
-
if (!identity.ok) return { error: identity.error };
|
|
533
|
-
if (opts.apiHost !== identity.host)
|
|
534
|
-
return { error: "approved hosting API host changed before execution" };
|
|
535
|
-
const remote = pushRemote(root);
|
|
536
|
-
const remoteIdentity = remote ? pushRemoteIdentity(remote) : null;
|
|
537
|
-
if (
|
|
538
|
-
!remote ||
|
|
539
|
-
!remoteIdentity ||
|
|
540
|
-
!pushTargetIsStable(root, remote) ||
|
|
541
|
-
opts.remote !== remoteIdentity
|
|
542
|
-
)
|
|
543
|
-
return { error: "approved merge destination changed before execution" };
|
|
544
|
-
if (!hostingApiHostMatches(remote, identity.host))
|
|
545
|
-
return { error: "hosting API host does not match the merge destination" };
|
|
546
|
-
if (identity.username?.toLowerCase() !== opts.account.toLowerCase())
|
|
547
|
-
return { error: "approved hosting account changed before execution" };
|
|
548
|
-
if (!/^[a-f0-9]{40,64}$/i.test(opts.sourceCommit))
|
|
549
|
-
return { error: "approved merge source commit is invalid" };
|
|
550
|
-
const currentCommit = spawnSync("git", ["rev-parse", "--verify", `refs/heads/${opts.source}`], {
|
|
551
|
-
cwd: root,
|
|
552
|
-
encoding: "utf8",
|
|
553
|
-
});
|
|
554
|
-
if (currentCommit.status !== 0 || currentCommit.stdout.trim() !== opts.sourceCommit)
|
|
555
|
-
return { error: "approved merge source commit changed before execution" };
|
|
556
|
-
const repository = cliRepository(remote, provider, cfg);
|
|
557
|
-
const repoPath = remoteRepoPath(remote)?.path;
|
|
558
|
-
if (!repository || !repoPath?.includes("/"))
|
|
559
|
-
return { error: "hosting repository could not be resolved" };
|
|
560
|
-
if (!hostingCliAvailable(provider))
|
|
561
|
-
return { error: `workflow CLI missing (required for ${provider})` };
|
|
562
|
-
const owner = repoPath.split("/")[0];
|
|
563
|
-
const endpoint =
|
|
564
|
-
provider === "github"
|
|
565
|
-
? `repos/${repository.replace(/^[^/]+\//u, "")}/pulls?state=open&head=${encodeURIComponent(`${owner}:${opts.source}`)}&base=${encodeURIComponent(opts.target)}&per_page=100`
|
|
566
|
-
: `projects/${encodeURIComponent(repoPath)}/merge_requests?state=opened&source_branch=${encodeURIComponent(opts.source)}&target_branch=${encodeURIComponent(opts.target)}&per_page=100`;
|
|
567
|
-
const listOpen = () =>
|
|
568
|
-
spawnSync(provider === "github" ? "gh" : "glab", ["api", endpoint], {
|
|
569
|
-
cwd: root,
|
|
570
|
-
encoding: "utf8",
|
|
571
|
-
env: {
|
|
572
|
-
...process.env,
|
|
573
|
-
PATH: process.env.PATH ?? "",
|
|
574
|
-
...(provider === "github" ? { GH_HOST: identity.host } : { GITLAB_HOST: identity.host }),
|
|
575
|
-
},
|
|
576
|
-
});
|
|
577
|
-
const listedId = (stdout: string): string | null => {
|
|
578
|
-
try {
|
|
579
|
-
const records = JSON.parse(stdout) as Array<Record<string, any>>;
|
|
580
|
-
if (!Array.isArray(records) || records.length >= 100) return null;
|
|
581
|
-
const matches = records.filter((record) =>
|
|
582
|
-
provider === "github"
|
|
583
|
-
? record.state === "open" &&
|
|
584
|
-
record.head?.ref === opts.source &&
|
|
585
|
-
record.head?.sha === opts.sourceCommit &&
|
|
586
|
-
record.base?.ref === opts.target
|
|
587
|
-
: record.state === "opened" &&
|
|
588
|
-
record.source_branch === opts.source &&
|
|
589
|
-
record.sha === opts.sourceCommit &&
|
|
590
|
-
record.target_branch === opts.target,
|
|
591
|
-
);
|
|
592
|
-
if (matches.length !== 1) return null;
|
|
593
|
-
const id = provider === "github" ? matches[0].number : matches[0].iid;
|
|
594
|
-
return typeof id === "number" ? String(id) : null;
|
|
595
|
-
} catch {
|
|
596
|
-
return null;
|
|
597
|
-
}
|
|
598
|
-
};
|
|
599
|
-
const list = listOpen();
|
|
600
|
-
if (list.status !== 0) return { error: "approved merge request could not be resolved" };
|
|
601
|
-
const mergeId = listedId(list.stdout ?? "");
|
|
602
|
-
if (!mergeId) return { error: "exact approved merge request is not open" };
|
|
603
|
-
// The PR/MR target can change after the first lookup. Re-resolve the
|
|
604
|
-
// target-filtered request immediately before calling the provider merge CLI.
|
|
605
|
-
const current = listOpen();
|
|
606
|
-
if (current.status !== 0 || listedId(current.stdout ?? "") !== mergeId)
|
|
607
|
-
return { error: "approved merge target or source changed before merge" };
|
|
608
|
-
const pr = (cfg.pr ?? {}) as Record<string, any>;
|
|
609
|
-
const squash = pr.squashOnMerge !== false;
|
|
610
|
-
const removeBranch = pr.removeSourceBranch !== false;
|
|
611
|
-
let cmd: string[];
|
|
612
|
-
let cmdEnv: NodeJS.ProcessEnv;
|
|
613
|
-
if (provider === "gitlab") {
|
|
614
|
-
cmd = ["glab", "mr", "merge", mergeId, "--repo", repository, "--sha", opts.sourceCommit];
|
|
615
|
-
cmd.push(squash ? "--squash" : "--squash=false");
|
|
616
|
-
if (removeBranch) cmd.push("--remove-source-branch");
|
|
617
|
-
cmd.push("--yes");
|
|
618
|
-
cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GITLAB_HOST: identity.host };
|
|
619
|
-
} else {
|
|
620
|
-
cmd = [
|
|
621
|
-
"gh",
|
|
622
|
-
"pr",
|
|
623
|
-
"merge",
|
|
624
|
-
mergeId,
|
|
625
|
-
"--repo",
|
|
626
|
-
repository,
|
|
627
|
-
"--match-head-commit",
|
|
628
|
-
opts.sourceCommit,
|
|
629
|
-
];
|
|
630
|
-
cmd.push(squash ? "--squash" : "--merge");
|
|
631
|
-
if (removeBranch) cmd.push("--delete-branch");
|
|
632
|
-
// NOTE: gh pr merge has no --yes flag; fully-specified flags merge
|
|
633
|
-
// non-interactively on their own.
|
|
634
|
-
cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GH_HOST: identity.host };
|
|
635
|
-
}
|
|
636
|
-
const result = spawnSync(cmd[0], cmd.slice(1), { cwd: root, encoding: "utf8", env: cmdEnv });
|
|
637
|
-
if (result.status !== 0)
|
|
638
|
-
return {
|
|
639
|
-
error: "merge failed",
|
|
640
|
-
provider,
|
|
641
|
-
stderr: (result.stderr ?? result.stdout ?? "").trim().slice(0, 800),
|
|
642
|
-
};
|
|
643
|
-
return {
|
|
644
|
-
ok: true,
|
|
645
|
-
provider,
|
|
646
|
-
targetBranch: opts.target,
|
|
647
|
-
sourceBranch: opts.source,
|
|
648
|
-
output: (result.stdout ?? "").trim(),
|
|
649
|
-
};
|
|
650
|
-
}
|
|
651
|
-
|
|
652
420
|
// Moved to forge/pr-body.ts (S11); re-exported for the managed action path until S16.
|
|
653
421
|
export { buildBody, parseGhRepo, parseGhIssue };
|
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 },
|
package/src/ledger.ts
CHANGED
|
@@ -761,6 +761,8 @@ export type RejectReason =
|
|
|
761
761
|
| "no_session"
|
|
762
762
|
| "author_session"
|
|
763
763
|
| "failing_verdict"
|
|
764
|
+
/** The only passing current verdict is `type-check-only`, which proves no behavior. */
|
|
765
|
+
| "type_check_only"
|
|
764
766
|
| "no_verdict";
|
|
765
767
|
|
|
766
768
|
export type VerdictEntry = {
|
|
@@ -783,7 +785,10 @@ export type VerdictCheck = {
|
|
|
783
785
|
patchId: string | null;
|
|
784
786
|
/** Newest current verdict (else newest verdict): does it still apply? */
|
|
785
787
|
current: { basis: VerdictBasis; verdict: ReadRow | null };
|
|
786
|
-
/**
|
|
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
|
+
*/
|
|
787
792
|
accepted: { accepted: boolean; verdict: ReadRow | null; reasons: RejectReason[] };
|
|
788
793
|
review: ReviewLabel;
|
|
789
794
|
/** The newest current, strong, self verdict when `review` is self-reviewed. */
|
|
@@ -843,10 +848,17 @@ const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[]
|
|
|
843
848
|
/**
|
|
844
849
|
* The effective verdict of one kind, walking rows in order: a self verdict
|
|
845
850
|
* never displaces an independent one, and an independent failed/blocked
|
|
846
|
-
* verdict sticks until an independent verdict from a
|
|
847
|
-
* 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.
|
|
848
856
|
*/
|
|
849
|
-
function effectiveOf(
|
|
857
|
+
function effectiveOf(
|
|
858
|
+
rows: readonly ReadRow[],
|
|
859
|
+
authors: Set<string>,
|
|
860
|
+
sameCode: (failed: ReadRow, row: ReadRow) => boolean,
|
|
861
|
+
): ReadRow | null {
|
|
850
862
|
let effective: ReadRow | null = null;
|
|
851
863
|
for (const row of rows) {
|
|
852
864
|
if (!effective) {
|
|
@@ -859,7 +871,8 @@ function effectiveOf(rows: readonly ReadRow[], authors: Set<string>): ReadRow |
|
|
|
859
871
|
if (
|
|
860
872
|
effIndependent &&
|
|
861
873
|
FAILING.has(String(effective.result)) &&
|
|
862
|
-
row.actor.session === effective.actor.session
|
|
874
|
+
row.actor.session === effective.actor.session &&
|
|
875
|
+
sameCode(effective, row)
|
|
863
876
|
)
|
|
864
877
|
continue;
|
|
865
878
|
effective = row;
|
|
@@ -913,15 +926,24 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
913
926
|
const head = branchHead(cwd, branch);
|
|
914
927
|
const fallbackBase = defaultBase(cwd);
|
|
915
928
|
const keyCache = new Map<string, { patchId: string | null; diffHash: string | null }>();
|
|
916
|
-
const
|
|
917
|
-
if (!base || !
|
|
918
|
-
|
|
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);
|
|
919
933
|
if (!key) {
|
|
920
|
-
key = { patchId: patchId(cwd, base,
|
|
921
|
-
keyCache.set(
|
|
934
|
+
key = { patchId: patchId(cwd, base, at), diffHash: diffHash(cwd, base, at) };
|
|
935
|
+
keyCache.set(cacheKey, key);
|
|
922
936
|
}
|
|
923
937
|
return key;
|
|
924
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);
|
|
925
947
|
const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head });
|
|
926
948
|
const byKind = new Map<string, ReadRow[]>();
|
|
927
949
|
for (const row of rows)
|
|
@@ -931,7 +953,7 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
931
953
|
}
|
|
932
954
|
const verdicts: VerdictEntry[] = [];
|
|
933
955
|
for (const [kind, list] of byKind) {
|
|
934
|
-
const row = effectiveOf(list, authors);
|
|
956
|
+
const row = effectiveOf(list, authors, sameCode);
|
|
935
957
|
if (!row) continue;
|
|
936
958
|
let basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
|
|
937
959
|
if (basis === "stale" && restackCarries(rows, branch, row.head, head)) basis = "carried";
|
|
@@ -957,7 +979,10 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
957
979
|
const failing = verdicts.find(
|
|
958
980
|
(entry) => entry.current && entry.independent && FAILING.has(String(entry.verdict.result)),
|
|
959
981
|
);
|
|
960
|
-
const newestAccepted =
|
|
982
|
+
const newestAccepted =
|
|
983
|
+
verdicts.findLast(
|
|
984
|
+
(entry) => entry.accepted && STRONG_RESULTS.has(String(entry.verdict.result)),
|
|
985
|
+
) ?? null;
|
|
961
986
|
const accepted =
|
|
962
987
|
newestAccepted && !failing
|
|
963
988
|
? { accepted: true, verdict: newestAccepted.verdict, reasons: [] }
|
|
@@ -966,7 +991,11 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
966
991
|
verdict: failing?.verdict ?? null,
|
|
967
992
|
reasons: failing
|
|
968
993
|
? (["failing_verdict"] as RejectReason[])
|
|
969
|
-
:
|
|
994
|
+
: !newestCurrent
|
|
995
|
+
? (["no_verdict"] as RejectReason[])
|
|
996
|
+
: newestCurrent.reasons.length
|
|
997
|
+
? newestCurrent.reasons
|
|
998
|
+
: (["type_check_only"] as RejectReason[]),
|
|
970
999
|
};
|
|
971
1000
|
const selfEntry = failing
|
|
972
1001
|
? undefined
|
|
@@ -1344,7 +1373,7 @@ export type RowSummary = {
|
|
|
1344
1373
|
};
|
|
1345
1374
|
|
|
1346
1375
|
/** Row types that count only when an observing verb wrote them. */
|
|
1347
|
-
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]);
|
|
1348
1377
|
|
|
1349
1378
|
export function rowLabels(row: ReadRow): RowSummary["labels"] {
|
|
1350
1379
|
const labels: RowSummary["labels"] = [];
|
|
@@ -1390,6 +1419,9 @@ export function summarizeRow(row: ReadRow): RowSummary {
|
|
|
1390
1419
|
case "pr.merged":
|
|
1391
1420
|
summary = `#${row.pr ?? "?"} ${text("method")} at ${(row.head ?? "?").slice(0, 12)}${text("mergeSha") ? ` -> ${text("mergeSha").slice(0, 12)}` : ""}`;
|
|
1392
1421
|
break;
|
|
1422
|
+
case "merge.unverified":
|
|
1423
|
+
summary = `#${row.pr ?? "?"} unverified merge requested at ${(row.head ?? "?").slice(0, 12)} (reason: ${text("reason")})`;
|
|
1424
|
+
break;
|
|
1393
1425
|
case "delivery.verified":
|
|
1394
1426
|
summary = `${text("expect")} delivered at ${(row.head ?? "?").slice(0, 12)}`;
|
|
1395
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);
|