@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.
- package/README.md +3 -3
- package/dist/cli.js +725 -530
- 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.
|
|
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
|
|
542
|
-
if (!isPendingReview(
|
|
543
|
-
return { 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(
|
|
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=${
|
|
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,
|
|
564
|
-
const args = ["api", "--method", "POST", `${reviewsPath(pr2)}/${
|
|
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,
|
|
568
|
-
await exec2("gh", ["api", "--method", "DELETE", `${reviewsPath(pr2)}/${
|
|
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
|
-
|
|
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: (
|
|
579
|
-
submit: (pr2,
|
|
580
|
-
discard: (pr2,
|
|
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:
|
|
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
|
|
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
|
|
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/
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
|
872
|
-
|
|
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
|
|
877
|
-
return
|
|
865
|
+
function hasMarker(prior, marker) {
|
|
866
|
+
return prior.some((comment) => comment.body.includes(marker));
|
|
878
867
|
}
|
|
879
|
-
function
|
|
880
|
-
|
|
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
|
|
885
|
-
if (
|
|
886
|
-
|
|
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
|
|
892
|
-
|
|
893
|
-
|
|
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
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
|
900
|
-
return
|
|
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
|
|
906
|
-
const
|
|
907
|
-
if (!
|
|
908
|
-
const
|
|
909
|
-
if (
|
|
910
|
-
|
|
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
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
return
|
|
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
|
|
920
|
-
|
|
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
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
943
|
-
|
|
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 \`
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
1118
|
+
const review2 = await deps.api.open(pr2);
|
|
1402
1119
|
try {
|
|
1403
|
-
const url = await deps.api.submit(pr2,
|
|
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,
|
|
1123
|
+
await discard(pr2, review2, deps);
|
|
1407
1124
|
throw error;
|
|
1408
1125
|
}
|
|
1409
1126
|
}
|
|
1410
|
-
async function discard(pr2,
|
|
1127
|
+
async function discard(pr2, review2, { api, log }) {
|
|
1411
1128
|
try {
|
|
1412
|
-
await api.discard(pr2,
|
|
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
|
|
1141
|
+
const review2 = await api.open(pr2);
|
|
1425
1142
|
try {
|
|
1426
1143
|
if (nothingNew) {
|
|
1427
|
-
const url2 = await api.submit(pr2,
|
|
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(
|
|
1431
|
-
const url = await api.submit(pr2,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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) &&
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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) &&
|
|
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
|
-
${
|
|
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(
|
|
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
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2560
|
-
- **One, not yoke's:** run \`yoke review
|
|
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:
|
|
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) && !
|
|
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
|
|
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) &&
|
|
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/
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
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
|
-
|
|
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
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
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
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
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
|
-
|
|
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 (
|
|
2802
|
-
|
|
2803
|
-
process.exitCode = 1;
|
|
2606
|
+
if (error instanceof ReviewError && error.message.includes("(HTTP 404)")) return void 0;
|
|
2607
|
+
throw error;
|
|
2804
2608
|
}
|
|
2805
|
-
}
|
|
2806
|
-
|
|
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
|
-
|
|
2650
|
+
return await api.post(`repos/${repo.full_name}/rulesets`, gateRuleset);
|
|
2809
2651
|
} catch (error) {
|
|
2810
|
-
if (
|
|
2811
|
-
|
|
2812
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|