@makerx/yoke 0.11.0 → 0.13.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 +2 -1
- package/dist/cli.js +219 -72
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ yoke sync --user # ~/.claude/skills
|
|
|
23
23
|
|
|
24
24
|
- `/pr` raises a PR with a short What/Why/(How) description.
|
|
25
25
|
- `/review [number]` reviews a PR with Claude and Codex, combines their findings and offers to post them. [Configure](src/features/review/README.md#settings).
|
|
26
|
-
- `/review-ci`
|
|
26
|
+
- `/review-ci` makes CI review each PR, adding a workflow or switching the repo's existing LLM review workflow over, and helps set its variable and secret. [Details](src/features/review/README.md#ci).
|
|
27
27
|
|
|
28
28
|
## CLI
|
|
29
29
|
|
|
@@ -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.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",
|
|
@@ -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()) {
|
|
@@ -756,44 +795,125 @@ function repoRoot(cwd = process.cwd()) {
|
|
|
756
795
|
throw new ReviewError("Not in a git repository; run `yoke review-ci` from the repo to review.");
|
|
757
796
|
}
|
|
758
797
|
}
|
|
759
|
-
|
|
798
|
+
var editInPlaceMessage = [
|
|
799
|
+
`${callerWorkflowPath} already exists and differs from yoke's version, so it was left as it is.`,
|
|
800
|
+
"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."
|
|
801
|
+
].join("\n");
|
|
802
|
+
function writeCallerWorkflow(root, content) {
|
|
760
803
|
const path = join3(root, callerWorkflowPath);
|
|
761
804
|
if (existsSync2(path)) {
|
|
762
805
|
if (readFileSync2(path, "utf8") === content) return "unchanged";
|
|
763
|
-
|
|
806
|
+
throw new ReviewError(editInPlaceMessage);
|
|
764
807
|
}
|
|
765
808
|
mkdirSync2(dirname2(path), { recursive: true });
|
|
766
809
|
writeFileSync2(path, content);
|
|
767
810
|
return "wrote";
|
|
768
811
|
}
|
|
769
|
-
function
|
|
770
|
-
|
|
812
|
+
function reportHeading(outcome, workflow) {
|
|
813
|
+
if (outcome === "printed")
|
|
814
|
+
return [`yoke's version of ${callerWorkflowPath}, not saved to the repo:`, "", renderCallerWorkflow(workflow).trimEnd()];
|
|
815
|
+
if (outcome === "unchanged") return [`${callerWorkflowPath} already matches yoke's version; nothing changed.`];
|
|
816
|
+
return [`Added ${callerWorkflowPath}, which reviews each PR opened from this repo.`];
|
|
817
|
+
}
|
|
818
|
+
function variableAndSecretSetup(settings, workflow) {
|
|
819
|
+
const hasSecret = workflowSecrets(workflow).length > 0;
|
|
771
820
|
return [
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
`
|
|
775
|
-
` audience: ${credential.audience}`,
|
|
776
|
-
` subject: ${credential.subject}`,
|
|
777
|
-
"for example with:",
|
|
778
|
-
` ${federatedCredentialCommand(clientId, credential)}`,
|
|
779
|
-
"and give the app the Cognitive Services User role on the Foundry resource.",
|
|
780
|
-
...environment ? [`The job runs in the ${environment} environment; create it on the repo if it does not exist.`] : []
|
|
821
|
+
hasSecret ? `The review reads the model server's address from the repo variable ${baseUrlVariable} and its API key from the secret ${apiKeySecret}. Set them with:` : `The review reads the model server's address from the repo variable ${baseUrlVariable}. Set it with:`,
|
|
822
|
+
...setupCommands(settings, workflow).map((command) => ` ${command}`),
|
|
823
|
+
...hasSecret ? [`Skip the secret if ${apiKeySecret} is already set for your organisation.`] : []
|
|
781
824
|
];
|
|
782
825
|
}
|
|
783
826
|
function reviewCiReport(outcome, settings, workflow, repo) {
|
|
784
|
-
const action = outcome === "wrote" ? "wrote" : "kept";
|
|
785
|
-
const reads = [`variable ${baseUrlVariable}`, ...workflowSecrets(workflow).map((secret2) => `secret ${secret2}`)];
|
|
786
827
|
return [
|
|
787
|
-
|
|
828
|
+
...reportHeading(outcome, workflow),
|
|
788
829
|
"",
|
|
789
|
-
|
|
790
|
-
...setupCommands(settings, workflow).map((command) => ` ${command}`),
|
|
830
|
+
...variableAndSecretSetup(settings, workflow),
|
|
791
831
|
...workflow.entra ? entraSetup(workflow.entra, repo()) : [],
|
|
792
832
|
"",
|
|
793
|
-
"
|
|
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."
|
|
794
838
|
].join("\n");
|
|
795
839
|
}
|
|
796
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
|
+
|
|
797
917
|
// src/features/review/comments.ts
|
|
798
918
|
var markerPrefix = "<!-- yoke-review:";
|
|
799
919
|
function slug(text2) {
|
|
@@ -935,7 +1055,8 @@ function selfReviewNote(diff) {
|
|
|
935
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.
|
|
936
1056
|
|
|
937
1057
|
- Calling the workflow at \`@main\` rather than a tag or SHA.
|
|
938
|
-
- 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
|
+
- The \`actions: read\` permission: the gate reads earlier runs of the workflow to find the PR's latest review.
|
|
939
1060
|
- Draft PRs being reviewed twice: the reusable workflow already skips drafts.
|
|
940
1061
|
|
|
941
1062
|
Real defects in it are still findings, such as:
|
|
@@ -1294,7 +1415,7 @@ async function listModels(settings, fetchModels = fetch) {
|
|
|
1294
1415
|
}
|
|
1295
1416
|
|
|
1296
1417
|
// src/features/review/review.ts
|
|
1297
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as
|
|
1418
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync8, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
|
|
1298
1419
|
import { homedir as homedir2, tmpdir } from "os";
|
|
1299
1420
|
import { join as join8 } from "path";
|
|
1300
1421
|
|
|
@@ -1364,10 +1485,10 @@ async function withEntraToken(settings, { env, fetch: fetchFn = fetch, mask }) {
|
|
|
1364
1485
|
|
|
1365
1486
|
// src/features/review/harness.ts
|
|
1366
1487
|
import { spawn } from "child_process";
|
|
1367
|
-
import { existsSync as existsSync3, readFileSync as
|
|
1488
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
1368
1489
|
|
|
1369
1490
|
// src/features/review/usage.ts
|
|
1370
|
-
import { appendFileSync } from "fs";
|
|
1491
|
+
import { appendFileSync as appendFileSync2 } from "fs";
|
|
1371
1492
|
var fields2 = ["input", "output", "cacheRead", "cacheWrite"];
|
|
1372
1493
|
var rows = [
|
|
1373
1494
|
["reviewer1", "Reviewer 1"],
|
|
@@ -1430,7 +1551,7 @@ function writeJobSummary(summary, usage, log, env = process.env) {
|
|
|
1430
1551
|
const path = env.GITHUB_STEP_SUMMARY;
|
|
1431
1552
|
if (!path) return;
|
|
1432
1553
|
try {
|
|
1433
|
-
|
|
1554
|
+
appendFileSync2(path, `${jobSummary(summary, usage)}
|
|
1434
1555
|
`);
|
|
1435
1556
|
log("wrote the review summary and token usage to the job summary");
|
|
1436
1557
|
} catch (error) {
|
|
@@ -1556,7 +1677,7 @@ function exitError(harness, code, output) {
|
|
|
1556
1677
|
return [`${harness} exited with code ${code ?? "unknown"}`, ...detail ? [detail] : []].join("\n");
|
|
1557
1678
|
}
|
|
1558
1679
|
function hasReview(path) {
|
|
1559
|
-
return existsSync3(path) &&
|
|
1680
|
+
return existsSync3(path) && readFileSync4(path, "utf8").trim().length > 0;
|
|
1560
1681
|
}
|
|
1561
1682
|
var runHarness = (run2) => new Promise((resolve) => {
|
|
1562
1683
|
const shell = process.platform === "win32";
|
|
@@ -1610,11 +1731,11 @@ var runHarness = (run2) => new Promise((resolve) => {
|
|
|
1610
1731
|
import { execFile as execFile2 } from "child_process";
|
|
1611
1732
|
|
|
1612
1733
|
// src/features/review/run.ts
|
|
1613
|
-
import { existsSync as existsSync6, readFileSync as
|
|
1734
|
+
import { existsSync as existsSync6, readFileSync as readFileSync7, rmSync, writeFileSync as writeFileSync6 } from "fs";
|
|
1614
1735
|
import { join as join6 } from "path";
|
|
1615
1736
|
|
|
1616
1737
|
// src/features/review/cachedSlots.ts
|
|
1617
|
-
import { existsSync as existsSync4, readFileSync as
|
|
1738
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
1618
1739
|
import { join as join4 } from "path";
|
|
1619
1740
|
function slotsPath(reviewDir) {
|
|
1620
1741
|
return join4(reviewDir, "slots.json");
|
|
@@ -1625,7 +1746,7 @@ function isCachedSlots(value) {
|
|
|
1625
1746
|
function readCachedSlots(reviewDir) {
|
|
1626
1747
|
const path = slotsPath(reviewDir);
|
|
1627
1748
|
if (!existsSync4(path)) return {};
|
|
1628
|
-
const parsed = JSON.parse(
|
|
1749
|
+
const parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
1629
1750
|
return isCachedSlots(parsed) ? parsed : {};
|
|
1630
1751
|
}
|
|
1631
1752
|
function cachedSlot(reviewDir, step) {
|
|
@@ -1779,7 +1900,7 @@ ${files.join("\n")}
|
|
|
1779
1900
|
}
|
|
1780
1901
|
|
|
1781
1902
|
// src/features/review/usedModels.ts
|
|
1782
|
-
import { existsSync as existsSync5, readFileSync as
|
|
1903
|
+
import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
1783
1904
|
import { join as join5 } from "path";
|
|
1784
1905
|
var repoUrl = "https://github.com/MakerXStudio/yoke";
|
|
1785
1906
|
function modelsPath(reviewDir) {
|
|
@@ -1791,7 +1912,7 @@ function isUsedModels(value) {
|
|
|
1791
1912
|
function readUsedModels(reviewDir) {
|
|
1792
1913
|
const path = modelsPath(reviewDir);
|
|
1793
1914
|
if (!existsSync5(path)) return {};
|
|
1794
|
-
const parsed = JSON.parse(
|
|
1915
|
+
const parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
1795
1916
|
return isUsedModels(parsed) ? parsed : {};
|
|
1796
1917
|
}
|
|
1797
1918
|
function recordUsedModel(reviewDir, step, harness, model) {
|
|
@@ -1862,7 +1983,7 @@ async function runStep({ name, label, slot, ...step }, context) {
|
|
|
1862
1983
|
}
|
|
1863
1984
|
function hasOutput({ name, slot, outputPath }, context) {
|
|
1864
1985
|
if (cachedSlot(context.reviewDir, name) !== describeSlot(slot)) return false;
|
|
1865
|
-
return existsSync6(outputPath) &&
|
|
1986
|
+
return existsSync6(outputPath) && readFileSync7(outputPath, "utf8").trim().length > 0;
|
|
1866
1987
|
}
|
|
1867
1988
|
async function runOrReuse(step, context) {
|
|
1868
1989
|
if (!hasOutput(step, context)) return { ok: await runStep(step, context), ran: true };
|
|
@@ -1889,7 +2010,7 @@ function synthesisResult(markdown) {
|
|
|
1889
2010
|
function rawReviews(succeeded, context) {
|
|
1890
2011
|
const report = succeeded.map((reviewer) => `# ${reviewerLabel(reviewer, context)} review
|
|
1891
2012
|
|
|
1892
|
-
${
|
|
2013
|
+
${readFileSync7(reviewPath(context, reviewer), "utf8").trim()}`).join("\n\n");
|
|
1893
2014
|
return { report, synthesis: null, markdown: null };
|
|
1894
2015
|
}
|
|
1895
2016
|
function synthesisSlot(succeeded, context) {
|
|
@@ -1906,7 +2027,7 @@ async function synthesise(requestPath, succeeded, reviewersRan, context) {
|
|
|
1906
2027
|
}));
|
|
1907
2028
|
const prompt = buildSynthesisPrompt(requestPath, files);
|
|
1908
2029
|
if ((await runOrReuse({ name: "synthesis", label: `synthesis (${describeSlot(slot)})`, slot, prompt, outputPath }, context)).ok)
|
|
1909
|
-
return synthesisResult(
|
|
2030
|
+
return synthesisResult(readFileSync7(outputPath, "utf8"));
|
|
1910
2031
|
if (context.strict) throw new ReviewFailure("synthesis failed", "The synthesis failed; nothing to post.");
|
|
1911
2032
|
context.log("showing the raw reviews instead");
|
|
1912
2033
|
return rawReviews(succeeded, context);
|
|
@@ -2014,21 +2135,21 @@ async function assertViable(slots2, check = checks) {
|
|
|
2014
2135
|
}
|
|
2015
2136
|
|
|
2016
2137
|
// src/features/review/reviewLog.ts
|
|
2017
|
-
import { appendFileSync as
|
|
2138
|
+
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync3 } from "fs";
|
|
2018
2139
|
import { join as join7 } from "path";
|
|
2019
2140
|
function reviewLog(command = process.argv.slice(2), now = () => /* @__PURE__ */ new Date()) {
|
|
2020
2141
|
let path;
|
|
2021
2142
|
let buffered = [];
|
|
2022
2143
|
return {
|
|
2023
2144
|
append: (line) => {
|
|
2024
|
-
if (path)
|
|
2145
|
+
if (path) appendFileSync3(path, `${line}
|
|
2025
2146
|
`);
|
|
2026
2147
|
else buffered.push(line);
|
|
2027
2148
|
},
|
|
2028
2149
|
attach: (reviewDir) => {
|
|
2029
2150
|
mkdirSync3(reviewDir, { recursive: true });
|
|
2030
2151
|
path = join7(reviewDir, "review.log");
|
|
2031
|
-
|
|
2152
|
+
appendFileSync3(path, `${["", `=== ${now().toISOString()} ===`, `$ yoke ${command.join(" ")}`, ...buffered].join("\n")}
|
|
2032
2153
|
`);
|
|
2033
2154
|
buffered = [];
|
|
2034
2155
|
}
|
|
@@ -2087,7 +2208,7 @@ async function reviewAtHead(pr2, reviewDir, request, slots2, ui, strict) {
|
|
|
2087
2208
|
}
|
|
2088
2209
|
function savedReview(path, post) {
|
|
2089
2210
|
if (!post || !existsSync7(path)) return null;
|
|
2090
|
-
return parseSynthesis(
|
|
2211
|
+
return parseSynthesis(readFileSync8(path, "utf8"));
|
|
2091
2212
|
}
|
|
2092
2213
|
function saveReview(path, markdown) {
|
|
2093
2214
|
if (markdown) writeFileSync7(path, markdown);
|
|
@@ -2149,7 +2270,7 @@ async function reviewPr(number, settings, { post, force }, io) {
|
|
|
2149
2270
|
}
|
|
2150
2271
|
|
|
2151
2272
|
// src/features/sync/sync.ts
|
|
2152
|
-
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as
|
|
2273
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "fs";
|
|
2153
2274
|
import { homedir as homedir3 } from "os";
|
|
2154
2275
|
import { join as join9 } from "path";
|
|
2155
2276
|
|
|
@@ -2185,23 +2306,40 @@ If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
|
|
|
2185
2306
|
// src/features/review/ciSkill.ts
|
|
2186
2307
|
var reviewCiSkill = {
|
|
2187
2308
|
name: "review-ci",
|
|
2188
|
-
description: "Add yoke's adversarial PR review workflow to this repo and set the variable and secret it needs.",
|
|
2309
|
+
description: "Add yoke's adversarial PR review workflow to this repo, or switch its existing LLM review workflow to it, and set the variable and secret it needs.",
|
|
2189
2310
|
disableModelInvocation: true,
|
|
2190
2311
|
userLevel: true,
|
|
2191
2312
|
body: `
|
|
2192
|
-
|
|
2313
|
+
Read \`.github/workflows/\`, recent review runs (\`gh run list\`, \`gh run view --log\`) and \`yoke config list\` before asking anything. Only ask what they don't answer, in plain words about the repo, not flags.
|
|
2193
2314
|
|
|
2194
|
-
|
|
2315
|
+
## Workflow
|
|
2195
2316
|
|
|
2196
|
-
|
|
2197
|
-
- \`REVIEW_CI_BASE_URL\` is a variable, not a secret: confirm the URL with the user and run the printed \`gh variable set\` command.
|
|
2198
|
-
- \`REVIEW_CI_API_KEY\` must never enter this conversation: ask the user to run \`gh secret set REVIEW_CI_API_KEY\` in their own terminal, outside this session, and paste the key at its prompt. With Entra auth there is no API key to set.
|
|
2317
|
+
A workflow is an LLM review if it has a model review PRs, whatever tool it uses.
|
|
2199
2318
|
|
|
2200
|
-
|
|
2319
|
+
- **None:** run \`yoke review-ci $ARGUMENTS\`.
|
|
2320
|
+
- **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.
|
|
2321
|
+
- **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.
|
|
2201
2322
|
|
|
2202
|
-
|
|
2323
|
+
Never ask whether to overwrite a workflow. If \`review-pr.yml\` exists and differs, edit it in place.
|
|
2203
2324
|
|
|
2204
|
-
|
|
2325
|
+
The \`review / gate\` merge gate needs the \`synchronize\` trigger and \`actions: read\` permission from \`--print\`; add them to an edited workflow if missing.
|
|
2326
|
+
|
|
2327
|
+
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.
|
|
2328
|
+
|
|
2329
|
+
For Foundry with Entra, reuse the client and tenant IDs from the existing workflow; ask only if none is there.
|
|
2330
|
+
|
|
2331
|
+
## Variable and secret
|
|
2332
|
+
|
|
2333
|
+
Never ask whether they are set for the organisation, and don't query org settings.
|
|
2334
|
+
|
|
2335
|
+
- \`REVIEW_CI_BASE_URL\` not in \`gh variable list\`: set it on the repo with the base URL from the workflow or the printed command.
|
|
2336
|
+
- \`REVIEW_CI_API_KEY\` not in \`gh secret list\`: tell the user to run \`gh secret set REVIEW_CI_API_KEY\` in their own terminal, and to skip it if the secret is already set for the organisation. Never take the key in this conversation.
|
|
2337
|
+
|
|
2338
|
+
With Entra, check \`az ad app federated-credential list --id <client-id>\` and show the printed create command if the credential is missing.
|
|
2339
|
+
|
|
2340
|
+
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.
|
|
2341
|
+
|
|
2342
|
+
If \`yoke\` is not found, run \`npm i -g @makerx/yoke\`.
|
|
2205
2343
|
`
|
|
2206
2344
|
};
|
|
2207
2345
|
|
|
@@ -2225,7 +2363,7 @@ function skillsRoot(user, cwd = process.cwd(), home = homedir3()) {
|
|
|
2225
2363
|
return join9(user ? home : cwd, ".claude", "skills");
|
|
2226
2364
|
}
|
|
2227
2365
|
function isOwnedByUser(skillFile) {
|
|
2228
|
-
return existsSync8(skillFile) && !
|
|
2366
|
+
return existsSync8(skillFile) && !readFileSync9(skillFile, "utf8").includes(ownerMarker);
|
|
2229
2367
|
}
|
|
2230
2368
|
function syncSkills(destination, userDestination, force = false, skills = bundledSkills) {
|
|
2231
2369
|
const result = { written: [], skipped: [] };
|
|
@@ -2310,14 +2448,14 @@ addSettingOptions(
|
|
|
2310
2448
|
).option("--provider <provider>", "litellm or foundry; every slot uses it in CI. Default: review.reviewer1.provider, else litellm").option(
|
|
2311
2449
|
"--azure-client-id <id>",
|
|
2312
2450
|
"Entra app (client) ID to sign in to Foundry with GitHub OIDC instead of an API key; needs --provider foundry"
|
|
2313
|
-
).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("--
|
|
2451
|
+
).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}`),
|
|
2314
2452
|
ciKeys
|
|
2315
2453
|
).addHelpText("after", settingsHelp).action((options) => {
|
|
2316
2454
|
try {
|
|
2317
2455
|
const settings = resolveSettings(flagSettings(options, ciKeys), process.env, readUserConfig(userConfigPath()));
|
|
2318
2456
|
const workflow = callerWorkflow(settings, options.provider, options);
|
|
2319
2457
|
const root = repoRoot();
|
|
2320
|
-
const outcome = writeCallerWorkflow(root, renderCallerWorkflow(workflow)
|
|
2458
|
+
const outcome = options.print ? "printed" : writeCallerWorkflow(root, renderCallerWorkflow(workflow));
|
|
2321
2459
|
console.log(reviewCiReport(outcome, settings, workflow, () => githubRepo(root)));
|
|
2322
2460
|
} catch (error) {
|
|
2323
2461
|
if (!(error instanceof ReviewError || error instanceof ConfigError)) throw error;
|
|
@@ -2325,6 +2463,15 @@ addSettingOptions(
|
|
|
2325
2463
|
process.exitCode = 1;
|
|
2326
2464
|
}
|
|
2327
2465
|
});
|
|
2466
|
+
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) => {
|
|
2467
|
+
try {
|
|
2468
|
+
if (!await runReviewGate(result, console.log)) process.exitCode = 1;
|
|
2469
|
+
} catch (error) {
|
|
2470
|
+
if (!(error instanceof ReviewError)) throw error;
|
|
2471
|
+
console.error(error.message);
|
|
2472
|
+
process.exitCode = 1;
|
|
2473
|
+
}
|
|
2474
|
+
});
|
|
2328
2475
|
addSettingOptions(
|
|
2329
2476
|
program.command("models").description("List the models each configured provider serves, for review.*.provider and review.*.model"),
|
|
2330
2477
|
providerKeys
|