Skip to content
Merged
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
14 changes: 13 additions & 1 deletion .github/workflows/deploy-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
14 changes: 13 additions & 1 deletion .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
109 changes: 109 additions & 0 deletions apps/api/src/app/api/integrations/linear/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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`,
);
}
Comment on lines +33 to +41
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Wrap JSON parsing in try-catch for malformed state.

If the state parameter is valid base64url but contains invalid JSON, JSON.parse will throw an unhandled exception, resulting in a 500 error instead of a graceful redirect to the error page.

🔎 Proposed fix
+	let decodedState: unknown;
+	try {
+		decodedState = JSON.parse(
+			Buffer.from(state, "base64url").toString("utf-8"),
+		);
+	} catch {
+		return Response.redirect(
+			`${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=invalid_state`,
+		);
+	}
+
 	const parsed = stateSchema.safeParse(
-		JSON.parse(Buffer.from(state, "base64url").toString("utf-8")),
+		decodedState,
 	);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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`,
);
}
let decodedState: unknown;
try {
decodedState = JSON.parse(
Buffer.from(state, "base64url").toString("utf-8"),
);
} catch {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=invalid_state`,
);
}
const parsed = stateSchema.safeParse(
decodedState,
);
if (!parsed.success) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=invalid_state`,
);
}
🤖 Prompt for AI Agents
In apps/api/src/app/api/integrations/linear/callback/route.ts around lines 33 to
41, the code calls JSON.parse directly on the decoded state which can throw for
malformed JSON; wrap the decode+JSON.parse in a try-catch, and on any exception
return the same Response.redirect to
`${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=invalid_state` so
malformed JSON results in a graceful redirect instead of an unhandled 500; keep
the subsequent schema.safeParse usage (or run safeParse on the parsed object)
inside the try block and handle parse/schema failures with the existing
redirect.


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`);
}
61 changes: 61 additions & 0 deletions apps/api/src/app/api/integrations/linear/connect/route.ts
Original file line number Diff line number Diff line change
@@ -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());
}
126 changes: 126 additions & 0 deletions apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Comment on lines +41 to +44
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Wrap JSON.parse in try-catch to handle malformed payloads.

Same issue as in sync-task route - JSON.parse(body) will throw on malformed JSON, resulting in a 500 instead of a 400.

🔎 Proposed fix
-  const parsed = payloadSchema.safeParse(JSON.parse(body));
-  if (!parsed.success) {
-    return Response.json({ error: "Invalid payload" }, { status: 400 });
-  }
+  let jsonBody: unknown;
+  try {
+    jsonBody = JSON.parse(body);
+  } catch {
+    return Response.json({ error: "Invalid JSON" }, { status: 400 });
+  }
+
+  const parsed = payloadSchema.safeParse(jsonBody);
+  if (!parsed.success) {
+    return Response.json({ error: "Invalid payload" }, { status: 400 });
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const parsed = payloadSchema.safeParse(JSON.parse(body));
if (!parsed.success) {
return Response.json({ error: "Invalid payload" }, { status: 400 });
}
let jsonBody: unknown;
try {
jsonBody = JSON.parse(body);
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = payloadSchema.safeParse(jsonBody);
if (!parsed.success) {
return Response.json({ error: "Invalid payload" }, { status: 400 });
}
🤖 Prompt for AI Agents
In apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts around
lines 38 to 41, JSON.parse(body) can throw on malformed JSON causing a 500; wrap
the JSON.parse call in a try-catch and if parsing fails return Response.json({
error: "Invalid JSON payload" }, { status: 400 }); then continue to call
payloadSchema.safeParse on the parsed object and return the existing 400
response for schema validation failures.


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 });
}
Comment on lines +55 to +57
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add status code for "No connection found" response.

The error response defaults to 200 OK, which is misleading.

🔎 Proposed fix
   if (!connection) {
-    return Response.json({ error: "No connection found", skipped: true });
+    return Response.json({ error: "No connection found", skipped: true }, { status: 404 });
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!connection) {
return Response.json({ error: "No connection found", skipped: true });
}
if (!connection) {
return Response.json({ error: "No connection found", skipped: true }, { status: 404 });
}
🤖 Prompt for AI Agents
In apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts around
lines 52 to 54, the handler returns a JSON error when no connection is found but
doesn't set an HTTP status code (defaults to 200). Update the return to include
an appropriate status (e.g., 404) by returning Response.json({ error: "No
connection found", skipped: true }, { status: 404 }) so clients receive a proper
Not Found status.


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,
},
});
}
}
Loading
Loading