@makerx/yoke 0.8.0 → 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 +365 -172
- 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,22 +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)}`),
|
|
673
|
-
" secrets:
|
|
705
|
+
...workflow.inputs.map(([name, value]) => ` ${name}: ${yamlValue(value)}`),
|
|
706
|
+
" secrets:",
|
|
707
|
+
...workflowSecrets(workflow).map((secret2) => ` ${secret2}: \${{ secrets.${secret2} }}`),
|
|
674
708
|
""
|
|
675
709
|
].join("\n");
|
|
676
710
|
}
|
|
677
711
|
function shellQuote(value) {
|
|
678
712
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
679
713
|
}
|
|
680
|
-
function secretCommands(settings,
|
|
681
|
-
const baseUrl = settings[`${provider}.baseUrl`];
|
|
682
|
-
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
|
+
}
|
|
683
749
|
}
|
|
684
750
|
function repoRoot(cwd = process.cwd()) {
|
|
685
751
|
try {
|
|
@@ -698,131 +764,34 @@ function writeCallerWorkflow(root, content, force) {
|
|
|
698
764
|
writeFileSync2(path, content);
|
|
699
765
|
return "wrote";
|
|
700
766
|
}
|
|
701
|
-
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) {
|
|
702
782
|
const action = outcome === "wrote" ? "wrote" : "kept";
|
|
783
|
+
const secrets = workflowSecrets(workflow);
|
|
703
784
|
return [
|
|
704
785
|
`${action} ${callerWorkflowPath}`,
|
|
705
786
|
"",
|
|
706
|
-
`The workflow reads ${
|
|
707
|
-
...
|
|
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()) : [],
|
|
708
790
|
"",
|
|
709
791
|
"Commit and push the workflow; each PR opened from this repo then gets a review."
|
|
710
792
|
].join("\n");
|
|
711
793
|
}
|
|
712
794
|
|
|
713
|
-
// src/features/review/slots.ts
|
|
714
|
-
var slotNames = ["reviewer1", "reviewer2", "synthesis"];
|
|
715
|
-
function readProvider(settings, kind) {
|
|
716
|
-
const baseUrlKey = `${kind}.baseUrl`;
|
|
717
|
-
const apiKeyKey = `${kind}.apiKey`;
|
|
718
|
-
const baseUrl = settings[baseUrlKey];
|
|
719
|
-
const apiKey = settings[apiKeyKey];
|
|
720
|
-
if (baseUrl && apiKey) return { kind, baseUrl, apiKey };
|
|
721
|
-
return [
|
|
722
|
-
baseUrl ? null : `${baseUrlKey} is not set; ${howToSet(baseUrlKey, "<url>")}`,
|
|
723
|
-
apiKey ? null : `${apiKeyKey} is not set; ${howToSet(apiKeyKey, "<key>")}`
|
|
724
|
-
].filter((error) => error !== null);
|
|
725
|
-
}
|
|
726
|
-
function readSpec(settings, slot, errors) {
|
|
727
|
-
const keys = {
|
|
728
|
-
harness: `review.${slot}.harness`,
|
|
729
|
-
provider: `review.${slot}.provider`,
|
|
730
|
-
model: `review.${slot}.model`
|
|
731
|
-
};
|
|
732
|
-
const harness = settings[keys.harness];
|
|
733
|
-
const provider = settings[keys.provider];
|
|
734
|
-
const model = settings[keys.model];
|
|
735
|
-
if (!harness && !provider && !model) return void 0;
|
|
736
|
-
if (harness && !isHarness(harness))
|
|
737
|
-
errors.push(`${keys.harness} "${harness}" must be claude or codex; ${howToSet(keys.harness, "<claude|codex>")}`);
|
|
738
|
-
if (provider && !isProviderKind(provider))
|
|
739
|
-
errors.push(
|
|
740
|
-
`${keys.provider} "${provider}" must be ${providerKinds.join(" or ")}; ${howToSet(keys.provider, `<${providerKinds.join("|")}>`)}`
|
|
741
|
-
);
|
|
742
|
-
if (provider && !model)
|
|
743
|
-
errors.push(`${keys.model} is required when ${keys.provider} is set; ${howToSet(keys.model, "<model>")} (see \`yoke models\`)`);
|
|
744
|
-
return {
|
|
745
|
-
...harness && isHarness(harness) ? { harness } : {},
|
|
746
|
-
...provider && isProviderKind(provider) ? { provider } : {},
|
|
747
|
-
...model ? { model } : {}
|
|
748
|
-
};
|
|
749
|
-
}
|
|
750
|
-
function toSlot({ harness: chosen, model, provider: kind }, fallback, providers) {
|
|
751
|
-
const harness = chosen ?? fallback;
|
|
752
|
-
if (!model) return { harness };
|
|
753
|
-
const slot = { harness, model };
|
|
754
|
-
const provider = kind && providers[kind];
|
|
755
|
-
if (provider) return { ...slot, provider: kind, override: buildOverride(slot, provider), probe: buildProbe(slot, provider) };
|
|
756
|
-
return { ...slot, override: ownAuthOverride(slot) };
|
|
757
|
-
}
|
|
758
|
-
function resolveSlots(settings) {
|
|
759
|
-
const specs = {};
|
|
760
|
-
const errors = [];
|
|
761
|
-
for (const name of slotNames) specs[name] = readSpec(settings, name, errors);
|
|
762
|
-
const providers = {};
|
|
763
|
-
for (const kind of new Set(Object.values(specs).flatMap((spec) => spec?.provider ? [spec.provider] : []))) {
|
|
764
|
-
const read = readProvider(settings, kind);
|
|
765
|
-
if (Array.isArray(read)) errors.push(...read);
|
|
766
|
-
else providers[kind] = read;
|
|
767
|
-
}
|
|
768
|
-
if (errors.length) throw new ReviewError(["Review settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
769
|
-
const reviewer1 = toSlot(specs.reviewer1 ?? {}, "claude", providers);
|
|
770
|
-
return {
|
|
771
|
-
reviewer1,
|
|
772
|
-
reviewer2: toSlot(specs.reviewer2 ?? {}, "codex", providers),
|
|
773
|
-
synthesis: specs.synthesis && toSlot(specs.synthesis, reviewer1.harness, providers)
|
|
774
|
-
};
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
// src/features/review/models.ts
|
|
778
|
-
var timeout = 6e4;
|
|
779
|
-
var maxBodyChars = 300;
|
|
780
|
-
var providerWidth = Math.max(...providerKinds.map((kind) => kind.length));
|
|
781
|
-
function modelsRequest({ kind, baseUrl, apiKey }) {
|
|
782
|
-
const url = `${deriveEndpoints(kind, baseUrl).responses}/models`;
|
|
783
|
-
return { url, headers: kind === "foundry" ? { "api-key": apiKey } : { Authorization: `Bearer ${apiKey}` } };
|
|
784
|
-
}
|
|
785
|
-
function isModelList(value) {
|
|
786
|
-
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");
|
|
787
|
-
}
|
|
788
|
-
async function get(url, headers, fetchModels) {
|
|
789
|
-
try {
|
|
790
|
-
return await fetchModels(url, { headers, signal: AbortSignal.timeout(timeout) });
|
|
791
|
-
} catch (error) {
|
|
792
|
-
throw new ReviewError(`Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
async function providerModels(provider, fetchModels) {
|
|
796
|
-
const { url, headers } = modelsRequest(provider);
|
|
797
|
-
const response = await get(url, headers, fetchModels);
|
|
798
|
-
if (!response.ok)
|
|
799
|
-
throw new ReviewError(`${url} returned HTTP ${response.status}: ${(await response.text()).trim().slice(0, maxBodyChars)}`);
|
|
800
|
-
const body = await response.json().catch(() => void 0);
|
|
801
|
-
if (!isModelList(body)) throw new ReviewError(`${url} returned an unexpected model list`);
|
|
802
|
-
return body.data.map(({ id }) => `${provider.kind.padEnd(providerWidth)} ${id}`);
|
|
803
|
-
}
|
|
804
|
-
function isConfigured(settings, kind) {
|
|
805
|
-
return Boolean(settings[`${kind}.baseUrl`] || settings[`${kind}.apiKey`]);
|
|
806
|
-
}
|
|
807
|
-
async function listModels(settings, fetchModels = fetch) {
|
|
808
|
-
const kinds = providerKinds.filter((kind) => isConfigured(settings, kind));
|
|
809
|
-
if (!kinds.length)
|
|
810
|
-
throw new ReviewError(
|
|
811
|
-
`No model provider is configured; set ${providerKinds.map((kind) => `${kind}.baseUrl and ${kind}.apiKey`).join(", or ")} (see \`yoke config\`)`
|
|
812
|
-
);
|
|
813
|
-
const providers = kinds.map((kind) => readProvider(settings, kind));
|
|
814
|
-
const errors = providers.flatMap((provider) => Array.isArray(provider) ? provider : []);
|
|
815
|
-
if (errors.length) throw new ReviewError(["Model provider settings are incomplete:", ...errors.map((error) => `- ${error}`)].join("\n"));
|
|
816
|
-
const ready = providers.filter((provider) => !Array.isArray(provider));
|
|
817
|
-
const lists = await Promise.all(ready.map((provider) => providerModels(provider, fetchModels)));
|
|
818
|
-
return lists.flat().toSorted();
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
// src/features/review/review.ts
|
|
822
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync7, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
|
|
823
|
-
import { homedir as homedir2, tmpdir } from "os";
|
|
824
|
-
import { join as join8 } from "path";
|
|
825
|
-
|
|
826
795
|
// src/features/review/comments.ts
|
|
827
796
|
var markerPrefix = "<!-- yoke-review:";
|
|
828
797
|
function slug(text2) {
|
|
@@ -912,10 +881,22 @@ function formatSynthesis({ summary, findings }) {
|
|
|
912
881
|
return lines.join("\n").trimEnd();
|
|
913
882
|
}
|
|
914
883
|
|
|
915
|
-
// src/features/review/
|
|
884
|
+
// src/features/review/diff.ts
|
|
916
885
|
var fileHeader = /^\+\+\+ (.+)$/;
|
|
917
886
|
var hunkHeader = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
918
|
-
var
|
|
887
|
+
var escapes = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };
|
|
888
|
+
function parseDiff(diff) {
|
|
889
|
+
let file = null;
|
|
890
|
+
const hunk = { next: 0, oldLeft: 0, newLeft: 0 };
|
|
891
|
+
return diff.split("\n").map((text2) => {
|
|
892
|
+
if (hunk.oldLeft > 0 || hunk.newLeft > 0) return { text: text2, file, line: hunkLine(text2, hunk) };
|
|
893
|
+
const header = text2.match(fileHeader)?.[1];
|
|
894
|
+
if (header) file = header === "/dev/null" ? null : diffPath(header);
|
|
895
|
+
const range = text2.match(hunkHeader);
|
|
896
|
+
if (range) Object.assign(hunk, { oldLeft: Number(range[1] ?? 1), next: Number(range[2]), newLeft: Number(range[3] ?? 1) });
|
|
897
|
+
return { text: text2, file, line: null };
|
|
898
|
+
});
|
|
899
|
+
}
|
|
919
900
|
function hunkLine(text2, hunk) {
|
|
920
901
|
if (text2.startsWith("-")) hunk.oldLeft--;
|
|
921
902
|
else if (text2.startsWith("+")) hunk.newLeft--;
|
|
@@ -925,33 +906,46 @@ function hunkLine(text2, hunk) {
|
|
|
925
906
|
} else return null;
|
|
926
907
|
return text2.startsWith("-") ? null : hunk.next++;
|
|
927
908
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
const
|
|
931
|
-
|
|
932
|
-
return Buffer.from([escapes[code] ?? code.charCodeAt(0)]);
|
|
909
|
+
function diffPath(raw) {
|
|
910
|
+
const quoted = raw.match(/^"(.*)"$/)?.[1];
|
|
911
|
+
const path = quoted === void 0 ? raw : unquote(quoted);
|
|
912
|
+
return path.startsWith("b/") ? path.slice(2) : path;
|
|
933
913
|
}
|
|
934
914
|
function unquote(quoted) {
|
|
935
915
|
const parts = quoted.split(/(\\[0-7]{3}|\\.)/);
|
|
936
916
|
return Buffer.concat(parts.map((part, i) => i % 2 ? escapedBytes(part) : Buffer.from(part))).toString("utf8");
|
|
937
917
|
}
|
|
938
|
-
function
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
return
|
|
918
|
+
function escapedBytes(escape) {
|
|
919
|
+
const code = escape.slice(1);
|
|
920
|
+
if (/^[0-7]{3}$/.test(code)) return Buffer.from([parseInt(code, 8)]);
|
|
921
|
+
return Buffer.from([escapes[code] ?? code.charCodeAt(0)]);
|
|
942
922
|
}
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
923
|
+
|
|
924
|
+
// src/features/review/selfReviewNote.ts
|
|
925
|
+
var yokeCaller = /\buses:\s*['"]?MakerXStudio\/yoke\/\.github\/workflows\/review\.yml@/i;
|
|
926
|
+
function editsYokeCaller(diff) {
|
|
927
|
+
return parseDiff(diff).some(({ text: text2, file, line }) => line !== null && file?.startsWith(".github/workflows/") && yokeCaller.test(text2));
|
|
928
|
+
}
|
|
929
|
+
function selfReviewNote(diff) {
|
|
930
|
+
if (!editsYokeCaller(diff)) return "";
|
|
931
|
+
return `## Yoke's own review workflow
|
|
932
|
+
|
|
933
|
+
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.
|
|
934
|
+
|
|
935
|
+
- Calling the workflow at \`@main\` rather than a tag or SHA.
|
|
936
|
+
- The trigger types, including leaving out \`synchronize\`: a review is re-run by marking the PR as draft and then ready for review.
|
|
937
|
+
- Draft PRs being reviewed twice: the reusable workflow already skips drafts.
|
|
938
|
+
|
|
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.
|
|
944
|
+
`;
|
|
954
945
|
}
|
|
946
|
+
|
|
947
|
+
// src/features/review/request.ts
|
|
948
|
+
var gutterWidth = 6;
|
|
955
949
|
function gutter(line) {
|
|
956
950
|
return `${(line === null ? "" : String(line)).padStart(gutterWidth)} `;
|
|
957
951
|
}
|
|
@@ -970,6 +964,7 @@ function commentableLines(diff) {
|
|
|
970
964
|
function buildRequest(pr2, diff, prior = []) {
|
|
971
965
|
const files = pr2.files.map((f) => `- ${f.path}`).join("\n") || "(none)";
|
|
972
966
|
const priorSection = formatPriorComments(prior);
|
|
967
|
+
const note = selfReviewNote(diff);
|
|
973
968
|
return `# Code review request
|
|
974
969
|
|
|
975
970
|
- PR: #${pr2.number} ${pr2.title}
|
|
@@ -987,7 +982,8 @@ Each added or context line is prefixed with a left gutter showing its line numbe
|
|
|
987
982
|
\`\`\`diff
|
|
988
983
|
${withNewFileLineNumbers(diff.trimEnd())}
|
|
989
984
|
\`\`\`
|
|
990
|
-
${
|
|
985
|
+
${note && `
|
|
986
|
+
${note}`}${priorSection && `
|
|
991
987
|
${priorSection}`}`;
|
|
992
988
|
}
|
|
993
989
|
|
|
@@ -1181,6 +1177,189 @@ async function reportFailure(pr2, reason, ui, deps) {
|
|
|
1181
1177
|
}
|
|
1182
1178
|
}
|
|
1183
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
|
+
|
|
1184
1363
|
// src/features/review/harness.ts
|
|
1185
1364
|
import { spawn } from "child_process";
|
|
1186
1365
|
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
@@ -1198,11 +1377,11 @@ function count(source, key) {
|
|
|
1198
1377
|
const value = key ? source[key] : void 0;
|
|
1199
1378
|
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1200
1379
|
}
|
|
1201
|
-
function
|
|
1380
|
+
function isRecord2(value) {
|
|
1202
1381
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1203
1382
|
}
|
|
1204
1383
|
function parseUsage(raw, keys) {
|
|
1205
|
-
if (!
|
|
1384
|
+
if (!isRecord2(raw)) return void 0;
|
|
1206
1385
|
const usage = {};
|
|
1207
1386
|
for (const field2 of fields2) {
|
|
1208
1387
|
const value = count(raw, keys[field2]);
|
|
@@ -1877,6 +2056,10 @@ function logged(ui, append) {
|
|
|
1877
2056
|
}
|
|
1878
2057
|
|
|
1879
2058
|
// src/features/review/review.ts
|
|
2059
|
+
function maskInCi(secret2) {
|
|
2060
|
+
if (isCi()) process.stdout.write(`::add-mask::${secret2}
|
|
2061
|
+
`);
|
|
2062
|
+
}
|
|
1880
2063
|
function reviewRunDir(pr2, env = process.env, home = homedir2()) {
|
|
1881
2064
|
const [, owner = "", repo = ""] = new URL(pr2.url).pathname.split("/");
|
|
1882
2065
|
const run2 = `${pr2.number}-${pr2.baseRefOid}-${pr2.headRefOid}`;
|
|
@@ -1916,7 +2099,7 @@ async function reviewPr(number, settings, { post, force }, io) {
|
|
|
1916
2099
|
io.out(text2);
|
|
1917
2100
|
};
|
|
1918
2101
|
const { log } = ui;
|
|
1919
|
-
const slots2 = resolveSlots(settings);
|
|
2102
|
+
const slots2 = resolveSlots(await withEntraToken(settings, { env: process.env, mask: maskInCi }));
|
|
1920
2103
|
const pr2 = await timed(ui, number ? `fetching PR #${number}` : "fetching this branch's PR", () => fetchPr(number));
|
|
1921
2104
|
log(`#${pr2.number} ${pr2.title}`);
|
|
1922
2105
|
const [diff, prior] = await timed(
|
|
@@ -2006,9 +2189,13 @@ var reviewCiSkill = {
|
|
|
2006
2189
|
body: `
|
|
2007
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\`.
|
|
2008
2191
|
|
|
2009
|
-
|
|
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.
|
|
2010
2195
|
- \`REVIEW_CI_BASE_URL\` is not secret: confirm the URL with the user and run the printed command.
|
|
2011
|
-
- \`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.
|
|
2012
2199
|
|
|
2013
2200
|
Finish by running \`gh secret list\` and telling the user to commit and push the workflow file.
|
|
2014
2201
|
|
|
@@ -2116,14 +2303,20 @@ addSettingOptions(
|
|
|
2116
2303
|
});
|
|
2117
2304
|
var ciKeys = settingKeys.filter((key) => /^review\.\w+\.(harness|model)$/.test(key));
|
|
2118
2305
|
addSettingOptions(
|
|
2119
|
-
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`),
|
|
2120
2312
|
ciKeys
|
|
2121
2313
|
).addHelpText("after", settingsHelp).action((options) => {
|
|
2122
2314
|
try {
|
|
2123
2315
|
const settings = resolveSettings(flagSettings(options, ciKeys), process.env, readUserConfig(userConfigPath()));
|
|
2124
|
-
const workflow = callerWorkflow(settings, options.provider);
|
|
2125
|
-
const
|
|
2126
|
-
|
|
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)));
|
|
2127
2320
|
} catch (error) {
|
|
2128
2321
|
if (!(error instanceof ReviewError || error instanceof ConfigError)) throw error;
|
|
2129
2322
|
console.error(error.message);
|