@makerx/yoke 0.16.0 → 0.17.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.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.js +725 -530
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { Command } from "commander";
7
7
  // package.json
8
8
  var package_default = {
9
9
  name: "@makerx/yoke",
10
- version: "0.16.0",
10
+ version: "0.17.0",
11
11
  description: "Lean, user-invoked Claude Code skills for MakerX AI-assisted delivery, plus the CLI they call.",
12
12
  license: "MIT",
13
13
  author: "MakerX",
@@ -432,50 +432,6 @@ function raisePr({ title, body, base, draft }, run2 = runGh) {
432
432
  return output.at(-1) ?? "";
433
433
  }
434
434
 
435
- // src/features/review/ci.ts
436
- import { execFileSync as execFileSync2 } from "child_process";
437
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
438
- import { dirname as dirname2, join as join3 } from "path";
439
-
440
- // src/features/review/shellQuote.ts
441
- function shellQuote(value) {
442
- return `'${value.replaceAll("'", `'\\''`)}'`;
443
- }
444
-
445
- // src/features/review/ciEntra.ts
446
- var githubOidcIssuer = "https://token.actions.githubusercontent.com";
447
- var entraTokenExchangeAudience = "api://AzureADTokenExchange";
448
- function federatedCredential(repo, environment) {
449
- return {
450
- issuer: githubOidcIssuer,
451
- audience: entraTokenExchangeAudience,
452
- subject: environment ? `repo:${repo}:environment:${environment}` : `repo:${repo}:pull_request`
453
- };
454
- }
455
- function federatedCredentialCommand(clientId, credential) {
456
- const parameters = JSON.stringify({
457
- name: "yoke-review-ci",
458
- issuer: credential.issuer,
459
- subject: credential.subject,
460
- audiences: [credential.audience]
461
- });
462
- return `az ad app federated-credential create --id ${shellQuote(clientId)} --parameters ${shellQuote(parameters)}`;
463
- }
464
- function entraSetup({ clientId, environment }, repo) {
465
- const credential = federatedCredential(repo, environment);
466
- return [
467
- "",
468
- `Instead of an API key, the review signs in to Foundry as Entra app ${clientId}. So that the app trusts this repo's review runs, add a federated credential to it with:`,
469
- ` issuer: ${credential.issuer}`,
470
- ` audience: ${credential.audience}`,
471
- ` subject: ${credential.subject}`,
472
- "for example with:",
473
- ` ${federatedCredentialCommand(clientId, credential)}`,
474
- "and give the app the Cognitive Services User role on the Foundry resource.",
475
- ...environment ? [`The job runs in the ${environment} environment; create it on the repo if it does not exist.`] : []
476
- ];
477
- }
478
-
479
435
  // src/features/review/pr.ts
480
436
  import { execFile } from "child_process";
481
437
  import { join as join2 } from "path";
@@ -538,19 +494,19 @@ function isPendingReview(value) {
538
494
  }
539
495
  async function openPendingReview(pr2, exec2 = run) {
540
496
  const args = ["api", "--method", "POST", reviewsPath(pr2), "--input", "-", "--jq", "{id, node_id}"];
541
- const review = JSON.parse(await exec2("gh", args, void 0, JSON.stringify({ commit_id: pr2.headRefOid })));
542
- if (!isPendingReview(review)) throw new ReviewError("Opening a pending review returned an unexpected response");
543
- return { id: review.id, nodeId: review.node_id };
497
+ const review2 = JSON.parse(await exec2("gh", args, void 0, JSON.stringify({ commit_id: pr2.headRefOid })));
498
+ if (!isPendingReview(review2)) throw new ReviewError("Opening a pending review returned an unexpected response");
499
+ return { id: review2.id, nodeId: review2.node_id };
544
500
  }
545
501
  var addThread = `mutation($review: ID!, $path: String!, $line: Int!, $body: String!) {
546
502
  addPullRequestReviewThread(input: {pullRequestReviewId: $review, path: $path, line: $line, side: RIGHT, body: $body}) { thread { id } }
547
503
  }`;
548
- async function addReviewComment(review, { path, line, body }, exec2 = run) {
504
+ async function addReviewComment(review2, { path, line, body }, exec2 = run) {
549
505
  const variables = [
550
506
  "-f",
551
507
  `query=${addThread}`,
552
508
  "-f",
553
- `review=${review.nodeId}`,
509
+ `review=${review2.nodeId}`,
554
510
  "-f",
555
511
  `path=${path}`,
556
512
  "-F",
@@ -560,24 +516,28 @@ async function addReviewComment(review, { path, line, body }, exec2 = run) {
560
516
  ];
561
517
  await exec2("gh", ["api", "graphql", ...variables]);
562
518
  }
563
- async function submitPendingReview(pr2, review, body, exec2 = run) {
564
- const args = ["api", "--method", "POST", `${reviewsPath(pr2)}/${review.id}/events`, "--input", "-", "--jq", ".html_url"];
519
+ async function submitPendingReview(pr2, review2, body, exec2 = run) {
520
+ const args = ["api", "--method", "POST", `${reviewsPath(pr2)}/${review2.id}/events`, "--input", "-", "--jq", ".html_url"];
565
521
  return (await exec2("gh", args, void 0, JSON.stringify({ event: "COMMENT", body }))).trim();
566
522
  }
567
- async function discardPendingReview(pr2, review, exec2 = run) {
568
- await exec2("gh", ["api", "--method", "DELETE", `${reviewsPath(pr2)}/${review.id}`]);
523
+ async function discardPendingReview(pr2, review2, exec2 = run) {
524
+ await exec2("gh", ["api", "--method", "DELETE", `${reviewsPath(pr2)}/${review2.id}`]);
569
525
  }
570
526
  async function githubGet(path, exec2 = run) {
571
527
  return JSON.parse(await exec2("gh", ["api", path]));
572
528
  }
573
529
  async function githubPost(path, body, exec2 = run) {
574
- return JSON.parse(await exec2("gh", ["api", "--method", "POST", path, "--input", "-"], void 0, JSON.stringify(body)));
530
+ const output = await exec2("gh", ["api", "--method", "POST", path, "--input", "-"], void 0, JSON.stringify(body));
531
+ return output.trim() ? JSON.parse(output) : null;
532
+ }
533
+ async function githubDelete(path, exec2 = run) {
534
+ await exec2("gh", ["api", "--method", "DELETE", path]);
575
535
  }
576
536
  var github = {
577
537
  open: (pr2) => openPendingReview(pr2),
578
- add: (review, comment) => addReviewComment(review, comment),
579
- submit: (pr2, review, body) => submitPendingReview(pr2, review, body),
580
- discard: (pr2, review) => discardPendingReview(pr2, review)
538
+ add: (review2, comment) => addReviewComment(review2, comment),
539
+ submit: (pr2, review2, body) => submitPendingReview(pr2, review2, body),
540
+ discard: (pr2, review2) => discardPendingReview(pr2, review2)
581
541
  };
582
542
  function repoSlug(remoteUrl) {
583
543
  return remoteUrl.replace(/\.git$/, "").split(/[/:]/).slice(-2).join("/").toLowerCase();
@@ -691,12 +651,57 @@ function buildOverride({ harness, model }, provider) {
691
651
  return buildClaudeOverride(provider, endpoints, model);
692
652
  }
693
653
 
654
+ // src/features/review/ci.ts
655
+ import { execFileSync as execFileSync2 } from "child_process";
656
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
657
+ import { dirname as dirname2, join as join3 } from "path";
658
+
659
+ // src/features/review/shellQuote.ts
660
+ function shellQuote(value) {
661
+ return `'${value.replaceAll("'", `'\\''`)}'`;
662
+ }
663
+
664
+ // src/features/review/ciEntra.ts
665
+ var githubOidcIssuer = "https://token.actions.githubusercontent.com";
666
+ var entraTokenExchangeAudience = "api://AzureADTokenExchange";
667
+ function federatedCredential(repo, environment) {
668
+ return {
669
+ issuer: githubOidcIssuer,
670
+ audience: entraTokenExchangeAudience,
671
+ subject: environment ? `repo:${repo}:environment:${environment}` : `repo:${repo}:pull_request`
672
+ };
673
+ }
674
+ function federatedCredentialCommand(clientId, credential) {
675
+ const parameters = JSON.stringify({
676
+ name: "yoke-review-ci",
677
+ issuer: credential.issuer,
678
+ subject: credential.subject,
679
+ audiences: [credential.audience]
680
+ });
681
+ return `az ad app federated-credential create --id ${shellQuote(clientId)} --parameters ${shellQuote(parameters)}`;
682
+ }
683
+ function entraSetup({ clientId, environment }, repo) {
684
+ const credential = federatedCredential(repo, environment);
685
+ return [
686
+ "",
687
+ `Instead of an API key, the review signs in to Foundry as Entra app ${clientId}. So that the app trusts this repo's review runs, add a federated credential to it with:`,
688
+ ` issuer: ${credential.issuer}`,
689
+ ` audience: ${credential.audience}`,
690
+ ` subject: ${credential.subject}`,
691
+ "for example with:",
692
+ ` ${federatedCredentialCommand(clientId, credential)}`,
693
+ "and give the app the Cognitive Services User role on the Foundry resource.",
694
+ ...environment ? [`The job runs in the ${environment} environment; create it on the repo if it does not exist.`] : []
695
+ ];
696
+ }
697
+
694
698
  // src/features/review/ci.ts
695
699
  var reviewWorkflowRef = "MakerXStudio/yoke/.github/workflows/review.yml@main";
696
700
  var callerWorkflowPath = join3(".github", "workflows", "review-pr.yml");
697
701
  var baseUrlVariable = "REVIEW_CI_BASE_URL";
698
702
  var apiKeySecret = "REVIEW_CI_API_KEY";
699
703
  var callerTriggerTypes = ["opened", "synchronize", "reopened", "ready_for_review"];
704
+ var callerTargetTypes = ["labeled", "synchronize"];
700
705
  var gateCheck = "review / gate";
701
706
  function workflowSecrets({ entra }) {
702
707
  return entra ? [] : [apiKeySecret];
@@ -762,11 +767,14 @@ function renderCallerWorkflow(workflow) {
762
767
  "on:",
763
768
  " pull_request:",
764
769
  ` types: [${callerTriggerTypes.join(", ")}]`,
770
+ " pull_request_target:",
771
+ ` types: [${callerTargetTypes.join(", ")}]`,
765
772
  "",
766
773
  "permissions:",
767
- " actions: read",
774
+ " actions: write",
768
775
  " contents: read",
769
776
  " pull-requests: write",
777
+ " statuses: write",
770
778
  ...workflow.entra ? [" id-token: write"] : [],
771
779
  "",
772
780
  "jobs:",
@@ -802,12 +810,12 @@ function repoRoot(cwd = process.cwd()) {
802
810
  try {
803
811
  return execFileSync2("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
804
812
  } catch {
805
- throw new ReviewError("Not in a git repository; run `yoke review-ci` from the repo to review.");
813
+ throw new ReviewError("Not in a git repository; run `yoke review ci` from the repo to review.");
806
814
  }
807
815
  }
808
816
  var editInPlaceMessage = [
809
817
  `${callerWorkflowPath} already exists and differs from yoke's version, so it was left as it is.`,
810
- "Edit it in place to run yoke's review, keeping its provider, models and names. `yoke review-ci --print` shows yoke's version without saving it."
818
+ "Edit it in place to run yoke's review, keeping its provider, models and names. `yoke review ci --print` shows yoke's version without saving it."
811
819
  ].join("\n");
812
820
  function writeCallerWorkflow(root, content) {
813
821
  const path = join3(root, callerWorkflowPath);
@@ -846,369 +854,77 @@ function reviewCiReport(outcome, settings, workflow, repo) {
846
854
  ].join("\n");
847
855
  }
848
856
 
849
- // src/features/review/ciGate.ts
850
- import { z as z3 } from "zod";
851
-
852
- // src/features/review/ciGateCaller.ts
853
- import { parse } from "yaml";
854
- import { z as z2 } from "zod";
855
- var yokeRepo = "MakerXStudio/yoke";
856
- var localReviewWorkflow = "./.github/workflows/review.yml";
857
- var reviewWorkflowPrefix = reviewWorkflowRef.slice(0, reviewWorkflowRef.indexOf("@") + 1).toLowerCase();
858
- var workflowSchema = z2.looseObject({
859
- on: z2.unknown().optional(),
860
- permissions: z2.unknown().optional(),
861
- jobs: z2.record(z2.string(), z2.looseObject({ uses: z2.unknown().optional(), permissions: z2.unknown().optional() }).nullable()).optional()
862
- });
863
- function parseWorkflow(content) {
864
- try {
865
- const result = workflowSchema.safeParse(parse(content));
866
- return result.success ? result.data : void 0;
867
- } catch {
868
- return void 0;
869
- }
857
+ // src/features/review/comments.ts
858
+ var markerPrefix = "<!-- yoke-review:";
859
+ function slug(text2) {
860
+ return text2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
870
861
  }
871
- function callsReview(uses, repo) {
872
- if (typeof uses !== "string") return false;
873
- if (uses.toLowerCase().startsWith(reviewWorkflowPrefix)) return true;
874
- return repo.toLowerCase() === yokeRepo.toLowerCase() && uses === localReviewWorkflow;
862
+ function findingMarker({ file, title }) {
863
+ return `${markerPrefix} ${file ?? "n/a"}#${slug(title)} -->`;
875
864
  }
876
- function listIncludes(value, item) {
877
- return value === item || Array.isArray(value) && value.includes(item);
865
+ function hasMarker(prior, marker) {
866
+ return prior.some((comment) => comment.body.includes(marker));
878
867
  }
879
- function pullRequestTrigger(on) {
880
- if (typeof on !== "object" || on === null || Array.isArray(on) || !("pull_request" in on)) return void 0;
881
- const trigger = on.pull_request;
882
- return typeof trigger === "object" && trigger !== null && !Array.isArray(trigger) ? { ...trigger } : void 0;
868
+ function withoutMarkers(body) {
869
+ return body.split("\n").filter((line) => !line.trimStart().startsWith(markerPrefix)).join("\n").trim();
883
870
  }
884
- function runsOnSynchronize(on) {
885
- if (typeof on === "string" || Array.isArray(on)) return listIncludes(on, "pull_request");
886
- if (typeof on !== "object" || on === null || !("pull_request" in on)) return false;
887
- const trigger = pullRequestTrigger(on);
888
- if (!trigger || !("types" in trigger)) return true;
889
- return listIncludes(trigger.types, "synchronize");
871
+ function heading({ path, line }) {
872
+ if (!path) return "### PR review";
873
+ return line === null ? `### ${path}` : `### ${path}:${line}`;
890
874
  }
891
- function globMatches(pattern, branch) {
892
- const source = pattern.split(/(\*\*|\*|\?)/).map((part) => ({ "**": ".*", "*": "[^/]*", "?": "." })[part] ?? part.replace(/[.+^${}()|[\]\\]/g, "\\$&")).join("");
893
- return new RegExp(`^${source}$`).test(branch);
875
+ function formatPriorComments(prior) {
876
+ if (!prior.length) return "";
877
+ const blocks = prior.map((comment) => `${heading(comment)}
878
+
879
+ **${comment.author}**: ${withoutMarkers(comment.body)}`);
880
+ return `## Prior review comments
881
+
882
+ The PR already has the review comments below, including resolved and outdated threads. Do not re-raise a finding a prior comment substantively covers.
883
+
884
+ ${blocks.join("\n\n")}
885
+ `;
894
886
  }
895
- function patterns(value) {
896
- if (typeof value === "string") return [value];
897
- return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
887
+
888
+ // src/features/review/findings.ts
889
+ var severities = ["blocker", "major", "minor", "nit"];
890
+ var sources = ["confirmed", "disputed", "reviewer1-only", "reviewer2-only", "already-raised"];
891
+ function oneOf(values, raw) {
892
+ const value = raw.toLowerCase();
893
+ return values.find((v) => v === value) ?? null;
898
894
  }
899
- function branchesInclude(value, branch) {
900
- return patterns(value).reduce(
901
- (included, pattern) => pattern.startsWith("!") ? included && !globMatches(pattern.slice(1), branch) : included || globMatches(pattern, branch),
902
- false
903
- );
895
+ function field(block, name) {
896
+ return block.match(new RegExp(`^-\\s+${name}:\\s*(.*)$`, "im"))?.[1]?.trim() ?? "";
904
897
  }
905
- function skipFilters(on, branch) {
906
- const trigger = pullRequestTrigger(on);
907
- if (!trigger) return [];
908
- const filters = ["paths", "paths-ignore"].filter((key) => key in trigger);
909
- if ("branches" in trigger && !branchesInclude(trigger.branches, branch)) filters.push("branches");
910
- if ("branches-ignore" in trigger && patterns(trigger["branches-ignore"]).some((pattern) => globMatches(pattern, branch)))
911
- filters.push("branches-ignore");
912
- return filters;
898
+ function parseLocation(raw) {
899
+ const location = raw.replace(/^`+|`+$/g, "").trim();
900
+ if (!location || location.toLowerCase() === "n/a") return { file: null, line: null };
901
+ const match = location.match(/^(.+?):(\d+)(?:-\d+)?$/);
902
+ if (!match?.[1] || !match[2]) return { file: location, line: null };
903
+ return { file: match[1], line: Number(match[2]) };
913
904
  }
914
- function canReadActions(permissions) {
915
- if (permissions === "read-all" || permissions === "write-all") return true;
916
- if (typeof permissions !== "object" || permissions === null || !("actions" in permissions)) return false;
917
- return permissions.actions === "read" || permissions.actions === "write";
905
+ function parseFinding(block) {
906
+ const [heading2 = "", ...rest] = block.split("\n");
907
+ const body = rest.join("\n");
908
+ return {
909
+ title: heading2.trim(),
910
+ severity: oneOf(severities, field(body, "Severity")),
911
+ source: oneOf(sources, field(body, "Source")),
912
+ ...parseLocation(field(body, "Location")),
913
+ impact: field(body, "Impact"),
914
+ recommendation: field(body, "Recommendation")
915
+ };
918
916
  }
919
- function checkWorkflow(path, workflow, repo, branch) {
920
- const jobs = Object.values(workflow.jobs ?? {}).filter((job) => job && callsReview(job.uses, repo));
921
- if (jobs.length === 0) return void 0;
922
- const problems2 = [];
923
- if (!runsOnSynchronize(workflow.on)) problems2.push("lacks the `synchronize` type in its `pull_request` trigger");
924
- if (!jobs.some((job) => canReadActions(job?.permissions ?? workflow.permissions)))
925
- problems2.push("lacks `actions: read` in its permissions");
926
- const filters = skipFilters(workflow.on, branch);
927
- if (filters.length > 0)
928
- problems2.push(
929
- `skips some PRs to ${branch} through its \`pull_request\` ${filters.map((filter) => `\`${filter}\``).join(", ")} filter${filters.length > 1 ? "s" : ""}, so those PRs could never merge`
930
- );
931
- return { path, problems: problems2 };
917
+ function parseSummary(markdown) {
918
+ return markdown.match(/^##\s+Summary\s*$([\s\S]*?)(?=^##\s|(?![\s\S]))/m)?.[1]?.trim() ?? "";
932
919
  }
933
- function checkCallerWorkflows(files, repo, branch) {
934
- const callers = files.flatMap(({ path, content }) => {
935
- const workflow = parseWorkflow(content);
936
- const check = workflow && checkWorkflow(path, workflow, repo, branch);
937
- return check ? [check] : [];
938
- });
939
- return callers.find((caller) => caller.problems.length === 0) ?? callers[0];
920
+ function rank(finding) {
921
+ const severity = finding.severity ? severities.indexOf(finding.severity) : severities.length;
922
+ const source = finding.source ? sources.indexOf(finding.source) : sources.length;
923
+ return severity * (sources.length + 1) + source;
940
924
  }
941
-
942
- // src/features/review/ciGate.ts
943
- var gateRuleset = {
944
- name: "yoke review",
945
- target: "branch",
946
- enforcement: "active",
947
- conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
948
- rules: [
949
- {
950
- type: "required_status_checks",
951
- parameters: { strict_required_status_checks_policy: false, required_status_checks: [{ context: gateCheck }] }
952
- }
953
- ]
954
- };
955
- var recentPrCount = 20;
956
- var repoSchema = z3.object({
957
- full_name: z3.string(),
958
- html_url: z3.string(),
959
- default_branch: z3.string()
960
- });
961
- var branchRulesSchema = z3.array(
962
- z3.object({
963
- type: z3.string(),
964
- ruleset_id: z3.number().optional(),
965
- parameters: z3.object({ required_status_checks: z3.array(z3.object({ context: z3.string() })).optional() }).optional()
966
- })
967
- );
968
- var directorySchema = z3.array(z3.object({ type: z3.string(), name: z3.string(), path: z3.string() }));
969
- var fileSchema = z3.object({ content: z3.string() });
970
- var pullsSchema = z3.array(z3.object({ head: z3.object({ sha: z3.string() }) }));
971
- var checkRunsSchema = z3.object({ total_count: z3.number() });
972
- var createdSchema = z3.object({ id: z3.number() });
973
- var rulesetsSchema = z3.array(z3.object({ id: z3.number(), name: z3.string(), enforcement: z3.string() }));
974
- var githubApi = { get: (path) => githubGet(path), post: (path, body) => githubPost(path, body) };
975
- async function getUnlessMissing(api, path) {
976
- try {
977
- return await api.get(path);
978
- } catch (error) {
979
- if (error instanceof ReviewError && error.message.includes("(HTTP 404)")) return void 0;
980
- throw error;
981
- }
982
- }
983
- async function workflowFiles(api, repo) {
984
- const ref = `ref=${encodeURIComponent(repo.default_branch)}`;
985
- const listing = await getUnlessMissing(api, `repos/${repo.full_name}/contents/.github/workflows?${ref}`);
986
- if (listing === void 0) return [];
987
- const files = directorySchema.parse(listing).filter((entry) => entry.type === "file" && /\.ya?ml$/.test(entry.name));
988
- return Promise.all(
989
- files.map(async ({ path }) => {
990
- const { content } = fileSchema.parse(await api.get(`repos/${repo.full_name}/contents/${path}?${ref}`));
991
- return { path, content: Buffer.from(content, "base64").toString("utf8") };
992
- })
993
- );
994
- }
995
- async function callerProblem(api, repo) {
996
- const branch = repo.default_branch;
997
- const caller = checkCallerWorkflows(await workflowFiles(api, repo), repo.full_name, branch);
998
- if (!caller) return `No workflow on ${branch} calls yoke's review.yml. Run /review-ci, then merge its ${callerWorkflowPath} to ${branch}.`;
999
- if (caller.problems.length > 0)
1000
- return `${caller.path} on ${branch} calls yoke's review.yml but ${caller.problems.join(" and ")}. Fix it and merge the change to ${branch}.`;
1001
- return void 0;
1002
- }
1003
- async function gateHasReported(api, repo) {
1004
- const pulls = pullsSchema.parse(
1005
- await api.get(`repos/${repo.full_name}/pulls?state=all&sort=updated&direction=desc&per_page=${recentPrCount}`)
1006
- );
1007
- for (const { head } of pulls) {
1008
- const runs = checkRunsSchema.parse(
1009
- await api.get(`repos/${repo.full_name}/commits/${head.sha}/check-runs?check_name=${encodeURIComponent(gateCheck)}&per_page=1`)
1010
- );
1011
- if (runs.total_count > 0) return true;
1012
- }
1013
- return false;
1014
- }
1015
- async function rulesetProblem(api, repo) {
1016
- const rulesets = rulesetsSchema.parse(await api.get(`repos/${repo.full_name}/rulesets?includes_parents=false&per_page=100`));
1017
- const existing = rulesets.find((ruleset) => ruleset.name === gateRuleset.name);
1018
- if (!existing) return void 0;
1019
- return `The "${gateRuleset.name}" ruleset already exists with enforcement \`${existing.enforcement}\` but doesn't require ${gateCheck} on ${repo.default_branch}: ${repo.html_url}/rules/${existing.id}. Set its enforcement to Active and its required check to ${gateCheck}, or delete it and run this again.`;
1020
- }
1021
- async function createRuleset(api, repo) {
1022
- try {
1023
- return await api.post(`repos/${repo.full_name}/rulesets`, gateRuleset);
1024
- } catch (error) {
1025
- if (error instanceof ReviewError && /\(HTTP 40[34]\)/.test(error.message))
1026
- throw new ReviewError(
1027
- `GitHub refused to add the "${gateRuleset.name}" ruleset to ${repo.full_name}: your gh user can't edit its repository rules. Ask a repo admin, or someone whose role can edit repository rules, to run \`yoke review-ci-gate\`.`
1028
- );
1029
- throw error;
1030
- }
1031
- }
1032
- async function problems(api, repo) {
1033
- const found = [];
1034
- const ruleset = await rulesetProblem(api, repo);
1035
- if (ruleset) found.push(ruleset);
1036
- const caller = await callerProblem(api, repo);
1037
- if (caller) found.push(caller);
1038
- if (!await gateHasReported(api, repo))
1039
- found.push(
1040
- `No ${gateCheck} check run on the ${recentPrCount} most recently updated PRs. Open or push to a PR so the gate reports, then run this again.`
1041
- );
1042
- return found;
1043
- }
1044
- async function enforceReviewGate(api = githubApi) {
1045
- const repo = repoSchema.parse(await api.get("repos/{owner}/{repo}"));
1046
- const branch = repo.default_branch;
1047
- const rules = branchRulesSchema.parse(await api.get(`repos/${repo.full_name}/rules/branches/${encodeURIComponent(branch)}`));
1048
- const existing = rules.find(
1049
- (rule) => rule.type === "required_status_checks" && rule.parameters?.required_status_checks?.some((check) => check.context === gateCheck)
1050
- );
1051
- if (existing) {
1052
- const where = existing.ruleset_id ? ` by ${repo.html_url}/rules/${existing.ruleset_id}` : "";
1053
- return { created: false, message: `${gateCheck} is already required on ${branch}${where}; nothing changed.` };
1054
- }
1055
- const found = await problems(api, repo);
1056
- if (found.length > 0)
1057
- throw new ReviewError([`Cannot require ${gateCheck} on ${branch} yet:`, ...found.map((problem) => `- ${problem}`)].join("\n"));
1058
- const { id } = createdSchema.parse(await createRuleset(api, repo));
1059
- return {
1060
- created: true,
1061
- message: `Added the "${gateRuleset.name}" ruleset, which blocks merging to ${branch} until ${gateCheck} passes: ${repo.html_url}/rules/${id}`
1062
- };
1063
- }
1064
-
1065
- // src/features/review/gate.ts
1066
- import { appendFileSync, readFileSync as readFileSync3 } from "fs";
1067
- import { setTimeout } from "timers/promises";
1068
- import { z as z4 } from "zod";
1069
- var eventSchema = z4.object({
1070
- action: z4.string(),
1071
- pull_request: z4.object({
1072
- number: z4.number(),
1073
- head: z4.object({ ref: z4.string(), repo: z4.object({ full_name: z4.string() }).nullable() }),
1074
- user: z4.object({ type: z4.string() })
1075
- })
1076
- });
1077
- var runsSchema = z4.object({
1078
- workflow_runs: z4.array(z4.object({ id: z4.number(), run_number: z4.number(), pull_requests: z4.array(z4.object({ number: z4.number() })) }))
1079
- });
1080
- var jobsSchema = z4.object({ jobs: z4.array(z4.object({ name: z4.string(), status: z4.string(), conclusion: z4.string().nullable() })) });
1081
- var pollInterval = 3e4;
1082
- var rerunHint = "Mark the PR as draft, then ready for review, to run the review again.";
1083
- var firstRunHint = "Mark the PR as ready for review (from draft if it is not one) to run it.";
1084
- function verdict(conclusion, message) {
1085
- const pass = conclusion === "success";
1086
- return { pass, message: pass ? message : `${message} ${rerunHint}` };
1087
- }
1088
- function gateContext(env = process.env) {
1089
- const { GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_RUN_NUMBER, GITHUB_SERVER_URL, GITHUB_EVENT_PATH } = env;
1090
- if (!GITHUB_REPOSITORY || !GITHUB_RUN_ID || !GITHUB_RUN_NUMBER || !GITHUB_SERVER_URL || !GITHUB_EVENT_PATH) {
1091
- throw new ReviewError("yoke review-gate runs only in a GitHub Actions pull_request workflow");
1092
- }
1093
- const event = eventSchema.safeParse(JSON.parse(readFileSync3(GITHUB_EVENT_PATH, "utf8")));
1094
- if (!event.success) throw new ReviewError("yoke review-gate runs only on pull_request events");
1095
- return {
1096
- repo: GITHUB_REPOSITORY,
1097
- runId: GITHUB_RUN_ID,
1098
- runNumber: Number(GITHUB_RUN_NUMBER),
1099
- serverUrl: GITHUB_SERVER_URL,
1100
- event: event.data
1101
- };
1102
- }
1103
- async function earlierRuns({ repo, runId, runNumber, event }, deps) {
1104
- const { workflow_id } = z4.object({ workflow_id: z4.number() }).parse(await deps.get(`repos/${repo}/actions/runs/${runId}`));
1105
- const query = new URLSearchParams({ event: "pull_request", branch: event.pull_request.head.ref, per_page: "100" });
1106
- const { workflow_runs } = runsSchema.parse(await deps.get(`repos/${repo}/actions/workflows/${workflow_id}/runs?${query.toString()}`));
1107
- return workflow_runs.filter((run2) => run2.run_number < runNumber && run2.pull_requests.some((pr2) => pr2.number === event.pull_request.number)).toSorted((a, b) => b.run_number - a.run_number).map((run2) => run2.id);
1108
- }
1109
- async function settledReview(repo, run2, deps) {
1110
- for (; ; ) {
1111
- const { jobs } = jobsSchema.parse(await deps.get(`repos/${repo}/actions/runs/${run2}/jobs`));
1112
- const review = jobs.find((job) => job.name.endsWith(" / review"));
1113
- if (!review || review.conclusion === "skipped") return void 0;
1114
- if (review.status === "completed") return review.conclusion ?? "unknown";
1115
- deps.log(`Waiting for the review in run ${run2} to finish (${review.status})`);
1116
- await deps.sleep(pollInterval);
1117
- }
1118
- }
1119
- async function reviewGate(reviewResult, context, deps) {
1120
- if (reviewResult !== "skipped") return verdict(reviewResult, `The review on this event finished with ${reviewResult}.`);
1121
- const { pull_request } = context.event;
1122
- if (pull_request.head.repo?.full_name !== context.repo || pull_request.user.type === "Bot") {
1123
- return verdict("success", "No review runs for PRs from forks or bots.");
1124
- }
1125
- for (const run2 of await earlierRuns(context, deps)) {
1126
- const conclusion = await settledReview(context.repo, run2, deps);
1127
- const url = `${context.serverUrl}/${context.repo}/actions/runs/${run2}`;
1128
- if (conclusion) return verdict(conclusion, `The latest review for this PR, in run ${url}, finished with ${conclusion}.`);
1129
- }
1130
- return { pass: false, message: `No review has completed for this PR yet. ${firstRunHint}` };
1131
- }
1132
- async function runReviewGate(reviewResult, out, env = process.env) {
1133
- const deps = { get: (path) => githubGet(path), sleep: (ms) => setTimeout(ms), log: out };
1134
- const { pass, message } = await reviewGate(reviewResult, gateContext(env), deps);
1135
- out(pass ? message : `::error::${message}`);
1136
- if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, `${message}
1137
- `);
1138
- return pass;
1139
- }
1140
-
1141
- // src/features/review/comments.ts
1142
- var markerPrefix = "<!-- yoke-review:";
1143
- function slug(text2) {
1144
- return text2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1145
- }
1146
- function findingMarker({ file, title }) {
1147
- return `${markerPrefix} ${file ?? "n/a"}#${slug(title)} -->`;
1148
- }
1149
- function hasMarker(prior, marker) {
1150
- return prior.some((comment) => comment.body.includes(marker));
1151
- }
1152
- function withoutMarkers(body) {
1153
- return body.split("\n").filter((line) => !line.trimStart().startsWith(markerPrefix)).join("\n").trim();
1154
- }
1155
- function heading({ path, line }) {
1156
- if (!path) return "### PR review";
1157
- return line === null ? `### ${path}` : `### ${path}:${line}`;
1158
- }
1159
- function formatPriorComments(prior) {
1160
- if (!prior.length) return "";
1161
- const blocks = prior.map((comment) => `${heading(comment)}
1162
-
1163
- **${comment.author}**: ${withoutMarkers(comment.body)}`);
1164
- return `## Prior review comments
1165
-
1166
- The PR already has the review comments below, including resolved and outdated threads. Do not re-raise a finding a prior comment substantively covers.
1167
-
1168
- ${blocks.join("\n\n")}
1169
- `;
1170
- }
1171
-
1172
- // src/features/review/findings.ts
1173
- var severities = ["blocker", "major", "minor", "nit"];
1174
- var sources = ["confirmed", "disputed", "reviewer1-only", "reviewer2-only", "already-raised"];
1175
- function oneOf(values, raw) {
1176
- const value = raw.toLowerCase();
1177
- return values.find((v) => v === value) ?? null;
1178
- }
1179
- function field(block, name) {
1180
- return block.match(new RegExp(`^-\\s+${name}:\\s*(.*)$`, "im"))?.[1]?.trim() ?? "";
1181
- }
1182
- function parseLocation(raw) {
1183
- const location = raw.replace(/^`+|`+$/g, "").trim();
1184
- if (!location || location.toLowerCase() === "n/a") return { file: null, line: null };
1185
- const match = location.match(/^(.+?):(\d+)(?:-\d+)?$/);
1186
- if (!match?.[1] || !match[2]) return { file: location, line: null };
1187
- return { file: match[1], line: Number(match[2]) };
1188
- }
1189
- function parseFinding(block) {
1190
- const [heading2 = "", ...rest] = block.split("\n");
1191
- const body = rest.join("\n");
1192
- return {
1193
- title: heading2.trim(),
1194
- severity: oneOf(severities, field(body, "Severity")),
1195
- source: oneOf(sources, field(body, "Source")),
1196
- ...parseLocation(field(body, "Location")),
1197
- impact: field(body, "Impact"),
1198
- recommendation: field(body, "Recommendation")
1199
- };
1200
- }
1201
- function parseSummary(markdown) {
1202
- return markdown.match(/^##\s+Summary\s*$([\s\S]*?)(?=^##\s|(?![\s\S]))/m)?.[1]?.trim() ?? "";
1203
- }
1204
- function rank(finding) {
1205
- const severity = finding.severity ? severities.indexOf(finding.severity) : severities.length;
1206
- const source = finding.source ? sources.indexOf(finding.source) : sources.length;
1207
- return severity * (sources.length + 1) + source;
1208
- }
1209
- function parseSynthesis(markdown) {
1210
- const findings = markdown.split(/^###\s+Finding:\s*/m).slice(1).map(parseFinding);
1211
- return { summary: parseSummary(markdown), findings: findings.toSorted((a, b) => rank(a) - rank(b)) };
925
+ function parseSynthesis(markdown) {
926
+ const findings = markdown.split(/^###\s+Finding:\s*/m).slice(1).map(parseFinding);
927
+ return { summary: parseSummary(markdown), findings: findings.toSorted((a, b) => rank(a) - rank(b)) };
1212
928
  }
1213
929
  function formatLocation({ file, line }) {
1214
930
  if (!file) return "no location";
@@ -1281,14 +997,15 @@ This change adds or edits a workflow that calls \`MakerXStudio/yoke/.github/work
1281
997
  - Calling the workflow at \`@main\` rather than a tag or SHA.
1282
998
  - The trigger types, including \`synchronize\`: pushes run only the \`review / gate\` job, and a review is re-run by marking the PR as draft and then ready for review.
1283
999
  - No \`concurrency:\` block, with or without \`synchronize\`: the reusable workflow's \`review\` job skips \`synchronize\` events, so pushes and force-pushes start only gate jobs and don't overlap full reviews.
1284
- - The \`actions: read\` permission: the gate reads earlier runs of the workflow to find the PR's latest review.
1000
+ - The \`pull_request_target\` \`labeled\` trigger: a maintainer reads a fork PR's diff, then adds the \`review\` label to run its review with secrets. The reusable workflow runs the review on it only for that label on fork PRs, and refuses to review any commit pushed after the label. Its \`synchronize\` type removes the label when the fork pushes again.
1001
+ - The \`actions: write\` and \`statuses: write\` permissions (or \`actions: read\` in older callers): the gate reads earlier runs to find the PR's latest review, records a fork's approved review as a commit status, and re-runs the PR head's gate.
1285
1002
  - Draft PRs being reviewed twice: the reusable workflow already skips drafts.
1286
1003
 
1287
1004
  Real defects in it are still findings, such as:
1288
1005
 
1289
1006
  - Passing every secret with \`secrets: inherit\` instead of only \`REVIEW_CI_API_KEY\` (plus \`REVIEW_CI_BASE_URL\` from callers that still pass the URL as a secret).
1290
1007
  - An input the reusable workflow does not declare.
1291
- - No \`permissions:\` block limiting the token to \`contents: read\` and \`pull-requests: write\`, plus \`id-token: write\` with \`azure-client-id\`. The reusable workflow sets no permissions of its own, so the caller's block is the only limit on the token its agents run with.
1008
+ - No \`permissions:\` block limiting the token to \`actions: write\`, \`contents: read\`, \`pull-requests: write\` and \`statuses: write\`, plus \`id-token: write\` with \`azure-client-id\`. The reusable workflow sets no permissions of its own, so the caller's block is the only limit on the token its agents run with.
1292
1009
  `;
1293
1010
  }
1294
1011
 
@@ -1378,11 +1095,11 @@ ${outside.map(outsideEntry).join("\n")}`);
1378
1095
  function postFooter(footer, ci) {
1379
1096
  return [...footer ? [footer] : [], ...ci ? [`_${rerunNote}_`] : []].join("\n\n");
1380
1097
  }
1381
- async function addComments(review, comments, { api, log }) {
1098
+ async function addComments(review2, comments, { api, log }) {
1382
1099
  const rejected = [];
1383
1100
  for (const { finding, ...comment } of comments) {
1384
1101
  try {
1385
- await api.add(review, comment);
1102
+ await api.add(review2, comment);
1386
1103
  } catch (error) {
1387
1104
  if (!(error instanceof ReviewError)) throw error;
1388
1105
  rejected.push(finding);
@@ -1398,18 +1115,18 @@ function failedReviewBody(reason, runUrl, footer = "") {
1398
1115
  return sections.join("\n\n");
1399
1116
  }
1400
1117
  async function postFailure(pr2, reason, runUrl, deps) {
1401
- const review = await deps.api.open(pr2);
1118
+ const review2 = await deps.api.open(pr2);
1402
1119
  try {
1403
- const url = await deps.api.submit(pr2, review, failedReviewBody(reason, runUrl, postFooter(deps.footer, true)));
1120
+ const url = await deps.api.submit(pr2, review2, failedReviewBody(reason, runUrl, postFooter(deps.footer, true)));
1404
1121
  deps.log(`posted the failed review: ${url}`);
1405
1122
  } catch (error) {
1406
- await discard(pr2, review, deps);
1123
+ await discard(pr2, review2, deps);
1407
1124
  throw error;
1408
1125
  }
1409
1126
  }
1410
- async function discard(pr2, review, { api, log }) {
1127
+ async function discard(pr2, review2, { api, log }) {
1411
1128
  try {
1412
- await api.discard(pr2, review);
1129
+ await api.discard(pr2, review2);
1413
1130
  } catch (discardError) {
1414
1131
  log(`could not discard the pending review: ${discardError instanceof Error ? discardError.message : String(discardError)}`);
1415
1132
  }
@@ -1421,17 +1138,17 @@ async function postFindings(pr2, synthesis, diff, prior, deps) {
1421
1138
  const nothingNew = !comments.length && !outside.length;
1422
1139
  if (nothingNew && !ci) return log("nothing new to post");
1423
1140
  const footer = postFooter(deps.footer, ci);
1424
- const review = await api.open(pr2);
1141
+ const review2 = await api.open(pr2);
1425
1142
  try {
1426
1143
  if (nothingNew) {
1427
- const url2 = await api.submit(pr2, review, reviewBody(synthesis.summary, [], footer, noFindingsNote));
1144
+ const url2 = await api.submit(pr2, review2, reviewBody(synthesis.summary, [], footer, noFindingsNote));
1428
1145
  return log(`nothing new to post, so posted a review without findings: ${url2}`);
1429
1146
  }
1430
- const rejected = await addComments(review, comments, deps);
1431
- const url = await api.submit(pr2, review, reviewBody(synthesis.summary, [...outside, ...rejected], footer));
1147
+ const rejected = await addComments(review2, comments, deps);
1148
+ const url = await api.submit(pr2, review2, reviewBody(synthesis.summary, [...outside, ...rejected], footer));
1432
1149
  log(`posted ${comments.length - rejected.length} inline comment(s) and ${outside.length + rejected.length} in the review body: ${url}`);
1433
1150
  } catch (error) {
1434
- await discard(pr2, review, deps);
1151
+ await discard(pr2, review2, deps);
1435
1152
  throw error;
1436
1153
  }
1437
1154
  }
@@ -1640,7 +1357,7 @@ async function listModels(settings, fetchModels = fetch) {
1640
1357
  }
1641
1358
 
1642
1359
  // src/features/review/review.ts
1643
- import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync8, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
1360
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
1644
1361
  import { homedir as homedir2, tmpdir } from "os";
1645
1362
  import { join as join8 } from "path";
1646
1363
 
@@ -1710,10 +1427,10 @@ async function withEntraToken(settings, { env, fetch: fetchFn = fetch, mask }) {
1710
1427
 
1711
1428
  // src/features/review/harness.ts
1712
1429
  import { spawn } from "child_process";
1713
- import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
1430
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
1714
1431
 
1715
1432
  // src/features/review/usage.ts
1716
- import { appendFileSync as appendFileSync2 } from "fs";
1433
+ import { appendFileSync } from "fs";
1717
1434
  var fields2 = ["input", "output", "cacheRead", "cacheWrite"];
1718
1435
  var rows = [
1719
1436
  ["reviewer1", "Reviewer 1"],
@@ -1776,7 +1493,7 @@ function writeJobSummary(summary, usage, log, env = process.env) {
1776
1493
  const path = env.GITHUB_STEP_SUMMARY;
1777
1494
  if (!path) return;
1778
1495
  try {
1779
- appendFileSync2(path, `${jobSummary(summary, usage)}
1496
+ appendFileSync(path, `${jobSummary(summary, usage)}
1780
1497
  `);
1781
1498
  log("wrote the review summary and token usage to the job summary");
1782
1499
  } catch (error) {
@@ -1872,11 +1589,12 @@ function parseHarnessEvent(harness, line) {
1872
1589
 
1873
1590
  // src/features/review/harness.ts
1874
1591
  var tailLines = 20;
1875
- function harnessArgs({ harness, reviewDir, outputPath, override }) {
1592
+ function harnessArgs({ harness, reviewDir, outputPath, override, untrusted }) {
1876
1593
  const extra = override?.args ?? [];
1877
1594
  if (harness === "codex") return ["exec", "--sandbox", "read-only", "--json", "--output-last-message", outputPath, ...extra, "-"];
1878
1595
  return [
1879
1596
  "-p",
1597
+ ...untrusted ? ["--setting-sources", "user", "--strict-mcp-config"] : [],
1880
1598
  "--add-dir",
1881
1599
  reviewDir,
1882
1600
  "--disallowedTools",
@@ -1902,7 +1620,7 @@ function exitError(harness, code, output) {
1902
1620
  return [`${harness} exited with code ${code ?? "unknown"}`, ...detail ? [detail] : []].join("\n");
1903
1621
  }
1904
1622
  function hasReview(path) {
1905
- return existsSync3(path) && readFileSync4(path, "utf8").trim().length > 0;
1623
+ return existsSync3(path) && readFileSync3(path, "utf8").trim().length > 0;
1906
1624
  }
1907
1625
  var runHarness = (run2) => new Promise((resolve3) => {
1908
1626
  const shell = process.platform === "win32";
@@ -1956,11 +1674,11 @@ var runHarness = (run2) => new Promise((resolve3) => {
1956
1674
  import { execFile as execFile2 } from "child_process";
1957
1675
 
1958
1676
  // src/features/review/run.ts
1959
- import { existsSync as existsSync6, readFileSync as readFileSync7, rmSync, writeFileSync as writeFileSync6 } from "fs";
1677
+ import { existsSync as existsSync6, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync6 } from "fs";
1960
1678
  import { join as join6 } from "path";
1961
1679
 
1962
1680
  // src/features/review/cachedSlots.ts
1963
- import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
1681
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
1964
1682
  import { join as join4 } from "path";
1965
1683
  function slotsPath(reviewDir) {
1966
1684
  return join4(reviewDir, "slots.json");
@@ -1971,7 +1689,7 @@ function isCachedSlots(value) {
1971
1689
  function readCachedSlots(reviewDir) {
1972
1690
  const path = slotsPath(reviewDir);
1973
1691
  if (!existsSync4(path)) return {};
1974
- const parsed = JSON.parse(readFileSync5(path, "utf8"));
1692
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
1975
1693
  return isCachedSlots(parsed) ? parsed : {};
1976
1694
  }
1977
1695
  function cachedSlot(reviewDir, step) {
@@ -2125,7 +1843,7 @@ ${files.join("\n")}
2125
1843
  }
2126
1844
 
2127
1845
  // src/features/review/usedModels.ts
2128
- import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
1846
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "fs";
2129
1847
  import { join as join5 } from "path";
2130
1848
  var repoUrl = "https://github.com/MakerXStudio/yoke";
2131
1849
  function modelsPath(reviewDir) {
@@ -2137,7 +1855,7 @@ function isUsedModels(value) {
2137
1855
  function readUsedModels(reviewDir) {
2138
1856
  const path = modelsPath(reviewDir);
2139
1857
  if (!existsSync5(path)) return {};
2140
- const parsed = JSON.parse(readFileSync6(path, "utf8"));
1858
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
2141
1859
  return isUsedModels(parsed) ? parsed : {};
2142
1860
  }
2143
1861
  function recordUsedModel(reviewDir, step, harness, model) {
@@ -2190,6 +1908,7 @@ async function runStep({ name, label, slot, ...step }, context) {
2190
1908
  ...step,
2191
1909
  harness,
2192
1910
  override,
1911
+ untrusted: context.untrusted,
2193
1912
  cwd: context.cwd,
2194
1913
  reviewDir: context.reviewDir,
2195
1914
  onProgress: status.update
@@ -2208,7 +1927,7 @@ async function runStep({ name, label, slot, ...step }, context) {
2208
1927
  }
2209
1928
  function hasOutput({ name, slot, outputPath }, context) {
2210
1929
  if (cachedSlot(context.reviewDir, name) !== describeSlot(slot)) return false;
2211
- return existsSync6(outputPath) && readFileSync7(outputPath, "utf8").trim().length > 0;
1930
+ return existsSync6(outputPath) && readFileSync6(outputPath, "utf8").trim().length > 0;
2212
1931
  }
2213
1932
  async function runOrReuse(step, context) {
2214
1933
  if (!hasOutput(step, context)) return { ok: await runStep(step, context), ran: true };
@@ -2235,7 +1954,7 @@ function synthesisResult(markdown) {
2235
1954
  function rawReviews(succeeded, context) {
2236
1955
  const report = succeeded.map((reviewer) => `# ${reviewerLabel(reviewer, context)} review
2237
1956
 
2238
- ${readFileSync7(reviewPath(context, reviewer), "utf8").trim()}`).join("\n\n");
1957
+ ${readFileSync6(reviewPath(context, reviewer), "utf8").trim()}`).join("\n\n");
2239
1958
  return { report, synthesis: null, markdown: null };
2240
1959
  }
2241
1960
  function synthesisSlot(succeeded, context) {
@@ -2252,7 +1971,7 @@ async function synthesise(requestPath, succeeded, reviewersRan, context) {
2252
1971
  }));
2253
1972
  const prompt = buildSynthesisPrompt(requestPath, files);
2254
1973
  if ((await runOrReuse({ name: "synthesis", label: `synthesis (${describeSlot(slot)})`, slot, prompt, outputPath }, context)).ok)
2255
- return synthesisResult(readFileSync7(outputPath, "utf8"));
1974
+ return synthesisResult(readFileSync6(outputPath, "utf8"));
2256
1975
  if (context.strict) throw new ReviewFailure("synthesis failed", "The synthesis failed; nothing to post.");
2257
1976
  context.log("showing the raw reviews instead");
2258
1977
  return rawReviews(succeeded, context);
@@ -2360,21 +2079,21 @@ async function assertViable(slots2, check = checks) {
2360
2079
  }
2361
2080
 
2362
2081
  // src/features/review/reviewLog.ts
2363
- import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync3 } from "fs";
2082
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync3 } from "fs";
2364
2083
  import { join as join7 } from "path";
2365
2084
  function reviewLog(command = process.argv.slice(2), now = () => /* @__PURE__ */ new Date()) {
2366
2085
  let path;
2367
2086
  let buffered = [];
2368
2087
  return {
2369
2088
  append: (line) => {
2370
- if (path) appendFileSync3(path, `${line}
2089
+ if (path) appendFileSync2(path, `${line}
2371
2090
  `);
2372
2091
  else buffered.push(line);
2373
2092
  },
2374
2093
  attach: (reviewDir) => {
2375
2094
  mkdirSync3(reviewDir, { recursive: true });
2376
2095
  path = join7(reviewDir, "review.log");
2377
- appendFileSync3(path, `${["", `=== ${now().toISOString()} ===`, `$ yoke ${command.join(" ")}`, ...buffered].join("\n")}
2096
+ appendFileSync2(path, `${["", `=== ${now().toISOString()} ===`, `$ yoke ${command.join(" ")}`, ...buffered].join("\n")}
2378
2097
  `);
2379
2098
  buffered = [];
2380
2099
  }
@@ -2418,12 +2137,12 @@ function prepareRunDir(reviewDir, force) {
2418
2137
  mkdirSync4(reviewDir, { recursive: true });
2419
2138
  if (force) for (const name of cachedOutputs) rmSync2(join8(reviewDir, name), { force: true });
2420
2139
  }
2421
- async function reviewAtHead(pr2, reviewDir, request, slots2, ui, strict) {
2140
+ async function reviewAtHead(pr2, reviewDir, request, slots2, ui, { strict, untrusted }) {
2422
2141
  const checkoutDir = mkdtempSync(join8(tmpdir(), "yoke-review-"));
2423
2142
  try {
2424
2143
  const checkout = await timed(ui, `checking out ${pr2.headRefOid.slice(0, 7)}`, () => checkoutHead(pr2, checkoutDir));
2425
2144
  try {
2426
- return await runReview(request, { cwd: checkout.cwd, reviewDir, slots: slots2, runHarness, log: ui.log, track: ui.track, strict });
2145
+ return await runReview(request, { cwd: checkout.cwd, reviewDir, slots: slots2, runHarness, log: ui.log, track: ui.track, strict, untrusted });
2427
2146
  } finally {
2428
2147
  await checkout.remove();
2429
2148
  }
@@ -2433,13 +2152,19 @@ async function reviewAtHead(pr2, reviewDir, request, slots2, ui, strict) {
2433
2152
  }
2434
2153
  function savedReview(path, post) {
2435
2154
  if (!post || !existsSync7(path)) return null;
2436
- return parseSynthesis(readFileSync8(path, "utf8"));
2155
+ return parseSynthesis(readFileSync7(path, "utf8"));
2156
+ }
2157
+ function assertHead(pr2, head) {
2158
+ if (head && pr2.headRefOid !== head)
2159
+ throw new ReviewError(
2160
+ `#${pr2.number} moved from ${head.slice(0, 7)} to ${pr2.headRefOid.slice(0, 7)}, so its new commits were not approved; not reviewing it`
2161
+ );
2437
2162
  }
2438
2163
  function saveReview(path, markdown) {
2439
2164
  if (markdown) writeFileSync7(path, markdown);
2440
2165
  else rmSync2(path, { force: true });
2441
2166
  }
2442
- async function reviewPr(number, settings, { post, force }, io) {
2167
+ async function reviewPr(number, settings, { post, force, head }, io) {
2443
2168
  const file = reviewLog();
2444
2169
  const ui = logged(io.ui, file.append);
2445
2170
  const out = (text2) => {
@@ -2455,11 +2180,12 @@ async function reviewPr(number, settings, { post, force }, io) {
2455
2180
  "fetching the diff and earlier review comments",
2456
2181
  () => Promise.all([fetchDiff(pr2), fetchPriorComments(pr2)])
2457
2182
  );
2183
+ assertHead(pr2, head);
2458
2184
  const reviewDir = reviewRunDir(pr2);
2459
2185
  prepareRunDir(reviewDir, force);
2460
2186
  file.attach(reviewDir);
2461
2187
  const ci = post && isCi();
2462
- const review = async () => {
2188
+ const review2 = async () => {
2463
2189
  const path = join8(reviewDir, "review.md");
2464
2190
  const saved = savedReview(path, post);
2465
2191
  if (saved) {
@@ -2469,7 +2195,10 @@ async function reviewPr(number, settings, { post, force }, io) {
2469
2195
  }
2470
2196
  await timed(ui, "checking each reviewer can run", () => assertViable(slots2));
2471
2197
  log(`review folder: ${reviewDir}`);
2472
- const result = await reviewAtHead(pr2, reviewDir, buildRequest(pr2, diff, prior), slots2, ui, ci);
2198
+ const result = await reviewAtHead(pr2, reviewDir, buildRequest(pr2, diff, prior), slots2, ui, {
2199
+ strict: ci,
2200
+ untrusted: head !== void 0
2201
+ });
2473
2202
  out(result.report);
2474
2203
  saveReview(path, result.markdown);
2475
2204
  if (post && !result.synthesis)
@@ -2482,7 +2211,7 @@ async function reviewPr(number, settings, { post, force }, io) {
2482
2211
  const footer = () => modelsFooter(readUsedModels(reviewDir));
2483
2212
  let findings;
2484
2213
  try {
2485
- const { synthesis: synthesis2, usage } = await review();
2214
+ const { synthesis: synthesis2, usage } = await review2();
2486
2215
  findings = synthesis2;
2487
2216
  if (ci && synthesis2) writeJobSummary(synthesis2.summary, usage, log);
2488
2217
  } catch (error) {
@@ -2495,7 +2224,7 @@ async function reviewPr(number, settings, { post, force }, io) {
2495
2224
  }
2496
2225
 
2497
2226
  // src/features/sync/sync.ts
2498
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "fs";
2227
+ import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync8 } from "fs";
2499
2228
  import { homedir as homedir3 } from "os";
2500
2229
  import { join as join9 } from "path";
2501
2230
 
@@ -2535,7 +2264,7 @@ var reviewCiGateSkill = {
2535
2264
  disableModelInvocation: true,
2536
2265
  userLevel: true,
2537
2266
  body: `
2538
- Run \`yoke review-ci-gate\` and relay its output.
2267
+ Run \`yoke review ci-gate\` and relay its output.
2539
2268
 
2540
2269
  If it fails, say what is missing and how to fix it, as it reports. Don't create the ruleset any other way.
2541
2270
 
@@ -2556,13 +2285,13 @@ Read \`.github/workflows/\`, recent review runs (\`gh run list\`, \`gh run view
2556
2285
 
2557
2286
  A workflow is an LLM review if it has a model review PRs, whatever tool it uses.
2558
2287
 
2559
- - **None:** run \`yoke review-ci $ARGUMENTS\`.
2560
- - **One, not yoke's:** run \`yoke review-ci $ARGUMENTS --print\` with its provider and models, then edit that file in place to run yoke's review. Keep its name, triggers, provider, models, base URL, variable and secret names.
2288
+ - **None:** run \`yoke review ci $ARGUMENTS\`.
2289
+ - **One, not yoke's:** run \`yoke review ci $ARGUMENTS --print\` with its provider and models, then edit that file in place to run yoke's review. Keep its name, triggers, provider, models, base URL, variable and secret names.
2561
2290
  - **Already yoke's** (calls MakerXStudio/yoke's \`review.yml\`, or \`./.github/workflows/review.yml\` in yoke itself): compare with \`--print\` and add only what is missing.
2562
2291
 
2563
2292
  Never ask whether to overwrite a workflow. If \`review-pr.yml\` exists and differs, edit it in place.
2564
2293
 
2565
- The \`review / gate\` merge gate needs the \`synchronize\` trigger and \`actions: read\` permission from \`--print\`; add them to an edited workflow if missing.
2294
+ The \`review / gate\` merge gate needs the \`synchronize\` trigger, the \`pull_request_target\` \`labeled\` and \`synchronize\` triggers, and the \`actions: write\` and \`statuses: write\` permissions from \`--print\`; add them to an edited workflow if missing.
2566
2295
 
2567
2296
  If no workflow or setting names the models, ask which model each reviewer runs. For LiteLLM, offer \`yoke models\`. For Foundry, ask for deployment names.
2568
2297
 
@@ -2603,7 +2332,7 @@ function skillsRoot(user, cwd = process.cwd(), home = homedir3()) {
2603
2332
  return join9(user ? home : cwd, ".claude", "skills");
2604
2333
  }
2605
2334
  function isOwnedByUser(skillFile) {
2606
- return existsSync8(skillFile) && !readFileSync9(skillFile, "utf8").includes(ownerMarker);
2335
+ return existsSync8(skillFile) && !readFileSync8(skillFile, "utf8").includes(ownerMarker);
2607
2336
  }
2608
2337
  function syncSkills(destination, userDestination, force = false, skills = bundledSkills) {
2609
2338
  const result = { written: [], skipped: [] };
@@ -2659,14 +2388,14 @@ function detectInstall(dir, globalRoot = npmGlobalRoot, toplevel = gitToplevel)
2659
2388
  }
2660
2389
 
2661
2390
  // src/features/update/targets.ts
2662
- import { existsSync as existsSync9, readFileSync as readFileSync10 } from "fs";
2391
+ import { existsSync as existsSync9, readFileSync as readFileSync9 } from "fs";
2663
2392
  import { homedir as homedir4 } from "os";
2664
2393
  import { join as join10, resolve as resolve2 } from "path";
2665
2394
  function holdsYokeSkill(root, skills) {
2666
2395
  return skills.some((skill) => {
2667
2396
  if (skill.userLevel) return false;
2668
2397
  const file = join10(root, skill.name, "SKILL.md");
2669
- return existsSync9(file) && readFileSync10(file, "utf8").includes(ownerMarker);
2398
+ return existsSync9(file) && readFileSync9(file, "utf8").includes(ownerMarker);
2670
2399
  });
2671
2400
  }
2672
2401
  function syncTargets(cwd = process.cwd(), home = homedir4(), skills = bundledSkills) {
@@ -2731,96 +2460,562 @@ function updateYoke({ dir, install, targets, run: run2 = runInherited, log, erro
2731
2460
  return 0;
2732
2461
  }
2733
2462
 
2734
- // src/cli.ts
2735
- var { version } = package_default;
2736
- var program = new Command("yoke").description("Lean Claude Code skills and the CLI they call.").version(version);
2737
- program.command("sync").description("Write yoke's skills into this repo's .claude/skills, or ~/.claude/skills with --user").option("--user", "write to ~/.claude/skills instead, for every repo").option("--force", "overwrite same-named skills that yoke didn't write").action((options) => {
2738
- const user = options.user ?? false;
2739
- const cwd = process.cwd();
2740
- const display = (path) => path.startsWith(cwd) ? relative2(cwd, path) : path;
2741
- const { written, skipped } = syncSkills(skillsRoot(user), skillsRoot(true), options.force ?? false);
2742
- for (const file of written) console.log(`wrote ${display(file)}`);
2743
- for (const dir of skipped) console.log(`skipped ${display(dir)} (not yoke's skill; rerun with --force to overwrite it)`);
2744
- });
2745
- program.command("update").description("Update yoke to the latest version, then re-sync its skills wherever they were synced before").action(() => {
2746
- const dir = installDir();
2747
- process.exitCode = updateYoke({
2748
- dir,
2749
- install: detectInstall(dir),
2750
- targets: () => syncTargets(),
2751
- log: console.log,
2752
- error: console.error
2753
- });
2754
- });
2755
- var pr = program.command("pr").description("Pull request commands");
2756
- pr.command("raise").description("Validate a PR title and body, then create the PR with gh").requiredOption("--title <title>", "single-line PR title").requiredOption("--body <body>", "PR body with ## What, ## Why and optionally ## How").option("--base <branch>", "base branch (defaults to the repo default)").option("--no-draft", "create a ready-for-review PR instead of a draft").addHelpText("after", raiseGuide).action((options) => {
2757
- try {
2758
- console.log(raisePr(options));
2759
- } catch (error) {
2760
- if (!(error instanceof PrValidationError)) throw error;
2761
- console.error(error.message);
2762
- process.exitCode = 1;
2763
- }
2463
+ // src/features/review/ciGate.ts
2464
+ import { z as z3 } from "zod";
2465
+
2466
+ // src/features/review/ciGateCaller.ts
2467
+ import { parse } from "yaml";
2468
+ import { z as z2 } from "zod";
2469
+ var yokeRepo = "MakerXStudio/yoke";
2470
+ var localReviewWorkflow = "./.github/workflows/review.yml";
2471
+ var reviewWorkflowPrefix = reviewWorkflowRef.slice(0, reviewWorkflowRef.indexOf("@") + 1).toLowerCase();
2472
+ var workflowSchema = z2.looseObject({
2473
+ on: z2.unknown().optional(),
2474
+ permissions: z2.unknown().optional(),
2475
+ jobs: z2.record(z2.string(), z2.looseObject({ uses: z2.unknown().optional(), permissions: z2.unknown().optional() }).nullable()).optional()
2764
2476
  });
2765
- var providerKeys = settingKeys.filter((key) => !key.startsWith("review."));
2766
- addSettingOptions(
2767
- program.command("review").description("Review the current branch's PR, or PR <number>, with Claude and Codex in parallel, then combine their findings").argument("[number]", "PR number; reviewed without checking it out").option("--post", "post the findings to the PR as one review, reusing the saved review of the PR head when there is one").option("--force", "discard this PR head's cached reviews and run every pass again").option("--verbose", "print every step and each reviewer's tool calls as log lines instead of live spinners"),
2768
- settingKeys
2769
- ).addHelpText("after", settingsHelp).action(async (number, options) => {
2770
- const verbose = options.verbose ?? false;
2771
- const ui = process.stderr.isTTY && !verbose ? spinnerUi(process.stderr) : lineUi(console.error, { activity: verbose });
2477
+ function parseWorkflow(content) {
2772
2478
  try {
2773
- const settings = resolveSettings(flagSettings(options, settingKeys), process.env, readUserConfig(userConfigPath()));
2774
- const run2 = { post: options.post ?? false, force: options.force ?? false };
2775
- await reviewPr(number, settings, run2, { out: console.log, ui });
2776
- } catch (error) {
2777
- if (!(error instanceof ReviewError || error instanceof ConfigError)) throw error;
2778
- console.error(error.message);
2779
- process.exitCode = 1;
2780
- } finally {
2781
- ui.stop();
2479
+ const result = workflowSchema.safeParse(parse(content));
2480
+ return result.success ? result.data : void 0;
2481
+ } catch {
2482
+ return void 0;
2782
2483
  }
2484
+ }
2485
+ function callsReview(uses, repo) {
2486
+ if (typeof uses !== "string") return false;
2487
+ if (uses.toLowerCase().startsWith(reviewWorkflowPrefix)) return true;
2488
+ return repo.toLowerCase() === yokeRepo.toLowerCase() && uses === localReviewWorkflow;
2489
+ }
2490
+ function listIncludes(value, item) {
2491
+ return value === item || Array.isArray(value) && value.includes(item);
2492
+ }
2493
+ function property(value, key) {
2494
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
2495
+ return new Map(Object.entries(value)).get(key);
2496
+ }
2497
+ function eventTrigger(on, event) {
2498
+ const trigger = property(on, event);
2499
+ return typeof trigger === "object" && trigger !== null && !Array.isArray(trigger) ? { ...trigger } : void 0;
2500
+ }
2501
+ function runsOn(on, event, type, byDefault) {
2502
+ if (typeof on === "string" || Array.isArray(on)) return byDefault && listIncludes(on, event);
2503
+ if (typeof on !== "object" || on === null || !(event in on)) return false;
2504
+ const trigger = eventTrigger(on, event);
2505
+ if (!trigger || !("types" in trigger)) return byDefault;
2506
+ return listIncludes(trigger.types, type);
2507
+ }
2508
+ function globMatches(pattern, branch) {
2509
+ const source = pattern.split(/(\*\*|\*|\?)/).map((part) => ({ "**": ".*", "*": "[^/]*", "?": "." })[part] ?? part.replace(/[.+^${}()|[\]\\]/g, "\\$&")).join("");
2510
+ return new RegExp(`^${source}$`).test(branch);
2511
+ }
2512
+ function patterns(value) {
2513
+ if (typeof value === "string") return [value];
2514
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
2515
+ }
2516
+ function branchesInclude(value, branch) {
2517
+ return patterns(value).reduce(
2518
+ (included, pattern) => pattern.startsWith("!") ? included && !globMatches(pattern.slice(1), branch) : included || globMatches(pattern, branch),
2519
+ false
2520
+ );
2521
+ }
2522
+ function skipFilters(on, event, branch) {
2523
+ const trigger = eventTrigger(on, event);
2524
+ if (!trigger) return [];
2525
+ const filters = ["paths", "paths-ignore"].filter((key) => key in trigger);
2526
+ if ("branches" in trigger && !branchesInclude(trigger.branches, branch)) filters.push("branches");
2527
+ if ("branches-ignore" in trigger && patterns(trigger["branches-ignore"]).some((pattern) => globMatches(pattern, branch)))
2528
+ filters.push("branches-ignore");
2529
+ return filters;
2530
+ }
2531
+ function canWrite(permissions, scope) {
2532
+ if (permissions === "write-all") return true;
2533
+ return property(permissions, scope) === "write";
2534
+ }
2535
+ var writeScopes = ["actions", "statuses"];
2536
+ function filterProblem(on, event, branch) {
2537
+ const filters = skipFilters(on, event, branch);
2538
+ if (filters.length === 0) return [];
2539
+ const named = filters.map((filter) => `\`${filter}\``).join(", ");
2540
+ return [
2541
+ `skips some PRs to ${branch} through its \`${event}\` ${named} filter${filters.length > 1 ? "s" : ""}, so those PRs could never merge`
2542
+ ];
2543
+ }
2544
+ function checkWorkflow(path, workflow, repo, branch) {
2545
+ const jobs = Object.values(workflow.jobs ?? {}).filter((job) => job && callsReview(job.uses, repo));
2546
+ if (jobs.length === 0) return void 0;
2547
+ const problems2 = [];
2548
+ if (!runsOn(workflow.on, "pull_request", "synchronize", true)) problems2.push("lacks the `synchronize` type in its `pull_request` trigger");
2549
+ if (!runsOn(workflow.on, "pull_request_target", "labeled", false))
2550
+ problems2.push("lacks a `pull_request_target` trigger with the `labeled` type, so fork PRs can never pass the gate");
2551
+ else if (!runsOn(workflow.on, "pull_request_target", "synchronize", true))
2552
+ problems2.push(
2553
+ "lacks the `synchronize` type in its `pull_request_target` trigger, so a push to an approved fork PR keeps the `review` label and a maintainer can't re-approve it"
2554
+ );
2555
+ const missing = writeScopes.filter((scope) => !jobs.some((job) => canWrite(job?.permissions ?? workflow.permissions, scope)));
2556
+ if (missing.length > 0) problems2.push(`lacks ${missing.map((scope) => `\`${scope}: write\``).join(" and ")} in its permissions`);
2557
+ problems2.push(...filterProblem(workflow.on, "pull_request", branch), ...filterProblem(workflow.on, "pull_request_target", branch));
2558
+ return { path, problems: problems2 };
2559
+ }
2560
+ function checkCallerWorkflows(files, repo, branch) {
2561
+ const callers = files.flatMap(({ path, content }) => {
2562
+ const workflow = parseWorkflow(content);
2563
+ const check = workflow && checkWorkflow(path, workflow, repo, branch);
2564
+ return check ? [check] : [];
2565
+ });
2566
+ return callers.find((caller) => caller.problems.length === 0) ?? callers[0];
2567
+ }
2568
+
2569
+ // src/features/review/ciGate.ts
2570
+ var gateRuleset = {
2571
+ name: "yoke review",
2572
+ target: "branch",
2573
+ enforcement: "active",
2574
+ conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
2575
+ rules: [
2576
+ {
2577
+ type: "required_status_checks",
2578
+ parameters: { strict_required_status_checks_policy: false, required_status_checks: [{ context: gateCheck }] }
2579
+ }
2580
+ ]
2581
+ };
2582
+ var recentPrCount = 20;
2583
+ var repoSchema = z3.object({
2584
+ full_name: z3.string(),
2585
+ html_url: z3.string(),
2586
+ default_branch: z3.string()
2783
2587
  });
2784
- var ciKeys = settingKeys.filter((key) => /^review\.\w+\.(harness|model)$/.test(key));
2785
- addSettingOptions(
2786
- program.command("review-ci").description(
2787
- `Write ${callerWorkflowPath} so each PR in this repo is reviewed in CI, then print the secrets, or Entra federated credential, it needs`
2788
- ).option("--provider <provider>", "litellm or foundry; every slot uses it in CI. Default: review.reviewer1.provider, else litellm").option(
2789
- "--azure-client-id <id>",
2790
- "Entra app (client) ID to sign in to Foundry with GitHub OIDC instead of an API key; needs --provider foundry"
2791
- ).option("--azure-tenant-id <id>", "Entra tenant ID of that app").option("--environment <name>", "GitHub environment the review job runs in, matching the app's federated credential subject").option("--print", `print the workflow and setup commands without writing ${callerWorkflowPath}`),
2792
- ciKeys
2793
- ).addHelpText("after", settingsHelp).action((options) => {
2588
+ var branchRulesSchema = z3.array(
2589
+ z3.object({
2590
+ type: z3.string(),
2591
+ ruleset_id: z3.number().optional(),
2592
+ parameters: z3.object({ required_status_checks: z3.array(z3.object({ context: z3.string() })).optional() }).optional()
2593
+ })
2594
+ );
2595
+ var directorySchema = z3.array(z3.object({ type: z3.string(), name: z3.string(), path: z3.string() }));
2596
+ var fileSchema = z3.object({ content: z3.string() });
2597
+ var pullsSchema = z3.array(z3.object({ head: z3.object({ sha: z3.string() }) }));
2598
+ var checkRunsSchema = z3.object({ total_count: z3.number() });
2599
+ var createdSchema = z3.object({ id: z3.number() });
2600
+ var rulesetsSchema = z3.array(z3.object({ id: z3.number(), name: z3.string(), enforcement: z3.string() }));
2601
+ var githubApi = { get: (path) => githubGet(path), post: (path, body) => githubPost(path, body) };
2602
+ async function getUnlessMissing(api, path) {
2794
2603
  try {
2795
- const settings = resolveSettings(flagSettings(options, ciKeys), process.env, readUserConfig(userConfigPath()));
2796
- const workflow = callerWorkflow(settings, options.provider, options);
2797
- const root = repoRoot();
2798
- const outcome = options.print ? "printed" : writeCallerWorkflow(root, renderCallerWorkflow(workflow));
2799
- console.log(reviewCiReport(outcome, settings, workflow, () => githubRepo(root)));
2604
+ return await api.get(path);
2800
2605
  } catch (error) {
2801
- if (!(error instanceof ReviewError || error instanceof ConfigError)) throw error;
2802
- console.error(error.message);
2803
- process.exitCode = 1;
2606
+ if (error instanceof ReviewError && error.message.includes("(HTTP 404)")) return void 0;
2607
+ throw error;
2804
2608
  }
2805
- });
2806
- program.command("review-ci-gate").description(`Require the ${gateCheck} check on the default branch with a ruleset, once the CI review workflow is merged`).action(async () => {
2609
+ }
2610
+ async function workflowFiles(api, repo) {
2611
+ const ref = `ref=${encodeURIComponent(repo.default_branch)}`;
2612
+ const listing = await getUnlessMissing(api, `repos/${repo.full_name}/contents/.github/workflows?${ref}`);
2613
+ if (listing === void 0) return [];
2614
+ const files = directorySchema.parse(listing).filter((entry) => entry.type === "file" && /\.ya?ml$/.test(entry.name));
2615
+ return Promise.all(
2616
+ files.map(async ({ path }) => {
2617
+ const { content } = fileSchema.parse(await api.get(`repos/${repo.full_name}/contents/${path}?${ref}`));
2618
+ return { path, content: Buffer.from(content, "base64").toString("utf8") };
2619
+ })
2620
+ );
2621
+ }
2622
+ async function callerProblem(api, repo) {
2623
+ const branch = repo.default_branch;
2624
+ const caller = checkCallerWorkflows(await workflowFiles(api, repo), repo.full_name, branch);
2625
+ if (!caller) return `No workflow on ${branch} calls yoke's review.yml. Run /review-ci, then merge its ${callerWorkflowPath} to ${branch}.`;
2626
+ if (caller.problems.length > 0)
2627
+ return `${caller.path} on ${branch} calls yoke's review.yml but ${caller.problems.join(" and ")}. Fix it and merge the change to ${branch}.`;
2628
+ return void 0;
2629
+ }
2630
+ async function gateHasReported(api, repo) {
2631
+ const pulls = pullsSchema.parse(
2632
+ await api.get(`repos/${repo.full_name}/pulls?state=all&sort=updated&direction=desc&per_page=${recentPrCount}`)
2633
+ );
2634
+ for (const { head } of pulls) {
2635
+ const runs = checkRunsSchema.parse(
2636
+ await api.get(`repos/${repo.full_name}/commits/${head.sha}/check-runs?check_name=${encodeURIComponent(gateCheck)}&per_page=1`)
2637
+ );
2638
+ if (runs.total_count > 0) return true;
2639
+ }
2640
+ return false;
2641
+ }
2642
+ async function rulesetProblem(api, repo) {
2643
+ const rulesets = rulesetsSchema.parse(await api.get(`repos/${repo.full_name}/rulesets?includes_parents=false&per_page=100`));
2644
+ const existing = rulesets.find((ruleset) => ruleset.name === gateRuleset.name);
2645
+ if (!existing) return void 0;
2646
+ return `The "${gateRuleset.name}" ruleset already exists with enforcement \`${existing.enforcement}\` but doesn't require ${gateCheck} on ${repo.default_branch}: ${repo.html_url}/rules/${existing.id}. Set its enforcement to Active and its required check to ${gateCheck}, or delete it and run this again.`;
2647
+ }
2648
+ async function createRuleset(api, repo) {
2807
2649
  try {
2808
- console.log((await enforceReviewGate()).message);
2650
+ return await api.post(`repos/${repo.full_name}/rulesets`, gateRuleset);
2809
2651
  } catch (error) {
2810
- if (!(error instanceof ReviewError)) throw error;
2811
- console.error(error.message);
2812
- process.exitCode = 1;
2652
+ if (error instanceof ReviewError && /\(HTTP 40[34]\)/.test(error.message))
2653
+ throw new ReviewError(
2654
+ `GitHub refused to add the "${gateRuleset.name}" ruleset to ${repo.full_name}: your gh user can't edit its repository rules. Ask a repo admin, or someone whose role can edit repository rules, to run \`yoke review ci-gate\`.`
2655
+ );
2656
+ throw error;
2813
2657
  }
2658
+ }
2659
+ async function problems(api, repo) {
2660
+ const found = [];
2661
+ const ruleset = await rulesetProblem(api, repo);
2662
+ if (ruleset) found.push(ruleset);
2663
+ const caller = await callerProblem(api, repo);
2664
+ if (caller) found.push(caller);
2665
+ if (!await gateHasReported(api, repo))
2666
+ found.push(
2667
+ `No ${gateCheck} check run on the ${recentPrCount} most recently updated PRs. Open or push to a PR so the gate reports, then run this again.`
2668
+ );
2669
+ return found;
2670
+ }
2671
+ async function enforceReviewGate(api = githubApi) {
2672
+ const repo = repoSchema.parse(await api.get("repos/{owner}/{repo}"));
2673
+ const branch = repo.default_branch;
2674
+ const rules = branchRulesSchema.parse(await api.get(`repos/${repo.full_name}/rules/branches/${encodeURIComponent(branch)}`));
2675
+ const existing = rules.find(
2676
+ (rule) => rule.type === "required_status_checks" && rule.parameters?.required_status_checks?.some((check) => check.context === gateCheck)
2677
+ );
2678
+ if (existing) {
2679
+ const where = existing.ruleset_id ? ` by ${repo.html_url}/rules/${existing.ruleset_id}` : "";
2680
+ return { created: false, message: `${gateCheck} is already required on ${branch}${where}; nothing changed.` };
2681
+ }
2682
+ const found = await problems(api, repo);
2683
+ if (found.length > 0)
2684
+ throw new ReviewError([`Cannot require ${gateCheck} on ${branch} yet:`, ...found.map((problem) => `- ${problem}`)].join("\n"));
2685
+ const { id } = createdSchema.parse(await createRuleset(api, repo));
2686
+ return {
2687
+ created: true,
2688
+ message: `Added the "${gateRuleset.name}" ruleset, which blocks merging to ${branch} until ${gateCheck} passes: ${repo.html_url}/rules/${id}`
2689
+ };
2690
+ }
2691
+
2692
+ // src/features/review/gate.ts
2693
+ import { appendFileSync as appendFileSync3, readFileSync as readFileSync10 } from "fs";
2694
+ import { setTimeout } from "timers/promises";
2695
+ import { z as z6 } from "zod";
2696
+
2697
+ // src/features/review/gateHandoff.ts
2698
+ import { z as z5 } from "zod";
2699
+
2700
+ // src/features/review/approver.ts
2701
+ import { z as z4 } from "zod";
2702
+ var permissionSchema = z4.object({ permission: z4.string(), role_name: z4.string().optional() });
2703
+ var approverPermissions = /* @__PURE__ */ new Set(["admin", "write"]);
2704
+ async function approverRejection({ repo, event }, get2) {
2705
+ const login = event.sender?.login;
2706
+ if (!login) return "was not run: the label event names no sender to check";
2707
+ const { permission, role_name } = permissionSchema.parse(await get2(`repos/${repo}/collaborators/${login}/permission`));
2708
+ if (approverPermissions.has(permission)) return void 0;
2709
+ return `was not run: ${login} added the label with ${role_name ?? permission} access; approving needs write, maintain or admin`;
2710
+ }
2711
+
2712
+ // src/features/review/gateHandoff.ts
2713
+ var approvedReviewContext = "review / approved review";
2714
+ var reviewLabel = "review";
2715
+ var runPollInterval = 15e3;
2716
+ var runSchema = z5.object({ id: z5.number(), run_number: z5.number(), status: z5.string().nullable() });
2717
+ var runsSchema = z5.object({ workflow_runs: z5.array(runSchema) });
2718
+ var jobsSchema = z5.object({ jobs: z5.array(z5.object({ id: z5.number(), name: z5.string() })) });
2719
+ var statusesSchema = z5.array(z5.object({ context: z5.string(), target_url: z5.string().nullable() }));
2720
+ var pendingResult = "pending";
2721
+ function approvalState(reviewResult) {
2722
+ if (reviewResult === pendingResult) return { state: "pending", description: "is running" };
2723
+ return { state: reviewResult === "success" ? "success" : "failure", description: `finished with ${reviewResult}` };
2724
+ }
2725
+ async function postApproval(approval, { repo, runId, serverUrl, event }, deps) {
2726
+ await deps.post(`repos/${repo}/statuses/${event.pull_request.head.sha}`, {
2727
+ ...approval,
2728
+ context: approvedReviewContext,
2729
+ target_url: `${serverUrl}/${repo}/actions/runs/${runId}`
2730
+ });
2731
+ }
2732
+ async function newerApprovalRun({ repo, runId, runNumber, event }, deps) {
2733
+ const statuses = statusesSchema.parse(await deps.get(`repos/${repo}/commits/${event.pull_request.head.sha}/statuses?per_page=100`));
2734
+ const owner = statuses.find((status) => status.context === approvedReviewContext)?.target_url ?? "";
2735
+ const id = /\/actions\/runs\/(\d+)/.exec(owner)?.[1];
2736
+ if (!id || id === runId) return void 0;
2737
+ const { run_number } = runSchema.parse(await deps.get(`repos/${repo}/actions/runs/${id}`));
2738
+ return run_number > runNumber ? owner : void 0;
2739
+ }
2740
+ async function latestPullRequestRun({ repo, runId, event }, deps) {
2741
+ const { workflow_id } = z5.object({ workflow_id: z5.number() }).parse(await deps.get(`repos/${repo}/actions/runs/${runId}`));
2742
+ const query = new URLSearchParams({ event: "pull_request", head_sha: event.pull_request.head.sha, per_page: "100" });
2743
+ const { workflow_runs } = runsSchema.parse(await deps.get(`repos/${repo}/actions/workflows/${workflow_id}/runs?${query.toString()}`));
2744
+ return workflow_runs.toSorted((a, b) => b.run_number - a.run_number)[0];
2745
+ }
2746
+ async function settledRun(repo, id, status, deps) {
2747
+ while (status !== "completed") {
2748
+ deps.log(`Waiting for run ${id} to finish (${status}) before re-running its gate`);
2749
+ await deps.sleep(runPollInterval);
2750
+ ({ status } = runSchema.parse(await deps.get(`repos/${repo}/actions/runs/${id}`)));
2751
+ }
2752
+ }
2753
+ async function rerunHeadGate(context, deps) {
2754
+ const { repo, serverUrl } = context;
2755
+ const sha = context.event.pull_request.head.sha.slice(0, 7);
2756
+ const run2 = await latestPullRequestRun(context, deps);
2757
+ if (!run2) return `No pull_request run of this workflow exists for ${sha}; its gate reads this result when it runs.`;
2758
+ await settledRun(repo, run2.id, run2.status, deps);
2759
+ const { jobs } = jobsSchema.parse(await deps.get(`repos/${repo}/actions/runs/${run2.id}/jobs`));
2760
+ const gate2 = jobs.find((job) => job.name.endsWith(" / gate"));
2761
+ const url = `${serverUrl}/${repo}/actions/runs/${run2.id}`;
2762
+ if (!gate2) return `Run ${url} for ${sha} has no gate job to re-run; its gate reads this result when it runs.`;
2763
+ await deps.post(`repos/${repo}/actions/jobs/${gate2.id}/rerun`, {});
2764
+ return `Re-ran the gate for ${sha} in ${url}.`;
2765
+ }
2766
+ async function handOffApprovedReview(reviewResult, context, deps) {
2767
+ const { action, label } = context.event;
2768
+ if (action !== "labeled" || label?.name !== reviewLabel || reviewResult === "skipped")
2769
+ return { pass: true, message: "No approved review ran on this event." };
2770
+ const rejection = await approverRejection(context, deps.get);
2771
+ const newer = await newerApprovalRun(context, deps);
2772
+ if (rejection) {
2773
+ if (newer) return { pass: false, message: `The review ${rejection}. A newer approved review, in ${newer}, owns the result.` };
2774
+ await postApproval({ state: "failure", description: rejection }, context, deps);
2775
+ return { pass: false, message: `The review ${rejection}. ${await rerunHeadGate(context, deps)}` };
2776
+ }
2777
+ if (newer)
2778
+ return {
2779
+ pass: reviewResult === pendingResult || reviewResult === "success",
2780
+ message: `A newer approved review, in ${newer}, owns the result for ${context.event.pull_request.head.sha.slice(0, 7)}, so this run's ${reviewResult} is not recorded.`
2781
+ };
2782
+ await postApproval(approvalState(reviewResult), context, deps);
2783
+ const rerun = await rerunHeadGate(context, deps);
2784
+ if (reviewResult === pendingResult) return { pass: true, message: `Marked the review a maintainer approved as running. ${rerun}` };
2785
+ const pass = reviewResult === "success";
2786
+ return { pass, message: `The review a maintainer approved finished with ${reviewResult}. ${rerun}` };
2787
+ }
2788
+
2789
+ // src/features/review/gate.ts
2790
+ var eventSchema = z6.object({
2791
+ action: z6.string(),
2792
+ label: z6.object({ name: z6.string() }).optional(),
2793
+ sender: z6.object({ login: z6.string() }).optional(),
2794
+ pull_request: z6.object({
2795
+ number: z6.number(),
2796
+ head: z6.object({ ref: z6.string(), sha: z6.string(), repo: z6.object({ full_name: z6.string() }).nullable() }),
2797
+ user: z6.object({ type: z6.string() }),
2798
+ labels: z6.array(z6.object({ name: z6.string() })).optional()
2799
+ })
2800
+ });
2801
+ var runsSchema2 = z6.object({
2802
+ workflow_runs: z6.array(z6.object({ id: z6.number(), run_number: z6.number(), pull_requests: z6.array(z6.object({ number: z6.number() })) }))
2814
2803
  });
2815
- program.command("review-gate").description("Block merging until the latest review that ran for the PR succeeds; fork and bot PRs pass. Used by review.yml's gate job").argument("<result>", "result of the review job on this event: success, failure, cancelled or skipped").action(async (result) => {
2804
+ var jobsSchema2 = z6.object({ jobs: z6.array(z6.object({ name: z6.string(), status: z6.string(), conclusion: z6.string().nullable() })) });
2805
+ var statusesSchema2 = z6.array(
2806
+ z6.object({ context: z6.string(), state: z6.string(), description: z6.string().nullable(), target_url: z6.string().nullable() })
2807
+ );
2808
+ var pollInterval = 3e4;
2809
+ var rerunHint = "Mark the PR as draft, then ready for review, to run the review again.";
2810
+ var firstRunHint = "Mark the PR as ready for review (from draft if it is not one) to run it.";
2811
+ var forkHint = "The review does not run automatically on PRs from forks. A maintainer must read the diff, then add the `review` label to run it. If adding the label runs nothing, the caller workflow needs the `pull_request_target` `labeled` and `synchronize` triggers and the `actions: write` and `statuses: write` permissions; run `yoke review ci` to update it.";
2812
+ var forkRerunHint = "A maintainer must read the diff, then remove and add the `review` label again to re-run it.";
2813
+ function verdict(conclusion, message) {
2814
+ const pass = conclusion === "success";
2815
+ return { pass, message: pass ? message : `${message} ${rerunHint}` };
2816
+ }
2817
+ function gateContext(env = process.env, command = "yoke review gate") {
2818
+ const { GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_RUN_NUMBER, GITHUB_SERVER_URL, GITHUB_EVENT_PATH, GITHUB_EVENT_NAME } = env;
2819
+ if (!GITHUB_REPOSITORY || !GITHUB_RUN_ID || !GITHUB_RUN_NUMBER || !GITHUB_SERVER_URL || !GITHUB_EVENT_PATH || !GITHUB_EVENT_NAME) {
2820
+ throw new ReviewError(`${command} runs only in a GitHub Actions pull_request workflow`);
2821
+ }
2822
+ const event = eventSchema.safeParse(JSON.parse(readFileSync10(GITHUB_EVENT_PATH, "utf8")));
2823
+ if (!event.success || GITHUB_EVENT_NAME !== "pull_request" && GITHUB_EVENT_NAME !== "pull_request_target")
2824
+ throw new ReviewError(`${command} runs only on pull_request and pull_request_target events`);
2825
+ return {
2826
+ repo: GITHUB_REPOSITORY,
2827
+ runId: GITHUB_RUN_ID,
2828
+ runNumber: Number(GITHUB_RUN_NUMBER),
2829
+ serverUrl: GITHUB_SERVER_URL,
2830
+ eventName: GITHUB_EVENT_NAME,
2831
+ event: event.data
2832
+ };
2833
+ }
2834
+ async function earlierRuns({ repo, runId, runNumber, event }, deps) {
2835
+ const { workflow_id } = z6.object({ workflow_id: z6.number() }).parse(await deps.get(`repos/${repo}/actions/runs/${runId}`));
2836
+ const query = new URLSearchParams({ event: "pull_request", branch: event.pull_request.head.ref, per_page: "100" });
2837
+ const { workflow_runs } = runsSchema2.parse(await deps.get(`repos/${repo}/actions/workflows/${workflow_id}/runs?${query.toString()}`));
2838
+ return workflow_runs.filter((run2) => run2.run_number < runNumber && run2.pull_requests.some((pr2) => pr2.number === event.pull_request.number)).toSorted((a, b) => b.run_number - a.run_number).map((run2) => run2.id);
2839
+ }
2840
+ async function settledReview(repo, run2, deps) {
2841
+ for (; ; ) {
2842
+ const { jobs } = jobsSchema2.parse(await deps.get(`repos/${repo}/actions/runs/${run2}/jobs`));
2843
+ const review2 = jobs.find((job) => job.name.endsWith(" / review"));
2844
+ if (!review2 || review2.conclusion === "skipped") return void 0;
2845
+ if (review2.status === "completed") return review2.conclusion ?? "unknown";
2846
+ deps.log(`Waiting for the review in run ${run2} to finish (${review2.status})`);
2847
+ await deps.sleep(pollInterval);
2848
+ }
2849
+ }
2850
+ async function forkVerdict({ repo, event }, deps) {
2851
+ const { sha } = event.pull_request.head;
2852
+ const statuses = statusesSchema2.parse(await deps.get(`repos/${repo}/commits/${sha}/statuses?per_page=100`));
2853
+ const approved = statuses.find((status) => status.context === approvedReviewContext);
2854
+ if (!approved) return { pass: false, message: forkHint };
2855
+ const where = approved.target_url ? `, in ${approved.target_url},` : "";
2856
+ const message = `The review a maintainer approved for ${sha.slice(0, 7)}${where} ${approved.description ?? `finished with ${approved.state}`}.`;
2857
+ if (approved.state === "pending") return { pass: false, message: `${message} The gate re-runs when it finishes.` };
2858
+ return approved.state === "success" ? { pass: true, message } : { pass: false, message: `${message} ${forkRerunHint}` };
2859
+ }
2860
+ async function reviewGate(reviewResult, context, deps) {
2861
+ if (context.eventName === "pull_request_target") return handOffApprovedReview(reviewResult, context, deps);
2862
+ if (reviewResult !== "skipped") return verdict(reviewResult, `The review on this event finished with ${reviewResult}.`);
2863
+ const { pull_request } = context.event;
2864
+ if (pull_request.user.type === "Bot") return verdict("success", "No review runs for PRs from bots.");
2865
+ if (pull_request.head.repo?.full_name !== context.repo) return forkVerdict(context, deps);
2866
+ for (const run2 of await earlierRuns(context, deps)) {
2867
+ const conclusion = await settledReview(context.repo, run2, deps);
2868
+ const url = `${context.serverUrl}/${context.repo}/actions/runs/${run2}`;
2869
+ if (conclusion) return verdict(conclusion, `The latest review for this PR, in run ${url}, finished with ${conclusion}.`);
2870
+ }
2871
+ return { pass: false, message: `No review has completed for this PR yet. ${firstRunHint}` };
2872
+ }
2873
+ async function runReviewGate(reviewResult, out, env = process.env) {
2874
+ const deps = { get: (path) => githubGet(path), post: githubPost, sleep: (ms) => setTimeout(ms), log: out };
2875
+ const { pass, message } = await reviewGate(reviewResult, gateContext(env), deps);
2876
+ out(pass ? message : `::error::${message}`);
2877
+ if (env.GITHUB_STEP_SUMMARY) appendFileSync3(env.GITHUB_STEP_SUMMARY, `${message}
2878
+ `);
2879
+ return pass;
2880
+ }
2881
+
2882
+ // src/features/review/unapprove.ts
2883
+ function isNotFound(error) {
2884
+ return error instanceof Error && error.message.includes("(HTTP 404)");
2885
+ }
2886
+ async function unapproveReview({ repo, eventName, event }, remove) {
2887
+ const { number, head, labels } = event.pull_request;
2888
+ if (eventName !== "pull_request_target" || event.action !== "synchronize")
2889
+ return "Only a push to a fork PR, on pull_request_target, removes its approval.";
2890
+ if (head.repo?.full_name === repo) return `#${number} is not from a fork, so it has no approval to remove.`;
2891
+ if (!labels?.some((label) => label.name === reviewLabel)) return `#${number} has no \`${reviewLabel}\` label to remove.`;
2892
+ try {
2893
+ await remove(`repos/${repo}/issues/${number}/labels/${reviewLabel}`);
2894
+ } catch (error) {
2895
+ if (!isNotFound(error)) throw error;
2896
+ return `#${number} no longer has the \`${reviewLabel}\` label; it was already removed.`;
2897
+ }
2898
+ return `Removed the \`${reviewLabel}\` label from #${number} after a push to ${head.sha.slice(0, 7)}. A maintainer must read the new commits, then add it again to review them.`;
2899
+ }
2900
+ async function runUnapproveReview(out, env = process.env) {
2901
+ out(await unapproveReview(gateContext(env, "yoke review unapprove"), (path) => githubDelete(path)));
2902
+ }
2903
+
2904
+ // src/reviewCiCommands.ts
2905
+ var ciKeys = settingKeys.filter((key) => /^review\.\w+\.(harness|model)$/.test(key));
2906
+ function fail(error, ...expected) {
2907
+ if (!expected.some((type) => error instanceof type) || !(error instanceof Error)) throw error;
2908
+ console.error(error.message);
2909
+ process.exitCode = 1;
2910
+ }
2911
+ var resultHelp = "result of the review job on this event: success, failure, cancelled or skipped; pending marks an approved review as running";
2912
+ async function gate(result) {
2816
2913
  try {
2817
2914
  if (!await runReviewGate(result, console.log)) process.exitCode = 1;
2818
2915
  } catch (error) {
2819
- if (!(error instanceof ReviewError)) throw error;
2916
+ fail(error, ReviewError);
2917
+ }
2918
+ }
2919
+ async function unapprove() {
2920
+ try {
2921
+ await runUnapproveReview(console.log);
2922
+ } catch (error) {
2923
+ fail(error, ReviewError);
2924
+ }
2925
+ }
2926
+ function addReviewCiCommands(review2) {
2927
+ addSettingOptions(
2928
+ review2.command("ci").description(
2929
+ `Write ${callerWorkflowPath} so each PR in this repo is reviewed in CI, then print the secrets, or Entra federated credential, it needs`
2930
+ ).option("--provider <provider>", "litellm or foundry; every slot uses it in CI. Default: review.reviewer1.provider, else litellm").option(
2931
+ "--azure-client-id <id>",
2932
+ "Entra app (client) ID to sign in to Foundry with GitHub OIDC instead of an API key; needs --provider foundry"
2933
+ ).option("--azure-tenant-id <id>", "Entra tenant ID of that app").option("--environment <name>", "GitHub environment the review job runs in, matching the app's federated credential subject").option("--print", `print the workflow and setup commands without writing ${callerWorkflowPath}`),
2934
+ ciKeys
2935
+ ).addHelpText("after", settingsHelp).action((options) => {
2936
+ try {
2937
+ const settings = resolveSettings(flagSettings(options, ciKeys), process.env, readUserConfig(userConfigPath()));
2938
+ const workflow = callerWorkflow(settings, options.provider, options);
2939
+ const root = repoRoot();
2940
+ const outcome = options.print ? "printed" : writeCallerWorkflow(root, renderCallerWorkflow(workflow));
2941
+ console.log(reviewCiReport(outcome, settings, workflow, () => githubRepo(root)));
2942
+ } catch (error) {
2943
+ fail(error, ReviewError, ConfigError);
2944
+ }
2945
+ });
2946
+ review2.command("ci-gate").description(`Require the ${gateCheck} check on the default branch with a ruleset, once the CI review workflow is merged`).action(async () => {
2947
+ try {
2948
+ console.log((await enforceReviewGate()).message);
2949
+ } catch (error) {
2950
+ fail(error, ReviewError);
2951
+ }
2952
+ });
2953
+ review2.command("gate").description(
2954
+ "Block merging until the latest review that ran for the PR succeeds, or for a fork PR, a review approved by a user with write access adding the `review` label; bot PRs pass. On pull_request_target, records the approved review on the PR head and re-runs its gate. Used by review.yml's review and gate jobs"
2955
+ ).argument("<result>", resultHelp).action(gate);
2956
+ review2.command("unapprove").description(
2957
+ "Remove the `review` label from a fork PR after a push, so a maintainer reads the new commits before they are reviewed. Used by review.yml's unapprove job on pull_request_target"
2958
+ ).action(unapprove);
2959
+ }
2960
+ function addLockedWorkflowAliases(program2) {
2961
+ program2.command("review-gate", { hidden: true }).argument("<result>", resultHelp).action(gate);
2962
+ program2.command("review-unapprove", { hidden: true }).action(unapprove);
2963
+ }
2964
+
2965
+ // src/cli.ts
2966
+ var { version } = package_default;
2967
+ var program = new Command("yoke").description("Lean Claude Code skills and the CLI they call.").version(version).enablePositionalOptions();
2968
+ program.command("sync").description("Write yoke's skills into this repo's .claude/skills, or ~/.claude/skills with --user").option("--user", "write to ~/.claude/skills instead, for every repo").option("--force", "overwrite same-named skills that yoke didn't write").action((options) => {
2969
+ const user = options.user ?? false;
2970
+ const cwd = process.cwd();
2971
+ const display = (path) => path.startsWith(cwd) ? relative2(cwd, path) : path;
2972
+ const { written, skipped } = syncSkills(skillsRoot(user), skillsRoot(true), options.force ?? false);
2973
+ for (const file of written) console.log(`wrote ${display(file)}`);
2974
+ for (const dir of skipped) console.log(`skipped ${display(dir)} (not yoke's skill; rerun with --force to overwrite it)`);
2975
+ });
2976
+ program.command("update").description("Update yoke to the latest version, then re-sync its skills wherever they were synced before").action(() => {
2977
+ const dir = installDir();
2978
+ process.exitCode = updateYoke({
2979
+ dir,
2980
+ install: detectInstall(dir),
2981
+ targets: () => syncTargets(),
2982
+ log: console.log,
2983
+ error: console.error
2984
+ });
2985
+ });
2986
+ var pr = program.command("pr").description("Pull request commands");
2987
+ pr.command("raise").description("Validate a PR title and body, then create the PR with gh").requiredOption("--title <title>", "single-line PR title").requiredOption("--body <body>", "PR body with ## What, ## Why and optionally ## How").option("--base <branch>", "base branch (defaults to the repo default)").option("--no-draft", "create a ready-for-review PR instead of a draft").addHelpText("after", raiseGuide).action((options) => {
2988
+ try {
2989
+ console.log(raisePr(options));
2990
+ } catch (error) {
2991
+ if (!(error instanceof PrValidationError)) throw error;
2820
2992
  console.error(error.message);
2821
2993
  process.exitCode = 1;
2822
2994
  }
2823
2995
  });
2996
+ var providerKeys = settingKeys.filter((key) => !key.startsWith("review."));
2997
+ var review = addSettingOptions(
2998
+ program.command("review").description("Review the current branch's PR, or PR <number>, with Claude and Codex in parallel, then combine their findings").argument("[number]", "PR number; reviewed without checking it out").option("--post", "post the findings to the PR as one review, reusing the saved review of the PR head when there is one").option("--force", "discard this PR head's cached reviews and run every pass again").option("--verbose", "print every step and each reviewer's tool calls as log lines instead of live spinners").option("--head <sha>", "refuse to review unless the PR's head is this commit; empty skips the check"),
2999
+ settingKeys
3000
+ ).addHelpText("after", settingsHelp).action(
3001
+ async (number, options) => {
3002
+ const verbose = options.verbose ?? false;
3003
+ const ui = process.stderr.isTTY && !verbose ? spinnerUi(process.stderr) : lineUi(console.error, { activity: verbose });
3004
+ try {
3005
+ const settings = resolveSettings(flagSettings(options, settingKeys), process.env, readUserConfig(userConfigPath()));
3006
+ const run2 = { post: options.post ?? false, force: options.force ?? false, head: options.head };
3007
+ await reviewPr(number, settings, run2, { out: console.log, ui });
3008
+ } catch (error) {
3009
+ if (!(error instanceof ReviewError || error instanceof ConfigError)) throw error;
3010
+ console.error(error.message);
3011
+ process.exitCode = 1;
3012
+ } finally {
3013
+ ui.stop();
3014
+ }
3015
+ }
3016
+ );
3017
+ addReviewCiCommands(review);
3018
+ addLockedWorkflowAliases(program);
2824
3019
  addSettingOptions(
2825
3020
  program.command("models").description("List the models each configured provider serves, for review.*.provider and review.*.model"),
2826
3021
  providerKeys