@makerx/yoke 0.13.1 → 0.14.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 -0
- package/dist/cli.js +284 -56
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ yoke sync --user # ~/.claude/skills
|
|
|
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
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
|
+
- `/review-ci-gate` blocks merging until the CI review passes, once its workflow is merged. [Details](src/features/review/README.md#merge-gate).
|
|
27
28
|
|
|
28
29
|
## CLI
|
|
29
30
|
|
|
@@ -31,6 +32,7 @@ yoke sync --user # ~/.claude/skills
|
|
|
31
32
|
yoke sync [--user] [--force] # write yoke's skills
|
|
32
33
|
yoke review [number] # review a PR (used by /review)
|
|
33
34
|
yoke review-ci # add the CI review workflow (used by /review-ci)
|
|
35
|
+
yoke review-ci-gate # require the review / gate check on the default branch (used by /review-ci-gate)
|
|
34
36
|
yoke review-gate <result> # block merging until the PR's latest review succeeds (used by review.yml)
|
|
35
37
|
yoke models # list each provider's models
|
|
36
38
|
```
|
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.14.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",
|
|
@@ -43,6 +43,7 @@ var package_default = {
|
|
|
43
43
|
dependencies: {
|
|
44
44
|
commander: "^15.0.0",
|
|
45
45
|
"log-update": "^8.0.0",
|
|
46
|
+
yaml: "^2.9.1",
|
|
46
47
|
zod: "^4.6.7"
|
|
47
48
|
},
|
|
48
49
|
devDependencies: {
|
|
@@ -54,8 +55,7 @@ var package_default = {
|
|
|
54
55
|
"oxlint-tsgolint": "^7.0.2004",
|
|
55
56
|
tsup: "^9.0.0",
|
|
56
57
|
typescript: "^6.0.3",
|
|
57
|
-
vitest: "^5.0.5"
|
|
58
|
-
yaml: "^2.9.1"
|
|
58
|
+
vitest: "^5.0.5"
|
|
59
59
|
},
|
|
60
60
|
overrides: {
|
|
61
61
|
minimatch: "^11.0.3"
|
|
@@ -159,6 +159,28 @@ function howToSet(key, placeholder = "<value>") {
|
|
|
159
159
|
return `${ways.slice(0, -1).join(", ")} or ${ways.at(-1)}`;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// src/cliSettings.ts
|
|
163
|
+
function addSettingOptions(command, keys) {
|
|
164
|
+
for (const key of keys) {
|
|
165
|
+
const flag = settingFlag(key);
|
|
166
|
+
if (flag) command.option(`${flag} <value>`, settingDescription(key));
|
|
167
|
+
}
|
|
168
|
+
return command;
|
|
169
|
+
}
|
|
170
|
+
function optionName(flag) {
|
|
171
|
+
return flag.slice(2).replace(/-(.)/g, (_, next) => next.toUpperCase());
|
|
172
|
+
}
|
|
173
|
+
function flagSettings(options, keys) {
|
|
174
|
+
const flags = {};
|
|
175
|
+
for (const key of keys) {
|
|
176
|
+
const flag = settingFlag(key);
|
|
177
|
+
const value = flag && options[optionName(flag)];
|
|
178
|
+
if (typeof value === "string") flags[key] = value;
|
|
179
|
+
}
|
|
180
|
+
return flags;
|
|
181
|
+
}
|
|
182
|
+
var settingsHelp = "\nEach setting falls back to its YOKE_* env var, then `yoke config set --user`; API keys come from env or config only. Run `yoke config` to see them all.";
|
|
183
|
+
|
|
162
184
|
// src/features/config/userConfig.ts
|
|
163
185
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
164
186
|
import { homedir } from "os";
|
|
@@ -548,6 +570,9 @@ async function discardPendingReview(pr2, review, exec2 = run) {
|
|
|
548
570
|
async function githubGet(path, exec2 = run) {
|
|
549
571
|
return JSON.parse(await exec2("gh", ["api", path]));
|
|
550
572
|
}
|
|
573
|
+
async function githubPost(path, body, exec2 = run) {
|
|
574
|
+
return JSON.parse(await exec2("gh", ["api", "--method", "POST", path, "--input", "-"], void 0, JSON.stringify(body)));
|
|
575
|
+
}
|
|
551
576
|
var github = {
|
|
552
577
|
open: (pr2) => openPendingReview(pr2),
|
|
553
578
|
add: (review, comment) => addReviewComment(review, comment),
|
|
@@ -761,21 +786,6 @@ function setupCommands(settings, workflow) {
|
|
|
761
786
|
...workflowSecrets(workflow).map((secret2) => `gh secret set ${secret2}`)
|
|
762
787
|
];
|
|
763
788
|
}
|
|
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 -`;
|
|
778
|
-
}
|
|
779
789
|
var unknownRepo = "<owner>/<repo>";
|
|
780
790
|
function githubRepo(cwd = process.cwd()) {
|
|
781
791
|
try {
|
|
@@ -832,28 +842,242 @@ function reviewCiReport(outcome, settings, workflow, repo) {
|
|
|
832
842
|
"",
|
|
833
843
|
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
844
|
"",
|
|
835
|
-
`To block merging while the review fails,
|
|
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."
|
|
845
|
+
`To block merging while the review fails, run /review-ci-gate once this workflow is merged to the default branch; it requires the ${gateCheck} check there.`
|
|
838
846
|
].join("\n");
|
|
839
847
|
}
|
|
840
848
|
|
|
849
|
+
// src/features/review/ciGate.ts
|
|
850
|
+
import { z as z3 } from "zod";
|
|
851
|
+
|
|
852
|
+
// src/features/review/ciGateCaller.ts
|
|
853
|
+
import { parse } from "yaml";
|
|
854
|
+
import { z as z2 } from "zod";
|
|
855
|
+
var yokeRepo = "MakerXStudio/yoke";
|
|
856
|
+
var localReviewWorkflow = "./.github/workflows/review.yml";
|
|
857
|
+
var reviewWorkflowPrefix = reviewWorkflowRef.slice(0, reviewWorkflowRef.indexOf("@") + 1).toLowerCase();
|
|
858
|
+
var workflowSchema = z2.looseObject({
|
|
859
|
+
on: z2.unknown().optional(),
|
|
860
|
+
permissions: z2.unknown().optional(),
|
|
861
|
+
jobs: z2.record(z2.string(), z2.looseObject({ uses: z2.unknown().optional(), permissions: z2.unknown().optional() }).nullable()).optional()
|
|
862
|
+
});
|
|
863
|
+
function parseWorkflow(content) {
|
|
864
|
+
try {
|
|
865
|
+
const result = workflowSchema.safeParse(parse(content));
|
|
866
|
+
return result.success ? result.data : void 0;
|
|
867
|
+
} catch {
|
|
868
|
+
return void 0;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
function callsReview(uses, repo) {
|
|
872
|
+
if (typeof uses !== "string") return false;
|
|
873
|
+
if (uses.toLowerCase().startsWith(reviewWorkflowPrefix)) return true;
|
|
874
|
+
return repo.toLowerCase() === yokeRepo.toLowerCase() && uses === localReviewWorkflow;
|
|
875
|
+
}
|
|
876
|
+
function listIncludes(value, item) {
|
|
877
|
+
return value === item || Array.isArray(value) && value.includes(item);
|
|
878
|
+
}
|
|
879
|
+
function pullRequestTrigger(on) {
|
|
880
|
+
if (typeof on !== "object" || on === null || Array.isArray(on) || !("pull_request" in on)) return void 0;
|
|
881
|
+
const trigger = on.pull_request;
|
|
882
|
+
return typeof trigger === "object" && trigger !== null && !Array.isArray(trigger) ? { ...trigger } : void 0;
|
|
883
|
+
}
|
|
884
|
+
function runsOnSynchronize(on) {
|
|
885
|
+
if (typeof on === "string" || Array.isArray(on)) return listIncludes(on, "pull_request");
|
|
886
|
+
if (typeof on !== "object" || on === null || !("pull_request" in on)) return false;
|
|
887
|
+
const trigger = pullRequestTrigger(on);
|
|
888
|
+
if (!trigger || !("types" in trigger)) return true;
|
|
889
|
+
return listIncludes(trigger.types, "synchronize");
|
|
890
|
+
}
|
|
891
|
+
function globMatches(pattern, branch) {
|
|
892
|
+
const source = pattern.split(/(\*\*|\*|\?)/).map((part) => ({ "**": ".*", "*": "[^/]*", "?": "." })[part] ?? part.replace(/[.+^${}()|[\]\\]/g, "\\$&")).join("");
|
|
893
|
+
return new RegExp(`^${source}$`).test(branch);
|
|
894
|
+
}
|
|
895
|
+
function patterns(value) {
|
|
896
|
+
if (typeof value === "string") return [value];
|
|
897
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
898
|
+
}
|
|
899
|
+
function branchesInclude(value, branch) {
|
|
900
|
+
return patterns(value).reduce(
|
|
901
|
+
(included, pattern) => pattern.startsWith("!") ? included && !globMatches(pattern.slice(1), branch) : included || globMatches(pattern, branch),
|
|
902
|
+
false
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
function skipFilters(on, branch) {
|
|
906
|
+
const trigger = pullRequestTrigger(on);
|
|
907
|
+
if (!trigger) return [];
|
|
908
|
+
const filters = ["paths", "paths-ignore"].filter((key) => key in trigger);
|
|
909
|
+
if ("branches" in trigger && !branchesInclude(trigger.branches, branch)) filters.push("branches");
|
|
910
|
+
if ("branches-ignore" in trigger && patterns(trigger["branches-ignore"]).some((pattern) => globMatches(pattern, branch)))
|
|
911
|
+
filters.push("branches-ignore");
|
|
912
|
+
return filters;
|
|
913
|
+
}
|
|
914
|
+
function canReadActions(permissions) {
|
|
915
|
+
if (permissions === "read-all" || permissions === "write-all") return true;
|
|
916
|
+
if (typeof permissions !== "object" || permissions === null || !("actions" in permissions)) return false;
|
|
917
|
+
return permissions.actions === "read" || permissions.actions === "write";
|
|
918
|
+
}
|
|
919
|
+
function checkWorkflow(path, workflow, repo, branch) {
|
|
920
|
+
const jobs = Object.values(workflow.jobs ?? {}).filter((job) => job && callsReview(job.uses, repo));
|
|
921
|
+
if (jobs.length === 0) return void 0;
|
|
922
|
+
const problems2 = [];
|
|
923
|
+
if (!runsOnSynchronize(workflow.on)) problems2.push("lacks the `synchronize` type in its `pull_request` trigger");
|
|
924
|
+
if (!jobs.some((job) => canReadActions(job?.permissions ?? workflow.permissions)))
|
|
925
|
+
problems2.push("lacks `actions: read` in its permissions");
|
|
926
|
+
const filters = skipFilters(workflow.on, branch);
|
|
927
|
+
if (filters.length > 0)
|
|
928
|
+
problems2.push(
|
|
929
|
+
`skips some PRs to ${branch} through its \`pull_request\` ${filters.map((filter) => `\`${filter}\``).join(", ")} filter${filters.length > 1 ? "s" : ""}, so those PRs could never merge`
|
|
930
|
+
);
|
|
931
|
+
return { path, problems: problems2 };
|
|
932
|
+
}
|
|
933
|
+
function checkCallerWorkflows(files, repo, branch) {
|
|
934
|
+
const callers = files.flatMap(({ path, content }) => {
|
|
935
|
+
const workflow = parseWorkflow(content);
|
|
936
|
+
const check = workflow && checkWorkflow(path, workflow, repo, branch);
|
|
937
|
+
return check ? [check] : [];
|
|
938
|
+
});
|
|
939
|
+
return callers.find((caller) => caller.problems.length === 0) ?? callers[0];
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// src/features/review/ciGate.ts
|
|
943
|
+
var gateRuleset = {
|
|
944
|
+
name: "yoke review",
|
|
945
|
+
target: "branch",
|
|
946
|
+
enforcement: "active",
|
|
947
|
+
conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
|
|
948
|
+
rules: [
|
|
949
|
+
{
|
|
950
|
+
type: "required_status_checks",
|
|
951
|
+
parameters: { strict_required_status_checks_policy: false, required_status_checks: [{ context: gateCheck }] }
|
|
952
|
+
}
|
|
953
|
+
]
|
|
954
|
+
};
|
|
955
|
+
var recentPrCount = 20;
|
|
956
|
+
var repoSchema = z3.object({
|
|
957
|
+
full_name: z3.string(),
|
|
958
|
+
html_url: z3.string(),
|
|
959
|
+
default_branch: z3.string()
|
|
960
|
+
});
|
|
961
|
+
var branchRulesSchema = z3.array(
|
|
962
|
+
z3.object({
|
|
963
|
+
type: z3.string(),
|
|
964
|
+
ruleset_id: z3.number().optional(),
|
|
965
|
+
parameters: z3.object({ required_status_checks: z3.array(z3.object({ context: z3.string() })).optional() }).optional()
|
|
966
|
+
})
|
|
967
|
+
);
|
|
968
|
+
var directorySchema = z3.array(z3.object({ type: z3.string(), name: z3.string(), path: z3.string() }));
|
|
969
|
+
var fileSchema = z3.object({ content: z3.string() });
|
|
970
|
+
var pullsSchema = z3.array(z3.object({ head: z3.object({ sha: z3.string() }) }));
|
|
971
|
+
var checkRunsSchema = z3.object({ total_count: z3.number() });
|
|
972
|
+
var createdSchema = z3.object({ id: z3.number() });
|
|
973
|
+
var rulesetsSchema = z3.array(z3.object({ id: z3.number(), name: z3.string(), enforcement: z3.string() }));
|
|
974
|
+
var githubApi = { get: (path) => githubGet(path), post: (path, body) => githubPost(path, body) };
|
|
975
|
+
async function getUnlessMissing(api, path) {
|
|
976
|
+
try {
|
|
977
|
+
return await api.get(path);
|
|
978
|
+
} catch (error) {
|
|
979
|
+
if (error instanceof ReviewError && error.message.includes("(HTTP 404)")) return void 0;
|
|
980
|
+
throw error;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
async function workflowFiles(api, repo) {
|
|
984
|
+
const ref = `ref=${encodeURIComponent(repo.default_branch)}`;
|
|
985
|
+
const listing = await getUnlessMissing(api, `repos/${repo.full_name}/contents/.github/workflows?${ref}`);
|
|
986
|
+
if (listing === void 0) return [];
|
|
987
|
+
const files = directorySchema.parse(listing).filter((entry) => entry.type === "file" && /\.ya?ml$/.test(entry.name));
|
|
988
|
+
return Promise.all(
|
|
989
|
+
files.map(async ({ path }) => {
|
|
990
|
+
const { content } = fileSchema.parse(await api.get(`repos/${repo.full_name}/contents/${path}?${ref}`));
|
|
991
|
+
return { path, content: Buffer.from(content, "base64").toString("utf8") };
|
|
992
|
+
})
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
async function callerProblem(api, repo) {
|
|
996
|
+
const branch = repo.default_branch;
|
|
997
|
+
const caller = checkCallerWorkflows(await workflowFiles(api, repo), repo.full_name, branch);
|
|
998
|
+
if (!caller) return `No workflow on ${branch} calls yoke's review.yml. Run /review-ci, then merge its ${callerWorkflowPath} to ${branch}.`;
|
|
999
|
+
if (caller.problems.length > 0)
|
|
1000
|
+
return `${caller.path} on ${branch} calls yoke's review.yml but ${caller.problems.join(" and ")}. Fix it and merge the change to ${branch}.`;
|
|
1001
|
+
return void 0;
|
|
1002
|
+
}
|
|
1003
|
+
async function gateHasReported(api, repo) {
|
|
1004
|
+
const pulls = pullsSchema.parse(
|
|
1005
|
+
await api.get(`repos/${repo.full_name}/pulls?state=all&sort=updated&direction=desc&per_page=${recentPrCount}`)
|
|
1006
|
+
);
|
|
1007
|
+
for (const { head } of pulls) {
|
|
1008
|
+
const runs = checkRunsSchema.parse(
|
|
1009
|
+
await api.get(`repos/${repo.full_name}/commits/${head.sha}/check-runs?check_name=${encodeURIComponent(gateCheck)}&per_page=1`)
|
|
1010
|
+
);
|
|
1011
|
+
if (runs.total_count > 0) return true;
|
|
1012
|
+
}
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
async function rulesetProblem(api, repo) {
|
|
1016
|
+
const rulesets = rulesetsSchema.parse(await api.get(`repos/${repo.full_name}/rulesets?includes_parents=false&per_page=100`));
|
|
1017
|
+
const existing = rulesets.find((ruleset) => ruleset.name === gateRuleset.name);
|
|
1018
|
+
if (!existing) return void 0;
|
|
1019
|
+
return `The "${gateRuleset.name}" ruleset already exists with enforcement \`${existing.enforcement}\` but doesn't require ${gateCheck} on ${repo.default_branch}: ${repo.html_url}/rules/${existing.id}. Set its enforcement to Active and its required check to ${gateCheck}, or delete it and run this again.`;
|
|
1020
|
+
}
|
|
1021
|
+
async function createRuleset(api, repo) {
|
|
1022
|
+
try {
|
|
1023
|
+
return await api.post(`repos/${repo.full_name}/rulesets`, gateRuleset);
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
if (error instanceof ReviewError && /\(HTTP 40[34]\)/.test(error.message))
|
|
1026
|
+
throw new ReviewError(
|
|
1027
|
+
`GitHub refused to add the "${gateRuleset.name}" ruleset to ${repo.full_name}: your gh user can't edit its repository rules. Ask a repo admin, or someone whose role can edit repository rules, to run \`yoke review-ci-gate\`.`
|
|
1028
|
+
);
|
|
1029
|
+
throw error;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
async function problems(api, repo) {
|
|
1033
|
+
const found = [];
|
|
1034
|
+
const ruleset = await rulesetProblem(api, repo);
|
|
1035
|
+
if (ruleset) found.push(ruleset);
|
|
1036
|
+
const caller = await callerProblem(api, repo);
|
|
1037
|
+
if (caller) found.push(caller);
|
|
1038
|
+
if (!await gateHasReported(api, repo))
|
|
1039
|
+
found.push(
|
|
1040
|
+
`No ${gateCheck} check run on the ${recentPrCount} most recently updated PRs. Open or push to a PR so the gate reports, then run this again.`
|
|
1041
|
+
);
|
|
1042
|
+
return found;
|
|
1043
|
+
}
|
|
1044
|
+
async function enforceReviewGate(api = githubApi) {
|
|
1045
|
+
const repo = repoSchema.parse(await api.get("repos/{owner}/{repo}"));
|
|
1046
|
+
const branch = repo.default_branch;
|
|
1047
|
+
const rules = branchRulesSchema.parse(await api.get(`repos/${repo.full_name}/rules/branches/${encodeURIComponent(branch)}`));
|
|
1048
|
+
const existing = rules.find(
|
|
1049
|
+
(rule) => rule.type === "required_status_checks" && rule.parameters?.required_status_checks?.some((check) => check.context === gateCheck)
|
|
1050
|
+
);
|
|
1051
|
+
if (existing) {
|
|
1052
|
+
const where = existing.ruleset_id ? ` by ${repo.html_url}/rules/${existing.ruleset_id}` : "";
|
|
1053
|
+
return { created: false, message: `${gateCheck} is already required on ${branch}${where}; nothing changed.` };
|
|
1054
|
+
}
|
|
1055
|
+
const found = await problems(api, repo);
|
|
1056
|
+
if (found.length > 0)
|
|
1057
|
+
throw new ReviewError([`Cannot require ${gateCheck} on ${branch} yet:`, ...found.map((problem) => `- ${problem}`)].join("\n"));
|
|
1058
|
+
const { id } = createdSchema.parse(await createRuleset(api, repo));
|
|
1059
|
+
return {
|
|
1060
|
+
created: true,
|
|
1061
|
+
message: `Added the "${gateRuleset.name}" ruleset, which blocks merging to ${branch} until ${gateCheck} passes: ${repo.html_url}/rules/${id}`
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
841
1065
|
// src/features/review/gate.ts
|
|
842
1066
|
import { appendFileSync, readFileSync as readFileSync3 } from "fs";
|
|
843
1067
|
import { setTimeout } from "timers/promises";
|
|
844
|
-
import { z as
|
|
845
|
-
var eventSchema =
|
|
846
|
-
action:
|
|
847
|
-
pull_request:
|
|
848
|
-
number:
|
|
849
|
-
head:
|
|
850
|
-
user:
|
|
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() })
|
|
851
1075
|
})
|
|
852
1076
|
});
|
|
853
|
-
var runsSchema =
|
|
854
|
-
workflow_runs:
|
|
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() })) }))
|
|
855
1079
|
});
|
|
856
|
-
var jobsSchema =
|
|
1080
|
+
var jobsSchema = z4.object({ jobs: z4.array(z4.object({ name: z4.string(), status: z4.string(), conclusion: z4.string().nullable() })) });
|
|
857
1081
|
var pollInterval = 3e4;
|
|
858
1082
|
var rerunHint = "Mark the PR as draft, then ready for review, to run the review again.";
|
|
859
1083
|
var firstRunHint = "Mark the PR as ready for review (from draft if it is not one) to run it.";
|
|
@@ -877,7 +1101,7 @@ function gateContext(env = process.env) {
|
|
|
877
1101
|
};
|
|
878
1102
|
}
|
|
879
1103
|
async function earlierRuns({ repo, runId, runNumber, event }, deps) {
|
|
880
|
-
const { workflow_id } =
|
|
1104
|
+
const { workflow_id } = z4.object({ workflow_id: z4.number() }).parse(await deps.get(`repos/${repo}/actions/runs/${runId}`));
|
|
881
1105
|
const query = new URLSearchParams({ event: "pull_request", branch: event.pull_request.head.ref, per_page: "100" });
|
|
882
1106
|
const { workflow_runs } = runsSchema.parse(await deps.get(`repos/${repo}/actions/workflows/${workflow_id}/runs?${query.toString()}`));
|
|
883
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);
|
|
@@ -2129,8 +2353,8 @@ function slotsToRun(slots2) {
|
|
|
2129
2353
|
return [...distinct.values()];
|
|
2130
2354
|
}
|
|
2131
2355
|
async function assertViable(slots2, check = checks) {
|
|
2132
|
-
const
|
|
2133
|
-
const errors =
|
|
2356
|
+
const problems2 = await Promise.all(slotsToRun(slots2).map((use) => slotProblem(use, check)));
|
|
2357
|
+
const errors = problems2.filter((problem) => problem !== void 0);
|
|
2134
2358
|
if (errors.length)
|
|
2135
2359
|
throw new ReviewFailure("a reviewer could not run", ["Cannot review:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
2136
2360
|
}
|
|
@@ -2304,6 +2528,21 @@ If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
|
|
|
2304
2528
|
`
|
|
2305
2529
|
};
|
|
2306
2530
|
|
|
2531
|
+
// src/features/review/ciGateSkill.ts
|
|
2532
|
+
var reviewCiGateSkill = {
|
|
2533
|
+
name: "review-ci-gate",
|
|
2534
|
+
description: "Block merging to this repo's default branch until yoke's CI review passes, once its review workflow is merged.",
|
|
2535
|
+
disableModelInvocation: true,
|
|
2536
|
+
userLevel: true,
|
|
2537
|
+
body: `
|
|
2538
|
+
Run \`yoke review-ci-gate\` and relay its output.
|
|
2539
|
+
|
|
2540
|
+
If it fails, say what is missing and how to fix it, as it reports. Don't create the ruleset any other way.
|
|
2541
|
+
|
|
2542
|
+
If \`yoke\` is not found, run \`npm i -g @makerx/yoke\`.
|
|
2543
|
+
`
|
|
2544
|
+
};
|
|
2545
|
+
|
|
2307
2546
|
// src/features/review/ciSkill.ts
|
|
2308
2547
|
var reviewCiSkill = {
|
|
2309
2548
|
name: "review-ci",
|
|
@@ -2338,7 +2577,7 @@ Never ask whether they are set for the organisation, and don't query org setting
|
|
|
2338
2577
|
|
|
2339
2578
|
With Entra, check \`az ad app federated-credential list --id <client-id>\` and show the printed create command if the credential is missing.
|
|
2340
2579
|
|
|
2341
|
-
Finish with what changed and what's left: commit and push the workflow, then,
|
|
2580
|
+
Finish with what changed and what's left: commit and push the workflow, then, once it is merged to the default branch, run \`/review-ci-gate\` to require \`review / gate\` before merging.
|
|
2342
2581
|
|
|
2343
2582
|
If \`yoke\` is not found, run \`npm i -g @makerx/yoke\`.
|
|
2344
2583
|
`
|
|
@@ -2359,7 +2598,7 @@ If \`yoke\` is not found, run \`npm i -g @makerx/yoke\` and retry.
|
|
|
2359
2598
|
};
|
|
2360
2599
|
|
|
2361
2600
|
// src/features/sync/sync.ts
|
|
2362
|
-
var bundledSkills = [prSkill, reviewSkill, reviewCiSkill];
|
|
2601
|
+
var bundledSkills = [prSkill, reviewSkill, reviewCiSkill, reviewCiGateSkill];
|
|
2363
2602
|
function skillsRoot(user, cwd = process.cwd(), home = homedir3()) {
|
|
2364
2603
|
return join9(user ? home : cwd, ".claude", "skills");
|
|
2365
2604
|
}
|
|
@@ -2404,26 +2643,6 @@ pr.command("raise").description("Validate a PR title and body, then create the P
|
|
|
2404
2643
|
}
|
|
2405
2644
|
});
|
|
2406
2645
|
var providerKeys = settingKeys.filter((key) => !key.startsWith("review."));
|
|
2407
|
-
function addSettingOptions(command, keys) {
|
|
2408
|
-
for (const key of keys) {
|
|
2409
|
-
const flag = settingFlag(key);
|
|
2410
|
-
if (flag) command.option(`${flag} <value>`, settingDescription(key));
|
|
2411
|
-
}
|
|
2412
|
-
return command;
|
|
2413
|
-
}
|
|
2414
|
-
function optionName(flag) {
|
|
2415
|
-
return flag.slice(2).replace(/-(.)/g, (_, next) => next.toUpperCase());
|
|
2416
|
-
}
|
|
2417
|
-
function flagSettings(options, keys) {
|
|
2418
|
-
const flags = {};
|
|
2419
|
-
for (const key of keys) {
|
|
2420
|
-
const flag = settingFlag(key);
|
|
2421
|
-
const value = flag && options[optionName(flag)];
|
|
2422
|
-
if (typeof value === "string") flags[key] = value;
|
|
2423
|
-
}
|
|
2424
|
-
return flags;
|
|
2425
|
-
}
|
|
2426
|
-
var settingsHelp = "\nEach setting falls back to its YOKE_* env var, then `yoke config set --user`; API keys come from env or config only. Run `yoke config` to see them all.";
|
|
2427
2646
|
addSettingOptions(
|
|
2428
2647
|
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"),
|
|
2429
2648
|
settingKeys
|
|
@@ -2464,6 +2683,15 @@ addSettingOptions(
|
|
|
2464
2683
|
process.exitCode = 1;
|
|
2465
2684
|
}
|
|
2466
2685
|
});
|
|
2686
|
+
program.command("review-ci-gate").description(`Require the ${gateCheck} check on the default branch with a ruleset, once the CI review workflow is merged`).action(async () => {
|
|
2687
|
+
try {
|
|
2688
|
+
console.log((await enforceReviewGate()).message);
|
|
2689
|
+
} catch (error) {
|
|
2690
|
+
if (!(error instanceof ReviewError)) throw error;
|
|
2691
|
+
console.error(error.message);
|
|
2692
|
+
process.exitCode = 1;
|
|
2693
|
+
}
|
|
2694
|
+
});
|
|
2467
2695
|
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
2696
|
try {
|
|
2469
2697
|
if (!await runReviewGate(result, console.log)) process.exitCode = 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makerx/yoke",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Lean, user-invoked Claude Code skills for MakerX AI-assisted delivery, plus the CLI they call.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "MakerX",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"commander": "^15.0.0",
|
|
38
38
|
"log-update": "^8.0.0",
|
|
39
|
+
"yaml": "^2.9.1",
|
|
39
40
|
"zod": "^4.6.7"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
@@ -47,8 +48,7 @@
|
|
|
47
48
|
"oxlint-tsgolint": "^7.0.2004",
|
|
48
49
|
"tsup": "^9.0.0",
|
|
49
50
|
"typescript": "^6.0.3",
|
|
50
|
-
"vitest": "^5.0.5"
|
|
51
|
-
"yaml": "^2.9.1"
|
|
51
|
+
"vitest": "^5.0.5"
|
|
52
52
|
},
|
|
53
53
|
"overrides": {
|
|
54
54
|
"minimatch": "^11.0.3"
|