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 CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,21 @@ DiffKit is a **pnpm monorepo** managed with **Turborepo**:
- **TanStack Router** — File-based routing in `apps/dashboard/src/routes/`
- **TanStack Query** — Server state management and caching
- **Drizzle ORM** — Database schema and migrations in `apps/dashboard/src/db/` and `apps/dashboard/drizzle/`
- **Better Auth** — Authentication with a GitHub OAuth App (+ GitHub App for webhooks)
- **Better Auth** — Authentication with a GitHub OAuth App, plus GitHub App user and installation tokens for installed repos
- **Cloudflare D1** — SQLite database at the edge

### GitHub Integration

DiffKit uses a hybrid GitHub auth model:

- The **GitHub OAuth App** signs users in and powers broad user-context reads, including public or external repositories where the GitHub App is not installed.
- The **GitHub App user token** powers installation discovery, including `GET /user/installations`.
- The **GitHub App installation token** is preferred for repo-scoped reads and writes when the app is installed for that owner.

Local development requires both app configs. The OAuth App callback is `/api/auth/callback/github`. The GitHub App user authorization callback is `/api/github/app/callback`, and the GitHub App setup URL is `/?show-org-setup=true` with **Redirect on update** enabled.

Required local variables are documented in `apps/dashboard/.dev.vars.example`. Do not commit real `.dev.vars` values or private keys. If a private key is exposed, revoke it in GitHub App settings and generate a replacement.

### Adding a New Route

Routes live in `apps/dashboard/src/routes/`. TanStack Router uses file-based routing — create a new file and the route is automatically registered.
Expand Down
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ A fast, design-first GitHub dashboard for developers who want to stay on top of
GITHUB_OAUTH_CLIENT_SECRET=your_oauth_app_client_secret
GITHUB_APP_CLIENT_ID=your_github_app_client_id
GITHUB_APP_CLIENT_SECRET=your_github_app_client_secret
GITHUB_APP_ID=your_numeric_github_app_id
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n"
GITHUB_APP_SLUG=your_github_app_slug
GITHUB_WEBHOOK_SECRET=your_github_webhook_secret
BETTER_AUTH_SECRET=a_random_32_character_string
BETTER_AUTH_URL=http://localhost:3000
Expand All @@ -74,16 +77,30 @@ A fast, design-first GitHub dashboard for developers who want to stay on top of
- Set the callback URL to `http://localhost:3000/api/auth/callback/github`
- Note the **Client ID** and generate a **Client Secret**

The OAuth App handles user login and provides a token with `repo` scope, which gives broad read access to public repositories (needed for cross-references and timeline events).
The OAuth App handles user login and broad user-context reads. DiffKit requests `repo`, `read:org`, and `user:email` scopes. OAuth is also the fallback path for public or external repositories where the GitHub App is not installed, such as upstream open source repositories.

5. **Create and install the GitHub App** (for webhooks and installations)

In [GitHub App settings](https://github.com/settings/apps):

- Set the callback URL to `http://localhost:3000/api/auth/callback/github`
- Grant the account permission `Email addresses: Read-only`
- Set the callback URL to `http://localhost:3000/api/github/app/callback`
- Set the setup URL to `http://localhost:3000/?show-org-setup=true`
- Enable **Redirect on update**
- Leave **Request user authorization (OAuth) during installation** unchecked
- Note the **Client ID**, generate a **Client Secret**, note the numeric **App ID**, and generate a private key
- Install the app on the repositories or organizations you want DiffKit to access

The GitHub App user authorization flow stores a `ghu_` user-to-server token for installation discovery. Repo-scoped reads and writes prefer GitHub App installation tokens when the app is installed, and fall back to OAuth for external/public repositories.

Store the downloaded private key as an escaped single-line value in `.dev.vars`. GitHub commonly downloads a PKCS#1 key with `BEGIN RSA PRIVATE KEY`; DiffKit normalizes it to the PKCS#8 format required by the GitHub App JWT library at runtime.

```bash
printf 'GITHUB_APP_PRIVATE_KEY="' > /tmp/github-app-private-key.env
sed 's/$/\\n/' /path/to/github-app-private-key.pem | tr -d '\n' >> /tmp/github-app-private-key.env
printf '"\n' >> /tmp/github-app-private-key.env
cat /tmp/github-app-private-key.env
```

Recommended GitHub App permissions derived from the current roadmap:

| Roadmap area | Roadmap items | GitHub App permission | Level | Notes |
Expand Down
21 changes: 16 additions & 5 deletions apps/dashboard/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,29 @@
# 1. Go to https://github.com/settings/developers > OAuth Apps > New OAuth App
# 2. Set the callback URL to http://localhost:3000/api/auth/callback/github
# 3. Note the Client ID and generate a Client Secret
# OAuth App tokens support scopes (repo, user:email) and don't expire.
# OAuth App tokens support scopes (repo, read:org, user:email) and don't expire.
# Used for login plus public/external repository reads where the GitHub App is not installed.
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=

# GitHub App credentials (used for webhooks and installation management)
# GitHub App credentials (used for installation discovery and app-scoped repo access)
# 1. Go to https://github.com/settings/apps
# 2. Create a new GitHub App (or use an existing one)
# 3. Set the callback URL to http://localhost:3000/api/auth/callback/github
# 4. Under Permissions & events, grant the permissions listed in the README
# 5. Install the app on the repositories or organizations you want DiffKit to access
# 3. Set the callback URL to http://localhost:3000/api/github/app/callback
# 4. Set the setup URL to http://localhost:3000/?show-org-setup=true
# 5. Enable "Redirect on update"
# 6. Leave "Request user authorization (OAuth) during installation" unchecked
# 7. Under Permissions & events, grant the permissions listed in the README
# 8. Install the app on the repositories or organizations you want DiffKit to access
GITHUB_APP_CLIENT_ID=
GITHUB_APP_CLIENT_SECRET=
# Numeric App ID from the GitHub App settings page
GITHUB_APP_ID=
# Private key generated from the GitHub App settings page.
# Store as a quoted value with escaped newlines:
# GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n"
# GitHub commonly downloads PKCS#1 ("BEGIN RSA PRIVATE KEY"); DiffKit normalizes it for Octokit at runtime.
GITHUB_APP_PRIVATE_KEY=
# The slug from your GitHub App URL (https://github.com/apps/<slug>)
GITHUB_APP_SLUG=

Expand Down
22 changes: 22 additions & 0 deletions apps/dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,28 @@ pnpm install
pnpm dev
```

## GitHub Configuration

The dashboard requires both a GitHub OAuth App and a GitHub App.

OAuth App:

- Callback URL: `http://localhost:3000/api/auth/callback/github`
- Environment variables: `GITHUB_OAUTH_CLIENT_ID`, `GITHUB_OAUTH_CLIENT_SECRET`
- Used for login and public/external repository reads.

GitHub App:

- Callback URL: `http://localhost:3000/api/github/app/callback`
- Setup URL: `http://localhost:3000/?show-org-setup=true`
- Enable **Redirect on update**
- Leave **Request user authorization (OAuth) during installation** unchecked
- Environment variables: `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_SLUG`, `GITHUB_WEBHOOK_SECRET`
- Webhook URL: `/api/webhooks/github`
- Used for installation discovery and app-scoped repo access.

Copy `.dev.vars.example` to `.dev.vars` and fill in the real values. GitHub commonly downloads a PKCS#1 private key with `BEGIN RSA PRIVATE KEY`; the dashboard normalizes it for Octokit at runtime. Never commit `.dev.vars` or private keys.

# Building For Production

To build this application for production:
Expand Down
29 changes: 28 additions & 1 deletion apps/dashboard/src/components/layouts/dashboard-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { getRouteApi, Outlet } from "@tanstack/react-router";
import { lazy, Suspense } from "react";
import { lazy, Suspense, useEffect, useRef } from "react";
import { getGitHubAppAccessState } from "#/lib/github.functions";
import {
githubMyIssuesQueryOptions,
githubMyPullsQueryOptions,
} from "#/lib/github.query";
import { useShowOrgSetupQueryState } from "#/lib/github-access-dialog-query";
import { openGitHubAccessPrompt } from "#/lib/github-access-modal-store";
import { useGitHubRevalidation } from "#/lib/use-github-revalidation";
import { useHasMounted } from "#/lib/use-has-mounted";
import { DashboardBottomBar } from "./dashboard-bottombar";
Expand All @@ -28,8 +31,16 @@ export function DashboardLayout() {
const { user } = routeApi.useRouteContext();
const scope = { userId: user.id };
const hasMounted = useHasMounted();
const missingAppAuthPromptedRef = useRef(false);
const [showOrgSetup, setShowOrgSetup] = useShowOrgSetupQueryState();
useGitHubRevalidation(user.id);

const githubAccessQuery = useQuery({
queryKey: ["github-app-access-state", user.id],
queryFn: () => getGitHubAppAccessState(),
enabled: hasMounted,
staleTime: 5 * 60 * 1000,
});
const pullsQuery = useQuery({
...githubMyPullsQueryOptions(scope),
enabled: hasMounted,
Expand All @@ -52,6 +63,22 @@ export function DashboardLayout() {
: undefined;
const tabsReady = hasMounted && Boolean(pullsQuery.data && issuesQuery.data);

useEffect(() => {
if (
!hasMounted ||
showOrgSetup ||
missingAppAuthPromptedRef.current ||
!githubAccessQuery.data ||
githubAccessQuery.data.installationsAvailable
) {
return;
}

missingAppAuthPromptedRef.current = true;
openGitHubAccessPrompt({ source: "onboarding" });
void setShowOrgSetup(true);
}, [githubAccessQuery.data, hasMounted, setShowOrgSetup, showOrgSetup]);

return (
<div className="isolate flex h-dvh flex-col bg-muted">
<DashboardTopbar
Expand Down
30 changes: 24 additions & 6 deletions apps/dashboard/src/components/layouts/github-access-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ import { useHasMounted } from "#/lib/use-has-mounted";

const ONBOARDING_STORAGE_KEY_PREFIX = "diffkit:github-access-onboarding:v1:";

function getExternalLinkProps(href: string) {
if (href.startsWith("http://") || href.startsWith("https://")) {
return { target: "_blank", rel: "noopener noreferrer" } as const;
}

return {};
}

function getOnboardingStorageKey(userId: string) {
return `${ONBOARDING_STORAGE_KEY_PREFIX}${userId}`;
}
Expand Down Expand Up @@ -92,8 +100,14 @@ export function GitHubAccessDialog({ userId }: { userId: string }) {
const description = prompt?.repo
? `DiffKit needs access to this repository.`
: "Configure the accounts DiffKit can access.";
const primaryHref =
highlightedHref ?? state?.publicInstallUrl ?? prompt?.fallbackHref ?? null;
const needsAppAuthorization =
Boolean(state) && state?.installationsAvailable === false;
const primaryHref = needsAppAuthorization
? state?.appAuthorizationUrl
: (highlightedHref ??
state?.publicInstallUrl ??
prompt?.fallbackHref ??
null);

return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
Expand Down Expand Up @@ -135,8 +149,8 @@ export function GitHubAccessDialog({ userId }: { userId: string }) {
</Button>
{primaryHref ? (
<Button asChild size="sm">
<a href={primaryHref} target="_blank" rel="noopener noreferrer">
Configure access
<a href={primaryHref} {...getExternalLinkProps(primaryHref)}>
{needsAppAuthorization ? "Authorize app" : "Configure access"}
</a>
</Button>
) : null}
Expand Down Expand Up @@ -257,8 +271,12 @@ function AccessList({
size="xs"
className="shrink-0"
>
<a href={target.href} target="_blank" rel="noopener noreferrer">
{target.status === "installed" ? "Manage" : "Configure"}
<a href={target.href} {...getExternalLinkProps(target.href)}>
{target.status === "installed"
? "Manage"
: target.status === "unknown"
? "Authorize"
: "Configure"}
</a>
</Button>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -843,7 +843,13 @@ function MergeFooter({
setIsMerging(true);
try {
const result = await mergePullRequest({
data: { owner, repo, pullNumber, mergeMethod },
data: {
owner,
repo,
pullNumber,
mergeMethod,
bypassProtections: bypassChecks,
},
});
if (result.ok) {
await queryClient.invalidateQueries({ queryKey: ["github"] });
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ declare namespace Cloudflare {
GITHUB_OAUTH_CLIENT_SECRET?: string;
GITHUB_APP_CLIENT_ID?: string;
GITHUB_APP_CLIENT_SECRET?: string;
GITHUB_APP_ID?: string;
GITHUB_APP_PRIVATE_KEY?: string;
GITHUB_APP_SLUG?: string;
GITHUB_WEBHOOK_SECRET?: string;
GITHUB_CLIENT_ID?: string;
Expand Down
18 changes: 17 additions & 1 deletion apps/dashboard/src/lib/auth-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Octokit } from "octokit";
import * as schema from "../db/schema";
import {
getGitHubAccessTokenByUserId,
getGitHubAppUserAccessTokenByUserId,
getGitHubOAuthConfig,
} from "./github-app.server";

Expand All @@ -27,7 +28,7 @@ function createAuth() {
github: {
clientId: github.clientId,
clientSecret: github.clientSecret,
scope: ["repo", "user:email"],
scope: ["repo", "read:org", "user:email"],
},
},
plugins: [tanstackStartCookies()],
Expand Down Expand Up @@ -57,3 +58,18 @@ export async function getGitHubClientByUserId(
throttle: { enabled: false },
});
}

export async function getGitHubAppUserClientByUserId(
userId: string,
): Promise<OctokitType | null> {
const token = await getGitHubAppUserAccessTokenByUserId(userId);
if (!token) {
return null;
}

return new Octokit({
auth: token,
retry: { enabled: false },
throttle: { enabled: false },
});
}
2 changes: 1 addition & 1 deletion apps/dashboard/src/lib/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function getAuth() {
github: {
clientId: github.clientId,
clientSecret: github.clientSecret,
scope: ["repo", "user:email"],
scope: ["repo", "read:org", "user:email"],
},
},
plugins: [tanstackStartCookies()],
Expand Down
13 changes: 13 additions & 0 deletions apps/dashboard/src/lib/github-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ import {
const state: GitHubAppAccessState = {
viewerLogin: "adn",
appSlug: "diff-kit",
appAuthorizationUrl:
"/api/github/app/authorize?returnTo=%2F%3Fshow-org-setup%3Dtrue",
publicInstallUrl: "https://github.com/apps/diff-kit/installations/new",
installationsAvailable: true,
personalInstallation: {
id: 1,
account: {
id: 100,
login: "adn",
name: null,
avatarUrl: null,
Expand All @@ -29,6 +32,7 @@ const state: GitHubAppAccessState = {
{
id: 2,
account: {
id: 200,
login: "supabase",
name: null,
avatarUrl: null,
Expand Down Expand Up @@ -84,6 +88,15 @@ describe("getAccessHrefForOwner", () => {
getAccessHrefForOwner(null, "vercel", "https://fallback.example"),
).toBe("https://fallback.example");
});

it("uses app authorization when installation status is unavailable", () => {
expect(
getAccessHrefForOwner(
{ ...state, installationsAvailable: false },
"supabase",
),
).toBe("/api/github/app/authorize?returnTo=%2F%3Fshow-org-setup%3Dtrue");
});
});

describe("buildGitHubOrganizationInstallationsUrl", () => {
Expand Down
13 changes: 13 additions & 0 deletions apps/dashboard/src/lib/github-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type GitHubInstallationTargetType = "Organization" | "User" | "Unknown";
export type GitHubAppInstallation = {
id: number;
account: {
id: number | null;
login: string;
name: string | null;
avatarUrl: string | null;
Expand All @@ -23,6 +24,7 @@ export type GitHubOrganization = {
export type GitHubAppAccessState = {
viewerLogin: string;
appSlug: string | null;
appAuthorizationUrl: string | null;
publicInstallUrl: string | null;
/** Whether the installations endpoint was reachable (false with OAuth App tokens). */
installationsAvailable: boolean;
Expand All @@ -36,6 +38,13 @@ export function buildGitHubAppInstallUrl(slug: string | null | undefined) {
return slug ? `https://github.com/apps/${slug}/installations/new` : null;
}

export function buildGitHubAppAuthorizePath(
returnTo = "/?show-org-setup=true",
) {
const params = new URLSearchParams({ returnTo });
return `/api/github/app/authorize?${params.toString()}`;
}

export function buildGitHubOrganizationInstallationsUrl(login: string) {
return `https://github.com/organizations/${login}/settings/installations`;
}
Expand Down Expand Up @@ -72,6 +81,10 @@ export function getAccessHrefForOwner(
}

const normalizedOwner = normalizeLogin(owner);
if (!state.installationsAvailable && state.appAuthorizationUrl) {
return state.appAuthorizationUrl;
}

const installation = findInstallationForOwner(state, owner);
if (installation?.manageUrl) {
return installation.manageUrl;
Expand Down
Loading
Loading