diff --git a/main.ts b/main.ts index 7bf799c..9609e16 100644 --- a/main.ts +++ b/main.ts @@ -17,7 +17,11 @@ import { } from "./utils/activity.ts"; import { resolveAgent, resolveModel } from "./utils/agent.ts"; import { apiFetch } from "./utils/apiFetch.ts"; -import { validateAgentApiKey } from "./utils/apiKeys.ts"; +import { + formatApiKeyErrorSummary, + isApiKeyAuthError, + validateAgentApiKey, +} from "./utils/apiKeys.ts"; import { isLocalApiUrl } from "./utils/apiUrl.ts"; import { resolveBody } from "./utils/body.ts"; import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts"; @@ -1035,10 +1039,15 @@ export async function main(): Promise { // the comment is still around to update; reportErrorToComment sets // wasUpdated=true and the !result.success guard skips deletion. if (!result.success && toolContext && toolState.progressComment) { - await reportErrorToComment({ - toolState, - error: result.error || "agent run failed", - }).catch((error) => { + const rawError = result.error || "agent run failed"; + const errorBody = isApiKeyAuthError(rawError) + ? formatApiKeyErrorSummary({ + owner: runContext.repo.owner, + name: runContext.repo.name, + raw: rawError, + }) + : rawError; + await reportErrorToComment({ toolState, error: errorBody }).catch((error) => { log.debug(`failure error report failed: ${error}`); }); } @@ -1114,11 +1123,20 @@ export async function main(): Promise { ? new BillingError(errorMessage, { code: "router_keylimit_exhausted" }) : null; + const apiKeyErrorSummary = + !billingError && isApiKeyAuthError(errorMessage) + ? formatApiKeyErrorSummary({ + owner: runContext.repo.owner, + name: runContext.repo.name, + raw: errorMessage, + }) + : null; + // best-effort summary — write the error so it's visible in the Actions summary tab try { const errorSummary = billingError ? formatBillingErrorSummary(billingError, runContext.repo.owner) - : `### ❌ Pullfrog failed\n\n\`\`\`\n${errorMessage}\n\`\`\``; + : (apiKeyErrorSummary ?? `### ❌ Pullfrog failed\n\n\`\`\`\n${errorMessage}\n\`\`\``); const usageSummary = formatUsageSummary(toolState.usageEntries); const parts = [errorSummary, toolState.lastProgressBody, usageSummary].filter(Boolean); await writeSummary(parts.join("\n\n")); @@ -1127,7 +1145,7 @@ export async function main(): Promise { try { const commentBody = billingError ? formatBillingErrorSummary(billingError, runContext.repo.owner) - : errorMessage; + : (apiKeyErrorSummary ?? errorMessage); await reportErrorToComment({ toolState, error: commentBody }); } catch { // error reporting failed, but don't let it mask the original error diff --git a/utils/apiKeys.ts b/utils/apiKeys.ts index d61bf3d..186f121 100644 --- a/utils/apiKeys.ts +++ b/utils/apiKeys.ts @@ -3,26 +3,19 @@ import { getApiUrl } from "./apiUrl.ts"; const knownApiKeys: Set = new Set(Object.values(providers).flatMap((p) => [...p.envVars])); +/** marker prefix on the throw message for the catch-side reclassification path */ +const MISSING_KEY_MARKER = "no API key found"; + +/** Markdown body used for both the thrown error and the formatted PR comment summary. */ function buildMissingApiKeyError(params: { owner: string; name: string }): string { - const apiUrl = getApiUrl(); - const settingsUrl = `${apiUrl}/console/${params.owner}/${params.name}`; + const githubSecretsUrl = `https://github.com/${params.owner}/${params.name}/settings/secrets/actions`; + const settingsUrl = `${getApiUrl()}/console/${params.owner}/${params.name}`; - const githubRepoUrl = `https://github.com/${params.owner}/${params.name}`; - const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`; - - return `no API key found. Pullfrog requires at least one LLM provider API key. - -to fix this, add the required secret to your GitHub repository: - -1. go to: ${githubSecretsUrl} -2. click "New repository secret" -3. set the name to your provider's key (e.g., \`ANTHROPIC_API_KEY\`, \`OPENAI_API_KEY\`, \`GEMINI_API_KEY\`) -4. set the value to your API key -5. click "Add secret" - -configure your model at ${settingsUrl} - -for full setup instructions, see https://docs.pullfrog.com/keys`; + return [ + `**${MISSING_KEY_MARKER}** — Pullfrog needs at least one LLM provider API key (e.g. \`ANTHROPIC_API_KEY\`, \`OPENAI_API_KEY\`, \`GEMINI_API_KEY\`) configured as a GitHub Actions secret.`, + "", + `[Open repo secrets →](${githubSecretsUrl}) · [Configure model →](${settingsUrl}) · [Setup docs →](https://docs.pullfrog.com/keys)`, + ].join("\n"); } function hasEnvVar(name: string): boolean { @@ -59,3 +52,44 @@ export function validateAgentApiKey(params: { throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name })); } } + +/** + * Detect agent-runtime auth failures that should be reformatted as an actionable + * key-fix CTA before being shown to the user. Covers the two shapes we see: + * - missing key (validateAgentApiKey throw): contains MISSING_KEY_MARKER + * - revoked / invalid key (Claude CLI 401 surfaced via api_error_status): + * "Invalid API key · Fix external API key" + similar provider variants + */ +export function isApiKeyAuthError(text: string): boolean { + if (!text) return false; + return ( + text.includes(MISSING_KEY_MARKER) || + /Invalid API key/i.test(text) || + /\bUser not found\b/i.test(text) || + /\bInvalid authentication\b/i.test(text) + ); +} + +/** + * Friendly Markdown summary for both the missing-key and invalid-key cases. + * Used in the catch / result-failure paths in `main.ts` to overwrite the raw + * agent error before it's posted to the PR progress comment. + */ +export function formatApiKeyErrorSummary(params: { + owner: string; + name: string; + raw: string; +}): string { + if (params.raw.includes(MISSING_KEY_MARKER)) { + return buildMissingApiKeyError({ owner: params.owner, name: params.name }); + } + + const githubSecretsUrl = `https://github.com/${params.owner}/${params.name}/settings/secrets/actions`; + const settingsUrl = `${getApiUrl()}/console/${params.owner}/${params.name}`; + + return [ + `**Your LLM provider API key was rejected (401).** Rotate the key in your provider dashboard, then update the matching GitHub Actions secret.`, + "", + `[Update repo secret →](${githubSecretsUrl}) · [Model settings →](${settingsUrl}) · [Setup docs →](https://docs.pullfrog.com/keys)`, + ].join("\n"); +}