diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index ddf98cbda90..2b2dabb894e 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -138,6 +138,12 @@ jobs: POSTHOG_PROJECT_ID: ${{ secrets.POSTHOG_PROJECT_ID }} KV_REST_API_URL: ${{ secrets.KV_REST_API_URL }} KV_REST_API_TOKEN: ${{ secrets.KV_REST_API_TOKEN }} + LINEAR_CLIENT_ID: ${{ secrets.LINEAR_CLIENT_ID }} + LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }} + LINEAR_WEBHOOK_SECRET: ${{ secrets.LINEAR_WEBHOOK_SECRET }} + QSTASH_TOKEN: ${{ secrets.QSTASH_TOKEN }} + QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }} + QSTASH_NEXT_SIGNING_KEY: ${{ secrets.QSTASH_NEXT_SIGNING_KEY }} run: | vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN @@ -160,7 +166,13 @@ jobs: --env POSTHOG_API_KEY=$POSTHOG_API_KEY \ --env POSTHOG_PROJECT_ID=$POSTHOG_PROJECT_ID \ --env KV_REST_API_URL=$KV_REST_API_URL \ - --env KV_REST_API_TOKEN=$KV_REST_API_TOKEN) + --env KV_REST_API_TOKEN=$KV_REST_API_TOKEN \ + --env LINEAR_CLIENT_ID=$LINEAR_CLIENT_ID \ + --env LINEAR_CLIENT_SECRET=$LINEAR_CLIENT_SECRET \ + --env LINEAR_WEBHOOK_SECRET=$LINEAR_WEBHOOK_SECRET \ + --env QSTASH_TOKEN=$QSTASH_TOKEN \ + --env QSTASH_CURRENT_SIGNING_KEY=$QSTASH_CURRENT_SIGNING_KEY \ + --env QSTASH_NEXT_SIGNING_KEY=$QSTASH_NEXT_SIGNING_KEY) vercel alias $VERCEL_URL ${{ env.API_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 335574137e1..44c807adf01 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -88,6 +88,12 @@ jobs: POSTHOG_PROJECT_ID: ${{ secrets.POSTHOG_PROJECT_ID }} KV_REST_API_URL: ${{ secrets.KV_REST_API_URL }} KV_REST_API_TOKEN: ${{ secrets.KV_REST_API_TOKEN }} + LINEAR_CLIENT_ID: ${{ secrets.LINEAR_CLIENT_ID }} + LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }} + LINEAR_WEBHOOK_SECRET: ${{ secrets.LINEAR_WEBHOOK_SECRET }} + QSTASH_TOKEN: ${{ secrets.QSTASH_TOKEN }} + QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }} + QSTASH_NEXT_SIGNING_KEY: ${{ secrets.QSTASH_NEXT_SIGNING_KEY }} run: | vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN @@ -110,7 +116,13 @@ jobs: --env POSTHOG_API_KEY=$POSTHOG_API_KEY \ --env POSTHOG_PROJECT_ID=$POSTHOG_PROJECT_ID \ --env KV_REST_API_URL=$KV_REST_API_URL \ - --env KV_REST_API_TOKEN=$KV_REST_API_TOKEN + --env KV_REST_API_TOKEN=$KV_REST_API_TOKEN \ + --env LINEAR_CLIENT_ID=$LINEAR_CLIENT_ID \ + --env LINEAR_CLIENT_SECRET=$LINEAR_CLIENT_SECRET \ + --env LINEAR_WEBHOOK_SECRET=$LINEAR_WEBHOOK_SECRET \ + --env QSTASH_TOKEN=$QSTASH_TOKEN \ + --env QSTASH_CURRENT_SIGNING_KEY=$QSTASH_CURRENT_SIGNING_KEY \ + --env QSTASH_NEXT_SIGNING_KEY=$QSTASH_NEXT_SIGNING_KEY deploy-web: name: Deploy Web to Vercel diff --git a/apps/api/package.json b/apps/api/package.json index 678ceae37a4..15f95eb72f5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,16 +13,19 @@ "dependencies": { "@clerk/backend": "^2.27.0", "@clerk/nextjs": "^6.36.2", + "@linear/sdk": "^68.1.0", "@sentry/nextjs": "^10.32.1", "@superset/db": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", "@trpc/server": "^11.7.1", + "@upstash/qstash": "^2.8.4", "@vercel/blob": "^2.0.0", "drizzle-orm": "0.45.1", "import-in-the-middle": "2.0.1", "jose": "^6.1.3", + "lodash.chunk": "^4.2.0", "next": "^16.0.10", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -31,6 +34,7 @@ }, "devDependencies": { "@superset/typescript": "workspace:*", + "@types/lodash.chunk": "^4.2.9", "@types/node": "^24.9.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/apps/api/src/app/api/integrations/linear/callback/route.ts b/apps/api/src/app/api/integrations/linear/callback/route.ts new file mode 100644 index 00000000000..d9304faf7d8 --- /dev/null +++ b/apps/api/src/app/api/integrations/linear/callback/route.ts @@ -0,0 +1,109 @@ +import { LinearClient } from "@linear/sdk"; +import { db } from "@superset/db/client"; +import { integrationConnections } from "@superset/db/schema"; +import { Client } from "@upstash/qstash"; +import { z } from "zod"; +import { env } from "@/env"; + +const qstash = new Client({ token: env.QSTASH_TOKEN }); + +const stateSchema = z.object({ + organizationId: z.string().min(1), + userId: z.string().min(1), +}); + +export async function GET(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + + if (error) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=oauth_denied`, + ); + } + + if (!code || !state) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=missing_params`, + ); + } + + const parsed = stateSchema.safeParse( + JSON.parse(Buffer.from(state, "base64url").toString("utf-8")), + ); + + if (!parsed.success) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=invalid_state`, + ); + } + + const { organizationId, userId } = parsed.data; + + const tokenResponse = await fetch("https://api.linear.app/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: env.LINEAR_CLIENT_ID, + client_secret: env.LINEAR_CLIENT_SECRET, + redirect_uri: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/callback`, + code, + }), + }); + + if (!tokenResponse.ok) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=token_exchange_failed`, + ); + } + + const tokenData: { access_token: string; expires_in?: number } = + await tokenResponse.json(); + + const linearClient = new LinearClient({ + accessToken: tokenData.access_token, + }); + const viewer = await linearClient.viewer; + const linearOrg = await viewer.organization; + + const tokenExpiresAt = tokenData.expires_in + ? new Date(Date.now() + tokenData.expires_in * 1000) + : null; + + await db + .insert(integrationConnections) + .values({ + organizationId, + connectedByUserId: userId, + provider: "linear", + accessToken: tokenData.access_token, + tokenExpiresAt, + externalOrgId: linearOrg.id, + externalOrgName: linearOrg.name, + }) + .onConflictDoUpdate({ + target: [ + integrationConnections.organizationId, + integrationConnections.provider, + ], + set: { + accessToken: tokenData.access_token, + tokenExpiresAt, + externalOrgId: linearOrg.id, + externalOrgName: linearOrg.name, + connectedByUserId: userId, + updatedAt: new Date(), + }, + }); + + await qstash.publishJSON({ + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/initial-sync`, + body: { organizationId, creatorUserId: userId }, + retries: 3, + }); + + return Response.redirect(`${env.NEXT_PUBLIC_WEB_URL}/integrations/linear`); +} diff --git a/apps/api/src/app/api/integrations/linear/connect/route.ts b/apps/api/src/app/api/integrations/linear/connect/route.ts new file mode 100644 index 00000000000..7a76e51a01e --- /dev/null +++ b/apps/api/src/app/api/integrations/linear/connect/route.ts @@ -0,0 +1,61 @@ +import { auth } from "@clerk/nextjs/server"; +import { db } from "@superset/db/client"; +import { organizationMembers, users } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; +import { env } from "@/env"; + +export async function GET(request: Request) { + const { userId: clerkUserId } = await auth(); + + if (!clerkUserId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(request.url); + const organizationId = url.searchParams.get("organizationId"); + + if (!organizationId) { + return Response.json( + { error: "Missing organizationId parameter" }, + { status: 400 }, + ); + } + + const user = await db.query.users.findFirst({ + where: eq(users.clerkId, clerkUserId), + }); + + if (!user) { + return Response.json({ error: "User not found" }, { status: 404 }); + } + + const membership = await db.query.organizationMembers.findFirst({ + where: and( + eq(organizationMembers.organizationId, organizationId), + eq(organizationMembers.userId, user.id), + ), + }); + + if (!membership) { + return Response.json( + { error: "User is not a member of this organization" }, + { status: 403 }, + ); + } + + const state = Buffer.from( + JSON.stringify({ organizationId, userId: user.id }), + ).toString("base64url"); + + const linearAuthUrl = new URL("https://linear.app/oauth/authorize"); + linearAuthUrl.searchParams.set("client_id", env.LINEAR_CLIENT_ID); + linearAuthUrl.searchParams.set( + "redirect_uri", + `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/callback`, + ); + linearAuthUrl.searchParams.set("response_type", "code"); + linearAuthUrl.searchParams.set("scope", "read,write,issues:create"); + linearAuthUrl.searchParams.set("state", state); + + return Response.redirect(linearAuthUrl.toString()); +} diff --git a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts new file mode 100644 index 00000000000..3642b4d3fc9 --- /dev/null +++ b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts @@ -0,0 +1,126 @@ +import { LinearClient } from "@linear/sdk"; +import { buildConflictUpdateColumns, db } from "@superset/db"; +import { integrationConnections, tasks, users } from "@superset/db/schema"; +import { Receiver } from "@upstash/qstash"; +import { and, eq, inArray } from "drizzle-orm"; +import chunk from "lodash.chunk"; +import { z } from "zod"; +import { env } from "@/env"; +import { fetchAllIssues, mapIssueToTask } from "./utils"; + +const BATCH_SIZE = 100; + +const receiver = new Receiver({ + currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, + nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, +}); + +const payloadSchema = z.object({ + organizationId: z.string().min(1), + creatorUserId: z.string().min(1), +}); + +export async function POST(request: Request) { + const body = await request.text(); + const signature = request.headers.get("upstash-signature"); + + if (!signature) { + return Response.json({ error: "Missing signature" }, { status: 401 }); + } + + const isValid = await receiver.verify({ + body, + signature, + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/initial-sync`, + }); + + if (!isValid) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } + + const parsed = payloadSchema.safeParse(JSON.parse(body)); + if (!parsed.success) { + return Response.json({ error: "Invalid payload" }, { status: 400 }); + } + + const { organizationId, creatorUserId } = parsed.data; + + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.organizationId, organizationId), + eq(integrationConnections.provider, "linear"), + ), + }); + + if (!connection) { + return Response.json({ error: "No connection found", skipped: true }); + } + + const client = new LinearClient({ accessToken: connection.accessToken }); + await performInitialSync(client, organizationId, creatorUserId); + + return Response.json({ success: true }); +} + +async function performInitialSync( + client: LinearClient, + organizationId: string, + creatorUserId: string, +) { + const issues = await fetchAllIssues(client); + + if (issues.length === 0) { + return; + } + + const assigneeEmails = [ + ...new Set( + issues.map((i) => i.assignee?.email).filter((e): e is string => !!e), + ), + ]; + + const matchedUsers = + assigneeEmails.length > 0 + ? await db.query.users.findMany({ + where: inArray(users.email, assigneeEmails), + }) + : []; + + const userByEmail = new Map(matchedUsers.map((u) => [u.email, u.id])); + + const taskValues = issues.map((issue) => + mapIssueToTask(issue, organizationId, creatorUserId, userByEmail), + ); + + const batches = chunk(taskValues, BATCH_SIZE); + + for (const batch of batches) { + await db + .insert(tasks) + .values(batch) + .onConflictDoUpdate({ + target: [tasks.externalProvider, tasks.externalId], + set: { + ...buildConflictUpdateColumns(tasks, [ + "slug", + "title", + "description", + "status", + "statusColor", + "statusType", + "priority", + "assigneeId", + "estimate", + "dueDate", + "labels", + "startedAt", + "completedAt", + "externalKey", + "externalUrl", + "lastSyncedAt", + ]), + syncError: null, + }, + }); + } +} diff --git a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts new file mode 100644 index 00000000000..e86c77c1093 --- /dev/null +++ b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts @@ -0,0 +1,123 @@ +import type { LinearClient } from "@linear/sdk"; +import { mapPriorityFromLinear } from "@superset/trpc/integrations/linear"; + +export interface LinearIssue { + id: string; + identifier: string; + title: string; + description: string | null; + priority: number; + estimate: number | null; + dueDate: string | null; + url: string; + startedAt: string | null; + completedAt: string | null; + assignee: { id: string; email: string } | null; + state: { id: string; name: string; color: string; type: string }; + labels: { nodes: Array<{ id: string; name: string }> }; +} + +interface IssuesQueryResponse { + issues: { + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: LinearIssue[]; + }; +} + +const ISSUES_QUERY = ` + query Issues($first: Int!, $after: String, $filter: IssueFilter) { + issues(first: $first, after: $after, filter: $filter) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + identifier + title + description + priority + estimate + dueDate + url + startedAt + completedAt + assignee { + id + email + } + state { + id + name + color + type + } + labels { + nodes { + id + name + } + } + } + } + } +`; + +export async function fetchAllIssues( + client: LinearClient, +): Promise { + const allIssues: LinearIssue[] = []; + let cursor: string | undefined; + + do { + const response = await client.client.request< + IssuesQueryResponse, + { first: number; after?: string; filter: object } + >(ISSUES_QUERY, { + first: 100, + after: cursor, + filter: { state: { type: { nin: ["canceled", "completed"] } } }, + }); + allIssues.push(...response.issues.nodes); + cursor = + response.issues.pageInfo.hasNextPage && response.issues.pageInfo.endCursor + ? response.issues.pageInfo.endCursor + : undefined; + } while (cursor); + + return allIssues; +} + +export function mapIssueToTask( + issue: LinearIssue, + organizationId: string, + creatorId: string, + userByEmail: Map, +) { + const assigneeId = issue.assignee?.email + ? (userByEmail.get(issue.assignee.email) ?? null) + : null; + + return { + organizationId, + creatorId, + slug: issue.identifier, + title: issue.title, + description: issue.description, + status: issue.state.name, + statusColor: issue.state.color, + statusType: issue.state.type, + priority: mapPriorityFromLinear(issue.priority), + assigneeId, + estimate: issue.estimate, + dueDate: issue.dueDate ? new Date(issue.dueDate) : null, + labels: issue.labels.nodes.map((l) => l.name), + startedAt: issue.startedAt ? new Date(issue.startedAt) : null, + completedAt: issue.completedAt ? new Date(issue.completedAt) : null, + externalProvider: "linear" as const, + externalId: issue.id, + externalKey: issue.identifier, + externalUrl: issue.url, + lastSyncedAt: new Date(), + }; +} diff --git a/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts b/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts new file mode 100644 index 00000000000..9c3f9ae437a --- /dev/null +++ b/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts @@ -0,0 +1,209 @@ +import type { LinearClient, WorkflowState } from "@linear/sdk"; +import { db } from "@superset/db/client"; +import type { LinearConfig, SelectTask } from "@superset/db/schema"; +import { integrationConnections, tasks } from "@superset/db/schema"; +import { + getLinearClient, + mapPriorityToLinear, +} from "@superset/trpc/integrations/linear"; +import { Receiver } from "@upstash/qstash"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { env } from "@/env"; + +const receiver = new Receiver({ + currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, + nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, +}); + +const payloadSchema = z.object({ + taskId: z.string().min(1), + teamId: z.string().optional(), +}); + +async function getNewTasksTeamId( + organizationId: string, +): Promise { + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.organizationId, organizationId), + eq(integrationConnections.provider, "linear"), + ), + }); + + if (!connection?.config) { + return null; + } + + const config = connection.config as LinearConfig; + return config.newTasksTeamId ?? null; +} + +async function findLinearState( + client: LinearClient, + teamId: string, + statusName: string, +): Promise { + const team = await client.team(teamId); + const states = await team.states(); + const match = states.nodes.find( + (s: WorkflowState) => s.name.toLowerCase() === statusName.toLowerCase(), + ); + return match?.id; +} + +async function syncTaskToLinear( + task: SelectTask, + teamId: string, +): Promise<{ + success: boolean; + externalId?: string; + externalKey?: string; + externalUrl?: string; + error?: string; +}> { + const client = await getLinearClient(task.organizationId); + + if (!client) { + return { success: false, error: "No Linear connection found" }; + } + + try { + const stateId = await findLinearState(client, teamId, task.status); + + if (task.externalProvider === "linear" && task.externalId) { + const result = await client.updateIssue(task.externalId, { + title: task.title, + description: task.description ?? undefined, + priority: mapPriorityToLinear(task.priority), + stateId, + estimate: task.estimate ?? undefined, + dueDate: task.dueDate?.toISOString().split("T")[0], + }); + + if (!result.success) { + return { success: false, error: "Failed to update issue" }; + } + + const issue = await result.issue; + if (!issue) { + return { success: false, error: "Issue not returned" }; + } + + await db + .update(tasks) + .set({ + lastSyncedAt: new Date(), + syncError: null, + }) + .where(eq(tasks.id, task.id)); + + return { + success: true, + externalId: issue.id, + externalKey: issue.identifier, + externalUrl: issue.url, + }; + } + + const result = await client.createIssue({ + teamId, + title: task.title, + description: task.description ?? undefined, + priority: mapPriorityToLinear(task.priority), + stateId, + estimate: task.estimate ?? undefined, + dueDate: task.dueDate?.toISOString().split("T")[0], + }); + + if (!result.success) { + return { success: false, error: "Failed to create issue" }; + } + + const issue = await result.issue; + if (!issue) { + return { success: false, error: "Issue not returned" }; + } + + await db + .update(tasks) + .set({ + externalProvider: "linear", + externalId: issue.id, + externalKey: issue.identifier, + externalUrl: issue.url, + lastSyncedAt: new Date(), + syncError: null, + }) + .where(eq(tasks.id, task.id)); + + return { + success: true, + externalId: issue.id, + externalKey: issue.identifier, + externalUrl: issue.url, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + + await db + .update(tasks) + .set({ syncError: errorMessage }) + .where(eq(tasks.id, task.id)); + + return { success: false, error: errorMessage }; + } +} + +export async function POST(request: Request) { + const body = await request.text(); + const signature = request.headers.get("upstash-signature"); + + if (!signature) { + return Response.json({ error: "Missing signature" }, { status: 401 }); + } + + const isValid = await receiver.verify({ + body, + signature, + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/sync-task`, + }); + + if (!isValid) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } + + const parsed = payloadSchema.safeParse(JSON.parse(body)); + if (!parsed.success) { + return Response.json({ error: "Invalid payload" }, { status: 400 }); + } + + const { taskId, teamId } = parsed.data; + + const task = await db.query.tasks.findFirst({ + where: eq(tasks.id, taskId), + }); + + if (!task) { + return Response.json({ error: "Task not found", skipped: true }); + } + + const resolvedTeamId = + teamId ?? (await getNewTasksTeamId(task.organizationId)); + if (!resolvedTeamId) { + return Response.json({ error: "No team configured", skipped: true }); + } + + const result = await syncTaskToLinear(task, resolvedTeamId); + + if (!result.success) { + return Response.json({ error: result.error }, { status: 500 }); + } + + return Response.json({ + success: true, + externalId: result.externalId, + externalKey: result.externalKey, + }); +} diff --git a/apps/api/src/app/api/integrations/linear/webhook/route.ts b/apps/api/src/app/api/integrations/linear/webhook/route.ts new file mode 100644 index 00000000000..e89d40f3b02 --- /dev/null +++ b/apps/api/src/app/api/integrations/linear/webhook/route.ts @@ -0,0 +1,146 @@ +import type { EntityWebhookPayloadWithIssueData } from "@linear/sdk/webhooks"; +import { + LINEAR_WEBHOOK_SIGNATURE_HEADER, + LinearWebhookClient, +} from "@linear/sdk/webhooks"; +import { db } from "@superset/db/client"; +import type { SelectIntegrationConnection } from "@superset/db/schema"; +import { + integrationConnections, + tasks, + users, + webhookEvents, +} from "@superset/db/schema"; +import { mapPriorityFromLinear } from "@superset/trpc/integrations/linear"; +import { and, eq } from "drizzle-orm"; +import { env } from "@/env"; + +const webhookClient = new LinearWebhookClient(env.LINEAR_WEBHOOK_SECRET); + +export async function POST(request: Request) { + const body = await request.text(); + const signature = request.headers.get(LINEAR_WEBHOOK_SIGNATURE_HEADER); + + if (!signature) { + return Response.json({ error: "Missing signature" }, { status: 401 }); + } + + const payload = webhookClient.parseData(Buffer.from(body), signature); + + const [webhookEvent] = await db + .insert(webhookEvents) + .values({ + provider: "linear", + eventId: `${payload.organizationId}-${payload.webhookTimestamp}`, + eventType: `${payload.type}.${payload.action}`, + payload: payload as unknown as Record, + status: "pending", + }) + .returning(); + + if (!webhookEvent) { + return Response.json({ error: "Failed to store event" }, { status: 500 }); + } + + const connection = await db.query.integrationConnections.findFirst({ + where: and( + eq(integrationConnections.externalOrgId, payload.organizationId), + eq(integrationConnections.provider, "linear"), + ), + }); + + if (!connection) { + await db + .update(webhookEvents) + .set({ status: "skipped", error: "No connection found" }) + .where(eq(webhookEvents.id, webhookEvent.id)); + return Response.json({ error: "Unknown organization" }, { status: 404 }); + } + + try { + if (payload.type === "Issue") { + await processIssueEvent( + payload as EntityWebhookPayloadWithIssueData, + connection, + ); + } + + await db + .update(webhookEvents) + .set({ status: "processed", processedAt: new Date() }) + .where(eq(webhookEvents.id, webhookEvent.id)); + + return Response.json({ success: true }); + } catch (error) { + await db + .update(webhookEvents) + .set({ + status: "failed", + error: error instanceof Error ? error.message : "Unknown error", + retryCount: webhookEvent.retryCount + 1, + }) + .where(eq(webhookEvents.id, webhookEvent.id)); + + return Response.json({ error: "Processing failed" }, { status: 500 }); + } +} + +async function processIssueEvent( + payload: EntityWebhookPayloadWithIssueData, + connection: SelectIntegrationConnection, +) { + const issue = payload.data; + + if (payload.action === "create" || payload.action === "update") { + let assigneeId: string | null = null; + if (issue.assignee?.email) { + const matchedUser = await db.query.users.findFirst({ + where: eq(users.email, issue.assignee.email), + }); + assigneeId = matchedUser?.id ?? null; + } + + const taskData = { + slug: issue.identifier, + title: issue.title, + description: issue.description ?? null, + status: issue.state.name, + statusColor: issue.state.color, + statusType: issue.state.type, + priority: mapPriorityFromLinear(issue.priority), + assigneeId, + estimate: issue.estimate ?? null, + dueDate: issue.dueDate ? new Date(issue.dueDate) : null, + labels: issue.labels.map((l) => l.name), + startedAt: issue.startedAt ? new Date(issue.startedAt) : null, + completedAt: issue.completedAt ? new Date(issue.completedAt) : null, + externalProvider: "linear" as const, + externalId: issue.id, + externalKey: issue.identifier, + externalUrl: issue.url, + lastSyncedAt: new Date(), + }; + + await db + .insert(tasks) + .values({ + ...taskData, + organizationId: connection.organizationId, + creatorId: connection.connectedByUserId, + }) + .onConflictDoUpdate({ + target: [tasks.externalProvider, tasks.externalId], + set: { ...taskData, syncError: null }, + }); + } else if (payload.action === "remove") { + await db + .update(tasks) + .set({ deletedAt: new Date() }) + .where( + and( + eq(tasks.externalProvider, "linear"), + eq(tasks.externalId, issue.id), + ), + ); + } +} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 29f0d502552..5b4eba4ae1e 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -12,9 +12,16 @@ export const env = createEnv({ GOOGLE_CLIENT_SECRET: z.string().min(1), GH_CLIENT_ID: z.string().min(1), GH_CLIENT_SECRET: z.string().min(1), + LINEAR_CLIENT_ID: z.string().min(1), + LINEAR_CLIENT_SECRET: z.string().min(1), + LINEAR_WEBHOOK_SECRET: z.string().min(1), + QSTASH_TOKEN: z.string().min(1), + QSTASH_CURRENT_SIGNING_KEY: z.string().min(1), + QSTASH_NEXT_SIGNING_KEY: z.string().min(1), SENTRY_AUTH_TOKEN: z.string().optional(), }, client: { + NEXT_PUBLIC_API_URL: z.string().url(), NEXT_PUBLIC_WEB_URL: z.string().url(), NEXT_PUBLIC_ADMIN_URL: z.string().url(), NEXT_PUBLIC_SENTRY_DSN_API: z.string().optional(), @@ -23,6 +30,7 @@ export const env = createEnv({ .optional(), }, experimental__runtimeEnv: { + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, NEXT_PUBLIC_ADMIN_URL: process.env.NEXT_PUBLIC_ADMIN_URL, NEXT_PUBLIC_SENTRY_DSN_API: process.env.NEXT_PUBLIC_SENTRY_DSN_API, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 899f8db9c0e..c7ac097bc4b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -74,7 +74,7 @@ "express": "^5.1.0", "fast-glob": "^3.3.3", "file-uri-to-path": "^1.0.0", - "framer-motion": "^12.23.24", + "framer-motion": "^12.23.26", "http-proxy": "^1.18.1", "jose": "^6.1.3", "line-column-path": "^3.0.0", diff --git a/apps/docs/package.json b/apps/docs/package.json index f9fe6c781eb..91cecbdbf49 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -17,7 +17,7 @@ "@superset/ui": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", "dotenv": "^17.2.3", - "framer-motion": "^12.23.24", + "framer-motion": "^12.23.26", "geist": "^1.5.1", "import-in-the-middle": "2.0.1", "next": "^16.0.10", diff --git a/apps/marketing/package.json b/apps/marketing/package.json index c24878011e8..9c51b1c72cf 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -18,7 +18,7 @@ "@superset/shared": "workspace:*", "@superset/ui": "workspace:*", "@t3-oss/env-nextjs": "^0.13.8", - "framer-motion": "^12.23.24", + "framer-motion": "^12.23.26", "geist": "^1.5.1", "import-in-the-middle": "2.0.1", "lucide-react": "^0.560.0", diff --git a/apps/web/package.json b/apps/web/package.json index 14793eebeff..301bcc3cbdb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,8 @@ "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", + "@uiw/react-md-editor": "^4.0.11", + "framer-motion": "^12.23.26", "geist": "^1.5.1", "import-in-the-middle": "2.0.1", "jose": "^6.1.3", diff --git a/apps/web/public/hero/agents.mp4 b/apps/web/public/hero/agents.mp4 new file mode 100644 index 00000000000..8bf054cc428 Binary files /dev/null and b/apps/web/public/hero/agents.mp4 differ diff --git a/apps/web/public/hero/changes.mp4 b/apps/web/public/hero/changes.mp4 new file mode 100644 index 00000000000..4324007757d Binary files /dev/null and b/apps/web/public/hero/changes.mp4 differ diff --git a/apps/web/public/hero/open-in.mp4 b/apps/web/public/hero/open-in.mp4 new file mode 100644 index 00000000000..42c419d141d Binary files /dev/null and b/apps/web/public/hero/open-in.mp4 differ diff --git a/apps/web/public/hero/tabs.mp4 b/apps/web/public/hero/tabs.mp4 new file mode 100644 index 00000000000..f3d7768ca9f Binary files /dev/null and b/apps/web/public/hero/tabs.mp4 differ diff --git a/apps/web/public/hero/worktrees.mp4 b/apps/web/public/hero/worktrees.mp4 new file mode 100644 index 00000000000..1760885242e Binary files /dev/null and b/apps/web/public/hero/worktrees.mp4 differ diff --git a/apps/web/src/app/(dashboard)/components/Footer/Footer.tsx b/apps/web/src/app/(dashboard)/components/Footer/Footer.tsx new file mode 100644 index 00000000000..8f16b8db631 --- /dev/null +++ b/apps/web/src/app/(dashboard)/components/Footer/Footer.tsx @@ -0,0 +1,32 @@ +import { env } from "@/env"; + +export function Footer() { + return ( + + ); +} diff --git a/apps/web/src/app/(dashboard)/components/Footer/index.ts b/apps/web/src/app/(dashboard)/components/Footer/index.ts new file mode 100644 index 00000000000..e5ea0e53f6a --- /dev/null +++ b/apps/web/src/app/(dashboard)/components/Footer/index.ts @@ -0,0 +1 @@ +export { Footer } from "./Footer"; diff --git a/apps/web/src/app/(dashboard)/components/Header/Header.tsx b/apps/web/src/app/(dashboard)/components/Header/Header.tsx new file mode 100644 index 00000000000..47dd869a3ac --- /dev/null +++ b/apps/web/src/app/(dashboard)/components/Header/Header.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { SignOutButton, useUser } from "@clerk/nextjs"; +import { getInitials } from "@superset/shared/names"; +import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { LogOut } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; + +export function Header() { + const { user } = useUser(); + + const initials = getInitials( + user?.fullName, + user?.primaryEmailAddress?.emailAddress, + ); + + return ( +
+
+ + Superset + + + + + + + + + + Logout + + + + +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/components/Header/index.ts b/apps/web/src/app/(dashboard)/components/Header/index.ts new file mode 100644 index 00000000000..c940126c9a2 --- /dev/null +++ b/apps/web/src/app/(dashboard)/components/Header/index.ts @@ -0,0 +1 @@ +export { Header } from "./Header"; diff --git a/apps/web/src/app/(dashboard)/components/ProductDemo/ProductDemo.tsx b/apps/web/src/app/(dashboard)/components/ProductDemo/ProductDemo.tsx new file mode 100644 index 00000000000..92b812f7983 --- /dev/null +++ b/apps/web/src/app/(dashboard)/components/ProductDemo/ProductDemo.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { MeshGradient } from "@superset/ui/mesh-gradient"; +import { motion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; + +const DEMO_OPTIONS = [ + { + label: "Use Any Agents", + videoPath: "/hero/agents.mp4", + colors: ["#7f1d1d", "#991b1b", "#450a0a", "#1a1a2e"] as const, + }, + { + label: "Create Parallel Branches", + videoPath: "/hero/worktrees.mp4", + colors: ["#1e40af", "#1e3a8a", "#172554", "#1a1a2e"] as const, + }, + { + label: "See Changes", + videoPath: "/hero/changes.mp4", + colors: ["#b45309", "#92400e", "#78350f", "#1a1a2e"] as const, + }, + { + label: "Open in Any IDE", + videoPath: "/hero/open-in.mp4", + colors: ["#047857", "#065f46", "#064e3b", "#1a1a2e"] as const, + }, +]; + +function DemoVideo({ src, isActive }: { src: string; isActive: boolean }) { + const videoRef = useRef(null); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + if (isActive) { + video.currentTime = 0; + video.play().catch(() => {}); + } else { + video.pause(); + } + }, [isActive]); + + return ( +