@makerx/yoke 0.8.1 → 0.9.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/dist/cli.js +319 -150
- 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.9.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",
|
|
@@ -80,6 +80,11 @@ var settingsSchema = z.object({
|
|
|
80
80
|
"litellm.apiKey": secret("LiteLLM API key. Env or --user config only"),
|
|
81
81
|
"foundry.baseUrl": setting("https://<resource>.services.ai.azure.com"),
|
|
82
82
|
"foundry.apiKey": secret("Foundry API key. Env or --user config only"),
|
|
83
|
+
"foundry.token": secret("Foundry Entra access token, sent as a bearer token in place of foundry.apiKey. Env or --user config only"),
|
|
84
|
+
"foundry.azureClientId": setting(
|
|
85
|
+
"Entra app (client) ID. In GitHub Actions, when a slot uses foundry, yoke exchanges the job OIDC token for foundry.token"
|
|
86
|
+
),
|
|
87
|
+
"foundry.azureTenantId": setting("Entra tenant ID of foundry.azureClientId"),
|
|
83
88
|
"review.reviewer1.harness": setting("claude or codex. Default: claude"),
|
|
84
89
|
"review.reviewer1.provider": setting(providerHelp),
|
|
85
90
|
"review.reviewer1.model": setting(modelHelp),
|
|
@@ -536,8 +541,11 @@ async function checkoutHead(pr2, reviewDir, exec2 = run) {
|
|
|
536
541
|
// src/features/review/provider.ts
|
|
537
542
|
var providerKinds = ["litellm", "foundry"];
|
|
538
543
|
var harnesses = ["claude", "codex"];
|
|
539
|
-
function codexKeyEnv(
|
|
540
|
-
return `YOKE_${kind.toUpperCase()}
|
|
544
|
+
function codexKeyEnv(provider) {
|
|
545
|
+
return `YOKE_${provider.kind.toUpperCase()}_${"token" in provider ? "TOKEN" : "API_KEY"}`;
|
|
546
|
+
}
|
|
547
|
+
function credentialOf(provider) {
|
|
548
|
+
return "token" in provider ? provider.token : provider.apiKey;
|
|
541
549
|
}
|
|
542
550
|
function isProviderKind(value) {
|
|
543
551
|
return providerKinds.includes(value);
|
|
@@ -553,8 +561,8 @@ function isHarness(value) {
|
|
|
553
561
|
function ownAuthOverride({ harness, model }) {
|
|
554
562
|
return { args: harness === "codex" ? ["-m", model] : ["--model", model], env: {} };
|
|
555
563
|
}
|
|
556
|
-
function buildCodexOverride(
|
|
557
|
-
const keyEnv = codexKeyEnv(
|
|
564
|
+
function buildCodexOverride(provider, endpoints, model) {
|
|
565
|
+
const keyEnv = codexKeyEnv(provider);
|
|
558
566
|
return {
|
|
559
567
|
args: [
|
|
560
568
|
"-c",
|
|
@@ -570,19 +578,19 @@ function buildCodexOverride({ kind, apiKey }, endpoints, model) {
|
|
|
570
578
|
"-m",
|
|
571
579
|
model
|
|
572
580
|
],
|
|
573
|
-
env: { [keyEnv]:
|
|
581
|
+
env: { [keyEnv]: credentialOf(provider) }
|
|
574
582
|
};
|
|
575
583
|
}
|
|
576
|
-
function claudeProviderEnv(
|
|
577
|
-
if (kind === "foundry")
|
|
578
|
-
return { CLAUDE_CODE_USE_FOUNDRY: "1", ANTHROPIC_FOUNDRY_BASE_URL: endpoints.anthropic, ANTHROPIC_FOUNDRY_API_KEY: apiKey };
|
|
579
|
-
return { ANTHROPIC_BASE_URL: endpoints.anthropic, ANTHROPIC_AUTH_TOKEN:
|
|
584
|
+
function claudeProviderEnv(provider, endpoints) {
|
|
585
|
+
if (provider.kind === "foundry" && "apiKey" in provider)
|
|
586
|
+
return { CLAUDE_CODE_USE_FOUNDRY: "1", ANTHROPIC_FOUNDRY_BASE_URL: endpoints.anthropic, ANTHROPIC_FOUNDRY_API_KEY: provider.apiKey };
|
|
587
|
+
return { ANTHROPIC_BASE_URL: endpoints.anthropic, ANTHROPIC_AUTH_TOKEN: credentialOf(provider) };
|
|
580
588
|
}
|
|
581
589
|
function buildClaudeOverride(provider, endpoints, model) {
|
|
582
590
|
return {
|
|
583
591
|
args: ["--model", model],
|
|
584
592
|
env: {
|
|
585
|
-
...claudeProviderEnv(provider
|
|
593
|
+
...claudeProviderEnv(provider, endpoints),
|
|
586
594
|
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
|
587
595
|
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
|
|
588
596
|
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
|
|
@@ -591,16 +599,17 @@ function buildClaudeOverride(provider, endpoints, model) {
|
|
|
591
599
|
}
|
|
592
600
|
};
|
|
593
601
|
}
|
|
594
|
-
function claudeAuthHeaders(
|
|
595
|
-
if (
|
|
596
|
-
|
|
602
|
+
function claudeAuthHeaders(provider) {
|
|
603
|
+
if ("token" in provider) return { Authorization: `Bearer ${provider.token}` };
|
|
604
|
+
if (provider.kind === "foundry") return { "x-api-key": provider.apiKey };
|
|
605
|
+
return { Authorization: `Bearer ${provider.apiKey}`, "x-api-key": provider.apiKey };
|
|
597
606
|
}
|
|
598
607
|
function buildProbe({ harness, model }, provider) {
|
|
599
608
|
const endpoints = deriveEndpoints(provider.kind, provider.baseUrl);
|
|
600
609
|
if (harness === "codex")
|
|
601
610
|
return {
|
|
602
611
|
url: `${endpoints.responses}/responses`,
|
|
603
|
-
headers: { Authorization: `Bearer ${provider
|
|
612
|
+
headers: { Authorization: `Bearer ${credentialOf(provider)}` },
|
|
604
613
|
body: { model, input: "ping", max_output_tokens: 16 }
|
|
605
614
|
};
|
|
606
615
|
return {
|
|
@@ -618,7 +627,24 @@ function buildOverride({ harness, model }, provider) {
|
|
|
618
627
|
// src/features/review/ci.ts
|
|
619
628
|
var reviewWorkflowRef = "MakerXStudio/yoke/.github/workflows/review.yml@main";
|
|
620
629
|
var callerWorkflowPath = join3(".github", "workflows", "review-pr.yml");
|
|
621
|
-
var
|
|
630
|
+
var baseUrlSecret = "REVIEW_CI_BASE_URL";
|
|
631
|
+
var apiKeySecret = "REVIEW_CI_API_KEY";
|
|
632
|
+
function workflowSecrets({ entra }) {
|
|
633
|
+
return entra ? [baseUrlSecret] : [baseUrlSecret, apiKeySecret];
|
|
634
|
+
}
|
|
635
|
+
function entraInputs({ azureClientId, azureTenantId, environment }, provider, errors) {
|
|
636
|
+
const clientId = azureClientId?.trim();
|
|
637
|
+
const tenantId = azureTenantId?.trim();
|
|
638
|
+
const env = environment?.trim();
|
|
639
|
+
if (!clientId && !tenantId) {
|
|
640
|
+
if (env) errors.push("--environment needs --azure-client-id and --azure-tenant-id");
|
|
641
|
+
return [];
|
|
642
|
+
}
|
|
643
|
+
if (!clientId) errors.push("--azure-client-id is required with --azure-tenant-id");
|
|
644
|
+
if (!tenantId) errors.push("--azure-tenant-id is required with --azure-client-id");
|
|
645
|
+
if (provider !== "foundry") errors.push("--azure-client-id and --azure-tenant-id need --provider foundry");
|
|
646
|
+
return [["azure-client-id", clientId ?? ""], ["azure-tenant-id", tenantId ?? ""], ...env ? [["environment", env]] : []];
|
|
647
|
+
}
|
|
622
648
|
var slots = [
|
|
623
649
|
{ slot: "reviewer1", defaultHarness: "claude", required: true },
|
|
624
650
|
{ slot: "reviewer2", defaultHarness: "codex", required: true },
|
|
@@ -641,19 +667,25 @@ function slotInputs(settings, errors) {
|
|
|
641
667
|
}
|
|
642
668
|
return inputs;
|
|
643
669
|
}
|
|
644
|
-
function callerWorkflow(settings, providerFlag) {
|
|
670
|
+
function callerWorkflow(settings, providerFlag, entraFlags = {}) {
|
|
645
671
|
const errors = [];
|
|
646
672
|
const provider = providerFlag ?? settings["review.reviewer1.provider"] ?? "litellm";
|
|
647
673
|
if (!isProviderKind(provider)) errors.push(`provider "${provider}" must be ${providerKinds.join(" or ")}; pass --provider`);
|
|
648
674
|
const inputs = slotInputs(settings, errors);
|
|
675
|
+
const entra = entraInputs(entraFlags, provider, errors);
|
|
649
676
|
if (errors.length || !isProviderKind(provider))
|
|
650
677
|
throw new ReviewError(["Cannot write the review workflow:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
651
|
-
|
|
678
|
+
const environment = entraFlags.environment?.trim();
|
|
679
|
+
return {
|
|
680
|
+
provider,
|
|
681
|
+
inputs: [["provider", provider], ...inputs, ...entra],
|
|
682
|
+
...entra.length > 0 && { entra: { clientId: entraFlags.azureClientId?.trim() ?? "", ...environment && { environment } } }
|
|
683
|
+
};
|
|
652
684
|
}
|
|
653
685
|
function yamlValue(value) {
|
|
654
686
|
return /^[\w./:@-]+$/.test(value) && !/^([-+.\d_eE]+|0[xo][\da-f]+|true|false|yes|no|on|off|y|n|null|~)$/i.test(value) ? value : JSON.stringify(value);
|
|
655
687
|
}
|
|
656
|
-
function renderCallerWorkflow(
|
|
688
|
+
function renderCallerWorkflow(workflow) {
|
|
657
689
|
return [
|
|
658
690
|
"name: Review PR",
|
|
659
691
|
"",
|
|
@@ -664,23 +696,56 @@ function renderCallerWorkflow({ inputs }) {
|
|
|
664
696
|
"permissions:",
|
|
665
697
|
" contents: read",
|
|
666
698
|
" pull-requests: write",
|
|
699
|
+
...workflow.entra ? [" id-token: write"] : [],
|
|
667
700
|
"",
|
|
668
701
|
"jobs:",
|
|
669
702
|
" review:",
|
|
670
703
|
` uses: ${reviewWorkflowRef}`,
|
|
671
704
|
" with:",
|
|
672
|
-
...inputs.map(([name, value]) => ` ${name}: ${yamlValue(value)}`),
|
|
705
|
+
...workflow.inputs.map(([name, value]) => ` ${name}: ${yamlValue(value)}`),
|
|
673
706
|
" secrets:",
|
|
674
|
-
...
|
|
707
|
+
...workflowSecrets(workflow).map((secret2) => ` ${secret2}: \${{ secrets.${secret2} }}`),
|
|
675
708
|
""
|
|
676
709
|
].join("\n");
|
|
677
710
|
}
|
|
678
711
|
function shellQuote(value) {
|
|
679
712
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
680
713
|
}
|
|
681
|
-
function secretCommands(settings,
|
|
682
|
-
const baseUrl = settings[`${provider}.baseUrl`];
|
|
683
|
-
return
|
|
714
|
+
function secretCommands(settings, workflow) {
|
|
715
|
+
const baseUrl = settings[`${workflow.provider}.baseUrl`];
|
|
716
|
+
return workflowSecrets(workflow).map(
|
|
717
|
+
(secret2) => secret2 === baseUrlSecret && baseUrl ? `gh secret set ${secret2} --body ${shellQuote(baseUrl)}` : `gh secret set ${secret2}`
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
var githubOidcIssuer = "https://token.actions.githubusercontent.com";
|
|
721
|
+
var entraTokenExchangeAudience = "api://AzureADTokenExchange";
|
|
722
|
+
function federatedCredential(repo, environment) {
|
|
723
|
+
return {
|
|
724
|
+
issuer: githubOidcIssuer,
|
|
725
|
+
audience: entraTokenExchangeAudience,
|
|
726
|
+
subject: environment ? `repo:${repo}:environment:${environment}` : `repo:${repo}:pull_request`
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
function federatedCredentialCommand(clientId, credential) {
|
|
730
|
+
const parameters = JSON.stringify({
|
|
731
|
+
name: "yoke-review-ci",
|
|
732
|
+
issuer: credential.issuer,
|
|
733
|
+
subject: credential.subject,
|
|
734
|
+
audiences: [credential.audience]
|
|
735
|
+
});
|
|
736
|
+
return `az ad app federated-credential create --id ${shellQuote(clientId)} --parameters ${shellQuote(parameters)}`;
|
|
737
|
+
}
|
|
738
|
+
var unknownRepo = "<owner>/<repo>";
|
|
739
|
+
function githubRepo(cwd = process.cwd()) {
|
|
740
|
+
try {
|
|
741
|
+
return execFileSync2("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], {
|
|
742
|
+
cwd,
|
|
743
|
+
encoding: "utf8",
|
|
744
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
745
|
+
}).trim() || unknownRepo;
|
|
746
|
+
} catch {
|
|
747
|
+
return unknownRepo;
|
|
748
|
+
}
|
|
684
749
|
}
|
|
685
750
|
function repoRoot(cwd = process.cwd()) {
|
|
686
751
|
try {
|
|
@@ -699,131 +764,34 @@ function writeCallerWorkflow(root, content, force) {
|
|
|
699
764
|
writeFileSync2(path, content);
|
|
700
765
|
return "wrote";
|
|
701
766
|
}
|
|
702
|
-
function
|
|
767
|
+
function entraSetup({ clientId, environment }, repo) {
|
|
768
|
+
const credential = federatedCredential(repo, environment);
|
|
769
|
+
return [
|
|
770
|
+
"",
|
|
771
|
+
`Instead of REVIEW_CI_API_KEY, the job signs in to Foundry as Entra app ${clientId} with its GitHub OIDC token. On that app, create a federated credential with:`,
|
|
772
|
+
` issuer: ${credential.issuer}`,
|
|
773
|
+
` audience: ${credential.audience}`,
|
|
774
|
+
` subject: ${credential.subject}`,
|
|
775
|
+
"for example with:",
|
|
776
|
+
` ${federatedCredentialCommand(clientId, credential)}`,
|
|
777
|
+
"and give the app the Cognitive Services User role on the Foundry resource.",
|
|
778
|
+
...environment ? [`The job runs in the ${environment} environment; create it on the repo if it does not exist.`] : []
|
|
779
|
+
];
|
|
780
|
+
}
|
|
781
|
+
function reviewCiReport(outcome, settings, workflow, repo) {
|
|
703
782
|
const action = outcome === "wrote" ? "wrote" : "kept";
|
|
783
|
+
const secrets = workflowSecrets(workflow);
|
|
704
784
|
return [
|
|
705
785
|
`${action} ${callerWorkflowPath}`,
|
|
706
786
|
"",
|
|
707
|
-
`The workflow reads ${
|
|
708
|
-
...
|
|
787
|
+
`The workflow reads ${secrets.join(" and ")}. Set ${secrets.length > 1 ? "them" : "it"} on the repo (or the org) with:`,
|
|
788
|
+
...secretCommands(settings, workflow).map((command) => ` ${command}`),
|
|
789
|
+
...workflow.entra ? entraSetup(workflow.entra, repo()) : [],
|
|
709
790
|
"",
|
|
710
791
|
"Commit and push the workflow; each PR opened from this repo then gets a review."
|
|
711
792
|
].join("\n");
|
|
712
793
|
}
|
|
713
794
|
|
|
714
|
-
// src/features/review/slots.ts
|
|
715
|
-
var slotNames = ["reviewer1", "reviewer2", "synthesis"];
|
|
716
|
-
function readProvider(settings, kind) {
|
|
717
|
-
const baseUrlKey = `${kind}.baseUrl`;
|
|
718
|
-
const apiKeyKey = `${kind}.apiKey`;
|
|
719
|
-
const baseUrl = settings[baseUrlKey];
|
|
720
|
-
const apiKey = settings[apiKeyKey];
|
|
721
|
-
if (baseUrl && apiKey) return { kind, baseUrl, apiKey };
|
|
722
|
-
return [
|
|
723
|
-
baseUrl ? null : `${baseUrlKey} is not set; ${howToSet(baseUrlKey, "<url>")}`,
|
|
724
|
-
apiKey ? null : `${apiKeyKey} is not set; ${howToSet(apiKeyKey, "<key>")}`
|
|
725
|
-
].filter((error) => error !== null);
|
|
726
|
-
}
|
|
727
|
-
function readSpec(settings, slot, errors) {
|
|
728
|
-
const keys = {
|
|
729
|
-
harness: `review.${slot}.harness`,
|
|
730
|
-
provider: `review.${slot}.provider`,
|
|
731
|
-
model: `review.${slot}.model`
|
|
732
|
-
};
|
|
733
|
-
const harness = settings[keys.harness];
|
|
734
|
-
const provider = settings[keys.provider];
|
|
735
|
-
const model = settings[keys.model];
|
|
736
|
-
if (!harness && !provider && !model) return void 0;
|
|
737
|
-
if (harness && !isHarness(harness))
|
|
738
|
-
errors.push(`${keys.harness} "${harness}" must be claude or codex; ${howToSet(keys.harness, "<claude|codex>")}`);
|
|
739
|
-
if (provider && !isProviderKind(provider))
|
|
740
|
-
errors.push(
|
|
741
|
-
`${keys.provider} "${provider}" must be ${providerKinds.join(" or ")}; ${howToSet(keys.provider, `<${providerKinds.join("|")}>`)}`
|
|
742
|
-
);
|
|
743
|
-
if (provider && !model)
|
|
744
|
-
errors.push(`${keys.model} is required when ${keys.provider} is set; ${howToSet(keys.model, "<model>")} (see \`yoke models\`)`);
|
|
745
|
-
return {
|
|
746
|
-
...harness && isHarness(harness) ? { harness } : {},
|
|
747
|
-
...provider && isProviderKind(provider) ? { provider } : {},
|
|
748
|
-
...model ? { model } : {}
|
|
749
|
-
};
|
|
750
|
-
}
|
|
751
|
-
function toSlot({ harness: chosen, model, provider: kind }, fallback, providers) {
|
|
752
|
-
const harness = chosen ?? fallback;
|
|
753
|
-
if (!model) return { harness };
|
|
754
|
-
const slot = { harness, model };
|
|
755
|
-
const provider = kind && providers[kind];
|
|
756
|
-
if (provider) return { ...slot, provider: kind, override: buildOverride(slot, provider), probe: buildProbe(slot, provider) };
|
|
757
|
-
return { ...slot, override: ownAuthOverride(slot) };
|
|
758
|
-
}
|
|
759
|
-
function resolveSlots(settings) {
|
|
760
|
-
const specs = {};
|
|
761
|
-
const errors = [];
|
|
762
|
-
for (const name of slotNames) specs[name] = readSpec(settings, name, errors);
|
|
763
|
-
const providers = {};
|
|
764
|
-
for (const kind of new Set(Object.values(specs).flatMap((spec) => spec?.provider ? [spec.provider] : []))) {
|
|
765
|
-
const read = readProvider(settings, kind);
|
|
766
|
-
if (Array.isArray(read)) errors.push(...read);
|
|
767
|
-
else providers[kind] = read;
|
|
768
|
-
}
|
|
769
|
-
if (errors.length) throw new ReviewError(["Review settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
770
|
-
const reviewer1 = toSlot(specs.reviewer1 ?? {}, "claude", providers);
|
|
771
|
-
return {
|
|
772
|
-
reviewer1,
|
|
773
|
-
reviewer2: toSlot(specs.reviewer2 ?? {}, "codex", providers),
|
|
774
|
-
synthesis: specs.synthesis && toSlot(specs.synthesis, reviewer1.harness, providers)
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
// src/features/review/models.ts
|
|
779
|
-
var timeout = 6e4;
|
|
780
|
-
var maxBodyChars = 300;
|
|
781
|
-
var providerWidth = Math.max(...providerKinds.map((kind) => kind.length));
|
|
782
|
-
function modelsRequest({ kind, baseUrl, apiKey }) {
|
|
783
|
-
const url = `${deriveEndpoints(kind, baseUrl).responses}/models`;
|
|
784
|
-
return { url, headers: kind === "foundry" ? { "api-key": apiKey } : { Authorization: `Bearer ${apiKey}` } };
|
|
785
|
-
}
|
|
786
|
-
function isModelList(value) {
|
|
787
|
-
return typeof value === "object" && value !== null && "data" in value && Array.isArray(value.data) && value.data.every((model) => typeof model === "object" && model !== null && "id" in model && typeof model.id === "string");
|
|
788
|
-
}
|
|
789
|
-
async function get(url, headers, fetchModels) {
|
|
790
|
-
try {
|
|
791
|
-
return await fetchModels(url, { headers, signal: AbortSignal.timeout(timeout) });
|
|
792
|
-
} catch (error) {
|
|
793
|
-
throw new ReviewError(`Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
async function providerModels(provider, fetchModels) {
|
|
797
|
-
const { url, headers } = modelsRequest(provider);
|
|
798
|
-
const response = await get(url, headers, fetchModels);
|
|
799
|
-
if (!response.ok)
|
|
800
|
-
throw new ReviewError(`${url} returned HTTP ${response.status}: ${(await response.text()).trim().slice(0, maxBodyChars)}`);
|
|
801
|
-
const body = await response.json().catch(() => void 0);
|
|
802
|
-
if (!isModelList(body)) throw new ReviewError(`${url} returned an unexpected model list`);
|
|
803
|
-
return body.data.map(({ id }) => `${provider.kind.padEnd(providerWidth)} ${id}`);
|
|
804
|
-
}
|
|
805
|
-
function isConfigured(settings, kind) {
|
|
806
|
-
return Boolean(settings[`${kind}.baseUrl`] || settings[`${kind}.apiKey`]);
|
|
807
|
-
}
|
|
808
|
-
async function listModels(settings, fetchModels = fetch) {
|
|
809
|
-
const kinds = providerKinds.filter((kind) => isConfigured(settings, kind));
|
|
810
|
-
if (!kinds.length)
|
|
811
|
-
throw new ReviewError(
|
|
812
|
-
`No model provider is configured; set ${providerKinds.map((kind) => `${kind}.baseUrl and ${kind}.apiKey`).join(", or ")} (see \`yoke config\`)`
|
|
813
|
-
);
|
|
814
|
-
const providers = kinds.map((kind) => readProvider(settings, kind));
|
|
815
|
-
const errors = providers.flatMap((provider) => Array.isArray(provider) ? provider : []);
|
|
816
|
-
if (errors.length) throw new ReviewError(["Model provider settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
817
|
-
const ready = providers.filter((provider) => !Array.isArray(provider));
|
|
818
|
-
const lists = await Promise.all(ready.map((provider) => providerModels(provider, fetchModels)));
|
|
819
|
-
return lists.flat().toSorted();
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// src/features/review/review.ts
|
|
823
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
|
|
824
|
-
import { homedir as homedir2, tmpdir } from "os";
|
|
825
|
-
import { join as join8 } from "path";
|
|
826
|
-
|
|
827
795
|
// src/features/review/comments.ts
|
|
828
796
|
var markerPrefix = "<!-- yoke-review:";
|
|
829
797
|
function slug(text2) {
|
|
@@ -968,7 +936,11 @@ This change adds or edits a workflow that calls \`MakerXStudio/yoke/.github/work
|
|
|
968
936
|
- The trigger types, including leaving out \`synchronize\`: a review is re-run by marking the PR as draft and then ready for review.
|
|
969
937
|
- Draft PRs being reviewed twice: the reusable workflow already skips drafts.
|
|
970
938
|
|
|
971
|
-
Real defects in it are still findings, such as
|
|
939
|
+
Real defects in it are still findings, such as:
|
|
940
|
+
|
|
941
|
+
- Passing every secret with \`secrets: inherit\` instead of only \`REVIEW_CI_BASE_URL\` and \`REVIEW_CI_API_KEY\`.
|
|
942
|
+
- An input the reusable workflow does not declare.
|
|
943
|
+
- No \`permissions:\` block limiting the token to \`contents: read\` and \`pull-requests: write\`, plus \`id-token: write\` with \`azure-client-id\`. The reusable workflow sets no permissions of its own, so the caller's block is the only limit on the token its agents run with.
|
|
972
944
|
`;
|
|
973
945
|
}
|
|
974
946
|
|
|
@@ -1205,6 +1177,189 @@ async function reportFailure(pr2, reason, ui, deps) {
|
|
|
1205
1177
|
}
|
|
1206
1178
|
}
|
|
1207
1179
|
|
|
1180
|
+
// src/features/review/slots.ts
|
|
1181
|
+
var slotNames = ["reviewer1", "reviewer2", "synthesis"];
|
|
1182
|
+
function notSet(key, secret2, placeholder, env) {
|
|
1183
|
+
return isCi(env) ? `Secret ${secret2} is not set` : `${key} is not set; ${howToSet(key, placeholder)}`;
|
|
1184
|
+
}
|
|
1185
|
+
function readProvider(settings, kind, env = process.env) {
|
|
1186
|
+
const baseUrlKey = `${kind}.baseUrl`;
|
|
1187
|
+
const apiKeyKey = `${kind}.apiKey`;
|
|
1188
|
+
const baseUrl = settings[baseUrlKey];
|
|
1189
|
+
const apiKey = settings[apiKeyKey];
|
|
1190
|
+
const token = kind === "foundry" ? settings["foundry.token"] : void 0;
|
|
1191
|
+
if (baseUrl && token) return { kind, baseUrl, token };
|
|
1192
|
+
if (baseUrl && apiKey) return { kind, baseUrl, apiKey };
|
|
1193
|
+
return [
|
|
1194
|
+
baseUrl ? null : notSet(baseUrlKey, baseUrlSecret, "<url>", env),
|
|
1195
|
+
apiKey || token ? null : notSet(apiKeyKey, apiKeySecret, "<key>", env)
|
|
1196
|
+
].filter((error) => error !== null);
|
|
1197
|
+
}
|
|
1198
|
+
function readSpec(settings, slot, errors) {
|
|
1199
|
+
const keys = {
|
|
1200
|
+
harness: `review.${slot}.harness`,
|
|
1201
|
+
provider: `review.${slot}.provider`,
|
|
1202
|
+
model: `review.${slot}.model`
|
|
1203
|
+
};
|
|
1204
|
+
const harness = settings[keys.harness];
|
|
1205
|
+
const provider = settings[keys.provider];
|
|
1206
|
+
const model = settings[keys.model];
|
|
1207
|
+
if (!harness && !provider && !model) return void 0;
|
|
1208
|
+
if (harness && !isHarness(harness))
|
|
1209
|
+
errors.push(`${keys.harness} "${harness}" must be claude or codex; ${howToSet(keys.harness, "<claude|codex>")}`);
|
|
1210
|
+
if (provider && !isProviderKind(provider))
|
|
1211
|
+
errors.push(
|
|
1212
|
+
`${keys.provider} "${provider}" must be ${providerKinds.join(" or ")}; ${howToSet(keys.provider, `<${providerKinds.join("|")}>`)}`
|
|
1213
|
+
);
|
|
1214
|
+
if (provider && !model)
|
|
1215
|
+
errors.push(`${keys.model} is required when ${keys.provider} is set; ${howToSet(keys.model, "<model>")} (see \`yoke models\`)`);
|
|
1216
|
+
return {
|
|
1217
|
+
...harness && isHarness(harness) ? { harness } : {},
|
|
1218
|
+
...provider && isProviderKind(provider) ? { provider } : {},
|
|
1219
|
+
...model ? { model } : {}
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
function toSlot({ harness: chosen, model, provider: kind }, fallback, providers) {
|
|
1223
|
+
const harness = chosen ?? fallback;
|
|
1224
|
+
if (!model) return { harness };
|
|
1225
|
+
const slot = { harness, model };
|
|
1226
|
+
const provider = kind && providers[kind];
|
|
1227
|
+
if (provider) return { ...slot, provider: kind, override: buildOverride(slot, provider), probe: buildProbe(slot, provider) };
|
|
1228
|
+
return { ...slot, override: ownAuthOverride(slot) };
|
|
1229
|
+
}
|
|
1230
|
+
function resolveSlots(settings) {
|
|
1231
|
+
const specs = {};
|
|
1232
|
+
const errors = [];
|
|
1233
|
+
for (const name of slotNames) specs[name] = readSpec(settings, name, errors);
|
|
1234
|
+
const providers = {};
|
|
1235
|
+
for (const kind of new Set(Object.values(specs).flatMap((spec) => spec?.provider ? [spec.provider] : []))) {
|
|
1236
|
+
const read = readProvider(settings, kind);
|
|
1237
|
+
if (Array.isArray(read)) errors.push(...read);
|
|
1238
|
+
else providers[kind] = read;
|
|
1239
|
+
}
|
|
1240
|
+
if (errors.length) throw new ReviewError(["Review settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
1241
|
+
const reviewer1 = toSlot(specs.reviewer1 ?? {}, "claude", providers);
|
|
1242
|
+
return {
|
|
1243
|
+
reviewer1,
|
|
1244
|
+
reviewer2: toSlot(specs.reviewer2 ?? {}, "codex", providers),
|
|
1245
|
+
synthesis: specs.synthesis && toSlot(specs.synthesis, reviewer1.harness, providers)
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// src/features/review/models.ts
|
|
1250
|
+
var timeout = 6e4;
|
|
1251
|
+
var maxBodyChars = 300;
|
|
1252
|
+
var providerWidth = Math.max(...providerKinds.map((kind) => kind.length));
|
|
1253
|
+
function modelsRequest(provider) {
|
|
1254
|
+
const url = `${deriveEndpoints(provider.kind, provider.baseUrl).responses}/models`;
|
|
1255
|
+
if (provider.kind === "foundry" && "apiKey" in provider) return { url, headers: { "api-key": provider.apiKey } };
|
|
1256
|
+
return { url, headers: { Authorization: `Bearer ${credentialOf(provider)}` } };
|
|
1257
|
+
}
|
|
1258
|
+
function isModelList(value) {
|
|
1259
|
+
return typeof value === "object" && value !== null && "data" in value && Array.isArray(value.data) && value.data.every((model) => typeof model === "object" && model !== null && "id" in model && typeof model.id === "string");
|
|
1260
|
+
}
|
|
1261
|
+
async function get(url, headers, fetchModels) {
|
|
1262
|
+
try {
|
|
1263
|
+
return await fetchModels(url, { headers, signal: AbortSignal.timeout(timeout) });
|
|
1264
|
+
} catch (error) {
|
|
1265
|
+
throw new ReviewError(`Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
async function providerModels(provider, fetchModels) {
|
|
1269
|
+
const { url, headers } = modelsRequest(provider);
|
|
1270
|
+
const response = await get(url, headers, fetchModels);
|
|
1271
|
+
if (!response.ok)
|
|
1272
|
+
throw new ReviewError(`${url} returned HTTP ${response.status}: ${(await response.text()).trim().slice(0, maxBodyChars)}`);
|
|
1273
|
+
const body = await response.json().catch(() => void 0);
|
|
1274
|
+
if (!isModelList(body)) throw new ReviewError(`${url} returned an unexpected model list`);
|
|
1275
|
+
return body.data.map(({ id }) => `${provider.kind.padEnd(providerWidth)} ${id}`);
|
|
1276
|
+
}
|
|
1277
|
+
function isConfigured(settings, kind) {
|
|
1278
|
+
return Boolean(settings[`${kind}.baseUrl`] || settings[`${kind}.apiKey`] || kind === "foundry" && settings["foundry.token"]);
|
|
1279
|
+
}
|
|
1280
|
+
async function listModels(settings, fetchModels = fetch) {
|
|
1281
|
+
const kinds = providerKinds.filter((kind) => isConfigured(settings, kind));
|
|
1282
|
+
if (!kinds.length)
|
|
1283
|
+
throw new ReviewError(
|
|
1284
|
+
`No model provider is configured; set ${providerKinds.map((kind) => `${kind}.baseUrl and ${kind}.apiKey`).join(", or ")} (see \`yoke config\`)`
|
|
1285
|
+
);
|
|
1286
|
+
const providers = kinds.map((kind) => readProvider(settings, kind));
|
|
1287
|
+
const errors = providers.flatMap((provider) => Array.isArray(provider) ? provider : []);
|
|
1288
|
+
if (errors.length) throw new ReviewError(["Model provider settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
1289
|
+
const ready = providers.filter((provider) => !Array.isArray(provider));
|
|
1290
|
+
const lists = await Promise.all(ready.map((provider) => providerModels(provider, fetchModels)));
|
|
1291
|
+
return lists.flat().toSorted();
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// src/features/review/review.ts
|
|
1295
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
|
|
1296
|
+
import { homedir as homedir2, tmpdir } from "os";
|
|
1297
|
+
import { join as join8 } from "path";
|
|
1298
|
+
|
|
1299
|
+
// src/features/review/entra.ts
|
|
1300
|
+
var tokenExchangeAudience = "api://AzureADTokenExchange";
|
|
1301
|
+
var cognitiveServicesScope = "https://cognitiveservices.azure.com/.default";
|
|
1302
|
+
var timeout2 = 3e4;
|
|
1303
|
+
function isRecord(value) {
|
|
1304
|
+
return typeof value === "object" && value !== null;
|
|
1305
|
+
}
|
|
1306
|
+
function errorDetail(json, text2) {
|
|
1307
|
+
const detail = [json.error_description, json.error, json.message].find((value) => typeof value === "string");
|
|
1308
|
+
return typeof detail === "string" ? detail : text2.trim();
|
|
1309
|
+
}
|
|
1310
|
+
async function readJson(response, failure) {
|
|
1311
|
+
const text2 = await response.text();
|
|
1312
|
+
let body;
|
|
1313
|
+
try {
|
|
1314
|
+
body = JSON.parse(text2);
|
|
1315
|
+
} catch {
|
|
1316
|
+
throw new ReviewError(`${failure}: HTTP ${response.status}: ${text2.trim().slice(0, 300)}`);
|
|
1317
|
+
}
|
|
1318
|
+
const json = isRecord(body) ? body : {};
|
|
1319
|
+
if (response.ok) return json;
|
|
1320
|
+
throw new ReviewError(`${failure}: HTTP ${response.status}: ${errorDetail(json, text2).slice(0, 300)}`);
|
|
1321
|
+
}
|
|
1322
|
+
async function githubOidcToken(env, fetchFn) {
|
|
1323
|
+
const { ACTIONS_ID_TOKEN_REQUEST_URL: url, ACTIONS_ID_TOKEN_REQUEST_TOKEN: bearer } = env;
|
|
1324
|
+
if (!url || !bearer) throw new ReviewError("No GitHub OIDC token endpoint; grant id-token: write in the calling workflow's permissions");
|
|
1325
|
+
const request = new URL(url);
|
|
1326
|
+
request.searchParams.set("audience", tokenExchangeAudience);
|
|
1327
|
+
const response = await fetchFn(request, { headers: { Authorization: `bearer ${bearer}` }, signal: AbortSignal.timeout(timeout2) });
|
|
1328
|
+
const { value } = await readJson(response, "GitHub OIDC token request failed");
|
|
1329
|
+
if (typeof value !== "string" || !value) throw new ReviewError("GitHub OIDC token response had no value");
|
|
1330
|
+
return value;
|
|
1331
|
+
}
|
|
1332
|
+
async function exchangeForEntraToken(clientId, tenantId, env, fetchFn = fetch) {
|
|
1333
|
+
const assertion = await githubOidcToken(env, fetchFn);
|
|
1334
|
+
const response = await fetchFn(`https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`, {
|
|
1335
|
+
method: "POST",
|
|
1336
|
+
body: new URLSearchParams({
|
|
1337
|
+
grant_type: "client_credentials",
|
|
1338
|
+
client_id: clientId,
|
|
1339
|
+
scope: cognitiveServicesScope,
|
|
1340
|
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
1341
|
+
client_assertion: assertion
|
|
1342
|
+
}),
|
|
1343
|
+
signal: AbortSignal.timeout(timeout2)
|
|
1344
|
+
});
|
|
1345
|
+
const { access_token: token } = await readJson(response, `Entra token exchange for client ${clientId} failed`);
|
|
1346
|
+
if (typeof token !== "string" || !token) throw new ReviewError("Entra token response had no access_token");
|
|
1347
|
+
return token;
|
|
1348
|
+
}
|
|
1349
|
+
function usesFoundry(settings) {
|
|
1350
|
+
return ["reviewer1", "reviewer2", "synthesis"].some((slot) => settings[`review.${slot}.provider`] === "foundry");
|
|
1351
|
+
}
|
|
1352
|
+
async function withEntraToken(settings, { env, fetch: fetchFn = fetch, mask }) {
|
|
1353
|
+
const clientId = settings["foundry.azureClientId"];
|
|
1354
|
+
const tenantId = settings["foundry.azureTenantId"];
|
|
1355
|
+
if (!clientId && !tenantId) return settings;
|
|
1356
|
+
if (!clientId || !tenantId) throw new ReviewError("Entra auth needs both foundry.azureClientId and foundry.azureTenantId");
|
|
1357
|
+
if (settings["foundry.token"] || !usesFoundry(settings) || !isCi(env)) return settings;
|
|
1358
|
+
const token = await exchangeForEntraToken(clientId, tenantId, env, fetchFn);
|
|
1359
|
+
mask(token);
|
|
1360
|
+
return { ...settings, "foundry.token": token };
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1208
1363
|
// src/features/review/harness.ts
|
|
1209
1364
|
import { spawn } from "child_process";
|
|
1210
1365
|
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
@@ -1222,11 +1377,11 @@ function count(source, key) {
|
|
|
1222
1377
|
const value = key ? source[key] : void 0;
|
|
1223
1378
|
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1224
1379
|
}
|
|
1225
|
-
function
|
|
1380
|
+
function isRecord2(value) {
|
|
1226
1381
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1227
1382
|
}
|
|
1228
1383
|
function parseUsage(raw, keys) {
|
|
1229
|
-
if (!
|
|
1384
|
+
if (!isRecord2(raw)) return void 0;
|
|
1230
1385
|
const usage = {};
|
|
1231
1386
|
for (const field2 of fields2) {
|
|
1232
1387
|
const value = count(raw, keys[field2]);
|
|
@@ -1901,6 +2056,10 @@ function logged(ui, append) {
|
|
|
1901
2056
|
}
|
|
1902
2057
|
|
|
1903
2058
|
// src/features/review/review.ts
|
|
2059
|
+
function maskInCi(secret2) {
|
|
2060
|
+
if (isCi()) process.stdout.write(`::add-mask::${secret2}
|
|
2061
|
+
`);
|
|
2062
|
+
}
|
|
1904
2063
|
function reviewRunDir(pr2, env = process.env, home = homedir2()) {
|
|
1905
2064
|
const [, owner = "", repo = ""] = new URL(pr2.url).pathname.split("/");
|
|
1906
2065
|
const run2 = `${pr2.number}-${pr2.baseRefOid}-${pr2.headRefOid}`;
|
|
@@ -1940,7 +2099,7 @@ async function reviewPr(number, settings, { post, force }, io) {
|
|
|
1940
2099
|
io.out(text2);
|
|
1941
2100
|
};
|
|
1942
2101
|
const { log } = ui;
|
|
1943
|
-
const slots2 = resolveSlots(settings);
|
|
2102
|
+
const slots2 = resolveSlots(await withEntraToken(settings, { env: process.env, mask: maskInCi }));
|
|
1944
2103
|
const pr2 = await timed(ui, number ? `fetching PR #${number}` : "fetching this branch's PR", () => fetchPr(number));
|
|
1945
2104
|
log(`#${pr2.number} ${pr2.title}`);
|
|
1946
2105
|
const [diff, prior] = await timed(
|
|
@@ -2030,9 +2189,13 @@ var reviewCiSkill = {
|
|
|
2030
2189
|
body: `
|
|
2031
2190
|
Run \`yoke review-ci $ARGUMENTS\`. If it reports that a model is required, run \`yoke models\` to list what the provider serves, ask the user which model each reviewer should run, and rerun with \`--reviewer-1-model\` and \`--reviewer-2-model\`. If it reports the workflow already exists and differs, show the user the difference and ask before rerunning with \`--force\`.
|
|
2032
2191
|
|
|
2033
|
-
|
|
2192
|
+
With \`--provider foundry\`, if the Foundry resource has local (API key) auth disabled or the user prefers Entra, rerun with \`--azure-client-id\` and \`--azure-tenant-id\` of the Entra app the job signs in as, plus \`--environment\` if the job should run in a GitHub environment. Ask the user for these values; don't guess them.
|
|
2193
|
+
|
|
2194
|
+
Then set the secrets it printed. Run \`gh secret list\` first; if a secret is missing, ask whether it is already set at the org level before setting it on the repo.
|
|
2034
2195
|
- \`REVIEW_CI_BASE_URL\` is not secret: confirm the URL with the user and run the printed command.
|
|
2035
|
-
- \`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.
|
|
2196
|
+
- \`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.
|
|
2197
|
+
|
|
2198
|
+
With Entra auth, show the user the federated credential it printed (issuer, audience, subject) and the \`az ad app federated-credential create\` command, and ask whether to run it or whether they will add the credential in the Azure portal. The app also needs the Cognitive Services User role on the Foundry resource, and the environment, if given, must exist on the repo.
|
|
2036
2199
|
|
|
2037
2200
|
Finish by running \`gh secret list\` and telling the user to commit and push the workflow file.
|
|
2038
2201
|
|
|
@@ -2140,14 +2303,20 @@ addSettingOptions(
|
|
|
2140
2303
|
});
|
|
2141
2304
|
var ciKeys = settingKeys.filter((key) => /^review\.\w+\.(harness|model)$/.test(key));
|
|
2142
2305
|
addSettingOptions(
|
|
2143
|
-
program.command("review-ci").description(
|
|
2306
|
+
program.command("review-ci").description(
|
|
2307
|
+
`Write ${callerWorkflowPath} so each PR in this repo is reviewed in CI, then print the secrets, or Entra federated credential, it needs`
|
|
2308
|
+
).option("--provider <provider>", "litellm or foundry; every slot uses it in CI. Default: review.reviewer1.provider, else litellm").option(
|
|
2309
|
+
"--azure-client-id <id>",
|
|
2310
|
+
"Entra app (client) ID to sign in to Foundry with GitHub OIDC instead of an API key; needs --provider foundry"
|
|
2311
|
+
).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("--force", `overwrite an existing ${callerWorkflowPath} that differs`),
|
|
2144
2312
|
ciKeys
|
|
2145
2313
|
).addHelpText("after", settingsHelp).action((options) => {
|
|
2146
2314
|
try {
|
|
2147
2315
|
const settings = resolveSettings(flagSettings(options, ciKeys), process.env, readUserConfig(userConfigPath()));
|
|
2148
|
-
const workflow = callerWorkflow(settings, options.provider);
|
|
2149
|
-
const
|
|
2150
|
-
|
|
2316
|
+
const workflow = callerWorkflow(settings, options.provider, options);
|
|
2317
|
+
const root = repoRoot();
|
|
2318
|
+
const outcome = writeCallerWorkflow(root, renderCallerWorkflow(workflow), options.force ?? false);
|
|
2319
|
+
console.log(reviewCiReport(outcome, settings, workflow, () => githubRepo(root)));
|
|
2151
2320
|
} catch (error) {
|
|
2152
2321
|
if (!(error instanceof ReviewError || error instanceof ConfigError)) throw error;
|
|
2153
2322
|
console.error(error.message);
|