diff --git a/QUICK_START.md b/QUICK_START.md index 9904470..a180159 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -121,7 +121,9 @@ curl http://localhost:4111/health ### Check Database ```bash -curl http://localhost:4111/api/linear/issues +```bash +# Replace {projectId} with your project's id +curl http://localhost:4111/projects/{projectId}/linear/issues # Should return grouped issues (or empty list if no issues yet) ``` diff --git a/docs/HANDOFF-MULTI-TENANT-HARDENING-2026-04-17.md b/docs/HANDOFF-MULTI-TENANT-HARDENING-2026-04-17.md index 4424c7f..e3ea2a7 100644 --- a/docs/HANDOFF-MULTI-TENANT-HARDENING-2026-04-17.md +++ b/docs/HANDOFF-MULTI-TENANT-HARDENING-2026-04-17.md @@ -313,10 +313,15 @@ Aterrizó la card de Linear + cleanup del `/settings` legacy + flip del `scoped- - **Resend card**: `fromEmail` como input manual (la API no tiene `/me`); test probando `GET /domains`. Meta = `{ fromEmail }`. Migrar `reporter_email` de `localStorage` (hoy en `chat.tsx:352,498,515`) a `meta.defaultReporterEmail`. - **Slack card**: `auth.test` + `conversations.list` → picker de channel → guardar `channelId` + `channelName` en meta. - **GitHub card**: `GET /user` + owner/repo como 2do paso (picker tipo GitHub App, o input manual con validación contra `GET /repos/:owner/:repo`). -- **Borrar endpoints legacy del runtime**: `/api/config/status`, `/api/linear/members` (global), `/api/linear/webhook/setup` + la ruta `/settings` completa + los imports en `mastra/index.ts`. Hacerlo después de los 3 cards arriba. -- **`webhook_secrets` refactor a PK compuesta**: sigue global. Cuando Linear card agregue "Register webhook" (dentro de #5c o slice dedicado), migrar a `(project_id, provider)` con `setWebhookSecret(projectId, provider, secret)`. Incluye cambio en el handler `/webhooks/linear` para lookup por project. -- **Drop columnas plaintext en `projects`**: `linear_token`, `linear_webhook_id/url`, `github_token`, `github_repo_owner/name`, `slack_enabled`, `slack_channel_id`, `slack_webhook_url`, `resend_api_key`, `linear_team_id` (leído solo por env-fallback hoy; una vez que todos los projects migren, se puede dropear). Después del cleanup de endpoints. - +> **COMPLETADO en TRI-61 / TRI-66-68 (2026-05-12)**: Los endpoints legacy fueron eliminados: +> - `/api/config/status`, `/api/linear/members` (global), `POST /api/wiki/generate`, `GET /api/wiki/status` +> - `/api/linear/issues`, `/api/linear/sync`, `/api/linear/sync/status`, `/api/linear/cycle/active` +> - Ruta frontend `/settings` eliminada +> - Webhook per-tenant implementado en `/projects/:id/linear/webhook/setup` + +- **Borrar endpoints legacy del runtime**: ✅ HECHO en TRI-61/TRI-66 (ver arriba). +- **`webhook_secrets` refactor a PK compuesta**: ✅ HECHO en TRI-67. `setWebhookSecret` ahora acepta `projectId`; el handler `/webhooks/linear` lee `?projectId=` del query string. +- **Drop columnas plaintext en `projects`**: ✅ HECHO en TRI-61. ### 5c. UI BYO keys — Slack/Resend/GitHub ✅ DONE (2026-04-22) Cerró el bucle de 5 providers en `/integrations`. 2 commits: `d3d9c7c` (backend) + `4a350be` (frontend). diff --git a/docs/HANDOFF-PR-READINESS-2026-04-09.md b/docs/HANDOFF-PR-READINESS-2026-04-09.md index 032d7b8..0a5a928 100644 --- a/docs/HANDOFF-PR-READINESS-2026-04-09.md +++ b/docs/HANDOFF-PR-READINESS-2026-04-09.md @@ -130,21 +130,21 @@ curl -s --max-time 15 -N http://127.0.0.1:4111/api/agents/orchestrator/stream \ ``` ✅ Must return `text-delta` events with actual text content. -2. **Linear issues endpoint**: +2. **Linear issues endpoint** (per-project since TRI-66): ```bash -curl -s http://127.0.0.1:4111/api/linear/issues | python3 -m json.tool | head -20 +curl -s http://127.0.0.1:4111/projects/{projectId}/linear/issues | python3 -m json.tool | head -20 ``` -✅ Must return `{"success":true,"data":{...}}` with grouped issues (already works, verified). +✅ Must return `{"success":true,"data":{...}}` with grouped issues. -3. **Linear members endpoint**: +3. **Linear members endpoint** (per-project since TRI-66): ```bash -curl -s http://127.0.0.1:4111/api/linear/members | python3 -m json.tool +curl -s http://127.0.0.1:4111/projects/{projectId}/linear/members | python3 -m json.tool ``` -✅ Must return team members array. +✅ Must return team members array for the project's Linear team. -4. **Wiki status endpoint** (stub): +4. **Wiki status endpoint** (per-project since TRI-61): ```bash -curl -s http://127.0.0.1:4111/api/wiki/status +curl -s http://127.0.0.1:4111/projects/{projectId}/wiki/status ``` ✅ Must return `{"success":true,"data":{"total":0,"processed":0,"done":true}}`. @@ -181,17 +181,17 @@ curl -s http://127.0.0.1:4111/health - Issues should populate from Linear API - If LINEAR_API_KEY is missing, should show error gracefully (not crash) -10. **Settings page** — open `http://localhost:3001/settings`: - - Team members section should list Linear team members - - Wiki generation form should be present - - Token validation UI should work +10. **Settings page** — was replaced by `/integrations` and `/onboarding` (TRI-61): + - Go to `http://localhost:3001/integrations` to manage per-project API keys + - Go to `http://localhost:3001/onboarding` for the setup wizard 11. **Routing through Caddy** — verify all these return 200: ```bash curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/ # SPA curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/chat # SPA route curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/board # SPA route -curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/settings # SPA route +curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/onboarding # SPA route +curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/integrations # SPA route curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/config.json # Config curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/health # Proxy ``` @@ -265,8 +265,9 @@ Body should include: ``` Frontend (Caddy :3001) ├── /chat → POST /chat → runtime:4111/chat (SSE stream) -├── /board → GET /api/linear/issues → runtime:4111/api/linear/issues -├── /settings → GET /api/linear/members, POST /api/wiki/generate +├── /board → GET /projects/:id/linear/issues → runtime:4111/projects/:id/linear/issues +├── /onboarding → Setup wizard for per-project integrations +├── /integrations → Per-project API key management └── /config.json → {"apiUrl": "/api"} Runtime (Mastra/Hono :4111) @@ -284,14 +285,19 @@ Runtime (Mastra/Hono :4111) │ └── GitHub: commentOnGitHubPR ├── Workflows │ └── triage-workflow (8 steps: intake→triage→dedup→ticket→notify→suspend→verify→notify-resolution) -├── API Routes (custom Hono) -│ ├── GET /api/linear/issues -│ ├── GET /api/linear/members -│ ├── POST /api/wiki/generate (stub) -│ ├── GET /api/wiki/status (stub) -│ ├── POST /api/webhooks/linear (stub) +├── API Routes (custom Hono) — all per-project since TRI-61/TRI-66 +│ ├── GET /projects/:id/linear/issues +│ ├── GET /projects/:id/linear/cycle +│ ├── GET /projects/:id/linear/members +│ ├── POST /projects/:id/linear/sync +│ ├── GET /projects/:id/linear/sync/status +│ ├── POST /projects/:id/linear/webhook/setup +│ ├── POST /projects/:id/wiki/generate +│ ├── GET /projects/:id/wiki/status +│ ├── POST /api/webhooks/linear │ └── POST /api/workflows/triage-workflow/trigger └── Storage: LibSQL (libsql:8080) +``` Langfuse (6 containers, observability) ├── langfuse-web :3000 diff --git a/docs/superpowers/specs/2026-04-08-mvp-pipeline-design.md b/docs/superpowers/specs/2026-04-08-mvp-pipeline-design.md index 385d0d6..230e55d 100644 --- a/docs/superpowers/specs/2026-04-08-mvp-pipeline-design.md +++ b/docs/superpowers/specs/2026-04-08-mvp-pipeline-design.md @@ -1,10 +1,16 @@ # MVP Pipeline Design — Triage SRE Agent -**Date:** 2026-04-08 +> **DEPRECATED — Historical reference only (2026-04-08)** +> Updated 2026-05-12 to reflect what was actually built: +> - All global endpoints (`/api/linear/*`, `/api/wiki/*`) were replaced by per-project scoped routes (`/projects/:id/linear/*`, `/projects/:id/wiki/*`) in TRI-61. +> - The `/settings` page was replaced by `/integrations` + `/onboarding` in TRI-61. +> - Webhook setup moved from global `/api/linear/webhook/setup` to per-project `/projects/:id/linear/webhook/setup` in TRI-67. +> - Board data fetching migrated from `/api/linear/issues` to `/projects/:id/linear/issues` in TRI-66. + +**Date:** 2026-04-08 **Authors:** Lalo, Koki -**Session:** planning +**Session:** planning **Status:** Approved for implementation - --- ## 1. Scope @@ -100,28 +106,27 @@ const orchestratorModel = openrouter(MODELS.orchestrator, { ## 3. First-time Onboarding -### Settings page (`/settings`) - -Fields: -- **Linear API Token** — text input, validated by calling `GET /api/linear/members` on save. Badge: "Connected" / "Invalid token". -- **GitHub Repo URL** — text input (public repos only for MVP). Button: "Import & Generate Wiki". -- **Team Members** — "Sync from Linear" button → fetches and displays list with name + email. +> ### Onboarding wizard (`/onboarding`) — reemplazó `/settings` en TRI-61 +> Campos por provider en wizard de 5 pasos: +> - **OpenRouter** — API key, test con `GET /models`. +> - **Linear** — PAT, test con `viewer { teams { nodes { id name key } } }`, picker de team → save con `meta.teamId/teamName/teamKey`. +> - **GitHub** — PAT, auto-skipped si repo no es github.com, probe con `GET /user` + `GET /repos/:owner/:repo`. +> - **Slack** — webhook URL, test con `auth.test` + `conversations.list`, picker de channel. +> - **Resend** — API key, test con `GET /domains`, input de `fromEmail`. +> Todo vive en `frontend/src/routes/onboarding.lazy.tsx` (~870 líneas). ### Wiki generation endpoint +> **Actualizado TRI-61:** `POST /projects/:id/wiki/generate` — per-project Hono route. +`POST /projects/:id/wiki/generate` — Hono route en el servidor Mastra (per-project): -`POST /api/wiki/generate` — new Hono route on the Mastra server: - -1. Accept `{ repoUrl: string }` in request body +1. Accept `{ repoUrl: string }` in request body (reads from project's `repository_url`) 2. `git clone --depth 3 /tmp/wiki-repo-` 3. Walk the cloned repo (ignore `node_modules`, `.git`, binary files) -4. For each file: call `generateWikiTool` with file content → LLM produces a structured summary (purpose, key functions, dependencies) -5. Chunk summaries → embed → store in LibSQL `wiki_chunks` table with DiskANN index -6. Update progress counter in a simple in-memory or LibSQL status row - -`GET /api/wiki/status` — returns `{ total: number, processed: number, done: boolean }`. - -Frontend polls every 2 seconds while wiki is generating; shows progress bar. +4. For each file: call `generateWikiTool` with file content → LLM produces a structured summary +5. Chunk summaries → embed → store in LibSQL `wiki_chunks` table +6. Update `projects.status` and `projects.documents_count/chunks_count` +`GET /projects/:id/wiki/status` — returns `{ total: number, processed: number, done: boolean }` (per-project). **Graphify (separate, demo only):** Run `graphify` CLI on the same cloned repo to produce `graph.html` for the interactive knowledge graph visualization in the demo video. Not part of the runtime query pipeline. --- @@ -372,39 +377,41 @@ If `verdict === 'approve'` (no issues found): **do not post** a comment. Only po --- -## 8. Frontend: Kanban Board - -### New runtime endpoint: `GET /api/linear/issues` - -Hono route in Mastra server: -- Calls `linearClient.issues({ filter: { team: { id: { eq: LINEAR_CONSTANTS.TEAM_ID } } }, first: 50 })` -- Groups by `state.name` -- Returns `{ backlog: Issue[], todo: Issue[], inProgress: Issue[], inReview: Issue[], done: Issue[] }` - -### `board.lazy.tsx` update - -- TanStack Query: `useQuery({ queryKey: ['linear-issues'], queryFn: () => apiFetch('/api/linear/issues'), refetchInterval: 30_000 })` -- Render each column with real issue cards -- Issue card: title, severity label badge, assignee initials avatar, Linear URL link - ---- - -## 9. Frontend: Settings Page - -### New runtime endpoints - -- `GET /api/linear/members` — proxies `getLinearTeamMembers` for the configured team -- `POST /api/wiki/generate` — triggers wiki generation (see §3) -- `GET /api/wiki/status` — returns generation progress - -### `settings.lazy.tsx` update - -Three sections: -1. **Integrations** — Linear token input + validation badge, GitHub repo URL input + "Generate Wiki" button -2. **Wiki** — progress bar (polling `/api/wiki/status`), "last generated" timestamp, file count -3. **Team Members** — "Sync from Linear" button + member list (name, email, displayName) - ---- +> ## 8. Frontend: Kanban Board +> **Actualizado TRI-66:** Todos los endpoints ahora son per-project. + +> ### Endpoint per-project: `GET /projects/:id/linear/issues` +> Hono route en Mastra server (per-project): +> - Resuelve API key + teamId desde `project_integrations` (no más `LINEAR_CONSTANTS.TEAM_ID` global) +> - Llama `linearClient.issues({ filter: { team: { id: { eq: teamId } } }, first: 50 })` +> - Groups by `state.name` +> - Returns grouped issues + +> ### `board.lazy.tsx` +> - TanStack Query: `useQuery({ queryKey: ['linear-issues', projectId], queryFn: () => apiFetch('/projects/\${projectId}/linear/issues') })` +> - Sync: `POST /projects/\${projectId}/linear/sync` +> - Cycle: `GET /projects/\${projectId}/linear/cycle` +> - Render each column with real issue cards +> - Issue card: title, severity label badge, assignee initials avatar, Linear URL link + +> --- +> +> ## 9. Frontend: Integrations Page +> **Actualizado TRI-61:** `/settings` fue reemplazado por `/integrations` + `/onboarding`. + +> ### Endpoints per-project disponibles +> - `GET /projects/:id/linear/members` — proxies `getLinearTeamMembers` para el team del proyecto +> - `POST /projects/:id/wiki/generate` — triggers wiki generation +> - `GET /projects/:id/wiki/status` — returns generation progress +> - `POST /projects/:id/linear/webhook/setup` — registers per-project Linear webhook (TRI-67) +> - `POST /projects/:id/linear/sync` — manual sync (TRI-66) +> - `GET /projects/:id/linear/sync/status` — sync progress (TRI-66) + +> ### `integrations.lazy.tsx` +> - Cards por provider: OpenRouter, Linear, GitHub, Slack, Resend +> - Cada card: test → save → reconfigure/delete +> - `WizardCta` redirige a `/onboarding` si faltan integraciones requeridas +> - `onboarding.lazy.tsx`: wizard de 5 pasos con auto-skip de GitHub para repos no-github ## 10. Multiple Issues (MVP vs Stretch) @@ -463,8 +470,8 @@ Note: Minimax via OpenRouter uses `OPENROUTER_API_KEY` — no separate key neede | `commentOnGitHubPRTool` | `runtime/src/mastra/tools/github.ts` | TODO | | Update orchestrator (model + tools) | `runtime/src/mastra/agents/orchestrator.ts` | TODO | | Wire all 8 workflow steps | `runtime/src/mastra/workflows/triage-workflow.ts` | TODO | -| Hono routes: /api/linear/issues, /api/linear/members, /api/wiki/generate, /api/wiki/status | `runtime/src/mastra/index.ts` | TODO | -| Workflow trigger endpoint | Mastra v1.24 exposes `POST /api/workflows/:id/trigger` by default — verify at runtime startup; if not present, add custom Hono route that calls `mastra.getWorkflow('triage-workflow').createRun().start(input)` | TODO | -| `onCreateTicket` in chat.tsx | `frontend/src/routes/chat.tsx` | TODO | -| Kanban data in board.lazy.tsx | `frontend/src/routes/board.lazy.tsx` | TODO | -| Settings page | `frontend/src/routes/settings.lazy.tsx` | TODO | +|| Hono routes per-project: `/projects/:id/linear/{issues,cycle,members,sync,sync/status,webhook/setup}`, `/projects/:id/wiki/{generate,status}` | `runtime/src/lib/scoped-routes.ts` | ✅ DONE (TRI-61/TRI-66) | +|| Workflow trigger endpoint | `POST /api/workflows/triage-workflow/trigger` expuesto por Mastra | ✅ DONE | +|| `onCreateTicket` in chat.tsx | `frontend/src/routes/chat.tsx` | ✅ DONE | +|| Kanban data in board.lazy.tsx — per-project endpoints | `frontend/src/routes/board.lazy.tsx` | ✅ DONE (TRI-66) | +|| Integrations page + onboarding wizard | `frontend/src/routes/integrations.lazy.tsx` + `onboarding.lazy.tsx` | ✅ DONE (TRI-61) | diff --git a/frontend/src/routes/board.lazy.tsx b/frontend/src/routes/board.lazy.tsx index 1004a49..5535451 100644 --- a/frontend/src/routes/board.lazy.tsx +++ b/frontend/src/routes/board.lazy.tsx @@ -530,33 +530,33 @@ function BoardPage() { const queryClient = useQueryClient(); const { data, isLoading, error } = useQuery({ - queryKey: ['linear-issues'], - queryFn: () => apiFetch('/linear/issues'), + queryKey: ['linear-issues', currentProjectId], + queryFn: () => apiFetch(`/projects/${currentProjectId}/linear/issues`), staleTime: 300_000, // 5 minutes — data is cached on the server enabled: !!currentProjectId, }); const { data: cycleData } = useQuery({ - queryKey: ['linear-cycle-active'], - queryFn: () => apiFetch('/linear/cycle/active'), + queryKey: ['linear-cycle-active', currentProjectId], + queryFn: () => apiFetch(`/projects/${currentProjectId}/linear/cycle`), staleTime: 300_000, enabled: !!currentProjectId, }); // Sync status query const { data: syncStatus } = useQuery<{ lastSyncedAt: string | null; syncInProgress: boolean }>({ - queryKey: ['linear-sync-status'], - queryFn: () => apiFetch('/linear/sync/status'), + queryKey: ['linear-sync-status', currentProjectId], + queryFn: () => apiFetch(`/projects/${currentProjectId}/linear/sync/status`), refetchInterval: 30_000, enabled: !!currentProjectId, }); // Manual sync mutation const syncMutation = useMutation({ - mutationFn: () => apiFetch('/linear/sync', { method: 'POST' }), + mutationFn: () => apiFetch(`/projects/${currentProjectId}/linear/sync`, { method: 'POST' }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['linear-issues'] }); - queryClient.invalidateQueries({ queryKey: ['linear-sync-status'] }); + queryClient.invalidateQueries({ queryKey: ['linear-issues', currentProjectId] }); + queryClient.invalidateQueries({ queryKey: ['linear-sync-status', currentProjectId] }); }, }); @@ -569,10 +569,10 @@ function BoardPage() { // Trigger a sync on mount (session start) useEffect(() => { if (currentProjectId) { - apiFetch('/linear/sync', { method: 'POST' }) + apiFetch(`/projects/${currentProjectId}/linear/sync`, { method: 'POST' }) .then(() => { - queryClient.invalidateQueries({ queryKey: ['linear-issues'] }); - queryClient.invalidateQueries({ queryKey: ['linear-sync-status'] }); + queryClient.invalidateQueries({ queryKey: ['linear-issues', currentProjectId] }); + queryClient.invalidateQueries({ queryKey: ['linear-sync-status', currentProjectId] }); }) .catch(() => { /* sync failure is non-fatal */ }); } diff --git a/frontend/src/routes/projects.lazy.tsx b/frontend/src/routes/projects.lazy.tsx index a179db6..ccedb14 100644 --- a/frontend/src/routes/projects.lazy.tsx +++ b/frontend/src/routes/projects.lazy.tsx @@ -1,4 +1,4 @@ -import { createLazyFileRoute, Link } from "@tanstack/react-router" +import { createLazyFileRoute, Link, useNavigate } from "@tanstack/react-router" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { useState } from "react" import { @@ -35,6 +35,7 @@ interface Project { function ProjectsPage() { const queryClient = useQueryClient() + const navigate = useNavigate() const [showForm, setShowForm] = useState(false) const [editingId, setEditingId] = useState(null) const [name, setName] = useState("") @@ -84,7 +85,7 @@ function ProjectsPage() { }) return (await res.json()) as { success: boolean; data?: Project } }, - onSuccess: (response) => { + onSuccess: async (response) => { // Inject the server-returned project into the cache so it shows up // immediately — otherwise the UI waits on the invalidation refetch // and the list looks empty for a beat after create. @@ -95,6 +96,35 @@ function ProjectsPage() { if (editingId) return old.map((p) => (p.id === editingId ? incoming : p)) return [incoming, ...old] }) + // If this is a brand-new project (not an edit) and it has no active + // integrations, redirect to the onboarding wizard so the user isn't + // left on an empty dashboard. + if (!editingId) { + try { + const intRes = await fetch(`/projects/${incoming.id}/integrations`, { + headers: { Accept: "application/json" }, + }) + const intJson = (await intRes.json()) as { + success: boolean + data?: Array<{ provider: string; status: string }> + } + const integrations = intJson.success ? intJson.data ?? [] : [] + const activeProviders = new Set( + integrations.filter((i) => i.status === "active").map((i) => i.provider), + ) + const needsGithub = + incoming.repositoryUrl && + /github\.com/i.test(incoming.repositoryUrl) + const required: string[] = ["openrouter", "linear", ...(needsGithub ? ["github"] : [])] + const missing = required.filter((p) => !activeProviders.has(p)) + if (missing.length > 0) { + navigate({ to: "/onboarding" }) + return + } + } catch { + // Onboarding check is best-effort; don't block project creation. + } + } } queryClient.invalidateQueries({ queryKey: ["projects"] }) resetForm() diff --git a/runtime/src/lib/scoped-routes.ts b/runtime/src/lib/scoped-routes.ts index 1cf0af8..c32a406 100644 --- a/runtime/src/lib/scoped-routes.ts +++ b/runtime/src/lib/scoped-routes.ts @@ -20,6 +20,7 @@ import type { Context } from 'hono'; import { assertProjectOwnership, authErrorResponse } from './auth-helpers'; import { getIntegrationKey } from './integration-keys'; import { generateWiki } from './wiki-rag'; +import { setWebhookSecret, LINEAR_PROVIDER } from './webhook-secrets'; let cachedClient: Client | null = null; function getDb(): Client { @@ -212,6 +213,230 @@ export const listProjectMembersRoute = registerApiRoute('/projects/:projectId/li }, }); +// Per-project sync tracking (mirrors linear-sync.ts globals but keyed by project) +const projectSyncMeta = new Map< + string, + { lastSyncedAt: Date | null; syncInProgress: boolean } +>() + +function getProjectSyncMeta(projectId: string) { + if (!projectSyncMeta.has(projectId)) { + projectSyncMeta.set(projectId, { lastSyncedAt: null, syncInProgress: false }) + } + return projectSyncMeta.get(projectId)! +} + +async function syncProjectLinearIssues( + projectId: string, + apiKey: string, + teamId: string, +): Promise>>> { + const meta = getProjectSyncMeta(projectId) + if (meta.syncInProgress) { + console.log(`[linear-sync] Sync already in progress for ${projectId}, skipping`) + const cached = await getCachedProjectIssues(projectId) + if (cached) return cached + throw new Error('Sync in progress and no cached data available') + } + + meta.syncInProgress = true + console.log(`[linear-sync] Starting sync for project ${projectId}...`) + const startTime = Date.now() + + try { + const linearClient = new LinearClient({ apiKey }) + const issues = await linearClient.issues({ + filter: { team: { id: { eq: teamId } } }, + first: 50, + }) + + const grouped: Record>> = {} + for (const issue of issues.nodes) { + const state = await issue.state + const stateName = state?.name ?? 'Unknown' + if (!grouped[stateName]) grouped[stateName] = [] + + const assigneeNode = await issue.assignee + const labelsConnection = await issue.labels() + let projectName: string | null = null + try { + const proj = await issue.project + if (proj) projectName = proj.name + } catch { /* project may not exist */ } + + grouped[stateName].push({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + priority: issue.priority, + estimate: issue.estimate ?? null, + project: projectName, + url: issue.url, + createdAt: issue.createdAt?.toISOString?.() ?? String(issue.createdAt), + updatedAt: issue.updatedAt?.toISOString?.() ?? String(issue.updatedAt), + assignee: assigneeNode ? { id: assigneeNode.id, name: assigneeNode.name } : null, + labels: labelsConnection.nodes.map((l: { id: string; name: string; color: string }) => ({ + id: l.id, + name: l.name, + color: l.color, + })), + }) + } + + const db = getDb() + const now = Date.now() + await db.execute({ + sql: `INSERT OR REPLACE INTO linear_sync_cache (id, team_id, data, synced_at) + VALUES (?, ?, ?, ?)`, + args: [projectId, teamId, JSON.stringify(grouped), now], + }) + + meta.lastSyncedAt = new Date(now) + const elapsed = Date.now() - startTime + const totalIssues = Object.values(grouped).flat().length + console.log(`[linear-sync] Sync complete for ${projectId}: ${totalIssues} issues in ${elapsed}ms`) + return grouped + } finally { + meta.syncInProgress = false + } +} + +async function getCachedProjectIssues( + projectId: string, +): Promise>> | null> { + try { + const db = getDb() + const result = await db.execute({ + sql: 'SELECT data, synced_at FROM linear_sync_cache WHERE id = ?', + args: [projectId], + }) + const row = result.rows[0] + if (!row) return null + const meta = getProjectSyncMeta(projectId) + if (!meta.lastSyncedAt && row.synced_at) { + meta.lastSyncedAt = new Date(Number(row.synced_at)) + } + return JSON.parse(row.data as string) as Record>> + } catch (error) { + console.error( + `[linear-sync] Failed to read cache for ${projectId}:`, + error instanceof Error ? error.message : error, + ) + return null + } +} + +function getProjectLastSyncedAt(projectId: string): Date | null { + return getProjectSyncMeta(projectId).lastSyncedAt +} + +function isProjectSyncInProgress(projectId: string): boolean { + return getProjectSyncMeta(projectId).syncInProgress +} + +// ---------- POST /api/projects/:projectId/linear/sync ---------- +export const syncProjectIssuesRoute = registerApiRoute('/projects/:projectId/linear/sync', { + method: 'POST' as const, + handler: async (c: Context) => { + try { + const projectId = c.req.param('projectId') + if (!projectId) return authErrorResponse(c, 404) + const auth = await assertProjectOwnership(c, projectId) + if (!auth.ok) return authErrorResponse(c, auth.status) + + const lc = await resolveLinearContext(projectId) + if (!lc.ok) return linearConfigError(c, lc.reason) + + const grouped = await syncProjectLinearIssues(projectId, lc.apiKey, lc.teamId) + const totalIssues = Object.values(grouped).flat().length + return c.json({ + success: true, + data: { + issueCount: totalIssues, + syncedAt: getProjectLastSyncedAt(projectId)?.toISOString() ?? null, + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return c.json({ success: false, error: { code: 'LINEAR_ERROR', message } }, 500) + } + }, +}) + +// ---------- GET /api/projects/:projectId/linear/sync/status ---------- +export const getProjectSyncStatusRoute = registerApiRoute('/projects/:projectId/linear/sync/status', { + method: 'GET' as const, + handler: async (c: Context) => { + try { + const projectId = c.req.param('projectId') + if (!projectId) return authErrorResponse(c, 404) + const auth = await assertProjectOwnership(c, projectId) + if (!auth.ok) return authErrorResponse(c, auth.status) + + return c.json({ + success: true, + data: { + lastSyncedAt: getProjectLastSyncedAt(projectId)?.toISOString() ?? null, + syncInProgress: isProjectSyncInProgress(projectId), + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return c.json({ success: false, error: { code: 'LINEAR_ERROR', message } }, 500) + } + }, +}) + +// ---------- POST /api/projects/:projectId/linear/webhook/setup ---------- +export const setupProjectWebhookRoute = registerApiRoute('/projects/:projectId/linear/webhook/setup', { + method: 'POST' as const, + handler: async (c: Context) => { + try { + const projectId = c.req.param('projectId') + if (!projectId) return authErrorResponse(c, 404) + const auth = await assertProjectOwnership(c, projectId) + if (!auth.ok) return authErrorResponse(c, auth.status) + + const lc = await resolveLinearContext(projectId) + if (!lc.ok) return linearConfigError(c, lc.reason) + + const body = (await c.req.json()) as { url?: string } + if (!body.url) { + return c.json({ success: false, error: { code: 'MISSING_URL', message: 'url is required' } }, 400) + } + + const linearClient = new LinearClient({ apiKey: lc.apiKey }) + const result = await linearClient.createWebhook({ + url: body.url, + teamId: lc.teamId, + resourceTypes: ['Issue'], + enabled: true, + }) + const webhook = await result.webhook + + if (webhook?.secret) { + await setWebhookSecret(LINEAR_PROVIDER, webhook.secret, webhook.id ?? null, projectId) + } else { + console.warn(`[webhook/setup] Linear response missing secret for project ${projectId}`) + } + + return c.json({ + success: true, + data: { + id: webhook?.id, + url: webhook?.url, + enabled: webhook?.enabled, + secretStored: Boolean(webhook?.secret), + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return c.json({ success: false, error: { code: 'LINEAR_ERROR', message } }, 500) + } + }, +}) + + // ---------- POST /api/projects/:projectId/wiki/generate ---------- export const generateProjectWikiRoute = registerApiRoute('/projects/:projectId/wiki/generate', { method: 'POST' as const, @@ -322,6 +547,9 @@ export const scopedRoutes = [ listProjectIssuesRoute, getProjectCycleRoute, listProjectMembersRoute, + syncProjectIssuesRoute, + getProjectSyncStatusRoute, + setupProjectWebhookRoute, generateProjectWikiRoute, getProjectWikiStatusRoute, -]; +] diff --git a/runtime/src/mastra/index.ts b/runtime/src/mastra/index.ts index 6166bf1..6d3061c 100644 --- a/runtime/src/mastra/index.ts +++ b/runtime/src/mastra/index.ts @@ -17,7 +17,7 @@ import { scopedRoutes } from '../lib/scoped-routes'; import { observabilityRoutes } from '../lib/observability-routes'; import { config, LINEAR_CONSTANTS, LINEAR_BASE_URL } from '../lib/config'; import { getMemoryInitializationContext } from '../lib/memory-context-init'; -import { syncLinearIssues, getCachedIssues, getLastSyncedAt, isSyncInProgress, initLinearSync } from '../lib/linear-sync'; +import { initLinearSync } from '../lib/linear-sync'; import { createClient } from '@libsql/client'; import { getLinearIssueComments, updateLinearIssue } from './tools/linear'; import { findGitHubEvidenceForIssueTool } from './tools/github'; @@ -122,114 +122,6 @@ export const mastra = new Mastra({ }, }), - // GET /api/linear/issues — serve from local cache (synced from Linear API) - // Falls back to a live sync if cache is empty - { - path: '/api/linear/issues', - method: 'GET' as const, - handler: async (c: Context) => { - try { - if (!linearClient) { - return c.json({ success: false, error: { code: 'NO_LINEAR_KEY', message: 'LINEAR_API_KEY not configured' } }, 500); - } - - // Try reading from cache first - let grouped = await getCachedIssues(); - - // If cache is empty, trigger a sync and wait for it - if (!grouped) { - console.log('[api/linear/issues] Cache empty, triggering sync...'); - grouped = await syncLinearIssues(); - } - - return c.json({ success: true, data: grouped }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return c.json({ success: false, error: { code: 'LINEAR_ERROR', message } }, 500); - } - }, - }, - - // POST /api/linear/sync — trigger a manual sync from Linear API - { - path: '/api/linear/sync', - method: 'POST' as const, - handler: async (c: Context) => { - try { - if (!linearClient) { - return c.json({ success: false, error: { code: 'NO_LINEAR_KEY', message: 'LINEAR_API_KEY not configured' } }, 500); - } - - const grouped = await syncLinearIssues(); - const totalIssues = Object.values(grouped).flat().length; - - return c.json({ - success: true, - data: { - issueCount: totalIssues, - syncedAt: getLastSyncedAt()?.toISOString() ?? null, - }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return c.json({ success: false, error: { code: 'LINEAR_ERROR', message } }, 500); - } - }, - }, - - // GET /api/linear/sync/status — check when data was last synced - { - path: '/api/linear/sync/status', - method: 'GET' as const, - handler: async (c: Context) => { - return c.json({ - success: true, - data: { - lastSyncedAt: getLastSyncedAt()?.toISOString() ?? null, - syncInProgress: isSyncInProgress(), - }, - }); - }, - }, - - // GET /api/linear/cycle/active — current active cycle with progress - { - path: '/api/linear/cycle/active', - method: 'GET' as const, - handler: async (c: Context) => { - try { - if (!linearClient) { - return c.json({ success: false, error: { code: 'NO_LINEAR_KEY', message: 'LINEAR_API_KEY not configured' } }, 500); - } - - const team = await linearClient.team(LINEAR_CONSTANTS.TEAM_ID); - const cyclesConnection = await team.cycles({ filter: { isActive: { eq: true } }, first: 1 }); - const activeCycle = cyclesConnection.nodes[0]; - - if (!activeCycle) { - return c.json({ success: true, data: null }); - } - - return c.json({ - success: true, - data: { - id: activeCycle.id, - name: activeCycle.name ?? `Cycle ${activeCycle.number}`, - number: activeCycle.number, - startsAt: activeCycle.startsAt?.toISOString?.() ?? String(activeCycle.startsAt ?? ''), - endsAt: activeCycle.endsAt?.toISOString?.() ?? String(activeCycle.endsAt ?? ''), - progress: activeCycle.progress ?? 0, - scopeCount: (activeCycle as unknown as Record).scopeCount ?? 0, - completedScopeCount: (activeCycle as unknown as Record).completedScopeCount ?? 0, - startedScopeCount: (activeCycle as unknown as Record).startedScopeCount ?? 0, - }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return c.json({ success: false, error: { code: 'LINEAR_ERROR', message } }, 500); - } - }, - }, // POST /api/linear/webhook/setup — register the Linear webhook for this deployment { @@ -278,9 +170,10 @@ export const mastra = new Mastra({ // ── Signature verification ────────────────────────────────── // Fail closed: reject unsigned or unverifiable webhooks before // parsing JSON, so a forged POST can't trigger workflow resume. - const secret = await getWebhookSecret(LINEAR_PROVIDER); + const projectId = c.req.query('projectId') ?? null; + const secret = await getWebhookSecret(LINEAR_PROVIDER, projectId ?? undefined); if (!secret) { - console.warn('[webhook/linear] No secret configured — rejecting. Call POST /api/linear/webhook/setup first.'); + console.warn(`[webhook/linear] No secret configured${projectId ? ` for project ${projectId}` : ''} — rejecting. Call POST /projects/:id/linear/webhook/setup first.`); return c.json( { success: false, error: { code: 'SECRET_NOT_CONFIGURED', message: 'Webhook signing secret not registered' } }, 503, diff --git a/scripts/setup-webhook-ngrok.sh b/scripts/setup-webhook-ngrok.sh index e4421cd..97c517d 100755 --- a/scripts/setup-webhook-ngrok.sh +++ b/scripts/setup-webhook-ngrok.sh @@ -1,26 +1,26 @@ #!/usr/bin/env bash # setup-webhook-ngrok.sh — expose the local runtime via ngrok and register -# the public URL as a Linear webhook for the configured team. +# a per-project Linear webhook for this deployment. # # Prerequisites: # 1. ngrok installed: https://ngrok.com/download (brew install ngrok) # 2. ngrok authenticated: ngrok config add-authtoken # 3. Runtime running: docker compose up -d -# 4. LINEAR_API_KEY set in .env +# 4. Project has Linear integration configured in /integrations # # Usage: -# ./scripts/setup-webhook-ngrok.sh +# ./scripts/setup-webhook-ngrok.sh # # What it does: # 1. Starts ngrok tunnel on port 3000 # 2. Waits for the public URL -# 3. Calls POST /api/linear/webhook/setup with the public URL +# 3. Calls POST /projects/:projectId/linear/webhook/setup with the public URL # 4. Prints the webhook ID — save this, Linear doesn't let you list webhooks # easily so you'll need it to delete/update later. # # To stop: # Ctrl+C (kills ngrok tunnel; the Linear webhook remains registered) -# To delete the webhook, go to Linear → Settings → API → Webhooks +# To delete it: Linear → Settings → API → Webhooks set -euo pipefail @@ -43,6 +43,15 @@ if ! curl -sf "$RUNTIME_URL/api/health" >/dev/null 2>&1; then exit 1 fi +# Resolve project id +PROJECT_ID="${1:-${PROJECT_ID:-}}" +if [ -z "$PROJECT_ID" ]; then + echo "ERROR: PROJECT_ID is required." + echo "Usage: ./scripts/setup-webhook-ngrok.sh " + echo " or: PROJECT_ID= ./scripts/setup-webhook-ngrok.sh" + exit 1 +fi + echo "Starting ngrok tunnel on port $NGROK_PORT..." ngrok http "$NGROK_PORT" --log=stdout > /tmp/ngrok.log 2>&1 & NGROK_PID=$! @@ -64,11 +73,11 @@ if [ -z "$PUBLIC_URL" ]; then exit 1 fi -WEBHOOK_URL="$PUBLIC_URL/api/webhooks/linear" +WEBHOOK_URL="$PUBLIC_URL/api/webhooks/linear?projectId=$PROJECT_ID" echo "Tunnel ready: $PUBLIC_URL" -echo "Registering webhook: $WEBHOOK_URL" +echo "Registering webhook for project $PROJECT_ID: $WEBHOOK_URL" -RESPONSE=$(curl -s -X POST "$RUNTIME_URL/api/linear/webhook/setup" \ +RESPONSE=$(curl -s -X POST "$RUNTIME_URL/projects/$PROJECT_ID/linear/webhook/setup" \ -H "Content-Type: application/json" \ -d "{\"url\": \"$WEBHOOK_URL\"}")