From bd40fee6ea2e620020951ba08c883f4703e6d972 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Thu, 9 Apr 2026 10:17:19 -0400 Subject: [PATCH 1/2] Switch to GitHub App auth and webhook revalidation --- CONTRIBUTING.md | 2 +- README.md | 70 +++- apps/dashboard/.dev.vars.example | 23 +- .../0002_github_revalidation_signal.sql | 4 + .../components/layouts/dashboard-layout.tsx | 2 + apps/dashboard/src/db/schema.ts | 8 + apps/dashboard/src/lib/auth-runtime.ts | 36 +- apps/dashboard/src/lib/auth.server.ts | 16 +- apps/dashboard/src/lib/debug.ts | 18 + apps/dashboard/src/lib/github-app.server.ts | 233 +++++++++++++ .../src/lib/github-cache-invalidation.test.ts | 94 +++++ apps/dashboard/src/lib/github-cache.test.ts | 41 +++ apps/dashboard/src/lib/github-cache.ts | 103 +++++- apps/dashboard/src/lib/github-revalidation.ts | 330 ++++++++++++++++++ apps/dashboard/src/lib/github.functions.ts | 84 +++++ apps/dashboard/src/lib/github.server.ts | 18 +- .../src/lib/use-github-revalidation.ts | 207 +++++++++++ apps/dashboard/src/routeTree.gen.ts | 21 ++ .../src/routes/api/webhooks/github.ts | 110 ++++++ apps/dashboard/vite.config.ts | 71 +++- scripts/run-d1-migrations.mjs | 7 +- 21 files changed, 1424 insertions(+), 74 deletions(-) create mode 100644 apps/dashboard/drizzle/0002_github_revalidation_signal.sql create mode 100644 apps/dashboard/src/lib/debug.ts create mode 100644 apps/dashboard/src/lib/github-app.server.ts create mode 100644 apps/dashboard/src/lib/github-cache-invalidation.test.ts create mode 100644 apps/dashboard/src/lib/github-revalidation.ts create mode 100644 apps/dashboard/src/lib/use-github-revalidation.ts create mode 100644 apps/dashboard/src/routes/api/webhooks/github.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f95aa2..ddbc708 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ 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 GitHub OAuth +- **Better Auth** — Authentication with a GitHub App - **Cloudflare D1** — SQLite database at the edge ### Adding a New Route diff --git a/README.md b/README.md index c039c83..0a72503 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A fast, design-first GitHub dashboard for developers who want to stay on top of | Routing | TanStack Router (file-based) | | Data | TanStack Query + Octokit | | Database | Cloudflare D1 (SQLite) via Drizzle ORM | -| Auth | Better Auth with GitHub OAuth | +| Auth | Better Auth with GitHub App | | Styling | Tailwind CSS 4 + Radix UI | | Icons | Lucide React | | Build | Vite 7 + Turborepo | @@ -32,7 +32,7 @@ A fast, design-first GitHub dashboard for developers who want to stay on top of - [Node.js](https://nodejs.org/) (v20+) - [pnpm](https://pnpm.io/) (v10+) -- A [GitHub OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) +- A [GitHub App](https://github.com/settings/apps) ### Setup @@ -54,21 +54,75 @@ A fast, design-first GitHub dashboard for developers who want to stay on top of Create a `.dev.vars` file in `apps/dashboard/`: ``` - GITHUB_CLIENT_ID=your_github_client_id - GITHUB_CLIENT_SECRET=your_github_client_secret + GITHUB_APP_CLIENT_ID=your_github_app_client_id + GITHUB_APP_CLIENT_SECRET=your_github_app_client_secret + GITHUB_WEBHOOK_SECRET=your_github_webhook_secret BETTER_AUTH_SECRET=a_random_32_character_string BETTER_AUTH_URL=http://localhost:3000 ``` - > To get GitHub OAuth credentials, create a new OAuth App in [GitHub Developer Settings](https://github.com/settings/developers) with the callback URL set to `http://localhost:3000/api/auth/callback/github`. + > DiffKit also accepts the legacy `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` names during migration, but new setups should use the `GITHUB_APP_*` names above. -4. **Run database migrations** +4. **Create and install the GitHub App** + + 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` + - Install the app on the repositories or organizations you want DiffKit to access + + Recommended GitHub App permissions derived from the current roadmap: + + | Roadmap area | Roadmap items | GitHub App permission | Level | Notes | + | --- | --- | --- | --- | --- | + | Auth | Sign in and identify the user | User `Email addresses` | Read-only | Required for Better Auth to resolve the user's email address. | + | Core dashboard | Overview, repo list, repo search, private repo access | Repository `Metadata` | Read-only | Required baseline permission for repository-aware reads. | + | Pull requests | View/edit PRs, update branch, request reviewers, review diffs, merge, close/reopen, link issues | Repository `Pull requests` | Read & write | Required now for existing PR mutations and future PR management. | + | Issues | View/create/edit/close issues, labels, milestones, comments | Repository `Issues` | Read & write | Required now for current label mutations and future issue workflows. | + | CI and review status | PR checks, CI-aware review flows, CI notification filtering | Repository `Checks` | Read-only | Required now for pull request status surfaces. | + | GitHub Actions | Workflow run history, job logs, artifacts, rerun/cancel/retry flows, Actions-focused UI | Repository `Actions` | Read & write | `Read` is enough for viewing workflow runs and logs. Use `Read & write` if the product should also rerun, cancel, delete, or otherwise manage workflow runs. | + | Collaborators and teams | Reviewer pickers, org team reviewer flows | Organization `Members` | Read-only | Required for org installs if reviewer assignment should include teams. | + | Repository content | File browser, README preview, branch/tag management, create PR from branches | Repository `Contents` | Read & write | Inference from the roadmap. Likely needed once repository browsing and branch operations ship. | + | Workflow files and policy | Editing `.github/workflows/*`, enabling/disabling workflows, workflow policy/config management | Repository `Workflows` | Read & write | Separate from `Actions`. Needed when the app modifies workflow definitions or workflow configuration, not just when it reads logs or manages runs. | + | Search | Global search across PRs, issues, and repos | No extra permission beyond the resources being searched | N/A | Search inherits access from `Metadata`, `Pull requests`, `Issues`, and likely `Contents`. | + | Notifications | Notification inbox, mark read/unread, filter by type | No matching GitHub App permission | N/A | GitHub's notifications REST endpoints do not support GitHub App user or installation tokens, so this roadmap area needs a different implementation strategy. | + + If we add new permissions after users have already installed the app, GitHub will require those installations to approve the expanded permission set. + + Recommended webhook events in GitHub App setup: + + | GitHub UI label | Enable now | Why | + | --- | --- | --- | + | `Check run` | Yes | Keeps PR status and check-derived cache fresh. | + | `Check suite` | Yes | Captures suite-level CI state changes for PR refreshes. | + | `Issue comment` | Yes | Refreshes issue and PR comment-related views. | + | `Issues` | Yes | Refreshes issue and PR metadata when titles, bodies, labels, or state change. | + | `Pull request` | Yes | Core PR invalidation event. | + | `Pull request review` | Yes | Refreshes review state and PR detail data. | + | `Pull request review comment` | Yes | Refreshes diff discussion and review comment data. | + | `Pull request review thread` | Yes | Refreshes review thread state changes. | + | `Workflow run` | Later | Recommended once the Actions dashboard ships. Useful for workflow-run level updates, logs, reruns, and run state transitions. | + | `Workflow job` | Later | Recommended once the Actions dashboard ships. Useful for job-level logs, timing, and per-job status updates. | + | `Push` | Later | Not used by the current invalidation code, but likely useful once branch-aware repo/activity features expand. | + | `Repository` | Later | Useful for repo settings and metadata changes if repository management surfaces expand. | + | `Create` | Later | Useful for branch/tag creation flows if repo management features ship. | + | `Delete` | Later | Useful for branch/tag deletion flows if repo management features ship. | + + `Workflow run` and `Workflow job` require at least repository `Actions: Read-only`. + + The current webhook invalidation route is wired for the first 8 events above. If you enable the `Later` events now, they are harmless, but the app will ignore them until we add handlers. + + Set the webhook URL to `/api/webhooks/github` on your deployed app. For local webhook testing, use a tunnel that forwards to `http://localhost:3000/api/webhooks/github`. + + For local Vite development, set `DEV_TUNNEL_URL` in `apps/dashboard/.dev.vars` to the full public tunnel URL, for example `https://your-subdomain.ngrok-free.app`. The dev server will use it to allow the tunnel host and configure HMR correctly. + +5. **Run database migrations** ```bash pnpm --filter dashboard migrate ``` -5. **Start the dev server** +6. **Start the dev server** ```bash pnpm dev @@ -156,7 +210,7 @@ A fast, design-first GitHub dashboard for developers who want to stay on top of ### General -- [x] GitHub OAuth authentication +- [x] GitHub App authentication - [x] Dark mode with system preference - [x] Response caching with ETags - [ ] Keyboard shortcuts diff --git a/apps/dashboard/.dev.vars.example b/apps/dashboard/.dev.vars.example index d5d4f41..3fee5ea 100644 --- a/apps/dashboard/.dev.vars.example +++ b/apps/dashboard/.dev.vars.example @@ -1,8 +1,21 @@ -# GitHub OAuth App credentials -# 1. Go to https://github.com/settings/developers -# 2. Click "New OAuth App" -# 3. Set "Authorization callback URL" to http://localhost:3000/api/auth/callback/github -# 4. Copy the Client ID and generate a Client Secret +# GitHub App credentials +# 1. Go to https://github.com/settings/apps +# 2. Create a new GitHub App +# 3. Set the callback URL to http://localhost:3000/api/auth/callback/github +# 4. Under Permissions & events, grant Email addresses account permission: Read-only +# 5. Install the app on the repositories or organizations you want DiffKit to access +GITHUB_APP_CLIENT_ID= +GITHUB_APP_CLIENT_SECRET= + +# GitHub webhook secret used to verify deliveries to /api/webhooks/github +# For local development, point your GitHub App webhook URL at a tunnel that forwards here. +GITHUB_WEBHOOK_SECRET= + +# Optional: full tunnel URL used by Vite dev server to allow webhook traffic and HMR +# Example: https://your-subdomain.ngrok-free.app +DEV_TUNNEL_URL= + +# Legacy OAuth-style names are still supported as a fallback during migration. GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= diff --git a/apps/dashboard/drizzle/0002_github_revalidation_signal.sql b/apps/dashboard/drizzle/0002_github_revalidation_signal.sql new file mode 100644 index 0000000..9e31add --- /dev/null +++ b/apps/dashboard/drizzle/0002_github_revalidation_signal.sql @@ -0,0 +1,4 @@ +CREATE TABLE `github_revalidation_signal` ( + `signal_key` text PRIMARY KEY NOT NULL, + `updated_at` integer NOT NULL +); diff --git a/apps/dashboard/src/components/layouts/dashboard-layout.tsx b/apps/dashboard/src/components/layouts/dashboard-layout.tsx index 4a4fd2b..1188e40 100644 --- a/apps/dashboard/src/components/layouts/dashboard-layout.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-layout.tsx @@ -5,6 +5,7 @@ import { githubMyIssuesQueryOptions, githubMyPullsQueryOptions, } from "#/lib/github.query"; +import { useGitHubRevalidation } from "#/lib/use-github-revalidation"; import { useHasMounted } from "#/lib/use-has-mounted"; import { DashboardTopbar } from "./dashboard-topbar"; @@ -14,6 +15,7 @@ export function DashboardLayout() { const { user } = routeApi.useRouteContext(); const scope = { userId: user.id }; const hasMounted = useHasMounted(); + useGitHubRevalidation(user.id); const pullsQuery = useQuery({ ...githubMyPullsQueryOptions(scope), diff --git a/apps/dashboard/src/db/schema.ts b/apps/dashboard/src/db/schema.ts index 1f1efa4..d12c687 100644 --- a/apps/dashboard/src/db/schema.ts +++ b/apps/dashboard/src/db/schema.ts @@ -79,3 +79,11 @@ export const githubResponseCache = sqliteTable( ), }), ); + +export const githubRevalidationSignal = sqliteTable( + "github_revalidation_signal", + { + signalKey: text("signal_key").primaryKey(), + updatedAt: integer("updated_at").notNull(), + }, +); diff --git a/apps/dashboard/src/lib/auth-runtime.ts b/apps/dashboard/src/lib/auth-runtime.ts index 1818501..370aaca 100644 --- a/apps/dashboard/src/lib/auth-runtime.ts +++ b/apps/dashboard/src/lib/auth-runtime.ts @@ -3,17 +3,20 @@ import { getRequest } from "@tanstack/react-start/server"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { tanstackStartCookies } from "better-auth/tanstack-start"; -import { and, eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/d1"; import type { Octokit as OctokitType } from "octokit"; import { Octokit } from "octokit"; -import { getDb } from "../db"; import * as schema from "../db/schema"; -import { account } from "../db/schema"; +import { + getGitHubAccessTokenByUserId, + getGitHubAppAuthConfig, +} from "./github-app.server"; const authDb = drizzle(env.DB, { schema }); function createAuth() { + const github = getGitHubAppAuthConfig(); + return betterAuth({ baseURL: env.BETTER_AUTH_URL, secret: env.BETTER_AUTH_SECRET, @@ -22,18 +25,8 @@ function createAuth() { }), socialProviders: { github: { - clientId: env.GITHUB_CLIENT_ID, - clientSecret: env.GITHUB_CLIENT_SECRET, - scope: [ - "read:user", - "user:email", - "repo", - "notifications", - "workflow", - "read:project", - "security_events", - "admin:repo_hook", - ], + clientId: github.clientId, + clientSecret: github.clientSecret, }, }, plugins: [tanstackStartCookies()], @@ -57,19 +50,8 @@ export async function getRequestSession() { export async function getGitHubClientByUserId( userId: string, ): Promise { - const db = getDb(); - const githubAccount = await db - .select() - .from(account) - .where(and(eq(account.userId, userId), eq(account.providerId, "github"))) - .get(); - - if (!githubAccount?.accessToken) { - throw new Error("No GitHub account linked"); - } - return new Octokit({ - auth: githubAccount.accessToken, + auth: await getGitHubAccessTokenByUserId(userId), retry: { enabled: false }, throttle: { enabled: false }, }); diff --git a/apps/dashboard/src/lib/auth.server.ts b/apps/dashboard/src/lib/auth.server.ts index 4161307..fb80571 100644 --- a/apps/dashboard/src/lib/auth.server.ts +++ b/apps/dashboard/src/lib/auth.server.ts @@ -5,9 +5,11 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { tanstackStartCookies } from "better-auth/tanstack-start"; import { drizzle } from "drizzle-orm/d1"; import * as schema from "../db/schema"; +import { getGitHubAppAuthConfig } from "./github-app.server"; export function getAuth() { const db = drizzle(env.DB, { schema }); + const github = getGitHubAppAuthConfig(); return betterAuth({ baseURL: env.BETTER_AUTH_URL, @@ -17,18 +19,8 @@ export function getAuth() { }), socialProviders: { github: { - clientId: env.GITHUB_CLIENT_ID, - clientSecret: env.GITHUB_CLIENT_SECRET, - scope: [ - "read:user", - "user:email", - "repo", - "notifications", - "workflow", - "read:project", - "security_events", - "admin:repo_hook", - ], + clientId: github.clientId, + clientSecret: github.clientSecret, }, }, plugins: [tanstackStartCookies()], diff --git a/apps/dashboard/src/lib/debug.ts b/apps/dashboard/src/lib/debug.ts new file mode 100644 index 0000000..0e456db --- /dev/null +++ b/apps/dashboard/src/lib/debug.ts @@ -0,0 +1,18 @@ +type DebugDetails = Record | undefined; + +function isDevEnvironment() { + return import.meta.env.DEV; +} + +export function debug(scope: string, message: string, details?: DebugDetails) { + if (!isDevEnvironment()) { + return; + } + + if (typeof details === "undefined") { + console.log(`[debug:${scope}] ${message}`); + return; + } + + console.log(`[debug:${scope}] ${message}`, details); +} diff --git a/apps/dashboard/src/lib/github-app.server.ts b/apps/dashboard/src/lib/github-app.server.ts new file mode 100644 index 0000000..1095b5f --- /dev/null +++ b/apps/dashboard/src/lib/github-app.server.ts @@ -0,0 +1,233 @@ +import "@tanstack/react-start/server-only"; +import { env } from "cloudflare:workers"; +import { and, eq } from "drizzle-orm"; +import { getDb } from "../db"; +import { account } from "../db/schema"; + +const GITHUB_TOKEN_REFRESH_BUFFER_MS = 60_000; +const GITHUB_ACCESS_TOKEN_ENDPOINT = + "https://github.com/login/oauth/access_token"; +const githubTokenRefreshes = new Map>(); + +type WorkerEnvRecord = typeof env & Record; +type GitHubAccountRecord = typeof account.$inferSelect; + +type GitHubTokenRefreshSuccess = { + access_token: string; + expires_in?: number; + refresh_token?: string; + refresh_token_expires_in?: number; + scope?: string; +}; + +type GitHubTokenRefreshFailure = { + error: string; + error_description?: string; +}; + +function getWorkerEnv() { + return env as WorkerEnvRecord; +} + +function pickFirstNonEmpty(...values: Array) { + return values.find((value) => typeof value === "string" && value.length > 0); +} + +function toFutureDate(seconds?: number) { + if ( + typeof seconds !== "number" || + !Number.isFinite(seconds) || + seconds <= 0 + ) { + return null; + } + + return new Date(Date.now() + seconds * 1_000); +} + +function needsGitHubAccessTokenRefresh(githubAccount: GitHubAccountRecord) { + if (!githubAccount.accessTokenExpiresAt) { + return false; + } + + return ( + githubAccount.accessTokenExpiresAt.getTime() <= + Date.now() + GITHUB_TOKEN_REFRESH_BUFFER_MS + ); +} + +export function getGitHubAppAuthConfig() { + const workerEnv = getWorkerEnv(); + const clientId = pickFirstNonEmpty( + workerEnv.GITHUB_APP_CLIENT_ID, + workerEnv.GITHUB_CLIENT_ID, + ); + const clientSecret = pickFirstNonEmpty( + workerEnv.GITHUB_APP_CLIENT_SECRET, + workerEnv.GITHUB_CLIENT_SECRET, + ); + + if (!clientId || !clientSecret) { + throw new Error( + "Missing GitHub app credentials. Set GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET.", + ); + } + + return { + clientId, + clientSecret, + }; +} + +export function getGitHubWebhookSecret() { + return pickFirstNonEmpty(getWorkerEnv().GITHUB_WEBHOOK_SECRET) ?? null; +} + +async function refreshGitHubAccessToken(githubAccount: GitHubAccountRecord) { + const { clientId, clientSecret } = getGitHubAppAuthConfig(); + + if (!githubAccount.refreshToken) { + throw new Error( + "GitHub access token expired and no refresh token is available.", + ); + } + + const body = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "refresh_token", + refresh_token: githubAccount.refreshToken, + }); + + const response = await fetch(GITHUB_ACCESS_TOKEN_ENDPOINT, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + }); + + const payload = (await response.json()) as + | GitHubTokenRefreshSuccess + | GitHubTokenRefreshFailure; + + if (!response.ok || "error" in payload || !payload.access_token) { + throw new Error( + "error" in payload + ? `GitHub token refresh failed: ${payload.error}` + : "GitHub token refresh failed.", + ); + } + + const db = getDb(); + await db + .update(account) + .set({ + accessToken: payload.access_token, + refreshToken: payload.refresh_token ?? githubAccount.refreshToken, + accessTokenExpiresAt: + toFutureDate(payload.expires_in) ?? githubAccount.accessTokenExpiresAt, + refreshTokenExpiresAt: + toFutureDate(payload.refresh_token_expires_in) ?? + githubAccount.refreshTokenExpiresAt, + scope: payload.scope ?? githubAccount.scope, + updatedAt: new Date(), + }) + .where(eq(account.id, githubAccount.id)); + + return payload.access_token; +} + +export async function getGitHubAccessTokenByUserId(userId: string) { + const db = getDb(); + const githubAccount = await db + .select() + .from(account) + .where(and(eq(account.userId, userId), eq(account.providerId, "github"))) + .get(); + + if (!githubAccount?.accessToken) { + throw new Error("No GitHub account linked"); + } + + if (!needsGitHubAccessTokenRefresh(githubAccount)) { + return githubAccount.accessToken; + } + + const existingRefresh = githubTokenRefreshes.get(githubAccount.id); + if (existingRefresh) { + return existingRefresh; + } + + const refreshTask = refreshGitHubAccessToken(githubAccount).finally(() => { + githubTokenRefreshes.delete(githubAccount.id); + }); + + githubTokenRefreshes.set(githubAccount.id, refreshTask); + + return refreshTask; +} + +function fromHex(hex: string) { + if (hex.length % 2 !== 0) { + return null; + } + + const bytes = new Uint8Array(hex.length / 2); + for (let index = 0; index < hex.length; index += 2) { + const byte = Number.parseInt(hex.slice(index, index + 2), 16); + if (!Number.isFinite(byte)) { + return null; + } + + bytes[index / 2] = byte; + } + + return bytes; +} + +function secureCompare(left: Uint8Array, right: Uint8Array) { + if (left.length !== right.length) { + return false; + } + + let mismatch = 0; + for (let index = 0; index < left.length; index += 1) { + mismatch |= left[index] ^ right[index]; + } + + return mismatch === 0; +} + +export async function verifyGitHubWebhookSignature({ + body, + secret, + signature, +}: { + body: string; + secret: string; + signature: string | null; +}) { + if (!signature?.startsWith("sha256=")) { + return false; + } + + const expectedSignature = fromHex(signature.slice("sha256=".length)); + if (!expectedSignature) { + return false; + } + + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const actualSignature = new Uint8Array( + await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)), + ); + + return secureCompare(actualSignature, expectedSignature); +} diff --git a/apps/dashboard/src/lib/github-cache-invalidation.test.ts b/apps/dashboard/src/lib/github-cache-invalidation.test.ts new file mode 100644 index 0000000..ad2c97b --- /dev/null +++ b/apps/dashboard/src/lib/github-cache-invalidation.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { getGitHubWebhookRevalidationSignalKeys } from "./github-revalidation"; + +describe("getGitHubWebhookRevalidationSignalKeys", () => { + it("maps pull request webhook events to list and pull signals", () => { + expect( + getGitHubWebhookRevalidationSignalKeys("pull_request", { + repository: { + name: "havana", + owner: { login: "stylessh" }, + }, + pull_request: { number: 42 }, + }), + ).toEqual(["pulls.mine", "pull:stylessh/havana#42"]); + }); + + it("treats issue comments on pull requests as pull signals", () => { + expect( + getGitHubWebhookRevalidationSignalKeys("issue_comment", { + repository: { + name: "havana", + owner: { login: "stylessh" }, + }, + issue: { + number: 7, + pull_request: { + url: "https://api.github.com/repos/stylessh/havana/pulls/7", + }, + }, + }), + ).toEqual(["pull:stylessh/havana#7"]); + }); + + it("maps plain issues webhook events to issue list and detail signals", () => { + expect( + getGitHubWebhookRevalidationSignalKeys("issues", { + repository: { + name: "havana", + owner: { login: "stylessh" }, + }, + issue: { + number: 9, + }, + }), + ).toEqual(["issues.mine", "issue:stylessh/havana#9"]); + }); + + it("extracts pull signals from check_run webhook payloads", () => { + expect( + getGitHubWebhookRevalidationSignalKeys("check_run", { + repository: { + name: "havana", + owner: { login: "stylessh" }, + }, + check_run: { + pull_requests: [{ number: 3 }, { number: 5 }], + }, + }), + ).toEqual(["pull:stylessh/havana#3", "pull:stylessh/havana#5"]); + }); + + it("maps workflow_run webhook events to repo and run signals", () => { + expect( + getGitHubWebhookRevalidationSignalKeys("workflow_run", { + repository: { + name: "havana", + owner: { login: "stylessh" }, + }, + workflow_run: { + id: 101, + }, + }), + ).toEqual(["actions:stylessh/havana", "workflowRun:stylessh/havana#101"]); + }); + + it("maps workflow_job webhook events to repo, run, and job signals", () => { + expect( + getGitHubWebhookRevalidationSignalKeys("workflow_job", { + repository: { + name: "havana", + owner: { login: "stylessh" }, + }, + workflow_job: { + id: 202, + run_id: 101, + }, + }), + ).toEqual([ + "actions:stylessh/havana", + "workflowRun:stylessh/havana#101", + "workflowJob:stylessh/havana#202", + ]); + }); +}); diff --git a/apps/dashboard/src/lib/github-cache.test.ts b/apps/dashboard/src/lib/github-cache.test.ts index 9f0083d..2dcf571 100644 --- a/apps/dashboard/src/lib/github-cache.test.ts +++ b/apps/dashboard/src/lib/github-cache.test.ts @@ -213,4 +213,45 @@ describe("getOrRevalidateGitHubResource", () => { }), ).resolves.toEqual([{ fullName: "owner/repo-b" }]); }); + + it("treats a newer revalidation signal as stale even before freshUntil expires", async () => { + const store = createMemoryStore([ + buildEntry({ + resource: "pulls.detail.raw", + cacheKey: + 'user-1::pulls.detail.raw::{"owner":"stylessh","repo":"havana","pullNumber":42}', + paramsJson: '{"owner":"stylessh","repo":"havana","pullNumber":42}', + payloadJson: JSON.stringify({ title: "Old title" }), + fetchedAt: 1_000, + freshUntil: 100_000, + }), + ]); + const fetcher = vi.fn< + (parameters: { + etag?: string | null; + lastModified?: string | null; + }) => Promise> + >(async () => ({ + kind: "success", + data: { title: "New title" }, + metadata: createGitHubResponseMetadata(200, { + etag: '"next"', + }), + })); + + const result = await getOrRevalidateGitHubResource({ + userId: "user-1", + resource: "pulls.detail.raw", + params: { owner: "stylessh", repo: "havana", pullNumber: 42 }, + signalKeys: ["pull:stylessh/havana#42"], + freshForMs: 60_000, + store, + now: () => 5_000, + getLatestSignalUpdatedAt: async () => 4_000, + fetcher, + }); + + expect(result).toEqual({ title: "New title" }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/dashboard/src/lib/github-cache.ts b/apps/dashboard/src/lib/github-cache.ts index 0cc471a..6daaaa3 100644 --- a/apps/dashboard/src/lib/github-cache.ts +++ b/apps/dashboard/src/lib/github-cache.ts @@ -48,11 +48,13 @@ type GetOrRevalidateGitHubResourceOptions = { resource: string; params?: unknown; freshForMs: number; + signalKeys?: string[]; fetcher: ( conditionals: GitHubConditionalHeaders, ) => Promise>; store?: GitHubCacheStore; inFlightCache?: Map>; + getLatestSignalUpdatedAt?: (signalKeys: string[]) => Promise; now?: () => number; }; @@ -184,6 +186,90 @@ async function getGitHubCacheStore(): Promise { }; } +async function getLatestGitHubRevalidationSignalUpdatedAt( + signalKeys: string[], +) { + if (signalKeys.length === 0) { + return null; + } + + const [{ inArray }, { getDb }, { githubRevalidationSignal }] = + await Promise.all([ + import("drizzle-orm"), + import("../db"), + import("../db/schema"), + ]); + const db = getDb(); + const signals = await db + .select({ + updatedAt: githubRevalidationSignal.updatedAt, + }) + .from(githubRevalidationSignal) + .where(inArray(githubRevalidationSignal.signalKey, signalKeys)); + + if (signals.length === 0) { + return null; + } + + return Math.max(...signals.map((signal) => signal.updatedAt)); +} + +export async function markGitHubRevalidationSignals( + signalKeys: string[], + at = Date.now(), +) { + if (signalKeys.length === 0) { + return 0; + } + + const uniqueSignalKeys = Array.from(new Set(signalKeys)); + const [{ getDb }, { githubRevalidationSignal }] = await Promise.all([ + import("../db"), + import("../db/schema"), + ]); + const db = getDb(); + + await db + .insert(githubRevalidationSignal) + .values( + uniqueSignalKeys.map((signalKey) => ({ + signalKey, + updatedAt: at, + })), + ) + .onConflictDoUpdate({ + target: githubRevalidationSignal.signalKey, + set: { + updatedAt: at, + }, + }); + + return uniqueSignalKeys.length; +} + +export async function getGitHubRevalidationSignals(signalKeys: string[]) { + if (signalKeys.length === 0) { + return []; + } + + const uniqueSignalKeys = Array.from(new Set(signalKeys)); + const [{ inArray }, { getDb }, { githubRevalidationSignal }] = + await Promise.all([ + import("drizzle-orm"), + import("../db"), + import("../db/schema"), + ]); + const db = getDb(); + + return db + .select({ + signalKey: githubRevalidationSignal.signalKey, + updatedAt: githubRevalidationSignal.updatedAt, + }) + .from(githubRevalidationSignal) + .where(inArray(githubRevalidationSignal.signalKey, uniqueSignalKeys)); +} + export async function bustGitHubCache( userId: string, resource: string, @@ -213,10 +299,12 @@ export async function getOrRevalidateGitHubResource({ resource, params, freshForMs, + signalKeys = [], fetcher, now = Date.now, store, inFlightCache, + getLatestSignalUpdatedAt = getLatestGitHubRevalidationSignalUpdatedAt, }: GetOrRevalidateGitHubResourceOptions): Promise { const resolvedStore = store ?? (await getGitHubCacheStore()); const paramsJson = stableSerialize(params); @@ -232,8 +320,19 @@ export async function getOrRevalidateGitHubResource({ const task = (async () => { const existingEntry = await resolvedStore.get(cacheKey); const currentTime = now(); - - if (existingEntry && existingEntry.freshUntil > currentTime) { + const latestSignalUpdatedAt = + signalKeys.length > 0 ? await getLatestSignalUpdatedAt(signalKeys) : null; + const isSignalNewerThanCache = Boolean( + existingEntry && + typeof latestSignalUpdatedAt === "number" && + latestSignalUpdatedAt > existingEntry.fetchedAt, + ); + + if ( + existingEntry && + existingEntry.freshUntil > currentTime && + !isSignalNewerThanCache + ) { return parseCachedPayload(existingEntry.payloadJson); } diff --git a/apps/dashboard/src/lib/github-revalidation.ts b/apps/dashboard/src/lib/github-revalidation.ts new file mode 100644 index 0000000..2019136 --- /dev/null +++ b/apps/dashboard/src/lib/github-revalidation.ts @@ -0,0 +1,330 @@ +import type { Tab } from "./tab-store"; + +export const githubRevalidationSignalKeys = { + pullsMine: "pulls.mine", + issuesMine: "issues.mine", + actionsRepo: (input: { owner: string; repo: string }) => + `actions:${input.owner}/${input.repo}`, + pullEntity: (input: { owner: string; repo: string; pullNumber: number }) => + `pull:${input.owner}/${input.repo}#${input.pullNumber}`, + issueEntity: (input: { owner: string; repo: string; issueNumber: number }) => + `issue:${input.owner}/${input.repo}#${input.issueNumber}`, + workflowRunEntity: (input: { owner: string; repo: string; runId: number }) => + `workflowRun:${input.owner}/${input.repo}#${input.runId}`, + workflowJobEntity: (input: { owner: string; repo: string; jobId: number }) => + `workflowJob:${input.owner}/${input.repo}#${input.jobId}`, +} as const; + +export type GitHubRevalidationSignalRecord = { + signalKey: string; + updatedAt: number; +}; + +export type GitHubRevalidationSignalInput = { + signalKeys: string[]; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object"; +} + +function getRepositoryIdentity(payload: unknown) { + if (!isRecord(payload)) { + return null; + } + + const repository = payload.repository; + if (!isRecord(repository)) { + return null; + } + + const repo = repository.name; + const owner = isRecord(repository.owner) ? repository.owner.login : null; + if (typeof owner !== "string" || typeof repo !== "string") { + return null; + } + + return { owner, repo }; +} + +function getPullRequestNumber(payload: unknown) { + if (!isRecord(payload) || !isRecord(payload.pull_request)) { + return null; + } + + return typeof payload.pull_request.number === "number" + ? payload.pull_request.number + : null; +} + +function getIssueIdentity(payload: unknown) { + if (!isRecord(payload) || !isRecord(payload.issue)) { + return null; + } + + const number = payload.issue.number; + if (typeof number !== "number") { + return null; + } + + return { + number, + isPullRequest: isRecord(payload.issue.pull_request), + }; +} + +function getWorkflowRunId(payload: unknown) { + if (!isRecord(payload)) { + return null; + } + + if ( + isRecord(payload.workflow_run) && + typeof payload.workflow_run.id === "number" + ) { + return payload.workflow_run.id; + } + + if ( + isRecord(payload.workflow_job) && + typeof payload.workflow_job.run_id === "number" + ) { + return payload.workflow_job.run_id; + } + + return null; +} + +function getWorkflowJobId(payload: unknown) { + if (!isRecord(payload) || !isRecord(payload.workflow_job)) { + return null; + } + + return typeof payload.workflow_job.id === "number" + ? payload.workflow_job.id + : null; +} + +function getCheckRunPullSignals(payload: unknown) { + const repository = getRepositoryIdentity(payload); + if (!repository || !isRecord(payload)) { + return []; + } + + const checkRun = payload.check_run; + if (!isRecord(checkRun) || !Array.isArray(checkRun.pull_requests)) { + return []; + } + + return checkRun.pull_requests.flatMap((pull) => { + if (!isRecord(pull) || typeof pull.number !== "number") { + return []; + } + + return [ + githubRevalidationSignalKeys.pullEntity({ + owner: repository.owner, + repo: repository.repo, + pullNumber: pull.number, + }), + ]; + }); +} + +function getCheckSuitePullSignals(payload: unknown) { + const repository = getRepositoryIdentity(payload); + if (!repository || !isRecord(payload)) { + return []; + } + + const checkSuite = payload.check_suite; + if (!isRecord(checkSuite) || !Array.isArray(checkSuite.pull_requests)) { + return []; + } + + return checkSuite.pull_requests.flatMap((pull) => { + if (!isRecord(pull) || typeof pull.number !== "number") { + return []; + } + + return [ + githubRevalidationSignalKeys.pullEntity({ + owner: repository.owner, + repo: repository.repo, + pullNumber: pull.number, + }), + ]; + }); +} + +export function getGitHubWebhookRevalidationSignalKeys( + event: string, + payload: unknown, +) { + const repository = getRepositoryIdentity(payload); + if (!repository) { + return []; + } + + if (event === "pull_request" || event === "pull_request_review") { + const pullNumber = getPullRequestNumber(payload); + return typeof pullNumber === "number" + ? [ + githubRevalidationSignalKeys.pullsMine, + githubRevalidationSignalKeys.pullEntity({ + owner: repository.owner, + repo: repository.repo, + pullNumber, + }), + ] + : [githubRevalidationSignalKeys.pullsMine]; + } + + if ( + event === "pull_request_review_comment" || + event === "pull_request_review_thread" + ) { + const pullNumber = getPullRequestNumber(payload); + return typeof pullNumber === "number" + ? [ + githubRevalidationSignalKeys.pullEntity({ + owner: repository.owner, + repo: repository.repo, + pullNumber, + }), + ] + : []; + } + + if (event === "issues") { + const issueIdentity = getIssueIdentity(payload); + if (!issueIdentity) { + return [githubRevalidationSignalKeys.issuesMine]; + } + + return issueIdentity.isPullRequest + ? [ + githubRevalidationSignalKeys.pullsMine, + githubRevalidationSignalKeys.pullEntity({ + owner: repository.owner, + repo: repository.repo, + pullNumber: issueIdentity.number, + }), + ] + : [ + githubRevalidationSignalKeys.issuesMine, + githubRevalidationSignalKeys.issueEntity({ + owner: repository.owner, + repo: repository.repo, + issueNumber: issueIdentity.number, + }), + ]; + } + + if (event === "issue_comment") { + const issueIdentity = getIssueIdentity(payload); + if (!issueIdentity) { + return []; + } + + return issueIdentity.isPullRequest + ? [ + githubRevalidationSignalKeys.pullEntity({ + owner: repository.owner, + repo: repository.repo, + pullNumber: issueIdentity.number, + }), + ] + : [ + githubRevalidationSignalKeys.issueEntity({ + owner: repository.owner, + repo: repository.repo, + issueNumber: issueIdentity.number, + }), + ]; + } + + if (event === "check_run") { + return getCheckRunPullSignals(payload); + } + + if (event === "check_suite") { + return getCheckSuitePullSignals(payload); + } + + if (event === "workflow_run") { + const runId = getWorkflowRunId(payload); + return typeof runId === "number" + ? [ + githubRevalidationSignalKeys.actionsRepo({ + owner: repository.owner, + repo: repository.repo, + }), + githubRevalidationSignalKeys.workflowRunEntity({ + owner: repository.owner, + repo: repository.repo, + runId, + }), + ] + : [ + githubRevalidationSignalKeys.actionsRepo({ + owner: repository.owner, + repo: repository.repo, + }), + ]; + } + + if (event === "workflow_job") { + const runId = getWorkflowRunId(payload); + const jobId = getWorkflowJobId(payload); + + return [ + githubRevalidationSignalKeys.actionsRepo({ + owner: repository.owner, + repo: repository.repo, + }), + ...(typeof runId === "number" + ? [ + githubRevalidationSignalKeys.workflowRunEntity({ + owner: repository.owner, + repo: repository.repo, + runId, + }), + ] + : []), + ...(typeof jobId === "number" + ? [ + githubRevalidationSignalKeys.workflowJobEntity({ + owner: repository.owner, + repo: repository.repo, + jobId, + }), + ] + : []), + ]; + } + + return []; +} + +export function getGitHubRevalidationSignalKeysForTab(tab: Tab) { + const [owner, repo] = tab.repo.split("/"); + + if (tab.type === "pull" || tab.type === "review") { + return [ + githubRevalidationSignalKeys.pullEntity({ + owner, + repo, + pullNumber: tab.number, + }), + ]; + } + + return [ + githubRevalidationSignalKeys.issueEntity({ + owner, + repo, + issueNumber: tab.number, + }), + ]; +} diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 21c4d21..ea5dc4f 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -32,9 +32,14 @@ import { createGitHubResponseMetadata, type GitHubConditionalHeaders, type GitHubFetchResult, + getGitHubRevalidationSignals, getOrRevalidateGitHubResource, } from "./github-cache"; import { githubCachePolicy } from "./github-cache-policy"; +import { + type GitHubRevalidationSignalInput, + githubRevalidationSignalKeys, +} from "./github-revalidation"; type GitHubClient = OctokitType; type AuthSession = { @@ -559,6 +564,7 @@ async function getCachedGitHubRequest({ resource, params, freshForMs, + signalKeys, request, mapData, }: { @@ -566,6 +572,7 @@ async function getCachedGitHubRequest({ resource: string; params: unknown; freshForMs: number; + signalKeys?: string[]; request: ( headers: Record, ) => Promise>; @@ -576,6 +583,7 @@ async function getCachedGitHubRequest({ resource, params, freshForMs, + signalKeys, fetcher: async (conditionals) => { const result = await executeGitHubRequest(request, conditionals); @@ -607,6 +615,13 @@ async function getCachedPullResponse({ resource, params: data, freshForMs, + signalKeys: [ + githubRevalidationSignalKeys.pullEntity({ + owner: data.owner, + repo: data.repo, + pullNumber: data.pullNumber, + }), + ], request: (headers) => context.octokit.rest.pulls.get({ owner: data.owner, @@ -645,6 +660,13 @@ async function getPullCommentsResult( resource: "pulls.comments", params: data, freshForMs: githubCachePolicy.activity.staleTimeMs, + signalKeys: [ + githubRevalidationSignalKeys.pullEntity({ + owner: data.owner, + repo: data.repo, + pullNumber: data.pullNumber, + }), + ], request: (headers) => context.octokit.rest.issues.listComments({ owner: data.owner, @@ -683,6 +705,13 @@ async function getPullCommitsResult( resource: "pulls.commits", params: data, freshForMs: githubCachePolicy.activity.staleTimeMs, + signalKeys: [ + githubRevalidationSignalKeys.pullEntity({ + owner: data.owner, + repo: data.repo, + pullNumber: data.pullNumber, + }), + ], request: (headers) => context.octokit.rest.pulls.listCommits({ owner: data.owner, @@ -811,6 +840,13 @@ async function getPullStatusResult( resource: "pulls.status.v1", params: data, freshForMs: githubCachePolicy.status.staleTimeMs, + signalKeys: [ + githubRevalidationSignalKeys.pullEntity({ + owner: data.owner, + repo: data.repo, + pullNumber: data.pullNumber, + }), + ], fetcher: async () => { const pullForStatus = pull ?? @@ -862,6 +898,13 @@ async function getIssueDetailResult( resource: "issues.detail", params: data, freshForMs: githubCachePolicy.detail.staleTimeMs, + signalKeys: [ + githubRevalidationSignalKeys.issueEntity({ + owner: data.owner, + repo: data.repo, + issueNumber: data.issueNumber, + }), + ], request: (headers) => context.octokit.rest.issues.get({ owner: data.owner, @@ -892,6 +935,13 @@ async function getIssueCommentsResult( resource: "issues.comments", params: data, freshForMs: githubCachePolicy.activity.staleTimeMs, + signalKeys: [ + githubRevalidationSignalKeys.issueEntity({ + owner: data.owner, + repo: data.repo, + issueNumber: data.issueNumber, + }), + ], request: (headers) => context.octokit.rest.issues.listComments({ owner: data.owner, @@ -993,6 +1043,7 @@ async function getMyPullSlice({ resource: `pulls.mine.${roleKey}`, params: { username, role }, freshForMs: githubCachePolicy.list.staleTimeMs, + signalKeys: [githubRevalidationSignalKeys.pullsMine], request: (headers) => context.octokit.rest.search.issuesAndPullRequests({ q: buildUserSearchQuery({ @@ -1026,6 +1077,7 @@ async function getMyIssueSlice({ resource: `issues.mine.${roleKey}`, params: { username, role }, freshForMs: githubCachePolicy.list.staleTimeMs, + signalKeys: [githubRevalidationSignalKeys.issuesMine], request: (headers) => context.octokit.rest.search.issuesAndPullRequests({ q: buildUserSearchQuery({ @@ -1047,6 +1099,20 @@ function identityValidator(data: TInput) { return data; } +export const getGitHubRevalidationSignalRecords = createServerFn({ + method: "POST", +}) + .inputValidator(identityValidator) + .handler(async ({ data }) => { + const { getRequestSession } = await import("./auth-runtime"); + const session = await getRequestSession(); + if (!session) { + return []; + } + + return getGitHubRevalidationSignals(data.signalKeys); + }); + export const getGitHubViewer = createServerFn({ method: "GET" }).handler( async () => { const context = await getGitHubContext(); @@ -1169,6 +1235,7 @@ export const getPullsFromUser = createServerFn({ method: "GET" }) repo: data.repo, }, freshForMs: githubCachePolicy.list.staleTimeMs, + signalKeys: [githubRevalidationSignalKeys.pullsMine], request: (headers) => context.octokit.rest.search.issuesAndPullRequests({ q: buildUserSearchQuery({ @@ -1213,6 +1280,7 @@ export const getPullsFromRepo = createServerFn({ method: "GET" }) direction: data.direction ?? "desc", }, freshForMs: githubCachePolicy.list.staleTimeMs, + signalKeys: [githubRevalidationSignalKeys.pullsMine], request: (headers) => context.octokit.rest.pulls.list({ owner: data.owner, @@ -1315,6 +1383,7 @@ export const getIssuesFromUser = createServerFn({ method: "GET" }) repo: data.repo, }, freshForMs: githubCachePolicy.list.staleTimeMs, + signalKeys: [githubRevalidationSignalKeys.issuesMine], request: (headers) => context.octokit.rest.search.issuesAndPullRequests({ q: buildUserSearchQuery({ @@ -1361,6 +1430,7 @@ export const getIssuesFromRepo = createServerFn({ method: "GET" }) direction: data.direction ?? "desc", }, freshForMs: githubCachePolicy.list.staleTimeMs, + signalKeys: [githubRevalidationSignalKeys.issuesMine], request: (headers) => context.octokit.rest.issues.listForRepo({ owner: data.owner, @@ -1490,6 +1560,13 @@ async function getPullFilesResult( resource: "pulls.files", params: data, freshForMs: githubCachePolicy.detail.staleTimeMs, + signalKeys: [ + githubRevalidationSignalKeys.pullEntity({ + owner: data.owner, + repo: data.repo, + pullNumber: data.pullNumber, + }), + ], request: (headers) => context.octokit.rest.pulls.listFiles({ owner: data.owner, @@ -1532,6 +1609,13 @@ async function getPullReviewCommentsResult( resource: "pulls.reviewComments", params: data, freshForMs: githubCachePolicy.activity.staleTimeMs, + signalKeys: [ + githubRevalidationSignalKeys.pullEntity({ + owner: data.owner, + repo: data.repo, + pullNumber: data.pullNumber, + }), + ], request: (headers) => context.octokit.rest.pulls.listReviewComments({ owner: data.owner, diff --git a/apps/dashboard/src/lib/github.server.ts b/apps/dashboard/src/lib/github.server.ts index 9f70f21..776a810 100644 --- a/apps/dashboard/src/lib/github.server.ts +++ b/apps/dashboard/src/lib/github.server.ts @@ -1,24 +1,10 @@ import "@tanstack/react-start/server-only"; -import { and, eq } from "drizzle-orm"; import { Octokit, type Octokit as OctokitType } from "octokit"; -import { getDb } from "../db"; -import { account } from "../db/schema"; +import { getGitHubAccessTokenByUserId } from "./github-app.server"; export async function getGitHubClient(userId: string): Promise { - const db = getDb(); - - const githubAccount = await db - .select() - .from(account) - .where(and(eq(account.userId, userId), eq(account.providerId, "github"))) - .get(); - - if (!githubAccount?.accessToken) { - throw new Error("No GitHub account linked"); - } - return new Octokit({ - auth: githubAccount.accessToken, + auth: await getGitHubAccessTokenByUserId(userId), retry: { enabled: false }, throttle: { enabled: false }, }); diff --git a/apps/dashboard/src/lib/use-github-revalidation.ts b/apps/dashboard/src/lib/use-github-revalidation.ts new file mode 100644 index 0000000..8ce9d4d --- /dev/null +++ b/apps/dashboard/src/lib/use-github-revalidation.ts @@ -0,0 +1,207 @@ +import { type QueryClient, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; +import { debug } from "./debug"; +import { getGitHubRevalidationSignalRecords } from "./github.functions"; +import { type GitHubQueryScope, githubQueryKeys } from "./github.query"; +import { + getGitHubRevalidationSignalKeysForTab, + githubRevalidationSignalKeys, +} from "./github-revalidation"; +import { type Tab, useTabs } from "./tab-store"; + +const GITHUB_REVALIDATION_POLL_INTERVAL_MS = 10_000; + +function getUniqueSignalKeys(tabs: Tab[]) { + return Array.from( + new Set([ + githubRevalidationSignalKeys.pullsMine, + githubRevalidationSignalKeys.issuesMine, + ...tabs.flatMap((tab) => getGitHubRevalidationSignalKeysForTab(tab)), + ]), + ); +} + +function getQueryUpdatedAt( + queryClient: QueryClient, + queryKey: readonly unknown[], +) { + return queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0; +} + +async function invalidatePullTabQueries( + queryClient: QueryClient, + scope: GitHubQueryScope, + tab: Tab, +) { + const [owner, repo] = tab.repo.split("/"); + const input = { owner, repo, pullNumber: tab.number }; + const queryKeys = [ + githubQueryKeys.pulls.page(scope, input), + githubQueryKeys.pulls.detail(scope, input), + githubQueryKeys.pulls.comments(scope, input), + githubQueryKeys.pulls.status(scope, input), + githubQueryKeys.pulls.files(scope, input), + githubQueryKeys.pulls.reviewComments(scope, input), + ]; + + const hasOlderQuery = queryKeys.some( + (queryKey) => getQueryUpdatedAt(queryClient, queryKey) > 0, + ); + if (!hasOlderQuery) { + return; + } + + await Promise.all( + queryKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + ); +} + +async function invalidateIssueTabQueries( + queryClient: QueryClient, + scope: GitHubQueryScope, + tab: Tab, +) { + const [owner, repo] = tab.repo.split("/"); + const input = { owner, repo, issueNumber: tab.number }; + const queryKeys = [ + githubQueryKeys.issues.page(scope, input), + githubQueryKeys.issues.detail(scope, input), + githubQueryKeys.issues.comments(scope, input), + ]; + + const hasOlderQuery = queryKeys.some( + (queryKey) => getQueryUpdatedAt(queryClient, queryKey) > 0, + ); + if (!hasOlderQuery) { + return; + } + + await Promise.all( + queryKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + ); +} + +export function useGitHubRevalidation(userId: string) { + const queryClient = useQueryClient(); + const tabs = useTabs(); + const scope = useMemo(() => ({ userId }), [userId]); + const signalKeys = useMemo(() => getUniqueSignalKeys(tabs), [tabs]); + + useEffect(() => { + let cancelled = false; + let timeoutId: number | undefined; + + const pollSignals = async () => { + try { + const records = await getGitHubRevalidationSignalRecords({ + data: { signalKeys }, + }); + if (cancelled) { + return; + } + + const signalsByKey = new Map( + records.map((record) => [record.signalKey, record.updatedAt]), + ); + const invalidations: Promise[] = []; + + const pullsMineUpdatedAt = + signalsByKey.get(githubRevalidationSignalKeys.pullsMine) ?? 0; + if ( + pullsMineUpdatedAt > + getQueryUpdatedAt(queryClient, githubQueryKeys.pulls.mine(scope)) + ) { + debug("github-revalidation", "invalidating pull list queries", { + pullsMineUpdatedAt, + }); + invalidations.push( + queryClient.invalidateQueries({ + queryKey: githubQueryKeys.pulls.mine(scope), + }), + ); + } + + const issuesMineUpdatedAt = + signalsByKey.get(githubRevalidationSignalKeys.issuesMine) ?? 0; + if ( + issuesMineUpdatedAt > + getQueryUpdatedAt(queryClient, githubQueryKeys.issues.mine(scope)) + ) { + debug("github-revalidation", "invalidating issue list queries", { + issuesMineUpdatedAt, + }); + invalidations.push( + queryClient.invalidateQueries({ + queryKey: githubQueryKeys.issues.mine(scope), + }), + ); + } + + for (const tab of tabs) { + const signalKey = getGitHubRevalidationSignalKeysForTab(tab)[0]; + const updatedAt = signalsByKey.get(signalKey) ?? 0; + if (updatedAt === 0) { + continue; + } + + if (tab.type === "pull" || tab.type === "review") { + const [owner, repo] = tab.repo.split("/"); + const comparisonKey = githubQueryKeys.pulls.page(scope, { + owner, + repo, + pullNumber: tab.number, + }); + if (updatedAt > getQueryUpdatedAt(queryClient, comparisonKey)) { + debug("github-revalidation", "invalidating pull tab queries", { + signalKey, + tabId: tab.id, + }); + invalidations.push( + invalidatePullTabQueries(queryClient, scope, tab), + ); + } + continue; + } + + const [owner, repo] = tab.repo.split("/"); + const comparisonKey = githubQueryKeys.issues.page(scope, { + owner, + repo, + issueNumber: tab.number, + }); + if (updatedAt > getQueryUpdatedAt(queryClient, comparisonKey)) { + debug("github-revalidation", "invalidating issue tab queries", { + signalKey, + tabId: tab.id, + }); + invalidations.push( + invalidateIssueTabQueries(queryClient, scope, tab), + ); + } + } + + await Promise.all(invalidations); + } catch (error) { + debug("github-revalidation", "poll failed", { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + if (!cancelled) { + timeoutId = window.setTimeout( + pollSignals, + GITHUB_REVALIDATION_POLL_INTERVAL_MS, + ); + } + } + }; + + void pollSignals(); + + return () => { + cancelled = true; + if (typeof timeoutId !== "undefined") { + window.clearTimeout(timeoutId); + } + }; + }, [queryClient, scope, signalKeys, tabs]); +} diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 379592e..37dd367 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as ProtectedIndexRouteImport } from './routes/_protected/index' import { Route as ProtectedReviewsRouteImport } from './routes/_protected/reviews' import { Route as ProtectedPullsRouteImport } from './routes/_protected/pulls' import { Route as ProtectedIssuesRouteImport } from './routes/_protected/issues' +import { Route as ApiWebhooksGithubRouteImport } from './routes/api/webhooks/github' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ProtectedOwnerRepoReviewPullIdRouteImport } from './routes/_protected/$owner/$repo/review.$pullId' import { Route as ProtectedOwnerRepoPullPullIdRouteImport } from './routes/_protected/$owner/$repo/pull.$pullId' @@ -61,6 +62,11 @@ const ProtectedIssuesRoute = ProtectedIssuesRouteImport.update({ path: '/issues', getParentRoute: () => ProtectedRoute, } as any) +const ApiWebhooksGithubRoute = ApiWebhooksGithubRouteImport.update({ + id: '/api/webhooks/github', + path: '/api/webhooks/github', + getParentRoute: () => rootRouteImport, +} as any) const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ id: '/api/auth/$', path: '/api/auth/$', @@ -94,6 +100,7 @@ export interface FileRoutesByFullPath { '/pulls': typeof ProtectedPullsRoute '/reviews': typeof ProtectedReviewsRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute @@ -107,6 +114,7 @@ export interface FileRoutesByTo { '/reviews': typeof ProtectedReviewsRoute '/': typeof ProtectedIndexRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute @@ -122,6 +130,7 @@ export interface FileRoutesById { '/_protected/reviews': typeof ProtectedReviewsRoute '/_protected/': typeof ProtectedIndexRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/webhooks/github': typeof ApiWebhooksGithubRoute '/_protected/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/_protected/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/_protected/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute @@ -137,6 +146,7 @@ export interface FileRouteTypes { | '/pulls' | '/reviews' | '/api/auth/$' + | '/api/webhooks/github' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/pull/$pullId' | '/$owner/$repo/review/$pullId' @@ -150,6 +160,7 @@ export interface FileRouteTypes { | '/reviews' | '/' | '/api/auth/$' + | '/api/webhooks/github' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/pull/$pullId' | '/$owner/$repo/review/$pullId' @@ -164,6 +175,7 @@ export interface FileRouteTypes { | '/_protected/reviews' | '/_protected/' | '/api/auth/$' + | '/api/webhooks/github' | '/_protected/$owner/$repo/issues/$issueId' | '/_protected/$owner/$repo/pull/$pullId' | '/_protected/$owner/$repo/review/$pullId' @@ -175,6 +187,7 @@ export interface RootRouteChildren { RobotsDottxtRoute: typeof RobotsDottxtRoute SitemapDotxmlRoute: typeof SitemapDotxmlRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute + ApiWebhooksGithubRoute: typeof ApiWebhooksGithubRoute } declare module '@tanstack/react-router' { @@ -235,6 +248,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedIssuesRouteImport parentRoute: typeof ProtectedRoute } + '/api/webhooks/github': { + id: '/api/webhooks/github' + path: '/api/webhooks/github' + fullPath: '/api/webhooks/github' + preLoaderRoute: typeof ApiWebhooksGithubRouteImport + parentRoute: typeof rootRouteImport + } '/api/auth/$': { id: '/api/auth/$' path: '/api/auth/$' @@ -296,6 +316,7 @@ const rootRouteChildren: RootRouteChildren = { RobotsDottxtRoute: RobotsDottxtRoute, SitemapDotxmlRoute: SitemapDotxmlRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, + ApiWebhooksGithubRoute: ApiWebhooksGithubRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/dashboard/src/routes/api/webhooks/github.ts b/apps/dashboard/src/routes/api/webhooks/github.ts new file mode 100644 index 0000000..f6fd47a --- /dev/null +++ b/apps/dashboard/src/routes/api/webhooks/github.ts @@ -0,0 +1,110 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { debug } from "#/lib/debug"; +import { + getGitHubWebhookSecret, + verifyGitHubWebhookSignature, +} from "#/lib/github-app.server"; +import { markGitHubRevalidationSignals } from "#/lib/github-cache"; +import { getGitHubWebhookRevalidationSignalKeys } from "#/lib/github-revalidation"; +import { PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; + +export const Route = createFileRoute("/api/webhooks/github")({ + headers: () => PRIVATE_ROUTE_HEADERS, + server: { + handlers: { + POST: async ({ request }) => { + const event = request.headers.get("x-github-event"); + const deliveryId = request.headers.get("x-github-delivery"); + const signature = request.headers.get("x-hub-signature-256"); + const webhookSecret = getGitHubWebhookSecret(); + + if (!webhookSecret) { + debug("github-webhook", "missing webhook secret", { + deliveryId, + event, + }); + return new Response("GitHub webhook secret is not configured.", { + status: 503, + }); + } + + const requestBody = await request.text(); + debug("github-webhook", "received webhook request", { + deliveryId, + event, + hasSignature: Boolean(signature), + userAgent: request.headers.get("user-agent"), + }); + + const isValid = await verifyGitHubWebhookSignature({ + body: requestBody, + secret: webhookSecret, + signature, + }); + + if (!isValid) { + debug("github-webhook", "rejected webhook due to invalid signature", { + deliveryId, + event, + }); + return new Response("Invalid webhook signature.", { + status: 401, + }); + } + + if (!event) { + debug("github-webhook", "rejected webhook due to missing event", { + deliveryId, + }); + return new Response("Missing GitHub event header.", { + status: 400, + }); + } + + let payload: unknown; + try { + payload = JSON.parse(requestBody) as unknown; + } catch { + debug("github-webhook", "rejected webhook due to invalid json", { + deliveryId, + event, + requestBody, + }); + return new Response("Invalid JSON payload.", { + status: 400, + }); + } + + debug("github-webhook", "parsed webhook payload", { + deliveryId, + event, + payload, + }); + + const signalKeys = getGitHubWebhookRevalidationSignalKeys( + event, + payload, + ); + const updatedSignalCount = + await markGitHubRevalidationSignals(signalKeys); + + debug("github-webhook", "processed webhook", { + deliveryId, + event, + signalKeys, + updatedSignalCount, + }); + + return Response.json( + { + ok: true, + event, + signalCount: signalKeys.length, + updatedSignalCount, + }, + { status: 202 }, + ); + }, + }, + }, +}); diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts index f061b43..4318ffb 100644 --- a/apps/dashboard/vite.config.ts +++ b/apps/dashboard/vite.config.ts @@ -1,9 +1,8 @@ +import { existsSync, readFileSync } from "node:fs"; import { cloudflare } from "@cloudflare/vite-plugin"; import tailwindcss from "@tailwindcss/vite"; import { devtools } from "@tanstack/devtools-vite"; - import { tanstackStart } from "@tanstack/react-start/plugin/vite"; - import viteReact from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; @@ -17,6 +16,73 @@ const worktreePersistState = isWorktreeCheckout(dashboardRoot) ? { persistState: { path: getSharedWranglerStatePath(dashboardRoot) } } : {}; +function getDevVarFromFile(key: string) { + const devVarsPath = new URL("./.dev.vars", dashboardRoot); + if (!existsSync(devVarsPath)) { + return null; + } + + const lines = readFileSync(devVarsPath, "utf8").split(/\r?\n/u); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + + const separatorIndex = trimmed.indexOf("="); + if (separatorIndex <= 0) { + continue; + } + + const parsedKey = trimmed.slice(0, separatorIndex).trim(); + if (parsedKey !== key) { + continue; + } + + const rawValue = trimmed.slice(separatorIndex + 1).trim(); + return rawValue.replace(/^['"]|['"]$/gu, ""); + } + + return null; +} + +function getDevTunnelUrl() { + return process.env.DEV_TUNNEL_URL ?? getDevVarFromFile("DEV_TUNNEL_URL"); +} + +function getTunnelServerConfig(): import("vite").UserConfig["server"] { + const tunnelUrl = getDevTunnelUrl(); + if (!tunnelUrl) { + return undefined; + } + + let parsedTunnelUrl: URL; + try { + parsedTunnelUrl = new URL(tunnelUrl); + } catch { + throw new Error( + `Invalid DEV_TUNNEL_URL: ${JSON.stringify(tunnelUrl)}. Expected a full URL like https://example.ngrok-free.app.`, + ); + } + + const isSecure = parsedTunnelUrl.protocol === "https:"; + const port = + parsedTunnelUrl.port.length > 0 + ? Number.parseInt(parsedTunnelUrl.port, 10) + : isSecure + ? 443 + : 80; + + return { + allowedHosts: [parsedTunnelUrl.hostname], + hmr: { + host: parsedTunnelUrl.hostname, + protocol: isSecure ? "wss" : "ws", + clientPort: port, + }, + }; +} + // Stub out shiki in the SSR (Cloudflare Worker) environment to prevent all // language grammars (~1.5 MB) from being bundled. Shiki is only used // client-side so the server never needs the real implementation. @@ -55,6 +121,7 @@ export default {};`; } const config = defineConfig({ + server: getTunnelServerConfig(), plugins: [ devtools(), shikiSSRStub(), diff --git a/scripts/run-d1-migrations.mjs b/scripts/run-d1-migrations.mjs index 9bfc723..ecd9e4a 100644 --- a/scripts/run-d1-migrations.mjs +++ b/scripts/run-d1-migrations.mjs @@ -26,8 +26,13 @@ function runPnpm(commandArgs) { const pnpmExecPath = process.env.npm_execpath; if (pnpmExecPath) { - return spawnSync(process.execPath, [pnpmExecPath, ...commandArgs], { + const shouldUseNode = /\.(c|m)?js$/u.test(pnpmExecPath); + const command = shouldUseNode ? process.execPath : pnpmExecPath; + const args = shouldUseNode ? [pnpmExecPath, ...commandArgs] : commandArgs; + + return spawnSync(command, args, { stdio: "inherit", + shell: process.platform === "win32" && !shouldUseNode, }); } From b670f01b682edcdaffcb032a4c6feba2ee0c94bb Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Thu, 9 Apr 2026 10:21:35 -0400 Subject: [PATCH 2/2] Limit tunnel config to dev server --- apps/dashboard/vite.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts index 4318ffb..3f7af76 100644 --- a/apps/dashboard/vite.config.ts +++ b/apps/dashboard/vite.config.ts @@ -120,8 +120,8 @@ export default {};`; }; } -const config = defineConfig({ - server: getTunnelServerConfig(), +const config = defineConfig(({ command }) => ({ + server: command === "serve" ? getTunnelServerConfig() : undefined, plugins: [ devtools(), shikiSSRStub(), @@ -134,6 +134,6 @@ const config = defineConfig({ tanstackStart(), viteReact(), ], -}); +})); export default config;