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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ GOOGLE_CLIENT_SECRET=
GH_CLIENT_ID=
GH_CLIENT_SECRET=

# -----------------------------------------------------------------------------
# GitHub App Credentials
# -----------------------------------------------------------------------------
GH_APP_ID=
GH_APP_PRIVATE_KEY=
GH_WEBHOOK_SECRET=

Comment on lines +44 to +50
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 | 🟠 Major

Add inline documentation for GitHub App credentials.

The new GitHub App credentials section lacks documentation to guide developers. Include comments explaining:

  • How to create a GitHub App and obtain these credentials
  • Expected format for each variable (especially the multi-line private key)
  • Whether these are required or optional
  • Link to GitHub App setup documentation
📝 Suggested documentation improvements
 # -----------------------------------------------------------------------------
-# GitHub App Credentials
+# GitHub App Credentials
+# Create a GitHub App at: https://github.com/settings/apps
+# Required for GitHub integration features (repository access, webhooks)
 # -----------------------------------------------------------------------------
+# Your GitHub App's numeric ID
 GH_APP_ID=
+# Path to your GitHub App's private key file (.pem)
+# Generate and download from your GitHub App settings
 GH_APP_PRIVATE_KEY=
+# Webhook secret for verifying GitHub webhook payloads
+# Set in your GitHub App's webhook configuration
 GH_WEBHOOK_SECRET=
📝 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
# -----------------------------------------------------------------------------
# GitHub App Credentials
# -----------------------------------------------------------------------------
GH_APP_ID=
GH_APP_PRIVATE_KEY=
GH_WEBHOOK_SECRET=
# -----------------------------------------------------------------------------
# GitHub App Credentials
# Create a GitHub App at: https://github.com/settings/apps
# Required for GitHub integration features (repository access, webhooks)
# -----------------------------------------------------------------------------
# Your GitHub App's numeric ID
GH_APP_ID=
# Path to your GitHub App's private key file (.pem)
# Generate and download from your GitHub App settings
GH_APP_PRIVATE_KEY=
# Webhook secret for verifying GitHub webhook payloads
# Set in your GitHub App's webhook configuration
GH_WEBHOOK_SECRET=
🤖 Prompt for AI Agents
In @.env.example around lines 44 - 50, Update the .env.example GitHub App
section by adding inline comments above GH_APP_ID, GH_APP_PRIVATE_KEY, and
GH_WEBHOOK_SECRET that briefly state how to create a GitHub App and obtain each
value, the expected formats (GH_APP_ID is the numeric App ID; GH_APP_PRIVATE_KEY
is the PEM private key — indicate it’s multi-line and suggest encoding/escaping
strategy for usage in env files, e.g., newline escapes or storing as a file;
GH_WEBHOOK_SECRET is a shared secret string), whether each variable is required
or optional for local/dev/production, and a link to GitHub’s official App setup
docs for reference; ensure the comments reference the exact variable names
GH_APP_ID, GH_APP_PRIVATE_KEY, and GH_WEBHOOK_SECRET so developers can find them
quickly.

# -----------------------------------------------------------------------------
# Blob Storage
# -----------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/deploy-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ jobs:
LINEAR_CLIENT_ID: ${{ secrets.LINEAR_CLIENT_ID }}
LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }}
LINEAR_WEBHOOK_SECRET: ${{ secrets.LINEAR_WEBHOOK_SECRET }}
GH_APP_ID: ${{ secrets.GH_APP_ID }}
GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }}
GH_WEBHOOK_SECRET: ${{ secrets.GH_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 }}
Expand Down Expand Up @@ -218,6 +221,9 @@ jobs:
--env LINEAR_CLIENT_ID=$LINEAR_CLIENT_ID \
--env LINEAR_CLIENT_SECRET=$LINEAR_CLIENT_SECRET \
--env LINEAR_WEBHOOK_SECRET=$LINEAR_WEBHOOK_SECRET \
--env GH_APP_ID="$GH_APP_ID" \
--env GH_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \
--env GH_WEBHOOK_SECRET="$GH_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 \
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ jobs:
LINEAR_CLIENT_ID: ${{ secrets.LINEAR_CLIENT_ID }}
LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }}
LINEAR_WEBHOOK_SECRET: ${{ secrets.LINEAR_WEBHOOK_SECRET }}
GH_APP_ID: ${{ secrets.GH_APP_ID }}
GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }}
GH_WEBHOOK_SECRET: ${{ secrets.GH_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 }}
Expand Down Expand Up @@ -122,6 +125,9 @@ jobs:
--env LINEAR_CLIENT_ID=$LINEAR_CLIENT_ID \
--env LINEAR_CLIENT_SECRET=$LINEAR_CLIENT_SECRET \
--env LINEAR_WEBHOOK_SECRET=$LINEAR_WEBHOOK_SECRET \
--env GH_APP_ID="$GH_APP_ID" \
--env GH_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \
--env GH_WEBHOOK_SECRET="$GH_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 \
Expand Down
3 changes: 3 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"dependencies": {
"@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724",
"@linear/sdk": "^68.1.0",
"@octokit/app": "^16.1.2",
"@octokit/rest": "^22.0.1",
"@octokit/webhooks": "^14.2.0",
"@sentry/nextjs": "^10.32.1",
"@superset/auth": "workspace:*",
"@superset/db": "workspace:*",
Expand Down
152 changes: 152 additions & 0 deletions apps/api/src/app/api/github/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { db } from "@superset/db/client";
import { githubInstallations, members } from "@superset/db/schema";
import { Client } from "@upstash/qstash";
import { and, eq } from "drizzle-orm";

import { env } from "@/env";
import { verifySignedState } from "@/lib/oauth-state";
import { githubApp } from "../octokit";

const qstash = new Client({ token: env.QSTASH_TOKEN });

/**
* Callback handler for GitHub App installation.
* GitHub redirects here after the user installs/configures the app.
*/
export async function GET(request: Request) {
const url = new URL(request.url);
const installationId = url.searchParams.get("installation_id");
const setupAction = url.searchParams.get("setup_action");
const state = url.searchParams.get("state");

if (setupAction === "cancel") {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=installation_cancelled`,
);
}

if (!installationId || !state) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=missing_params`,
);
}

// Verify signed state (prevents forgery)
const stateData = verifySignedState(state);
if (!stateData) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=invalid_state`,
);
}

const { organizationId, userId } = stateData;

// Re-verify membership at callback time (defense-in-depth)
const membership = await db.query.members.findFirst({
where: and(
eq(members.organizationId, organizationId),
eq(members.userId, userId),
),
});

if (!membership) {
console.error("[github/callback] Membership verification failed:", {
organizationId,
userId,
});
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=unauthorized`,
);
}

try {
const octokit = await githubApp.getInstallationOctokit(
Number(installationId),
);

const installationResult = await octokit
.request("GET /app/installations/{installation_id}", {
installation_id: Number(installationId),
})
.catch((error: Error) => {
console.error("[github/callback] Failed to fetch installation:", error);
return null;
});

if (!installationResult) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=installation_fetch_failed`,
);
}

const installation = installationResult.data;

// Extract account info - account can be User or Enterprise
const account = installation.account;
const accountLogin =
account && "login" in account ? account.login : (account?.name ?? "");
const accountType =
account && "type" in account ? account.type : "Organization";

// Save the installation to our database
const [savedInstallation] = await db
.insert(githubInstallations)
.values({
organizationId,
connectedByUserId: userId,
installationId: String(installation.id),
accountLogin,
accountType,
permissions: installation.permissions as Record<string, string>,
})
.onConflictDoUpdate({
target: [githubInstallations.organizationId],
set: {
connectedByUserId: userId,
installationId: String(installation.id),
accountLogin,
accountType,
permissions: installation.permissions as Record<string, string>,
suspended: false,
suspendedAt: null, // Clear suspension if reinstalling
updatedAt: new Date(),
},
})
.returning();

if (!savedInstallation) {
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=save_failed`,
);
}

// Queue initial sync job
try {
await qstash.publishJSON({
url: `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`,
body: {
installationDbId: savedInstallation.id,
organizationId,
},
retries: 3,
});
} catch (error) {
console.error(
"[github/callback] Failed to queue initial sync job:",
error,
);
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/github?warning=sync_queue_failed`,
);
}

return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/github?success=github_installed`,
);
} catch (error) {
console.error("[github/callback] Unexpected error:", error);
return Response.redirect(
`${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=unexpected`,
);
}
}
58 changes: 58 additions & 0 deletions apps/api/src/app/api/github/install/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { auth } from "@superset/auth/server";
import { db } from "@superset/db/client";
import { members } from "@superset/db/schema";
import { and, eq } from "drizzle-orm";

import { env } from "@/env";
import { createSignedState } from "@/lib/oauth-state";

export async function GET(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });

if (!session?.user) {
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 membership = await db.query.members.findFirst({
where: and(
eq(members.organizationId, organizationId),
eq(members.userId, session.user.id),
),
});

if (!membership) {
return Response.json(
{ error: "User is not a member of this organization" },
{ status: 403 },
);
}

if (!env.GH_APP_ID) {
return Response.json(
{ error: "GitHub App not configured" },
{ status: 500 },
);
}

const state = createSignedState({
organizationId,
userId: session.user.id,
});

const installUrl = new URL(
"https://github.com/apps/superset-app/installations/new",
);
installUrl.searchParams.set("state", state);

return Response.redirect(installUrl.toString());
}
Loading