Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions apps/api/src/services/repo-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
16 changes: 13 additions & 3 deletions apps/api/src/services/ticket-sync-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export async function syncAllTickets(): Promise<number> {
.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;

Expand All @@ -37,8 +37,11 @@ export async function syncAllTickets(): Promise<number> {
);
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,
Expand Down Expand Up @@ -142,6 +145,12 @@ export async function syncAllTickets(): Promise<number> {
// 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")
Expand All @@ -157,6 +166,7 @@ export async function syncAllTickets(): Promise<number> {
ticketSource: ticket.source,
ticketExternalId: ticket.externalId,
metadata: { ticketUrl: ticket.url },
workspaceId: repoConfig?.workspaceId,
});

await taskService.transitionTask(task.id, TaskState.QUEUED, "ticket_sync");
Expand Down
39 changes: 26 additions & 13 deletions packages/shared/src/error-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
}),
},
{
Expand All @@ -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,
}),
},
{
Expand All @@ -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,
}),
},
{
Expand All @@ -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,
}),
},
{
Expand All @@ -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,
}),
},
{
Expand Down
Loading