-
-
Notifications
You must be signed in to change notification settings - Fork 852
feat(webapp): github app installation flow #2463
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
8f83bcc
Add schemas for gh app installations
myftija 4fb34c7
Implement gh app installation flow
myftija 1900e6c
Make the gh app configs optional
myftija 114892f
Add additional org check on gh app installation callback
myftija 6b311cc
Save account handle and repo default branch on install
myftija ec03a4c
Do repo hard deletes in favor of simplicity
myftija a20d97b
Disable github app by default
myftija b2c20e2
Fix gh env schema union issue
myftija 417f239
Use octokit's iterator for paginating repos
myftija aa51adf
Parse gh app install callback with a discriminated union
myftija 6d375af
Remove duplicate env vars
myftija 21529b4
Use bigint for github integer IDs
myftija 1d931c8
Sanitize redirect paths in the gh installation and auth flow
myftija d07e605
Regenerate migration after rebase on main to fix ordering
myftija 9a7eb49
Handle gh install updates separately from new installs
myftija File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
import { type LoaderFunctionArgs } from "@remix-run/node"; | ||
import { z } from "zod"; | ||
import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server"; | ||
import { linkGitHubAppInstallation, updateGitHubAppInstallation } from "~/services/gitHub.server"; | ||
import { logger } from "~/services/logger.server"; | ||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; | ||
import { tryCatch } from "@trigger.dev/core"; | ||
import { $replica } from "~/db.server"; | ||
import { requireUser } from "~/services/session.server"; | ||
import { sanitizeRedirectPath } from "~/utils"; | ||
|
||
const QuerySchema = z.discriminatedUnion("setup_action", [ | ||
z.object({ | ||
setup_action: z.literal("install"), | ||
installation_id: z.coerce.number(), | ||
state: z.string(), | ||
}), | ||
z.object({ | ||
setup_action: z.literal("update"), | ||
installation_id: z.coerce.number(), | ||
state: z.string(), | ||
}), | ||
z.object({ | ||
setup_action: z.literal("request"), | ||
state: z.string(), | ||
}), | ||
]); | ||
|
||
export async function loader({ request }: LoaderFunctionArgs) { | ||
const url = new URL(request.url); | ||
const queryParams = Object.fromEntries(url.searchParams); | ||
const cookieHeader = request.headers.get("Cookie"); | ||
|
||
const result = QuerySchema.safeParse(queryParams); | ||
|
||
if (!result.success) { | ||
logger.warn("GitHub App callback with invalid params", { | ||
queryParams, | ||
}); | ||
return redirectWithErrorMessage("/", request, "Failed to install GitHub App"); | ||
} | ||
|
||
const callbackData = result.data; | ||
|
||
const sessionResult = await validateGitHubAppInstallSession(cookieHeader, callbackData.state); | ||
|
||
if (!sessionResult.valid) { | ||
logger.error("GitHub App callback with invalid session", { | ||
callbackData, | ||
error: sessionResult.error, | ||
}); | ||
|
||
return redirectWithErrorMessage("/", request, "Failed to install GitHub App"); | ||
} | ||
|
||
const { organizationId, redirectTo: unsafeRedirectTo } = sessionResult; | ||
const redirectTo = sanitizeRedirectPath(unsafeRedirectTo); | ||
|
||
const user = await requireUser(request); | ||
const org = await $replica.organization.findFirst({ | ||
where: { id: organizationId, members: { some: { userId: user.id } }, deletedAt: null }, | ||
orderBy: { createdAt: "desc" }, | ||
select: { | ||
id: true, | ||
}, | ||
}); | ||
|
||
if (!org) { | ||
// the secure cookie approach should already protect against this | ||
// just an additional check | ||
logger.error("GitHub app installation attempt on unauthenticated org", { | ||
userId: user.id, | ||
organizationId, | ||
}); | ||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App"); | ||
} | ||
|
||
switch (callbackData.setup_action) { | ||
case "install": { | ||
const [error] = await tryCatch( | ||
linkGitHubAppInstallation(callbackData.installation_id, organizationId) | ||
); | ||
|
||
if (error) { | ||
logger.error("Failed to link GitHub App installation", { | ||
error, | ||
}); | ||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App"); | ||
} | ||
|
||
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully"); | ||
} | ||
|
||
case "update": { | ||
const [error] = await tryCatch(updateGitHubAppInstallation(callbackData.installation_id)); | ||
|
||
if (error) { | ||
logger.error("Failed to update GitHub App installation", { | ||
error, | ||
}); | ||
return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App"); | ||
} | ||
|
||
return redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully"); | ||
} | ||
|
||
case "request": { | ||
// This happens when a non-admin user requests installation | ||
// The installation_id won't be available until an admin approves | ||
logger.info("GitHub App installation requested, awaiting approval", { | ||
callbackData, | ||
}); | ||
|
||
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installation requested"); | ||
} | ||
|
||
default: | ||
callbackData satisfies never; | ||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App"); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; | ||
import { redirect } from "remix-typedjson"; | ||
import { z } from "zod"; | ||
import { $replica } from "~/db.server"; | ||
import { createGitHubAppInstallSession } from "~/services/gitHubSession.server"; | ||
import { requireUser } from "~/services/session.server"; | ||
import { newOrganizationPath } from "~/utils/pathBuilder"; | ||
import { logger } from "~/services/logger.server"; | ||
import { sanitizeRedirectPath } from "~/utils"; | ||
|
||
const QuerySchema = z.object({ | ||
org_slug: z.string(), | ||
redirect_to: z.string().refine((value) => value === sanitizeRedirectPath(value), { | ||
message: "Invalid redirect path", | ||
}), | ||
}); | ||
|
||
export const loader = async ({ request }: LoaderFunctionArgs) => { | ||
const searchParams = new URL(request.url).searchParams; | ||
const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams)); | ||
|
||
if (!parsed.success) { | ||
logger.warn("GitHub App installation redirect with invalid params", { | ||
searchParams, | ||
error: parsed.error, | ||
}); | ||
throw redirect("/"); | ||
} | ||
|
||
const { org_slug, redirect_to } = parsed.data; | ||
const user = await requireUser(request); | ||
|
||
const org = await $replica.organization.findFirst({ | ||
where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null }, | ||
orderBy: { createdAt: "desc" }, | ||
select: { | ||
id: true, | ||
}, | ||
}); | ||
|
||
if (!org) { | ||
throw redirect(newOrganizationPath()); | ||
} | ||
|
||
const { url, cookieHeader } = await createGitHubAppInstallSession(org.id, redirect_to); | ||
|
||
return redirect(url, { | ||
headers: { | ||
"Set-Cookie": cookieHeader, | ||
}, | ||
}); | ||
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
import { App, type Octokit } from "octokit"; | ||
import { env } from "../env.server"; | ||
import { prisma } from "~/db.server"; | ||
import { logger } from "./logger.server"; | ||
|
||
export const githubApp = | ||
env.GITHUB_APP_ENABLED === "1" | ||
? new App({ | ||
appId: env.GITHUB_APP_ID, | ||
privateKey: env.GITHUB_APP_PRIVATE_KEY, | ||
webhooks: { | ||
secret: env.GITHUB_APP_WEBHOOK_SECRET, | ||
}, | ||
}) | ||
: null; | ||
|
||
/** | ||
* Links a GitHub App installation to a Trigger organization | ||
*/ | ||
export async function linkGitHubAppInstallation( | ||
installationId: number, | ||
organizationId: string | ||
): Promise<void> { | ||
if (!githubApp) { | ||
throw new Error("GitHub App is not enabled"); | ||
} | ||
|
||
const octokit = await githubApp.getInstallationOctokit(installationId); | ||
const { data: installation } = await octokit.rest.apps.getInstallation({ | ||
installation_id: installationId, | ||
}); | ||
|
||
const repositories = await fetchInstallationRepositories(octokit, installationId); | ||
|
||
const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED"; | ||
|
||
await prisma.githubAppInstallation.create({ | ||
data: { | ||
appInstallationId: installationId, | ||
organizationId, | ||
targetId: installation.target_id, | ||
targetType: installation.target_type, | ||
accountHandle: installation.account | ||
? "login" in installation.account | ||
? installation.account.login | ||
: "slug" in installation.account | ||
? installation.account.slug | ||
: "-" | ||
: "-", | ||
permissions: installation.permissions, | ||
repositorySelection, | ||
repositories: { | ||
create: repositories, | ||
}, | ||
}, | ||
}); | ||
myftija marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
myftija marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
/** | ||
* Links a GitHub App installation to a Trigger organization | ||
*/ | ||
export async function updateGitHubAppInstallation(installationId: number): Promise<void> { | ||
if (!githubApp) { | ||
throw new Error("GitHub App is not enabled"); | ||
} | ||
|
||
const octokit = await githubApp.getInstallationOctokit(installationId); | ||
const { data: installation } = await octokit.rest.apps.getInstallation({ | ||
installation_id: installationId, | ||
}); | ||
|
||
const existingInstallation = await prisma.githubAppInstallation.findFirst({ | ||
where: { appInstallationId: installationId }, | ||
}); | ||
|
||
if (!existingInstallation) { | ||
throw new Error("GitHub App installation not found"); | ||
} | ||
|
||
const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED"; | ||
|
||
// repos are updated asynchronously via webhook events | ||
await prisma.githubAppInstallation.update({ | ||
where: { id: existingInstallation?.id }, | ||
data: { | ||
appInstallationId: installationId, | ||
targetId: installation.target_id, | ||
targetType: installation.target_type, | ||
accountHandle: installation.account | ||
? "login" in installation.account | ||
? installation.account.login | ||
: "slug" in installation.account | ||
? installation.account.slug | ||
: "-" | ||
: "-", | ||
permissions: installation.permissions, | ||
suspendedAt: existingInstallation?.suspendedAt, | ||
repositorySelection, | ||
}, | ||
}); | ||
} | ||
|
||
async function fetchInstallationRepositories(octokit: Octokit, installationId: number) { | ||
const iterator = octokit.paginate.iterator(octokit.rest.apps.listReposAccessibleToInstallation, { | ||
installation_id: installationId, | ||
per_page: 100, | ||
}); | ||
|
||
const allRepos = []; | ||
const maxPages = 3; | ||
let pageCount = 0; | ||
|
||
for await (const { data } of iterator) { | ||
pageCount++; | ||
allRepos.push(...data); | ||
|
||
if (maxPages && pageCount >= maxPages) { | ||
logger.warn("GitHub installation repository fetch truncated", { | ||
installationId, | ||
maxPages, | ||
totalReposFetched: allRepos.length, | ||
}); | ||
break; | ||
} | ||
} | ||
|
||
return allRepos.map((repo) => ({ | ||
githubId: repo.id, | ||
name: repo.name, | ||
fullName: repo.full_name, | ||
htmlUrl: repo.html_url, | ||
private: repo.private, | ||
defaultBranch: repo.default_branch, | ||
})); | ||
} | ||
myftija marked this conversation as resolved.
Show resolved
Hide resolved
|
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.