diff --git a/apps/api/src/services/repo-service.ts b/apps/api/src/services/repo-service.ts index be0968fe..ccc6bbc2 100644 --- a/apps/api/src/services/repo-service.ts +++ b/apps/api/src/services/repo-service.ts @@ -3,6 +3,7 @@ import { db } from "../db/client.js"; import { repos, workspaces } from "../db/schema.js"; import { encrypt, decrypt, ALG_AES_256_GCM_V1 } from "./secret-service.js"; import { normalizeRepoUrl, parseRepoUrl } from "@optio/shared"; +import { logger } from "../logger.js"; export interface RepoRecord { id: string; @@ -130,7 +131,8 @@ export async function getRepoByUrl( conditions.push(eq(repos.workspaceId, workspaceId)); } else { // When no workspace is specified, try the default workspace first, - // then fall back to any repo with a NULL workspace_id + // then fall back to any repo with a NULL workspace_id, + // then fall back to any workspace (for background jobs like ticket sync) const defaultWsId = await getDefaultWorkspaceId(); if (defaultWsId) { const [repo] = await db @@ -139,7 +141,24 @@ export async function getRepoByUrl( .where(and(eq(repos.repoUrl, normalized), eq(repos.workspaceId, defaultWsId))); if (repo) return decryptRepoRow(repo); } - conditions.push(isNull(repos.workspaceId)); + // Try NULL workspace_id + const [nullWsRepo] = await db + .select() + .from(repos) + .where(and(eq(repos.repoUrl, normalized), isNull(repos.workspaceId))); + if (nullWsRepo) return decryptRepoRow(nullWsRepo); + // Final fallback: any workspace (background jobs like ticket sync have no workspace context) + const anyRepos = await db.select().from(repos).where(eq(repos.repoUrl, normalized)); + if (anyRepos.length > 0) { + if (anyRepos.length > 1) { + logger.warn( + { repoUrl: normalized, count: anyRepos.length }, + "Multiple repos found with same URL across workspaces - returning first match", + ); + } + return decryptRepoRow(anyRepos[0]); + } + return null; } const [repo] = await db .select() diff --git a/apps/api/src/services/ticket-sync-service.ts b/apps/api/src/services/ticket-sync-service.ts index 524b76dd..b6a5bfd1 100644 --- a/apps/api/src/services/ticket-sync-service.ts +++ b/apps/api/src/services/ticket-sync-service.ts @@ -22,7 +22,7 @@ export async function syncAllTickets(): Promise { .where(eq(ticketProviders.enabled, true)); // Fetch configured repos once before the provider loop (avoids redundant queries) - const configuredRepos = await db.select({ repoUrl: repos.repoUrl }).from(repos); + const configuredRepos = await db.select().from(repos); let totalSynced = 0; @@ -37,8 +37,11 @@ export async function syncAllTickets(): Promise { ); const credentials = JSON.parse(secretJson); mergedConfig = { ...mergedConfig, ...credentials }; - } catch { - // No secrets stored for this provider — use config as-is + } catch (secretErr) { + logger.warn( + { err: secretErr, source: providerConfig.source, id: providerConfig.id }, + "[ticket-sync] Failed to retrieve secret for provider — using config as-is", + ); } // GitHub fallback: if no token was supplied via config or provider secret, @@ -142,6 +145,12 @@ export async function syncAllTickets(): Promise { // Resolve agent type: ticket label > repo default > "claude-code" const { getRepoByUrl } = await import("./repo-service.js"); const repoConfig = await getRepoByUrl(repoUrl); + if (!repoConfig) { + logger.warn( + { repoUrl, ticketId: ticket.externalId }, + "[ticket-sync] Repository configuration not found. Task will be created without workspace context.", + ); + } const labelAgent = ticket.labels.includes("codex") ? "codex" : ticket.labels.includes("copilot") @@ -157,6 +166,7 @@ export async function syncAllTickets(): Promise { ticketSource: ticket.source, ticketExternalId: ticket.externalId, metadata: { ticketUrl: ticket.url }, + workspaceId: repoConfig?.workspaceId, }); await taskService.transitionTask(task.id, TaskState.QUEUED, "ticket_sync"); diff --git a/packages/shared/src/error-classifier.ts b/packages/shared/src/error-classifier.ts index 4a8041e8..9b03bc8a 100644 --- a/packages/shared/src/error-classifier.ts +++ b/packages/shared/src/error-classifier.ts @@ -48,14 +48,17 @@ const ERROR_PATTERNS: Array<{ }), }, { - pattern: /Secret not found: (\w+)/i, - classify: (match) => ({ - category: "auth", - title: `Missing secret: ${match[1]}`, - description: `The required secret "${match[1]}" is not configured. The agent needs this credential to run.`, - remedy: `Go to Secrets and add "${match[1]}", or re-run the setup wizard.`, - retryable: true, - }), + pattern: /Secret not found: (\w+)|no (\w+) secret found/i, + classify: (match) => { + const missingSecret = match[1] || match[2]; + return { + category: "auth", + title: `Missing secret: ${missingSecret}`, + description: `The required secret "${missingSecret}" is not configured. The agent needs this credential to run.`, + remedy: `Go to Secrets and add "${missingSecret}", or re-run the setup wizard.`, + retryable: false, // Don't retry missing secrets - requires user action + }; + }, }, { pattern: @@ -72,7 +75,7 @@ const ERROR_PATTERNS: Array<{ " security find-generic-password -s \"Claude Code-credentials\" -w | python3 -c \"import sys,json; print(json.load(sys.stdin)['claudeAiOauth']['accessToken'])\" | pbcopy\n\n" + "Or re-run 'claude setup-token' to go through the setup flow again.\n" + "Retry the failed tasks after updating the token.", - retryable: true, + retryable: false, }), }, { @@ -83,7 +86,7 @@ const ERROR_PATTERNS: Array<{ description: "No Anthropic API key is configured and Claude Code cannot authenticate.", remedy: "Go to Secrets and add ANTHROPIC_API_KEY, or switch to Max subscription auth in Settings.", - retryable: true, + retryable: false, }), }, { @@ -94,7 +97,7 @@ const ERROR_PATTERNS: Array<{ description: "No OpenAI API key is configured and the Codex agent cannot authenticate with the OpenAI API.", remedy: "Go to Secrets and add OPENAI_API_KEY with a valid OpenAI API key.", - retryable: true, + retryable: false, }), }, { @@ -105,7 +108,17 @@ const ERROR_PATTERNS: Array<{ description: "No OpenClaw API key is configured and the OpenClaw agent cannot authenticate.", remedy: "Go to Secrets and add OPENCLAW_API_KEY, or provide an ANTHROPIC_API_KEY or OPENAI_API_KEY instead.", - retryable: true, + retryable: false, + }), + }, + { + pattern: /GEMINI_API_KEY/i, + classify: () => ({ + category: "auth", + title: "Gemini API key missing", + description: "No Gemini API key is configured and Gemini cannot authenticate.", + remedy: "Go to Secrets and add GEMINI_API_KEY with a valid Gemini API key.", + retryable: false, }), }, { @@ -117,7 +130,7 @@ const ERROR_PATTERNS: Array<{ "No valid Copilot token is configured. The Copilot agent requires a GitHub token with Copilot Requests permission and an active Copilot subscription.", remedy: "Go to Secrets and add COPILOT_GITHUB_TOKEN with a fine-grained PAT that has the Copilot Requests permission. Classic PATs (ghp_) are not supported.", - retryable: true, + retryable: false, }), }, {