@makerx/yoke 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/cli.js +176 -58
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ yoke sync --user # ~/.claude/skills
|
|
|
31
31
|
yoke sync [--user] [--force] # write yoke's skills
|
|
32
32
|
yoke review [number] # review a PR (used by /review)
|
|
33
33
|
yoke review-ci # add the CI review workflow (used by /review-ci)
|
|
34
|
+
yoke review-gate <result> # block merging until the PR's latest review succeeds (used by review.yml)
|
|
34
35
|
yoke models # list each provider's models
|
|
35
36
|
```
|
|
36
37
|
|
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.13.1",
|
|
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",
|
|
@@ -415,6 +415,45 @@ import { execFileSync as execFileSync2 } from "child_process";
|
|
|
415
415
|
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
416
416
|
import { dirname as dirname2, join as join3 } from "path";
|
|
417
417
|
|
|
418
|
+
// src/features/review/shellQuote.ts
|
|
419
|
+
function shellQuote(value) {
|
|
420
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/features/review/ciEntra.ts
|
|
424
|
+
var githubOidcIssuer = "https://token.actions.githubusercontent.com";
|
|
425
|
+
var entraTokenExchangeAudience = "api://AzureADTokenExchange";
|
|
426
|
+
function federatedCredential(repo, environment) {
|
|
427
|
+
return {
|
|
428
|
+
issuer: githubOidcIssuer,
|
|
429
|
+
audience: entraTokenExchangeAudience,
|
|
430
|
+
subject: environment ? `repo:${repo}:environment:${environment}` : `repo:${repo}:pull_request`
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function federatedCredentialCommand(clientId, credential) {
|
|
434
|
+
const parameters = JSON.stringify({
|
|
435
|
+
name: "yoke-review-ci",
|
|
436
|
+
issuer: credential.issuer,
|
|
437
|
+
subject: credential.subject,
|
|
438
|
+
audiences: [credential.audience]
|
|
439
|
+
});
|
|
440
|
+
return `az ad app federated-credential create --id ${shellQuote(clientId)} --parameters ${shellQuote(parameters)}`;
|
|
441
|
+
}
|
|
442
|
+
function entraSetup({ clientId, environment }, repo) {
|
|
443
|
+
const credential = federatedCredential(repo, environment);
|
|
444
|
+
return [
|
|
445
|
+
"",
|
|
446
|
+
`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:`,
|
|
447
|
+
` issuer: ${credential.issuer}`,
|
|
448
|
+
` audience: ${credential.audience}`,
|
|
449
|
+
` subject: ${credential.subject}`,
|
|
450
|
+
"for example with:",
|
|
451
|
+
` ${federatedCredentialCommand(clientId, credential)}`,
|
|
452
|
+
"and give the app the Cognitive Services User role on the Foundry resource.",
|
|
453
|
+
...environment ? [`The job runs in the ${environment} environment; create it on the repo if it does not exist.`] : []
|
|
454
|
+
];
|
|
455
|
+
}
|
|
456
|
+
|
|
418
457
|
// src/features/review/pr.ts
|
|
419
458
|
import { execFile } from "child_process";
|
|
420
459
|
import { join as join2 } from "path";
|
|
@@ -506,6 +545,9 @@ async function submitPendingReview(pr2, review, body, exec2 = run) {
|
|
|
506
545
|
async function discardPendingReview(pr2, review, exec2 = run) {
|
|
507
546
|
await exec2("gh", ["api", "--method", "DELETE", `${reviewsPath(pr2)}/${review.id}`]);
|
|
508
547
|
}
|
|
548
|
+
async function githubGet(path, exec2 = run) {
|
|
549
|
+
return JSON.parse(await exec2("gh", ["api", path]));
|
|
550
|
+
}
|
|
509
551
|
var github = {
|
|
510
552
|
open: (pr2) => openPendingReview(pr2),
|
|
511
553
|
add: (review, comment) => addReviewComment(review, comment),
|
|
@@ -629,6 +671,8 @@ var reviewWorkflowRef = "MakerXStudio/yoke/.github/workflows/review.yml@main";
|
|
|
629
671
|
var callerWorkflowPath = join3(".github", "workflows", "review-pr.yml");
|
|
630
672
|
var baseUrlVariable = "REVIEW_CI_BASE_URL";
|
|
631
673
|
var apiKeySecret = "REVIEW_CI_API_KEY";
|
|
674
|
+
var callerTriggerTypes = ["opened", "synchronize", "reopened", "ready_for_review"];
|
|
675
|
+
var gateCheck = "review / gate";
|
|
632
676
|
function workflowSecrets({ entra }) {
|
|
633
677
|
return entra ? [] : [apiKeySecret];
|
|
634
678
|
}
|
|
@@ -692,9 +736,10 @@ function renderCallerWorkflow(workflow) {
|
|
|
692
736
|
"",
|
|
693
737
|
"on:",
|
|
694
738
|
" pull_request:",
|
|
695
|
-
|
|
739
|
+
` types: [${callerTriggerTypes.join(", ")}]`,
|
|
696
740
|
"",
|
|
697
741
|
"permissions:",
|
|
742
|
+
" actions: read",
|
|
698
743
|
" contents: read",
|
|
699
744
|
" pull-requests: write",
|
|
700
745
|
...workflow.entra ? [" id-token: write"] : [],
|
|
@@ -709,9 +754,6 @@ function renderCallerWorkflow(workflow) {
|
|
|
709
754
|
""
|
|
710
755
|
].join("\n");
|
|
711
756
|
}
|
|
712
|
-
function shellQuote(value) {
|
|
713
|
-
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
714
|
-
}
|
|
715
757
|
function setupCommands(settings, workflow) {
|
|
716
758
|
const baseUrl = settings[`${workflow.provider}.baseUrl`];
|
|
717
759
|
return [
|
|
@@ -719,23 +761,20 @@ function setupCommands(settings, workflow) {
|
|
|
719
761
|
...workflowSecrets(workflow).map((secret2) => `gh secret set ${secret2}`)
|
|
720
762
|
];
|
|
721
763
|
}
|
|
722
|
-
var
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
audiences: [credential.audience]
|
|
737
|
-
});
|
|
738
|
-
return `az ad app federated-credential create --id ${shellQuote(clientId)} --parameters ${shellQuote(parameters)}`;
|
|
764
|
+
var gateRuleset = {
|
|
765
|
+
name: "yoke review",
|
|
766
|
+
target: "branch",
|
|
767
|
+
enforcement: "active",
|
|
768
|
+
conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
|
|
769
|
+
rules: [
|
|
770
|
+
{
|
|
771
|
+
type: "required_status_checks",
|
|
772
|
+
parameters: { strict_required_status_checks_policy: false, required_status_checks: [{ context: gateCheck }] }
|
|
773
|
+
}
|
|
774
|
+
]
|
|
775
|
+
};
|
|
776
|
+
function rulesetCommand() {
|
|
777
|
+
return `echo ${shellQuote(JSON.stringify(gateRuleset))} | gh api --method POST 'repos/{owner}/{repo}/rulesets' --input -`;
|
|
739
778
|
}
|
|
740
779
|
var unknownRepo = "<owner>/<repo>";
|
|
741
780
|
function githubRepo(cwd = process.cwd()) {
|
|
@@ -770,20 +809,6 @@ function writeCallerWorkflow(root, content) {
|
|
|
770
809
|
writeFileSync2(path, content);
|
|
771
810
|
return "wrote";
|
|
772
811
|
}
|
|
773
|
-
function entraSetup({ clientId, environment }, repo) {
|
|
774
|
-
const credential = federatedCredential(repo, environment);
|
|
775
|
-
return [
|
|
776
|
-
"",
|
|
777
|
-
`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:`,
|
|
778
|
-
` issuer: ${credential.issuer}`,
|
|
779
|
-
` audience: ${credential.audience}`,
|
|
780
|
-
` subject: ${credential.subject}`,
|
|
781
|
-
"for example with:",
|
|
782
|
-
` ${federatedCredentialCommand(clientId, credential)}`,
|
|
783
|
-
"and give the app the Cognitive Services User role on the Foundry resource.",
|
|
784
|
-
...environment ? [`The job runs in the ${environment} environment; create it on the repo if it does not exist.`] : []
|
|
785
|
-
];
|
|
786
|
-
}
|
|
787
812
|
function reportHeading(outcome, workflow) {
|
|
788
813
|
if (outcome === "printed")
|
|
789
814
|
return [`yoke's version of ${callerWorkflowPath}, not saved to the repo:`, "", renderCallerWorkflow(workflow).trimEnd()];
|
|
@@ -805,10 +830,90 @@ function reviewCiReport(outcome, settings, workflow, repo) {
|
|
|
805
830
|
...variableAndSecretSetup(settings, workflow),
|
|
806
831
|
...workflow.entra ? entraSetup(workflow.entra, repo()) : [],
|
|
807
832
|
"",
|
|
808
|
-
outcome === "printed" ? "Once the repo's review workflow runs this review, commit and push it; each PR opened from this repo then gets a review." : `Commit and push ${callerWorkflowPath}; each PR opened from this repo then gets a review
|
|
833
|
+
outcome === "printed" ? "Once the repo's review workflow runs this review, commit and push it; each PR opened from this repo then gets a review." : `Commit and push ${callerWorkflowPath}; each PR opened from this repo then gets a review.`,
|
|
834
|
+
"",
|
|
835
|
+
`To block merging while the review fails, require the ${gateCheck} check on the default branch with:`,
|
|
836
|
+
` ${rulesetCommand()}`,
|
|
837
|
+
"Run it only after this workflow is merged to the default branch, or pushes to open PRs get stuck waiting for a status."
|
|
809
838
|
].join("\n");
|
|
810
839
|
}
|
|
811
840
|
|
|
841
|
+
// src/features/review/gate.ts
|
|
842
|
+
import { appendFileSync, readFileSync as readFileSync3 } from "fs";
|
|
843
|
+
import { setTimeout } from "timers/promises";
|
|
844
|
+
import { z as z2 } from "zod";
|
|
845
|
+
var eventSchema = z2.object({
|
|
846
|
+
action: z2.string(),
|
|
847
|
+
pull_request: z2.object({
|
|
848
|
+
number: z2.number(),
|
|
849
|
+
head: z2.object({ ref: z2.string(), repo: z2.object({ full_name: z2.string() }).nullable() }),
|
|
850
|
+
user: z2.object({ type: z2.string() })
|
|
851
|
+
})
|
|
852
|
+
});
|
|
853
|
+
var runsSchema = z2.object({
|
|
854
|
+
workflow_runs: z2.array(z2.object({ id: z2.number(), run_number: z2.number(), pull_requests: z2.array(z2.object({ number: z2.number() })) }))
|
|
855
|
+
});
|
|
856
|
+
var jobsSchema = z2.object({ jobs: z2.array(z2.object({ name: z2.string(), status: z2.string(), conclusion: z2.string().nullable() })) });
|
|
857
|
+
var pollInterval = 3e4;
|
|
858
|
+
var rerunHint = "Mark the PR as draft, then ready for review, to run the review again.";
|
|
859
|
+
var firstRunHint = "Mark the PR as ready for review (from draft if it is not one) to run it.";
|
|
860
|
+
function verdict(conclusion, message) {
|
|
861
|
+
const pass = conclusion === "success";
|
|
862
|
+
return { pass, message: pass ? message : `${message} ${rerunHint}` };
|
|
863
|
+
}
|
|
864
|
+
function gateContext(env = process.env) {
|
|
865
|
+
const { GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_RUN_NUMBER, GITHUB_SERVER_URL, GITHUB_EVENT_PATH } = env;
|
|
866
|
+
if (!GITHUB_REPOSITORY || !GITHUB_RUN_ID || !GITHUB_RUN_NUMBER || !GITHUB_SERVER_URL || !GITHUB_EVENT_PATH) {
|
|
867
|
+
throw new ReviewError("yoke review-gate runs only in a GitHub Actions pull_request workflow");
|
|
868
|
+
}
|
|
869
|
+
const event = eventSchema.safeParse(JSON.parse(readFileSync3(GITHUB_EVENT_PATH, "utf8")));
|
|
870
|
+
if (!event.success) throw new ReviewError("yoke review-gate runs only on pull_request events");
|
|
871
|
+
return {
|
|
872
|
+
repo: GITHUB_REPOSITORY,
|
|
873
|
+
runId: GITHUB_RUN_ID,
|
|
874
|
+
runNumber: Number(GITHUB_RUN_NUMBER),
|
|
875
|
+
serverUrl: GITHUB_SERVER_URL,
|
|
876
|
+
event: event.data
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
async function earlierRuns({ repo, runId, runNumber, event }, deps) {
|
|
880
|
+
const { workflow_id } = z2.object({ workflow_id: z2.number() }).parse(await deps.get(`repos/${repo}/actions/runs/${runId}`));
|
|
881
|
+
const query = new URLSearchParams({ event: "pull_request", branch: event.pull_request.head.ref, per_page: "100" });
|
|
882
|
+
const { workflow_runs } = runsSchema.parse(await deps.get(`repos/${repo}/actions/workflows/${workflow_id}/runs?${query.toString()}`));
|
|
883
|
+
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);
|
|
884
|
+
}
|
|
885
|
+
async function settledReview(repo, run2, deps) {
|
|
886
|
+
for (; ; ) {
|
|
887
|
+
const { jobs } = jobsSchema.parse(await deps.get(`repos/${repo}/actions/runs/${run2}/jobs`));
|
|
888
|
+
const review = jobs.find((job) => job.name.endsWith(" / review"));
|
|
889
|
+
if (!review || review.conclusion === "skipped") return void 0;
|
|
890
|
+
if (review.status === "completed") return review.conclusion ?? "unknown";
|
|
891
|
+
deps.log(`Waiting for the review in run ${run2} to finish (${review.status})`);
|
|
892
|
+
await deps.sleep(pollInterval);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
async function reviewGate(reviewResult, context, deps) {
|
|
896
|
+
if (reviewResult !== "skipped") return verdict(reviewResult, `The review on this event finished with ${reviewResult}.`);
|
|
897
|
+
const { pull_request } = context.event;
|
|
898
|
+
if (pull_request.head.repo?.full_name !== context.repo || pull_request.user.type === "Bot") {
|
|
899
|
+
return verdict("success", "No review runs for PRs from forks or bots.");
|
|
900
|
+
}
|
|
901
|
+
for (const run2 of await earlierRuns(context, deps)) {
|
|
902
|
+
const conclusion = await settledReview(context.repo, run2, deps);
|
|
903
|
+
const url = `${context.serverUrl}/${context.repo}/actions/runs/${run2}`;
|
|
904
|
+
if (conclusion) return verdict(conclusion, `The latest review for this PR, in run ${url}, finished with ${conclusion}.`);
|
|
905
|
+
}
|
|
906
|
+
return { pass: false, message: `No review has completed for this PR yet. ${firstRunHint}` };
|
|
907
|
+
}
|
|
908
|
+
async function runReviewGate(reviewResult, out, env = process.env) {
|
|
909
|
+
const deps = { get: (path) => githubGet(path), sleep: (ms) => setTimeout(ms), log: out };
|
|
910
|
+
const { pass, message } = await reviewGate(reviewResult, gateContext(env), deps);
|
|
911
|
+
out(pass ? message : `::error::${message}`);
|
|
912
|
+
if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, `${message}
|
|
913
|
+
`);
|
|
914
|
+
return pass;
|
|
915
|
+
}
|
|
916
|
+
|
|
812
917
|
// src/features/review/comments.ts
|
|
813
918
|
var markerPrefix = "<!-- yoke-review:";
|
|
814
919
|
function slug(text2) {
|
|
@@ -950,7 +1055,9 @@ function selfReviewNote(diff) {
|
|
|
950
1055
|
This change adds or edits a workflow that calls \`MakerXStudio/yoke/.github/workflows/review.yml\`, the CI setup for this review. Review it like any other file, but these are yoke's deliberate design: do not raise them as findings, keep them in a consolidated review, or mention them in a summary.
|
|
951
1056
|
|
|
952
1057
|
- Calling the workflow at \`@main\` rather than a tag or SHA.
|
|
953
|
-
- The trigger types, including
|
|
1058
|
+
- 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.
|
|
1059
|
+
- 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.
|
|
1060
|
+
- The \`actions: read\` permission: the gate reads earlier runs of the workflow to find the PR's latest review.
|
|
954
1061
|
- Draft PRs being reviewed twice: the reusable workflow already skips drafts.
|
|
955
1062
|
|
|
956
1063
|
Real defects in it are still findings, such as:
|
|
@@ -1309,7 +1416,7 @@ async function listModels(settings, fetchModels = fetch) {
|
|
|
1309
1416
|
}
|
|
1310
1417
|
|
|
1311
1418
|
// src/features/review/review.ts
|
|
1312
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as
|
|
1419
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync8, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
|
|
1313
1420
|
import { homedir as homedir2, tmpdir } from "os";
|
|
1314
1421
|
import { join as join8 } from "path";
|
|
1315
1422
|
|
|
@@ -1379,10 +1486,10 @@ async function withEntraToken(settings, { env, fetch: fetchFn = fetch, mask }) {
|
|
|
1379
1486
|
|
|
1380
1487
|
// src/features/review/harness.ts
|
|
1381
1488
|
import { spawn } from "child_process";
|
|
1382
|
-
import { existsSync as existsSync3, readFileSync as
|
|
1489
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
1383
1490
|
|
|
1384
1491
|
// src/features/review/usage.ts
|
|
1385
|
-
import { appendFileSync } from "fs";
|
|
1492
|
+
import { appendFileSync as appendFileSync2 } from "fs";
|
|
1386
1493
|
var fields2 = ["input", "output", "cacheRead", "cacheWrite"];
|
|
1387
1494
|
var rows = [
|
|
1388
1495
|
["reviewer1", "Reviewer 1"],
|
|
@@ -1445,7 +1552,7 @@ function writeJobSummary(summary, usage, log, env = process.env) {
|
|
|
1445
1552
|
const path = env.GITHUB_STEP_SUMMARY;
|
|
1446
1553
|
if (!path) return;
|
|
1447
1554
|
try {
|
|
1448
|
-
|
|
1555
|
+
appendFileSync2(path, `${jobSummary(summary, usage)}
|
|
1449
1556
|
`);
|
|
1450
1557
|
log("wrote the review summary and token usage to the job summary");
|
|
1451
1558
|
} catch (error) {
|
|
@@ -1571,7 +1678,7 @@ function exitError(harness, code, output) {
|
|
|
1571
1678
|
return [`${harness} exited with code ${code ?? "unknown"}`, ...detail ? [detail] : []].join("\n");
|
|
1572
1679
|
}
|
|
1573
1680
|
function hasReview(path) {
|
|
1574
|
-
return existsSync3(path) &&
|
|
1681
|
+
return existsSync3(path) && readFileSync4(path, "utf8").trim().length > 0;
|
|
1575
1682
|
}
|
|
1576
1683
|
var runHarness = (run2) => new Promise((resolve) => {
|
|
1577
1684
|
const shell = process.platform === "win32";
|
|
@@ -1625,11 +1732,11 @@ var runHarness = (run2) => new Promise((resolve) => {
|
|
|
1625
1732
|
import { execFile as execFile2 } from "child_process";
|
|
1626
1733
|
|
|
1627
1734
|
// src/features/review/run.ts
|
|
1628
|
-
import { existsSync as existsSync6, readFileSync as
|
|
1735
|
+
import { existsSync as existsSync6, readFileSync as readFileSync7, rmSync, writeFileSync as writeFileSync6 } from "fs";
|
|
1629
1736
|
import { join as join6 } from "path";
|
|
1630
1737
|
|
|
1631
1738
|
// src/features/review/cachedSlots.ts
|
|
1632
|
-
import { existsSync as existsSync4, readFileSync as
|
|
1739
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
1633
1740
|
import { join as join4 } from "path";
|
|
1634
1741
|
function slotsPath(reviewDir) {
|
|
1635
1742
|
return join4(reviewDir, "slots.json");
|
|
@@ -1640,7 +1747,7 @@ function isCachedSlots(value) {
|
|
|
1640
1747
|
function readCachedSlots(reviewDir) {
|
|
1641
1748
|
const path = slotsPath(reviewDir);
|
|
1642
1749
|
if (!existsSync4(path)) return {};
|
|
1643
|
-
const parsed = JSON.parse(
|
|
1750
|
+
const parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
1644
1751
|
return isCachedSlots(parsed) ? parsed : {};
|
|
1645
1752
|
}
|
|
1646
1753
|
function cachedSlot(reviewDir, step) {
|
|
@@ -1794,7 +1901,7 @@ ${files.join("\n")}
|
|
|
1794
1901
|
}
|
|
1795
1902
|
|
|
1796
1903
|
// src/features/review/usedModels.ts
|
|
1797
|
-
import { existsSync as existsSync5, readFileSync as
|
|
1904
|
+
import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
1798
1905
|
import { join as join5 } from "path";
|
|
1799
1906
|
var repoUrl = "https://github.com/MakerXStudio/yoke";
|
|
1800
1907
|
function modelsPath(reviewDir) {
|
|
@@ -1806,7 +1913,7 @@ function isUsedModels(value) {
|
|
|
1806
1913
|
function readUsedModels(reviewDir) {
|
|
1807
1914
|
const path = modelsPath(reviewDir);
|
|
1808
1915
|
if (!existsSync5(path)) return {};
|
|
1809
|
-
const parsed = JSON.parse(
|
|
1916
|
+
const parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
1810
1917
|
return isUsedModels(parsed) ? parsed : {};
|
|
1811
1918
|
}
|
|
1812
1919
|
function recordUsedModel(reviewDir, step, harness, model) {
|
|
@@ -1877,7 +1984,7 @@ async function runStep({ name, label, slot, ...step }, context) {
|
|
|
1877
1984
|
}
|
|
1878
1985
|
function hasOutput({ name, slot, outputPath }, context) {
|
|
1879
1986
|
if (cachedSlot(context.reviewDir, name) !== describeSlot(slot)) return false;
|
|
1880
|
-
return existsSync6(outputPath) &&
|
|
1987
|
+
return existsSync6(outputPath) && readFileSync7(outputPath, "utf8").trim().length > 0;
|
|
1881
1988
|
}
|
|
1882
1989
|
async function runOrReuse(step, context) {
|
|
1883
1990
|
if (!hasOutput(step, context)) return { ok: await runStep(step, context), ran: true };
|
|
@@ -1904,7 +2011,7 @@ function synthesisResult(markdown) {
|
|
|
1904
2011
|
function rawReviews(succeeded, context) {
|
|
1905
2012
|
const report = succeeded.map((reviewer) => `# ${reviewerLabel(reviewer, context)} review
|
|
1906
2013
|
|
|
1907
|
-
${
|
|
2014
|
+
${readFileSync7(reviewPath(context, reviewer), "utf8").trim()}`).join("\n\n");
|
|
1908
2015
|
return { report, synthesis: null, markdown: null };
|
|
1909
2016
|
}
|
|
1910
2017
|
function synthesisSlot(succeeded, context) {
|
|
@@ -1921,7 +2028,7 @@ async function synthesise(requestPath, succeeded, reviewersRan, context) {
|
|
|
1921
2028
|
}));
|
|
1922
2029
|
const prompt = buildSynthesisPrompt(requestPath, files);
|
|
1923
2030
|
if ((await runOrReuse({ name: "synthesis", label: `synthesis (${describeSlot(slot)})`, slot, prompt, outputPath }, context)).ok)
|
|
1924
|
-
return synthesisResult(
|
|
2031
|
+
return synthesisResult(readFileSync7(outputPath, "utf8"));
|
|
1925
2032
|
if (context.strict) throw new ReviewFailure("synthesis failed", "The synthesis failed; nothing to post.");
|
|
1926
2033
|
context.log("showing the raw reviews instead");
|
|
1927
2034
|
return rawReviews(succeeded, context);
|
|
@@ -2029,21 +2136,21 @@ async function assertViable(slots2, check = checks) {
|
|
|
2029
2136
|
}
|
|
2030
2137
|
|
|
2031
2138
|
// src/features/review/reviewLog.ts
|
|
2032
|
-
import { appendFileSync as
|
|
2139
|
+
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync3 } from "fs";
|
|
2033
2140
|
import { join as join7 } from "path";
|
|
2034
2141
|
function reviewLog(command = process.argv.slice(2), now = () => /* @__PURE__ */ new Date()) {
|
|
2035
2142
|
let path;
|
|
2036
2143
|
let buffered = [];
|
|
2037
2144
|
return {
|
|
2038
2145
|
append: (line) => {
|
|
2039
|
-
if (path)
|
|
2146
|
+
if (path) appendFileSync3(path, `${line}
|
|
2040
2147
|
`);
|
|
2041
2148
|
else buffered.push(line);
|
|
2042
2149
|
},
|
|
2043
2150
|
attach: (reviewDir) => {
|
|
2044
2151
|
mkdirSync3(reviewDir, { recursive: true });
|
|
2045
2152
|
path = join7(reviewDir, "review.log");
|
|
2046
|
-
|
|
2153
|
+
appendFileSync3(path, `${["", `=== ${now().toISOString()} ===`, `$ yoke ${command.join(" ")}`, ...buffered].join("\n")}
|
|
2047
2154
|
`);
|
|
2048
2155
|
buffered = [];
|
|
2049
2156
|
}
|
|
@@ -2102,7 +2209,7 @@ async function reviewAtHead(pr2, reviewDir, request, slots2, ui, strict) {
|
|
|
2102
2209
|
}
|
|
2103
2210
|
function savedReview(path, post) {
|
|
2104
2211
|
if (!post || !existsSync7(path)) return null;
|
|
2105
|
-
return parseSynthesis(
|
|
2212
|
+
return parseSynthesis(readFileSync8(path, "utf8"));
|
|
2106
2213
|
}
|
|
2107
2214
|
function saveReview(path, markdown) {
|
|
2108
2215
|
if (markdown) writeFileSync7(path, markdown);
|
|
@@ -2164,7 +2271,7 @@ async function reviewPr(number, settings, { post, force }, io) {
|
|
|
2164
2271
|
}
|
|
2165
2272
|
|
|
2166
2273
|
// src/features/sync/sync.ts
|
|
2167
|
-
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as
|
|
2274
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "fs";
|
|
2168
2275
|
import { homedir as homedir3 } from "os";
|
|
2169
2276
|
import { join as join9 } from "path";
|
|
2170
2277
|
|
|
@@ -2216,6 +2323,8 @@ A workflow is an LLM review if it has a model review PRs, whatever tool it uses.
|
|
|
2216
2323
|
|
|
2217
2324
|
Never ask whether to overwrite a workflow. If \`review-pr.yml\` exists and differs, edit it in place.
|
|
2218
2325
|
|
|
2326
|
+
The \`review / gate\` merge gate needs the \`synchronize\` trigger and \`actions: read\` permission from \`--print\`; add them to an edited workflow if missing.
|
|
2327
|
+
|
|
2219
2328
|
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.
|
|
2220
2329
|
|
|
2221
2330
|
For Foundry with Entra, reuse the client and tenant IDs from the existing workflow; ask only if none is there.
|
|
@@ -2229,7 +2338,7 @@ Never ask whether they are set for the organisation, and don't query org setting
|
|
|
2229
2338
|
|
|
2230
2339
|
With Entra, check \`az ad app federated-credential list --id <client-id>\` and show the printed create command if the credential is missing.
|
|
2231
2340
|
|
|
2232
|
-
Finish with what changed and what's left: commit and push the workflow.
|
|
2341
|
+
Finish with what changed and what's left: commit and push the workflow, then, only once it is merged to the default branch, run the printed \`gh api\` ruleset command requiring \`review / gate\`. Don't run it before then.
|
|
2233
2342
|
|
|
2234
2343
|
If \`yoke\` is not found, run \`npm i -g @makerx/yoke\`.
|
|
2235
2344
|
`
|
|
@@ -2255,7 +2364,7 @@ function skillsRoot(user, cwd = process.cwd(), home = homedir3()) {
|
|
|
2255
2364
|
return join9(user ? home : cwd, ".claude", "skills");
|
|
2256
2365
|
}
|
|
2257
2366
|
function isOwnedByUser(skillFile) {
|
|
2258
|
-
return existsSync8(skillFile) && !
|
|
2367
|
+
return existsSync8(skillFile) && !readFileSync9(skillFile, "utf8").includes(ownerMarker);
|
|
2259
2368
|
}
|
|
2260
2369
|
function syncSkills(destination, userDestination, force = false, skills = bundledSkills) {
|
|
2261
2370
|
const result = { written: [], skipped: [] };
|
|
@@ -2355,6 +2464,15 @@ addSettingOptions(
|
|
|
2355
2464
|
process.exitCode = 1;
|
|
2356
2465
|
}
|
|
2357
2466
|
});
|
|
2467
|
+
program.command("review-gate").description("Block merging until the latest review that ran for the PR succeeds; fork and bot PRs pass. Used by review.yml's gate job").argument("<result>", "result of the review job on this event: success, failure, cancelled or skipped").action(async (result) => {
|
|
2468
|
+
try {
|
|
2469
|
+
if (!await runReviewGate(result, console.log)) process.exitCode = 1;
|
|
2470
|
+
} catch (error) {
|
|
2471
|
+
if (!(error instanceof ReviewError)) throw error;
|
|
2472
|
+
console.error(error.message);
|
|
2473
|
+
process.exitCode = 1;
|
|
2474
|
+
}
|
|
2475
|
+
});
|
|
2358
2476
|
addSettingOptions(
|
|
2359
2477
|
program.command("models").description("List the models each configured provider serves, for review.*.provider and review.*.model"),
|
|
2360
2478
|
providerKeys
|