diff --git a/apps/dashboard/biome.jsonc b/apps/dashboard/biome.jsonc index 170d0f0..36aa0e0 100644 --- a/apps/dashboard/biome.jsonc +++ b/apps/dashboard/biome.jsonc @@ -3,6 +3,11 @@ "root": false, "extends": ["ultracite/biome/react", "ultracite/biome/vitest"], "files": { - "includes": ["**", "!**/src/routeTree.gen.ts", "!**/src/styles.css"] + "includes": [ + "**", + "!**/src/routeTree.gen.ts", + "!**/src/styles.css", + "!**/worker-configuration.d.ts" + ] } } diff --git a/apps/dashboard/drizzle/0001_outstanding_blizzard.sql b/apps/dashboard/drizzle/0001_outstanding_blizzard.sql new file mode 100644 index 0000000..5e063b5 --- /dev/null +++ b/apps/dashboard/drizzle/0001_outstanding_blizzard.sql @@ -0,0 +1,17 @@ +CREATE TABLE `github_response_cache` ( + `cache_key` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `resource` text NOT NULL, + `params_json` text NOT NULL, + `etag` text, + `last_modified` text, + `payload_json` text NOT NULL, + `fetched_at` integer NOT NULL, + `fresh_until` integer NOT NULL, + `rate_limit_remaining` integer, + `rate_limit_reset` integer, + `status_code` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `github_response_cache_user_resource_idx` ON `github_response_cache` (`user_id`,`resource`); \ No newline at end of file diff --git a/apps/dashboard/drizzle/meta/0001_snapshot.json b/apps/dashboard/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..4d2b7cc --- /dev/null +++ b/apps/dashboard/drizzle/meta/0001_snapshot.json @@ -0,0 +1,438 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "fdba126e-2eed-4796-b637-833cc50170d9", + "prevId": "90a22217-2269-45bc-94b5-49ac2a6031e8", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "github_response_cache": { + "name": "github_response_cache", + "columns": { + "cache_key": { + "name": "cache_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "params_json": { + "name": "params_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fresh_until": { + "name": "fresh_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rate_limit_remaining": { + "name": "rate_limit_remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rate_limit_reset": { + "name": "rate_limit_reset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "github_response_cache_user_resource_idx": { + "name": "github_response_cache_user_resource_idx", + "columns": ["user_id", "resource"], + "isUnique": false + } + }, + "foreignKeys": { + "github_response_cache_user_id_user_id_fk": { + "name": "github_response_cache_user_id_user_id_fk", + "tableFrom": "github_response_cache", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/dashboard/drizzle/meta/_journal.json b/apps/dashboard/drizzle/meta/_journal.json index 4935dc5..b767305 100644 --- a/apps/dashboard/drizzle/meta/_journal.json +++ b/apps/dashboard/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1775573905989, "tag": "0000_pretty_the_call", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1775606872196, + "tag": "0001_outstanding_blizzard", + "breakpoints": true } ] } diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 924a1ca..9893b7f 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -24,6 +24,7 @@ "@quickhub/ui": "workspace:*", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-devtools": "latest", + "@tanstack/react-query": "latest", "@tanstack/react-router": "latest", "@tanstack/react-router-devtools": "latest", "@tanstack/react-router-ssr-query": "latest", diff --git a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx index f997106..c75f165 100644 --- a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx @@ -26,7 +26,7 @@ import { } from "@quickhub/ui/components/dropdown-menu"; import { Link } from "@tanstack/react-router"; import { useTheme } from "next-themes"; -import { signOut } from "#/lib/auth.client"; +import { signOutToLogin } from "#/lib/auth-actions"; interface DashboardTopbarProps { user: { @@ -110,11 +110,9 @@ export function DashboardTopbar({ user }: DashboardTopbarProps) { - signOut().then(() => { - window.location.href = "/login"; - }) - } + onSelect={() => { + void signOutToLogin(); + }} > Sign out diff --git a/apps/dashboard/src/db/schema.ts b/apps/dashboard/src/db/schema.ts index 3496759..1f1efa4 100644 --- a/apps/dashboard/src/db/schema.ts +++ b/apps/dashboard/src/db/schema.ts @@ -1,4 +1,4 @@ -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const user = sqliteTable("user", { id: text("id").primaryKey(), @@ -53,3 +53,29 @@ export const verification = sqliteTable("verification", { createdAt: integer("created_at", { mode: "timestamp" }), updatedAt: integer("updated_at", { mode: "timestamp" }), }); + +export const githubResponseCache = sqliteTable( + "github_response_cache", + { + cacheKey: text("cache_key").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + resource: text("resource").notNull(), + paramsJson: text("params_json").notNull(), + etag: text("etag"), + lastModified: text("last_modified"), + payloadJson: text("payload_json").notNull(), + fetchedAt: integer("fetched_at").notNull(), + freshUntil: integer("fresh_until").notNull(), + rateLimitRemaining: integer("rate_limit_remaining"), + rateLimitReset: integer("rate_limit_reset"), + statusCode: integer("status_code").notNull(), + }, + (table) => ({ + userResourceIdx: index("github_response_cache_user_resource_idx").on( + table.userId, + table.resource, + ), + }), +); diff --git a/apps/dashboard/src/lib/auth-actions.ts b/apps/dashboard/src/lib/auth-actions.ts new file mode 100644 index 0000000..055ad5f --- /dev/null +++ b/apps/dashboard/src/lib/auth-actions.ts @@ -0,0 +1,12 @@ +import { createClientOnlyFn } from "@tanstack/react-start"; + +export const signInWithGitHub = createClientOnlyFn(async () => { + const { signIn } = await import("./auth.client"); + return signIn.social({ provider: "github" }); +}); + +export const signOutToLogin = createClientOnlyFn(async () => { + const { signOut } = await import("./auth.client"); + await signOut(); + window.location.href = "/login"; +}); diff --git a/apps/dashboard/src/lib/auth.functions.ts b/apps/dashboard/src/lib/auth.functions.ts index 6adfe19..d04ed89 100644 --- a/apps/dashboard/src/lib/auth.functions.ts +++ b/apps/dashboard/src/lib/auth.functions.ts @@ -1,9 +1,11 @@ import { createServerFn } from "@tanstack/react-start"; -import { getRequest } from "@tanstack/react-start/server"; -import { getAuth } from "./auth"; export const getSession = createServerFn({ method: "GET" }).handler( async () => { + const [{ getRequest }, { getAuth }] = await Promise.all([ + import("@tanstack/react-start/server"), + import("./auth.server"), + ]); const request = getRequest(); const auth = getAuth(); const session = await auth.api.getSession({ diff --git a/apps/dashboard/src/lib/auth.ts b/apps/dashboard/src/lib/auth.server.ts similarity index 95% rename from apps/dashboard/src/lib/auth.ts rename to apps/dashboard/src/lib/auth.server.ts index 9783dd8..4161307 100644 --- a/apps/dashboard/src/lib/auth.ts +++ b/apps/dashboard/src/lib/auth.server.ts @@ -1,3 +1,4 @@ +import "@tanstack/react-start/server-only"; import { env } from "cloudflare:workers"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; diff --git a/apps/dashboard/src/lib/github-cache-policy.ts b/apps/dashboard/src/lib/github-cache-policy.ts new file mode 100644 index 0000000..9ac4aa3 --- /dev/null +++ b/apps/dashboard/src/lib/github-cache-policy.ts @@ -0,0 +1,18 @@ +export const githubCachePolicy = { + viewer: { + staleTimeMs: 30 * 60 * 1000, + gcTimeMs: 24 * 60 * 60 * 1000, + }, + reposList: { + staleTimeMs: 10 * 60 * 1000, + gcTimeMs: 12 * 60 * 60 * 1000, + }, + list: { + staleTimeMs: 2 * 60 * 1000, + gcTimeMs: 60 * 60 * 1000, + }, + detail: { + staleTimeMs: 5 * 60 * 1000, + gcTimeMs: 6 * 60 * 60 * 1000, + }, +} as const; diff --git a/apps/dashboard/src/lib/github-cache.test.ts b/apps/dashboard/src/lib/github-cache.test.ts new file mode 100644 index 0000000..fde485b --- /dev/null +++ b/apps/dashboard/src/lib/github-cache.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createGitHubResponseMetadata, + type GitHubCacheStore, + type GitHubCacheStoreEntry, + type GitHubFetchResult, + getOrRevalidateGitHubResource, +} from "./github-cache"; + +function createMemoryStore( + initialEntries: GitHubCacheStoreEntry[] = [], +): GitHubCacheStore { + const entries = new Map( + initialEntries.map((entry) => [entry.cacheKey, structuredClone(entry)]), + ); + + return { + async get(cacheKey) { + return entries.get(cacheKey) ?? null; + }, + async upsert(entry) { + entries.set(entry.cacheKey, structuredClone(entry)); + }, + async delete(cacheKey) { + entries.delete(cacheKey); + }, + }; +} + +function buildEntry(overrides: Partial = {}) { + return { + cacheKey: "user-1::viewer::null", + userId: "user-1", + resource: "viewer", + paramsJson: "null", + etag: '"viewer-etag"', + lastModified: "Tue, 01 Apr 2025 10:00:00 GMT", + payloadJson: JSON.stringify({ login: "adn" }), + fetchedAt: 100, + freshUntil: 200, + rateLimitRemaining: 4999, + rateLimitReset: 1712487600, + statusCode: 200, + ...overrides, + }; +} + +describe("getOrRevalidateGitHubResource", () => { + it("returns a fresh cached payload without calling GitHub", async () => { + const store = createMemoryStore([buildEntry()]); + const fetcher = + vi.fn< + (parameters: { + etag?: string | null; + lastModified?: string | null; + }) => Promise> + >(); + + const result = await getOrRevalidateGitHubResource({ + userId: "user-1", + resource: "viewer", + freshForMs: 60_000, + store, + now: () => 150, + fetcher, + }); + + expect(result).toEqual({ login: "adn" }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("revalidates stale data with conditional headers and preserves payload on 304", async () => { + const store = createMemoryStore([ + buildEntry({ + freshUntil: 50, + }), + ]); + const fetcher = vi.fn< + (parameters: { + etag?: string | null; + lastModified?: string | null; + }) => Promise> + >(async (conditionals) => { + expect(conditionals).toEqual({ + etag: '"viewer-etag"', + lastModified: "Tue, 01 Apr 2025 10:00:00 GMT", + }); + + return { + kind: "not-modified", + metadata: createGitHubResponseMetadata(304, { + etag: '"viewer-etag"', + "x-ratelimit-remaining": "4988", + "x-ratelimit-reset": "1712487601", + }), + }; + }); + + const result = await getOrRevalidateGitHubResource({ + userId: "user-1", + resource: "viewer", + freshForMs: 1_000, + store, + now: () => 500, + fetcher, + }); + + expect(result).toEqual({ login: "adn" }); + expect(fetcher).toHaveBeenCalledTimes(1); + + const updatedEntry = await store.get("user-1::viewer::null"); + expect(updatedEntry?.freshUntil).toBe(1_500); + expect(updatedEntry?.rateLimitRemaining).toBe(4988); + expect(updatedEntry?.statusCode).toBe(304); + }); + + it("deduplicates concurrent stale refreshes for the same cache key", async () => { + const store = createMemoryStore([ + buildEntry({ + resource: "pulls.mine.reviewRequested", + cacheKey: + 'user-1::pulls.mine.reviewRequested::{"role":"review-requested"}', + paramsJson: '{"role":"review-requested"}', + freshUntil: 0, + payloadJson: JSON.stringify([{ id: 1 }]), + }), + ]); + let resolveFetch: + | ((value: GitHubFetchResult>) => void) + | undefined; + const fetcher = vi.fn( + () => + new Promise>>((resolve) => { + resolveFetch = resolve; + }), + ); + + const promiseA = getOrRevalidateGitHubResource({ + userId: "user-1", + resource: "pulls.mine.reviewRequested", + params: { role: "review-requested" }, + freshForMs: 1_000, + store, + now: () => 10, + fetcher, + }); + const promiseB = getOrRevalidateGitHubResource({ + userId: "user-1", + resource: "pulls.mine.reviewRequested", + params: { role: "review-requested" }, + freshForMs: 1_000, + store, + now: () => 10, + fetcher, + }); + + await Promise.resolve(); + + expect(fetcher).toHaveBeenCalledTimes(1); + + resolveFetch?.({ + kind: "success", + data: [{ id: 2 }], + metadata: createGitHubResponseMetadata(200, { + etag: '"next"', + }), + }); + + await expect(Promise.all([promiseA, promiseB])).resolves.toEqual([ + [{ id: 2 }], + [{ id: 2 }], + ]); + }); + + it("isolates cache entries by user even when the resource name matches", async () => { + const store = createMemoryStore([ + buildEntry({ + userId: "user-1", + cacheKey: "user-1::repos.list::null", + resource: "repos.list", + payloadJson: JSON.stringify([{ fullName: "owner/repo-a" }]), + }), + buildEntry({ + userId: "user-2", + cacheKey: "user-2::repos.list::null", + resource: "repos.list", + payloadJson: JSON.stringify([{ fullName: "owner/repo-b" }]), + }), + ]); + + await expect( + getOrRevalidateGitHubResource({ + userId: "user-1", + resource: "repos.list", + freshForMs: 60_000, + store, + now: () => 150, + fetcher: vi.fn(), + }), + ).resolves.toEqual([{ fullName: "owner/repo-a" }]); + + await expect( + getOrRevalidateGitHubResource({ + userId: "user-2", + resource: "repos.list", + freshForMs: 60_000, + store, + now: () => 150, + fetcher: vi.fn(), + }), + ).resolves.toEqual([{ fullName: "owner/repo-b" }]); + }); +}); diff --git a/apps/dashboard/src/lib/github-cache.ts b/apps/dashboard/src/lib/github-cache.ts new file mode 100644 index 0000000..7803e17 --- /dev/null +++ b/apps/dashboard/src/lib/github-cache.ts @@ -0,0 +1,257 @@ +export type GitHubConditionalHeaders = { + etag?: string | null; + lastModified?: string | null; +}; + +export type GitHubResponseMetadata = { + etag: string | null; + lastModified: string | null; + rateLimitRemaining: number | null; + rateLimitReset: number | null; + statusCode: number; +}; + +export type GitHubCacheStoreEntry = { + cacheKey: string; + userId: string; + resource: string; + paramsJson: string; + etag: string | null; + lastModified: string | null; + payloadJson: string; + fetchedAt: number; + freshUntil: number; + rateLimitRemaining: number | null; + rateLimitReset: number | null; + statusCode: number; +}; + +export type GitHubCacheStore = { + get(cacheKey: string): Promise; + upsert(entry: GitHubCacheStoreEntry): Promise; + delete(cacheKey: string): Promise; +}; + +export type GitHubFetchResult = + | { + kind: "not-modified"; + metadata: GitHubResponseMetadata; + } + | { + kind: "success"; + data: TData; + metadata: GitHubResponseMetadata; + }; + +type GetOrRevalidateGitHubResourceOptions = { + userId: string; + resource: string; + params?: unknown; + freshForMs: number; + fetcher: ( + conditionals: GitHubConditionalHeaders, + ) => Promise>; + store?: GitHubCacheStore; + now?: () => number; +}; + +const inFlightGitHubCacheReads = new Map>(); + +function parseNullableInt(value: string | null | undefined) { + if (!value) { + return null; + } + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function normalizeJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => normalizeJsonValue(item)); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (typeof value === "bigint") { + return value.toString(); + } + + if (value && typeof value === "object") { + return Object.keys(value as Record) + .sort() + .reduce>((accumulator, key) => { + const normalized = normalizeJsonValue( + (value as Record)[key], + ); + if (typeof normalized !== "undefined") { + accumulator[key] = normalized; + } + return accumulator; + }, {}); + } + + return value; +} + +function stableSerialize(value: unknown) { + return JSON.stringify(normalizeJsonValue(value ?? null)); +} + +function buildGitHubCacheKey({ + userId, + resource, + paramsJson, +}: { + userId: string; + resource: string; + paramsJson: string; +}) { + return `${userId}::${resource}::${paramsJson}`; +} + +function parseCachedPayload(payloadJson: string) { + return JSON.parse(payloadJson) as TData; +} + +async function getGitHubCacheStore(): Promise { + const [{ eq }, { getDb }, { githubResponseCache }] = await Promise.all([ + import("drizzle-orm"), + import("../db"), + import("../db/schema"), + ]); + const db = getDb(); + + return { + async get(cacheKey) { + const entry = await db + .select() + .from(githubResponseCache) + .where(eq(githubResponseCache.cacheKey, cacheKey)) + .get(); + + return entry ?? null; + }, + async upsert(entry) { + await db + .insert(githubResponseCache) + .values(entry) + .onConflictDoUpdate({ + target: githubResponseCache.cacheKey, + set: { + userId: entry.userId, + resource: entry.resource, + paramsJson: entry.paramsJson, + etag: entry.etag, + lastModified: entry.lastModified, + payloadJson: entry.payloadJson, + fetchedAt: entry.fetchedAt, + freshUntil: entry.freshUntil, + rateLimitRemaining: entry.rateLimitRemaining, + rateLimitReset: entry.rateLimitReset, + statusCode: entry.statusCode, + }, + }); + }, + async delete(cacheKey) { + await db + .delete(githubResponseCache) + .where(eq(githubResponseCache.cacheKey, cacheKey)); + }, + }; +} + +export function createGitHubResponseMetadata( + statusCode: number, + headers: Record, +): GitHubResponseMetadata { + return { + etag: headers.etag ?? null, + lastModified: headers["last-modified"] ?? null, + rateLimitRemaining: parseNullableInt(headers["x-ratelimit-remaining"]), + rateLimitReset: parseNullableInt(headers["x-ratelimit-reset"]), + statusCode, + }; +} + +export async function getOrRevalidateGitHubResource({ + userId, + resource, + params, + freshForMs, + fetcher, + now = Date.now, + store, +}: GetOrRevalidateGitHubResourceOptions): Promise { + const resolvedStore = store ?? (await getGitHubCacheStore()); + const paramsJson = stableSerialize(params); + const cacheKey = buildGitHubCacheKey({ userId, resource, paramsJson }); + + const existingInFlight = inFlightGitHubCacheReads.get(cacheKey); + if (existingInFlight) { + return existingInFlight as Promise; + } + + const task = (async () => { + const existingEntry = await resolvedStore.get(cacheKey); + const currentTime = now(); + + if (existingEntry && existingEntry.freshUntil > currentTime) { + return parseCachedPayload(existingEntry.payloadJson); + } + + const result = await fetcher({ + etag: existingEntry?.etag ?? null, + lastModified: existingEntry?.lastModified ?? null, + }); + + if (result.kind === "not-modified") { + if (!existingEntry) { + throw new Error( + `GitHub returned 304 without a cached payload for ${resource}.`, + ); + } + + await resolvedStore.upsert({ + ...existingEntry, + etag: result.metadata.etag ?? existingEntry.etag, + lastModified: + result.metadata.lastModified ?? existingEntry.lastModified, + fetchedAt: currentTime, + freshUntil: currentTime + freshForMs, + rateLimitRemaining: result.metadata.rateLimitRemaining, + rateLimitReset: result.metadata.rateLimitReset, + statusCode: result.metadata.statusCode, + }); + + return parseCachedPayload(existingEntry.payloadJson); + } + + await resolvedStore.upsert({ + cacheKey, + userId, + resource, + paramsJson, + etag: result.metadata.etag, + lastModified: result.metadata.lastModified, + payloadJson: JSON.stringify(result.data), + fetchedAt: currentTime, + freshUntil: currentTime + freshForMs, + rateLimitRemaining: result.metadata.rateLimitRemaining, + rateLimitReset: result.metadata.rateLimitReset, + statusCode: result.metadata.statusCode, + }); + + return result.data; + })(); + + inFlightGitHubCacheReads.set(cacheKey, task); + + try { + return await task; + } finally { + inFlightGitHubCacheReads.delete(cacheKey); + } +} diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 4bea7e3..0214641 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -1,7 +1,5 @@ import { createServerFn } from "@tanstack/react-start"; -import { getRequest } from "@tanstack/react-start/server"; -import { getAuth } from "./auth"; -import { getGitHubClient } from "./github"; +import { type Octokit as OctokitType, RequestError } from "octokit"; import type { GitHubActor, GitHubLabel, @@ -14,11 +12,33 @@ import type { RepositoryRef, UserRepoSummary, } from "./github.types"; +import { + createGitHubResponseMetadata, + type GitHubConditionalHeaders, + type GitHubFetchResult, + getOrRevalidateGitHubResource, +} from "./github-cache"; +import { githubCachePolicy } from "./github-cache-policy"; + +type GitHubClient = OctokitType; +type AuthSession = NonNullable>>; +type GitHubContext = { + session: AuthSession; + octokit: GitHubClient; +}; + +type GitHubRestResponse = { + data: TData; + headers: Record; + status: number; +}; -type GitHubClient = Awaited>; type SearchItem = Awaited< ReturnType >["data"]["items"][number]; +type SearchResult = Awaited< + ReturnType +>["data"]; type AuthenticatedUserRepo = Awaited< ReturnType >["data"][number]; @@ -59,7 +79,7 @@ type PullSearchRole = type IssueSearchRole = "all" | "assigned" | "author" | "mentioned"; -type PullsFromUserInput = { +export type PullsFromUserInput = { username?: string; state?: RepoState; page?: number; @@ -69,7 +89,7 @@ type PullsFromUserInput = { repo?: string; }; -type IssuesFromUserInput = { +export type IssuesFromUserInput = { username?: string; state?: RepoState; page?: number; @@ -79,7 +99,7 @@ type IssuesFromUserInput = { repo?: string; }; -type PullsFromRepoInput = { +export type PullsFromRepoInput = { owner: string; repo: string; state?: RepoState; @@ -89,13 +109,13 @@ type PullsFromRepoInput = { direction?: "asc" | "desc"; }; -type PullFromRepoInput = { +export type PullFromRepoInput = { owner: string; repo: string; pullNumber: number; }; -type IssuesFromRepoInput = { +export type IssuesFromRepoInput = { owner: string; repo: string; state?: RepoState; @@ -105,12 +125,32 @@ type IssuesFromRepoInput = { direction?: "asc" | "desc"; }; -type IssueFromRepoInput = { +export type IssueFromRepoInput = { owner: string; repo: string; issueNumber: number; }; +const myPullRoleDefinitions = [ + { key: "reviewRequested", role: "review-requested" }, + { key: "assigned", role: "assigned" }, + { key: "authored", role: "author" }, + { key: "mentioned", role: "mentioned" }, + { key: "involved", role: "involved" }, +] as const satisfies Array<{ + key: keyof MyPullsResult; + role: PullSearchRole; +}>; + +const myIssueRoleDefinitions = [ + { key: "assigned", role: "assigned" }, + { key: "authored", role: "author" }, + { key: "mentioned", role: "mentioned" }, +] as const satisfies Array<{ + key: keyof MyIssuesResult; + role: IssueSearchRole; +}>; + function clampPerPage(value: number | undefined, fallback = 30) { if (!Number.isFinite(value)) { return fallback; @@ -313,13 +353,43 @@ function mapIssueDetail( }; } +function mapPullSearchItems(items: SearchItem[]) { + return items + .map((item) => { + const repository = parseRepositoryRef(item.repository_url); + if (!repository) { + return null; + } + + return mapPullSummary(item, repository); + }) + .filter((item): item is PullSummary => Boolean(item)); +} + +function mapIssueSearchItems(items: SearchItem[]) { + return items + .map((item) => { + const repository = parseRepositoryRef(item.repository_url); + if (!repository) { + return null; + } + + return mapIssueSummary(item, repository); + }) + .filter((item): item is IssueSummary => Boolean(item)); +} + async function getSession() { + const [{ getRequest }, { getAuth }] = await Promise.all([ + import("@tanstack/react-start/server"), + import("./auth.server"), + ]); const request = getRequest(); const auth = getAuth(); return auth.api.getSession({ headers: request.headers }); } -async function getGitHubContext() { +async function getGitHubContext(): Promise { const session = await getSession(); if (!session) { return null; @@ -327,24 +397,12 @@ async function getGitHubContext() { return { session, - octokit: await getGitHubClient(session.user.id), + octokit: await (await import("./github.server")).getGitHubClient( + session.user.id, + ), }; } -async function getViewer(octokit: GitHubClient): Promise { - const { data } = await octokit.rest.users.getAuthenticated(); - return data; -} - -async function resolveUsername(octokit: GitHubClient, username?: string) { - if (username) { - return username; - } - - const viewer = await getViewer(octokit); - return viewer.login; -} - function buildUserSearchQuery({ itemType, role, @@ -378,6 +436,223 @@ function buildUserSearchQuery({ return `is:${itemType}${stateFilter}${scopeFilter}${roleFilter} archived:false`; } +function buildConditionalHeaders(conditionals: GitHubConditionalHeaders) { + const headers: Record = {}; + + if (conditionals.etag) { + headers["if-none-match"] = conditionals.etag; + } + + if (conditionals.lastModified) { + headers["if-modified-since"] = conditionals.lastModified; + } + + return headers; +} + +function normalizeResponseHeaders(headers: Record) { + return Object.entries(headers).reduce>( + (accumulator, [key, value]) => { + if (typeof value === "string") { + accumulator[key.toLowerCase()] = value; + } else if (typeof value === "number") { + accumulator[key.toLowerCase()] = String(value); + } else if (Array.isArray(value) && typeof value[0] === "string") { + accumulator[key.toLowerCase()] = value[0]; + } + + return accumulator; + }, + {}, + ); +} + +async function executeGitHubRequest( + request: ( + headers: Record, + ) => Promise>, + conditionals: GitHubConditionalHeaders, +): Promise> { + try { + const response = await request(buildConditionalHeaders(conditionals)); + + return { + kind: "success", + data: response.data, + metadata: createGitHubResponseMetadata( + response.status, + normalizeResponseHeaders(response.headers), + ), + }; + } catch (error) { + if ( + error instanceof RequestError && + error.status === 304 && + error.response?.headers + ) { + return { + kind: "not-modified", + metadata: createGitHubResponseMetadata( + 304, + normalizeResponseHeaders( + error.response.headers as Record, + ), + ), + }; + } + + throw error; + } +} + +async function getCachedGitHubRequest({ + context, + resource, + params, + freshForMs, + request, + mapData, +}: { + context: GitHubContext; + resource: string; + params: unknown; + freshForMs: number; + request: ( + headers: Record, + ) => Promise>; + mapData: (data: TGitHubData) => TResult; +}) { + return getOrRevalidateGitHubResource({ + userId: context.session.user.id, + resource, + params, + freshForMs, + fetcher: async (conditionals) => { + const result = await executeGitHubRequest(request, conditionals); + + if (result.kind === "not-modified") { + return result; + } + + return { + ...result, + data: mapData(result.data), + }; + }, + }); +} + +async function runWithConcurrency( + tasks: Array<() => Promise>, + concurrency = 2, +) { + const results = new Array(tasks.length); + let nextIndex = 0; + + const workers = Array.from( + { length: Math.min(Math.max(concurrency, 1), tasks.length) }, + () => + (async () => { + while (nextIndex < tasks.length) { + const taskIndex = nextIndex; + nextIndex += 1; + results[taskIndex] = await tasks[taskIndex](); + } + })(), + ); + + await Promise.all(workers); + + return results; +} + +async function getViewer(context: GitHubContext): Promise { + return getCachedGitHubRequest({ + context, + resource: "viewer", + params: null, + freshForMs: githubCachePolicy.viewer.staleTimeMs, + request: (headers) => + context.octokit.rest.users.getAuthenticated({ headers }), + mapData: (viewer) => viewer, + }); +} + +async function resolveUsername(context: GitHubContext, username?: string) { + if (username) { + return username; + } + + const viewer = await getViewer(context); + return viewer.login; +} + +async function getMyPullSlice({ + context, + username, + roleKey, + role, +}: { + context: GitHubContext; + username: string; + roleKey: keyof MyPullsResult; + role: PullSearchRole; +}) { + return getCachedGitHubRequest({ + context, + resource: `pulls.mine.${roleKey}`, + params: { username, role }, + freshForMs: githubCachePolicy.list.staleTimeMs, + request: (headers) => + context.octokit.rest.search.issuesAndPullRequests({ + q: buildUserSearchQuery({ + itemType: "pr", + role, + state: "open", + username, + }), + per_page: 30, + sort: "updated", + order: "desc", + headers, + }), + mapData: (result) => mapPullSearchItems(result.items), + }); +} + +async function getMyIssueSlice({ + context, + username, + roleKey, + role, +}: { + context: GitHubContext; + username: string; + roleKey: keyof MyIssuesResult; + role: IssueSearchRole; +}) { + return getCachedGitHubRequest({ + context, + resource: `issues.mine.${roleKey}`, + params: { username, role }, + freshForMs: githubCachePolicy.list.staleTimeMs, + request: (headers) => + context.octokit.rest.search.issuesAndPullRequests({ + q: buildUserSearchQuery({ + itemType: "issue", + role, + state: "open", + username, + }), + per_page: 30, + sort: "updated", + order: "desc", + headers, + }), + mapData: (result) => mapIssueSearchItems(result.items), + }); +} + function identityValidator(data: TInput) { return data; } @@ -389,7 +664,7 @@ export const getGitHubViewer = createServerFn({ method: "GET" }).handler( return null; } - const viewer = await getViewer(context.octokit); + const viewer = await getViewer(context); return { id: viewer.id, @@ -408,25 +683,33 @@ export const getUserRepos = createServerFn({ method: "GET" }).handler( return []; } - const { data } = await context.octokit.rest.repos.listForAuthenticatedUser({ - sort: "updated", - per_page: 10, + return getCachedGitHubRequest({ + context, + resource: "repos.list", + params: { sort: "updated", perPage: 10 }, + freshForMs: githubCachePolicy.reposList.staleTimeMs, + request: (headers) => + context.octokit.rest.repos.listForAuthenticatedUser({ + sort: "updated", + per_page: 10, + headers, + }), + mapData: (repos) => + repos.map( + (repo: AuthenticatedUserRepo): UserRepoSummary => ({ + id: repo.id, + name: repo.name, + fullName: repo.full_name, + description: repo.description, + stars: repo.stargazers_count, + language: repo.language, + updatedAt: repo.updated_at, + isPrivate: repo.private, + url: repo.html_url, + owner: repo.owner.login, + }), + ), }); - - return data.map( - (repo: AuthenticatedUserRepo): UserRepoSummary => ({ - id: repo.id, - name: repo.name, - fullName: repo.full_name, - description: repo.description, - stars: repo.stargazers_count, - language: repo.language, - updatedAt: repo.updated_at, - isPrivate: repo.private, - url: repo.html_url, - owner: repo.owner.login, - }), - ); }, ); @@ -443,87 +726,33 @@ export const getMyPulls = createServerFn({ method: "GET" }).handler( }; } - const viewer = await getViewer(context.octokit); - const perPage = 30; - - const [reviewRequested, assigned, authored, mentioned, involved] = - await Promise.all([ - context.octokit.rest.search.issuesAndPullRequests({ - q: buildUserSearchQuery({ - itemType: "pr", - role: "review-requested", - state: "open", - username: viewer.login, - }), - per_page: perPage, - sort: "updated", - order: "desc", - }), - context.octokit.rest.search.issuesAndPullRequests({ - q: buildUserSearchQuery({ - itemType: "pr", - role: "assigned", - state: "open", - username: viewer.login, - }), - per_page: perPage, - sort: "updated", - order: "desc", - }), - context.octokit.rest.search.issuesAndPullRequests({ - q: buildUserSearchQuery({ - itemType: "pr", - role: "author", - state: "open", - username: viewer.login, - }), - per_page: perPage, - sort: "updated", - order: "desc", - }), - context.octokit.rest.search.issuesAndPullRequests({ - q: buildUserSearchQuery({ - itemType: "pr", - role: "mentioned", - state: "open", - username: viewer.login, - }), - per_page: perPage, - sort: "updated", - order: "desc", - }), - context.octokit.rest.search.issuesAndPullRequests({ - q: buildUserSearchQuery({ - itemType: "pr", - role: "involved", - state: "open", - username: viewer.login, - }), - per_page: perPage, - sort: "updated", - order: "desc", + const viewer = await getViewer(context); + const slices = await runWithConcurrency( + myPullRoleDefinitions.map((definition) => async () => ({ + key: definition.key, + data: await getMyPullSlice({ + context, + username: viewer.login, + roleKey: definition.key, + role: definition.role, }), - ]); - - const mapItems = (items: SearchItem[]) => - items - .map((item) => { - const repository = parseRepositoryRef(item.repository_url); - if (!repository) { - return null; - } - - return mapPullSummary(item, repository); - }) - .filter((item): item is PullSummary => Boolean(item)); + })), + 2, + ); - return { - reviewRequested: mapItems(reviewRequested.data.items), - assigned: mapItems(assigned.data.items), - authored: mapItems(authored.data.items), - mentioned: mapItems(mentioned.data.items), - involved: mapItems(involved.data.items), - }; + return slices.reduce( + (accumulator, slice) => { + accumulator[slice.key] = slice.data; + return accumulator; + }, + { + reviewRequested: [], + assigned: [], + authored: [], + mentioned: [], + involved: [], + }, + ); }, ); @@ -535,34 +764,39 @@ export const getPullsFromUser = createServerFn({ method: "GET" }) return []; } - const username = await resolveUsername(context.octokit, data.username); - const { items } = ( - await context.octokit.rest.search.issuesAndPullRequests({ - q: buildUserSearchQuery({ - itemType: "pr", - role: data.role ?? "author", - state: data.state ?? "open", - username, - owner: data.owner, - repo: data.repo, - }), - page: clampPage(data.page), - per_page: clampPerPage(data.perPage), - sort: "updated", - order: "desc", - }) - ).data; - - return items - .map((item) => { - const repository = parseRepositoryRef(item.repository_url); - if (!repository) { - return null; - } + const username = await resolveUsername(context, data.username); - return mapPullSummary(item, repository); - }) - .filter((item): item is PullSummary => Boolean(item)); + return getCachedGitHubRequest({ + context, + resource: "pulls.user", + params: { + username, + state: data.state ?? "open", + page: clampPage(data.page), + perPage: clampPerPage(data.perPage), + role: data.role ?? "author", + owner: data.owner, + repo: data.repo, + }, + freshForMs: githubCachePolicy.list.staleTimeMs, + request: (headers) => + context.octokit.rest.search.issuesAndPullRequests({ + q: buildUserSearchQuery({ + itemType: "pr", + role: data.role ?? "author", + state: data.state ?? "open", + username, + owner: data.owner, + repo: data.repo, + }), + page: clampPage(data.page), + per_page: clampPerPage(data.perPage), + sort: "updated", + order: "desc", + headers, + }), + mapData: (result) => mapPullSearchItems(result.items), + }); }); export const getPullsFromRepo = createServerFn({ method: "GET" }) @@ -573,18 +807,38 @@ export const getPullsFromRepo = createServerFn({ method: "GET" }) return []; } - const repository = buildRepositoryRef(data.owner, data.repo); - const { data: pulls } = await context.octokit.rest.pulls.list({ - owner: data.owner, - repo: data.repo, - state: data.state ?? "open", - page: clampPage(data.page), - per_page: clampPerPage(data.perPage), - sort: data.sort ?? "updated", - direction: data.direction ?? "desc", + return getCachedGitHubRequest< + Awaited>["data"], + PullSummary[] + >({ + context, + resource: "pulls.repo", + params: { + owner: data.owner, + repo: data.repo, + state: data.state ?? "open", + page: clampPage(data.page), + perPage: clampPerPage(data.perPage), + sort: data.sort ?? "updated", + direction: data.direction ?? "desc", + }, + freshForMs: githubCachePolicy.list.staleTimeMs, + request: (headers) => + context.octokit.rest.pulls.list({ + owner: data.owner, + repo: data.repo, + state: data.state ?? "open", + page: clampPage(data.page), + per_page: clampPerPage(data.perPage), + sort: data.sort ?? "updated", + direction: data.direction ?? "desc", + headers, + }), + mapData: (pulls) => + pulls.map((pull) => + mapPullSummary(pull, buildRepositoryRef(data.owner, data.repo)), + ), }); - - return pulls.map((pull) => mapPullSummary(pull, repository)); }); export const getPullFromRepo = createServerFn({ method: "GET" }) @@ -595,13 +849,21 @@ export const getPullFromRepo = createServerFn({ method: "GET" }) return null; } - const { data: pull } = await context.octokit.rest.pulls.get({ - owner: data.owner, - repo: data.repo, - pull_number: data.pullNumber, + return getCachedGitHubRequest({ + context, + resource: "pulls.detail", + params: data, + freshForMs: githubCachePolicy.detail.staleTimeMs, + request: (headers) => + context.octokit.rest.pulls.get({ + owner: data.owner, + repo: data.repo, + pull_number: data.pullNumber, + headers, + }), + mapData: (pull) => + mapPullDetail(pull, buildRepositoryRef(data.owner, data.repo)), }); - - return mapPullDetail(pull, buildRepositoryRef(data.owner, data.repo)); }); export const getMyIssues = createServerFn({ method: "GET" }).handler( @@ -615,62 +877,31 @@ export const getMyIssues = createServerFn({ method: "GET" }).handler( }; } - const viewer = await getViewer(context.octokit); - const perPage = 30; - - const [assigned, authored, mentioned] = await Promise.all([ - context.octokit.rest.search.issuesAndPullRequests({ - q: buildUserSearchQuery({ - itemType: "issue", - role: "assigned", - state: "open", + const viewer = await getViewer(context); + const slices = await runWithConcurrency( + myIssueRoleDefinitions.map((definition) => async () => ({ + key: definition.key, + data: await getMyIssueSlice({ + context, username: viewer.login, + roleKey: definition.key, + role: definition.role, }), - per_page: perPage, - sort: "updated", - order: "desc", - }), - context.octokit.rest.search.issuesAndPullRequests({ - q: buildUserSearchQuery({ - itemType: "issue", - role: "author", - state: "open", - username: viewer.login, - }), - per_page: perPage, - sort: "updated", - order: "desc", - }), - context.octokit.rest.search.issuesAndPullRequests({ - q: buildUserSearchQuery({ - itemType: "issue", - role: "mentioned", - state: "open", - username: viewer.login, - }), - per_page: perPage, - sort: "updated", - order: "desc", - }), - ]); - - const mapItems = (items: SearchItem[]) => - items - .map((item) => { - const repository = parseRepositoryRef(item.repository_url); - if (!repository) { - return null; - } - - return mapIssueSummary(item, repository); - }) - .filter((item): item is IssueSummary => Boolean(item)); + })), + 2, + ); - return { - assigned: mapItems(assigned.data.items), - authored: mapItems(authored.data.items), - mentioned: mapItems(mentioned.data.items), - }; + return slices.reduce( + (accumulator, slice) => { + accumulator[slice.key] = slice.data; + return accumulator; + }, + { + assigned: [], + authored: [], + mentioned: [], + }, + ); }, ); @@ -682,34 +913,39 @@ export const getIssuesFromUser = createServerFn({ method: "GET" }) return []; } - const username = await resolveUsername(context.octokit, data.username); - const { items } = ( - await context.octokit.rest.search.issuesAndPullRequests({ - q: buildUserSearchQuery({ - itemType: "issue", - role: data.role ?? "author", - state: data.state ?? "open", - username, - owner: data.owner, - repo: data.repo, - }), - page: clampPage(data.page), - per_page: clampPerPage(data.perPage), - sort: "updated", - order: "desc", - }) - ).data; + const username = await resolveUsername(context, data.username); - return items - .map((item) => { - const repository = parseRepositoryRef(item.repository_url); - if (!repository) { - return null; - } - - return mapIssueSummary(item, repository); - }) - .filter((item): item is IssueSummary => Boolean(item)); + return getCachedGitHubRequest({ + context, + resource: "issues.user", + params: { + username, + state: data.state ?? "open", + page: clampPage(data.page), + perPage: clampPerPage(data.perPage), + role: data.role ?? "author", + owner: data.owner, + repo: data.repo, + }, + freshForMs: githubCachePolicy.list.staleTimeMs, + request: (headers) => + context.octokit.rest.search.issuesAndPullRequests({ + q: buildUserSearchQuery({ + itemType: "issue", + role: data.role ?? "author", + state: data.state ?? "open", + username, + owner: data.owner, + repo: data.repo, + }), + page: clampPage(data.page), + per_page: clampPerPage(data.perPage), + sort: "updated", + order: "desc", + headers, + }), + mapData: (result) => mapIssueSearchItems(result.items), + }); }); export const getIssuesFromRepo = createServerFn({ method: "GET" }) @@ -720,20 +956,42 @@ export const getIssuesFromRepo = createServerFn({ method: "GET" }) return []; } - const repository = buildRepositoryRef(data.owner, data.repo); - const { data: issues } = await context.octokit.rest.issues.listForRepo({ - owner: data.owner, - repo: data.repo, - state: data.state ?? "open", - page: clampPage(data.page), - per_page: clampPerPage(data.perPage), - sort: data.sort ?? "updated", - direction: data.direction ?? "desc", + return getCachedGitHubRequest< + Awaited< + ReturnType + >["data"], + IssueSummary[] + >({ + context, + resource: "issues.repo", + params: { + owner: data.owner, + repo: data.repo, + state: data.state ?? "open", + page: clampPage(data.page), + perPage: clampPerPage(data.perPage), + sort: data.sort ?? "updated", + direction: data.direction ?? "desc", + }, + freshForMs: githubCachePolicy.list.staleTimeMs, + request: (headers) => + context.octokit.rest.issues.listForRepo({ + owner: data.owner, + repo: data.repo, + state: data.state ?? "open", + page: clampPage(data.page), + per_page: clampPerPage(data.perPage), + sort: data.sort ?? "updated", + direction: data.direction ?? "desc", + headers, + }), + mapData: (issues) => + issues + .filter((issue) => !issue.pull_request) + .map((issue) => + mapIssueSummary(issue, buildRepositoryRef(data.owner, data.repo)), + ), }); - - return issues - .filter((issue) => !issue.pull_request) - .map((issue) => mapIssueSummary(issue, repository)); }); export const getIssueFromRepo = createServerFn({ method: "GET" }) @@ -744,15 +1002,24 @@ export const getIssueFromRepo = createServerFn({ method: "GET" }) return null; } - const { data: issue } = await context.octokit.rest.issues.get({ - owner: data.owner, - repo: data.repo, - issue_number: data.issueNumber, - }); - - if (issue.pull_request) { - return null; - } + return getCachedGitHubRequest({ + context, + resource: "issues.detail", + params: data, + freshForMs: githubCachePolicy.detail.staleTimeMs, + request: (headers) => + context.octokit.rest.issues.get({ + owner: data.owner, + repo: data.repo, + issue_number: data.issueNumber, + headers, + }), + mapData: (issue) => { + if (issue.pull_request) { + return null; + } - return mapIssueDetail(issue, buildRepositoryRef(data.owner, data.repo)); + return mapIssueDetail(issue, buildRepositoryRef(data.owner, data.repo)); + }, + }); }); diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts new file mode 100644 index 0000000..67e5176 --- /dev/null +++ b/apps/dashboard/src/lib/github.query.ts @@ -0,0 +1,234 @@ +import { queryOptions } from "@tanstack/react-query"; +import { + getGitHubViewer, + getIssueFromRepo, + getIssuesFromRepo, + getIssuesFromUser, + getMyIssues, + getMyPulls, + getPullFromRepo, + getPullsFromRepo, + getPullsFromUser, + getUserRepos, +} from "./github.functions"; +import { githubCachePolicy } from "./github-cache-policy"; + +type RepoState = "all" | "closed" | "open"; +type PullSort = "created" | "long-running" | "popularity" | "updated"; +type IssueSort = "comments" | "created" | "updated"; +type PullSearchRole = + | "all" + | "assigned" + | "author" + | "involved" + | "mentioned" + | "review-requested"; +type IssueSearchRole = "all" | "assigned" | "author" | "mentioned"; + +export type GitHubQueryScope = { + userId: string; +}; + +export type PullsFromUserQueryInput = { + username?: string; + state?: RepoState; + page?: number; + perPage?: number; + role?: PullSearchRole; + owner?: string; + repo?: string; +}; + +export type IssuesFromUserQueryInput = { + username?: string; + state?: RepoState; + page?: number; + perPage?: number; + role?: IssueSearchRole; + owner?: string; + repo?: string; +}; + +export type PullsFromRepoQueryInput = { + owner: string; + repo: string; + state?: RepoState; + page?: number; + perPage?: number; + sort?: PullSort; + direction?: "asc" | "desc"; +}; + +export type PullFromRepoQueryInput = { + owner: string; + repo: string; + pullNumber: number; +}; + +export type IssuesFromRepoQueryInput = { + owner: string; + repo: string; + state?: RepoState; + page?: number; + perPage?: number; + sort?: IssueSort; + direction?: "asc" | "desc"; +}; + +export type IssueFromRepoQueryInput = { + owner: string; + repo: string; + issueNumber: number; +}; + +const persistedMeta = { + persist: true, +} as const; + +export const githubQueryKeys = { + all: ["github"] as const, + viewer: (scope: GitHubQueryScope) => + ["github", scope.userId, "viewer"] as const, + repos: { + list: (scope: GitHubQueryScope) => + ["github", scope.userId, "repos", "list"] as const, + }, + pulls: { + mine: (scope: GitHubQueryScope) => + ["github", scope.userId, "pulls", "mine"] as const, + user: (scope: GitHubQueryScope, input: PullsFromUserQueryInput) => + ["github", scope.userId, "pulls", "user", input] as const, + repo: (scope: GitHubQueryScope, input: PullsFromRepoQueryInput) => + ["github", scope.userId, "pulls", "repo", input] as const, + detail: (scope: GitHubQueryScope, input: PullFromRepoQueryInput) => + ["github", scope.userId, "pulls", "detail", input] as const, + }, + issues: { + mine: (scope: GitHubQueryScope) => + ["github", scope.userId, "issues", "mine"] as const, + user: (scope: GitHubQueryScope, input: IssuesFromUserQueryInput) => + ["github", scope.userId, "issues", "user", input] as const, + repo: (scope: GitHubQueryScope, input: IssuesFromRepoQueryInput) => + ["github", scope.userId, "issues", "repo", input] as const, + detail: (scope: GitHubQueryScope, input: IssueFromRepoQueryInput) => + ["github", scope.userId, "issues", "detail", input] as const, + }, +}; + +export function githubViewerQueryOptions(scope: GitHubQueryScope) { + return queryOptions({ + queryKey: githubQueryKeys.viewer(scope), + queryFn: () => getGitHubViewer(), + staleTime: githubCachePolicy.viewer.staleTimeMs, + gcTime: githubCachePolicy.viewer.gcTimeMs, + meta: persistedMeta, + }); +} + +export function githubUserReposQueryOptions(scope: GitHubQueryScope) { + return queryOptions({ + queryKey: githubQueryKeys.repos.list(scope), + queryFn: () => getUserRepos(), + staleTime: githubCachePolicy.reposList.staleTimeMs, + gcTime: githubCachePolicy.reposList.gcTimeMs, + meta: persistedMeta, + }); +} + +export function githubMyPullsQueryOptions(scope: GitHubQueryScope) { + return queryOptions({ + queryKey: githubQueryKeys.pulls.mine(scope), + queryFn: () => getMyPulls(), + staleTime: githubCachePolicy.list.staleTimeMs, + gcTime: githubCachePolicy.list.gcTimeMs, + meta: persistedMeta, + }); +} + +export function githubPullsFromUserQueryOptions( + scope: GitHubQueryScope, + input: PullsFromUserQueryInput, +) { + return queryOptions({ + queryKey: githubQueryKeys.pulls.user(scope, input), + queryFn: () => getPullsFromUser({ data: input }), + staleTime: githubCachePolicy.list.staleTimeMs, + gcTime: githubCachePolicy.list.gcTimeMs, + meta: persistedMeta, + }); +} + +export function githubPullsFromRepoQueryOptions( + scope: GitHubQueryScope, + input: PullsFromRepoQueryInput, +) { + return queryOptions({ + queryKey: githubQueryKeys.pulls.repo(scope, input), + queryFn: () => getPullsFromRepo({ data: input }), + staleTime: githubCachePolicy.list.staleTimeMs, + gcTime: githubCachePolicy.list.gcTimeMs, + meta: persistedMeta, + }); +} + +export function githubPullDetailQueryOptions( + scope: GitHubQueryScope, + input: PullFromRepoQueryInput, +) { + return queryOptions({ + queryKey: githubQueryKeys.pulls.detail(scope, input), + queryFn: () => getPullFromRepo({ data: input }), + staleTime: githubCachePolicy.detail.staleTimeMs, + gcTime: githubCachePolicy.detail.gcTimeMs, + meta: persistedMeta, + }); +} + +export function githubMyIssuesQueryOptions(scope: GitHubQueryScope) { + return queryOptions({ + queryKey: githubQueryKeys.issues.mine(scope), + queryFn: () => getMyIssues(), + staleTime: githubCachePolicy.list.staleTimeMs, + gcTime: githubCachePolicy.list.gcTimeMs, + meta: persistedMeta, + }); +} + +export function githubIssuesFromUserQueryOptions( + scope: GitHubQueryScope, + input: IssuesFromUserQueryInput, +) { + return queryOptions({ + queryKey: githubQueryKeys.issues.user(scope, input), + queryFn: () => getIssuesFromUser({ data: input }), + staleTime: githubCachePolicy.list.staleTimeMs, + gcTime: githubCachePolicy.list.gcTimeMs, + meta: persistedMeta, + }); +} + +export function githubIssuesFromRepoQueryOptions( + scope: GitHubQueryScope, + input: IssuesFromRepoQueryInput, +) { + return queryOptions({ + queryKey: githubQueryKeys.issues.repo(scope, input), + queryFn: () => getIssuesFromRepo({ data: input }), + staleTime: githubCachePolicy.list.staleTimeMs, + gcTime: githubCachePolicy.list.gcTimeMs, + meta: persistedMeta, + }); +} + +export function githubIssueDetailQueryOptions( + scope: GitHubQueryScope, + input: IssueFromRepoQueryInput, +) { + return queryOptions({ + queryKey: githubQueryKeys.issues.detail(scope, input), + queryFn: () => getIssueFromRepo({ data: input }), + staleTime: githubCachePolicy.detail.staleTimeMs, + gcTime: githubCachePolicy.detail.gcTimeMs, + meta: persistedMeta, + }); +} diff --git a/apps/dashboard/src/lib/github.ts b/apps/dashboard/src/lib/github.server.ts similarity index 82% rename from apps/dashboard/src/lib/github.ts rename to apps/dashboard/src/lib/github.server.ts index 7320293..0c757fa 100644 --- a/apps/dashboard/src/lib/github.ts +++ b/apps/dashboard/src/lib/github.server.ts @@ -1,6 +1,6 @@ +import "@tanstack/react-start/server-only"; import { and, eq } from "drizzle-orm"; -import type { Octokit as OctokitType } from "octokit"; -import { Octokit } from "octokit"; +import { Octokit, type Octokit as OctokitType } from "octokit"; import { getDb } from "../db"; import { account } from "../db/schema"; diff --git a/apps/dashboard/src/lib/query-client.tsx b/apps/dashboard/src/lib/query-client.tsx new file mode 100644 index 0000000..edaebae --- /dev/null +++ b/apps/dashboard/src/lib/query-client.tsx @@ -0,0 +1,154 @@ +import { + dehydrate, + hydrate, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; +import { useEffect } from "react"; +import { githubCachePolicy } from "./github-cache-policy"; + +const GITHUB_QUERY_CACHE_STORAGE_KEY = "quickhub:github-query-cache:v1"; +const GITHUB_QUERY_CACHE_MAX_AGE_MS = githubCachePolicy.viewer.gcTimeMs; + +type PersistedGitHubQueryCache = { + version: 1; + persistedAt: number; + clientState: unknown; +}; + +function shouldPersistGitHubQuery(query: { + state: { status: string }; + meta?: Record; + queryKey: readonly unknown[]; +}) { + return ( + query.state.status === "success" && + query.meta?.persist === true && + query.queryKey[0] === "github" + ); +} + +function restorePersistedGitHubQueryCache(queryClient: QueryClient) { + if (typeof window === "undefined") { + return; + } + + const rawState = window.localStorage.getItem(GITHUB_QUERY_CACHE_STORAGE_KEY); + if (!rawState) { + return; + } + + try { + const persistedState = JSON.parse(rawState) as PersistedGitHubQueryCache; + const isExpired = + Date.now() - persistedState.persistedAt > GITHUB_QUERY_CACHE_MAX_AGE_MS; + + if (persistedState.version !== 1 || isExpired) { + window.localStorage.removeItem(GITHUB_QUERY_CACHE_STORAGE_KEY); + return; + } + + hydrate(queryClient, persistedState.clientState); + } catch { + window.localStorage.removeItem(GITHUB_QUERY_CACHE_STORAGE_KEY); + } +} + +function persistGitHubQueryCache(queryClient: QueryClient) { + if (typeof window === "undefined") { + return () => undefined; + } + + let timeoutId: number | undefined; + + const writeCache = () => { + const clientState = dehydrate(queryClient, { + shouldDehydrateQuery: shouldPersistGitHubQuery, + }); + + if (clientState.queries.length === 0) { + window.localStorage.removeItem(GITHUB_QUERY_CACHE_STORAGE_KEY); + return; + } + + const payload: PersistedGitHubQueryCache = { + version: 1, + persistedAt: Date.now(), + clientState, + }; + + window.localStorage.setItem( + GITHUB_QUERY_CACHE_STORAGE_KEY, + JSON.stringify(payload), + ); + }; + + const scheduleWrite = () => { + if (typeof timeoutId !== "undefined") { + window.clearTimeout(timeoutId); + } + + timeoutId = window.setTimeout(writeCache, 250); + }; + + const unsubscribe = queryClient.getQueryCache().subscribe(() => { + scheduleWrite(); + }); + + const flushOnUnload = () => { + if (typeof timeoutId !== "undefined") { + window.clearTimeout(timeoutId); + timeoutId = undefined; + } + writeCache(); + }; + + window.addEventListener("beforeunload", flushOnUnload); + + return () => { + unsubscribe(); + window.removeEventListener("beforeunload", flushOnUnload); + if (typeof timeoutId !== "undefined") { + window.clearTimeout(timeoutId); + } + }; +} + +function GitHubQueryPersistence({ queryClient }: { queryClient: QueryClient }) { + useEffect(() => { + return persistGitHubQueryCache(queryClient); + }, [queryClient]); + + return null; +} + +export function createAppQueryClient() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + networkMode: "online", + }, + }, + }); + + restorePersistedGitHubQueryCache(queryClient); + + return queryClient; +} + +export function AppQueryClientProvider({ + children, + queryClient, +}: { + children: React.ReactNode; + queryClient: QueryClient; +}) { + return ( + + + {children} + + ); +} diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 82bfb57..7c52bf7 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -12,6 +12,9 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LoginRouteImport } from './routes/login' import { Route as ProtectedRouteImport } from './routes/_protected' import { Route as ProtectedIndexRouteImport } from './routes/_protected/index' +import { Route as ProtectedReviewsRouteImport } from './routes/_protected/reviews' +import { Route as ProtectedPullRequestsRouteImport } from './routes/_protected/pull-requests' +import { Route as ProtectedIssuesRouteImport } from './routes/_protected/issues' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' const LoginRoute = LoginRouteImport.update({ @@ -28,6 +31,21 @@ const ProtectedIndexRoute = ProtectedIndexRouteImport.update({ path: '/', getParentRoute: () => ProtectedRoute, } as any) +const ProtectedReviewsRoute = ProtectedReviewsRouteImport.update({ + id: '/reviews', + path: '/reviews', + getParentRoute: () => ProtectedRoute, +} as any) +const ProtectedPullRequestsRoute = ProtectedPullRequestsRouteImport.update({ + id: '/pull-requests', + path: '/pull-requests', + getParentRoute: () => ProtectedRoute, +} as any) +const ProtectedIssuesRoute = ProtectedIssuesRouteImport.update({ + id: '/issues', + path: '/issues', + getParentRoute: () => ProtectedRoute, +} as any) const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ id: '/api/auth/$', path: '/api/auth/$', @@ -37,10 +55,16 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof ProtectedIndexRoute '/login': typeof LoginRoute + '/issues': typeof ProtectedIssuesRoute + '/pull-requests': typeof ProtectedPullRequestsRoute + '/reviews': typeof ProtectedReviewsRoute '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute + '/issues': typeof ProtectedIssuesRoute + '/pull-requests': typeof ProtectedPullRequestsRoute + '/reviews': typeof ProtectedReviewsRoute '/': typeof ProtectedIndexRoute '/api/auth/$': typeof ApiAuthSplatRoute } @@ -48,15 +72,32 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/_protected': typeof ProtectedRouteWithChildren '/login': typeof LoginRoute + '/_protected/issues': typeof ProtectedIssuesRoute + '/_protected/pull-requests': typeof ProtectedPullRequestsRoute + '/_protected/reviews': typeof ProtectedReviewsRoute '/_protected/': typeof ProtectedIndexRoute '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/login' | '/api/auth/$' + fullPaths: + | '/' + | '/login' + | '/issues' + | '/pull-requests' + | '/reviews' + | '/api/auth/$' fileRoutesByTo: FileRoutesByTo - to: '/login' | '/' | '/api/auth/$' - id: '__root__' | '/_protected' | '/login' | '/_protected/' | '/api/auth/$' + to: '/login' | '/issues' | '/pull-requests' | '/reviews' | '/' | '/api/auth/$' + id: + | '__root__' + | '/_protected' + | '/login' + | '/_protected/issues' + | '/_protected/pull-requests' + | '/_protected/reviews' + | '/_protected/' + | '/api/auth/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -88,6 +129,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedIndexRouteImport parentRoute: typeof ProtectedRoute } + '/_protected/reviews': { + id: '/_protected/reviews' + path: '/reviews' + fullPath: '/reviews' + preLoaderRoute: typeof ProtectedReviewsRouteImport + parentRoute: typeof ProtectedRoute + } + '/_protected/pull-requests': { + id: '/_protected/pull-requests' + path: '/pull-requests' + fullPath: '/pull-requests' + preLoaderRoute: typeof ProtectedPullRequestsRouteImport + parentRoute: typeof ProtectedRoute + } + '/_protected/issues': { + id: '/_protected/issues' + path: '/issues' + fullPath: '/issues' + preLoaderRoute: typeof ProtectedIssuesRouteImport + parentRoute: typeof ProtectedRoute + } '/api/auth/$': { id: '/api/auth/$' path: '/api/auth/$' @@ -99,10 +161,16 @@ declare module '@tanstack/react-router' { } interface ProtectedRouteChildren { + ProtectedIssuesRoute: typeof ProtectedIssuesRoute + ProtectedPullRequestsRoute: typeof ProtectedPullRequestsRoute + ProtectedReviewsRoute: typeof ProtectedReviewsRoute ProtectedIndexRoute: typeof ProtectedIndexRoute } const ProtectedRouteChildren: ProtectedRouteChildren = { + ProtectedIssuesRoute: ProtectedIssuesRoute, + ProtectedPullRequestsRoute: ProtectedPullRequestsRoute, + ProtectedReviewsRoute: ProtectedReviewsRoute, ProtectedIndexRoute: ProtectedIndexRoute, } diff --git a/apps/dashboard/src/router.tsx b/apps/dashboard/src/router.tsx index 0b1d0fb..5802a29 100644 --- a/apps/dashboard/src/router.tsx +++ b/apps/dashboard/src/router.tsx @@ -1,12 +1,32 @@ import { createRouter as createTanStackRouter } from "@tanstack/react-router"; +import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query"; +import { + AppQueryClientProvider, + createAppQueryClient, +} from "#/lib/query-client"; import { routeTree } from "./routeTree.gen"; export function getRouter() { + const queryClient = createAppQueryClient(); const router = createTanStackRouter({ routeTree, + context: { + queryClient, + }, scrollRestoration: true, defaultPreload: "intent", defaultPreloadStaleTime: 0, + Wrap: ({ children }) => ( + + {children} + + ), + }); + + setupRouterSsrQueryIntegration({ + router, + queryClient, + wrapQueryClient: false, }); return router; diff --git a/apps/dashboard/src/routes/__root.tsx b/apps/dashboard/src/routes/__root.tsx index fd50291..bfa6dea 100644 --- a/apps/dashboard/src/routes/__root.tsx +++ b/apps/dashboard/src/routes/__root.tsx @@ -1,6 +1,7 @@ import { TanStackDevtools } from "@tanstack/react-devtools"; +import type { QueryClient } from "@tanstack/react-query"; import { - createRootRoute, + createRootRouteWithContext, HeadContent, Outlet, Scripts, @@ -11,7 +12,9 @@ import { ThemeProvider } from "next-themes"; import appCss from "../styles.css?url"; -export const Route = createRootRoute({ +export const Route = createRootRouteWithContext<{ + queryClient: QueryClient; +}>()({ head: () => ({ meta: [ { charSet: "utf-8" }, diff --git a/apps/dashboard/src/routes/_protected/index.tsx b/apps/dashboard/src/routes/_protected/index.tsx index b56024d..89c1e53 100644 --- a/apps/dashboard/src/routes/_protected/index.tsx +++ b/apps/dashboard/src/routes/_protected/index.tsx @@ -1,9 +1,166 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; +import { + githubMyIssuesQueryOptions, + githubMyPullsQueryOptions, + githubUserReposQueryOptions, + githubViewerQueryOptions, +} from "#/lib/github.query"; export const Route = createFileRoute("/_protected/")({ + loader: async ({ context }) => { + const scope = { userId: context.user.id }; + + await Promise.all([ + context.queryClient.ensureQueryData(githubViewerQueryOptions(scope)), + context.queryClient.ensureQueryData(githubUserReposQueryOptions(scope)), + context.queryClient.ensureQueryData(githubMyPullsQueryOptions(scope)), + context.queryClient.ensureQueryData(githubMyIssuesQueryOptions(scope)), + ]); + }, component: OverviewPage, }); function OverviewPage() { - return
; + const { user } = Route.useRouteContext(); + const scope = { userId: user.id }; + const { data: viewer } = useSuspenseQuery(githubViewerQueryOptions(scope)); + const { data: repos } = useSuspenseQuery(githubUserReposQueryOptions(scope)); + const { data: pulls } = useSuspenseQuery(githubMyPullsQueryOptions(scope)); + const { data: issues } = useSuspenseQuery(githubMyIssuesQueryOptions(scope)); + + const pullCount = + pulls.reviewRequested.length + + pulls.assigned.length + + pulls.authored.length + + pulls.mentioned.length + + pulls.involved.length; + const issueCount = + issues.assigned.length + issues.authored.length + issues.mentioned.length; + + return ( +
+
+

+ GitHub cache primed +

+

+ {viewer?.name ?? viewer?.login ?? user.name ?? user.email} +

+

+ Overview is preloading your viewer, repo, pull request, and issue + queries so the next tabs feel instant. +

+
+ +
+ + + +
+ +
+ ({ + id: repo.id, + title: repo.fullName, + description: + repo.description ?? + `${repo.stars} stars${repo.language ? ` • ${repo.language}` : ""}`, + }))} + /> + ({ + id: pull.id, + title: `#${pull.number} ${pull.title}`, + description: pull.repository.fullName, + }))} + /> + ({ + id: issue.id, + title: `#${issue.number} ${issue.title}`, + description: issue.repository.fullName, + }))} + /> + ({ + id: pull.id, + title: `#${pull.number} ${pull.title}`, + description: pull.repository.fullName, + }))} + /> +
+
+ ); +} + +function SummaryCard({ + label, + value, + description, +}: { + label: string; + value: number; + description: string; +}) { + return ( +
+

{label}

+

{value}

+

{description}

+
+ ); +} + +function PreviewList({ + title, + emptyLabel, + items, +}: { + title: string; + emptyLabel: string; + items: Array<{ id: number; title: string; description: string }>; +}) { + return ( +
+
+

{title}

+
+ {items.length === 0 ? ( +

{emptyLabel}

+ ) : ( +
+ {items.map((item) => ( +
+

{item.title}

+

+ {item.description} +

+
+ ))} +
+ )} +
+ ); } diff --git a/apps/dashboard/src/routes/_protected/issues.tsx b/apps/dashboard/src/routes/_protected/issues.tsx new file mode 100644 index 0000000..5924301 --- /dev/null +++ b/apps/dashboard/src/routes/_protected/issues.tsx @@ -0,0 +1,72 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { githubMyIssuesQueryOptions } from "#/lib/github.query"; +import type { IssueSummary } from "#/lib/github.types"; + +export const Route = createFileRoute("/_protected/issues")({ + loader: async ({ context }) => { + await context.queryClient.ensureQueryData( + githubMyIssuesQueryOptions({ userId: context.user.id }), + ); + }, + component: IssuesPage, +}); + +function IssuesPage() { + const { user } = Route.useRouteContext(); + const { data } = useSuspenseQuery( + githubMyIssuesQueryOptions({ userId: user.id }), + ); + + return ( +
+
+

+ Cached issue groups +

+

Issues

+
+ +
+ + + +
+
+ ); +} + +function IssueGroup({ + title, + issues, +}: { + title: string; + issues: IssueSummary[]; +}) { + return ( +
+
+

{title}

+ {issues.length} +
+ {issues.length === 0 ? ( +

+ No issues in this slice. +

+ ) : ( +
+ {issues.map((issue) => ( +
+

+ #{issue.number} {issue.title} +

+

+ {issue.repository.fullName} +

+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/routes/_protected/pull-requests.tsx b/apps/dashboard/src/routes/_protected/pull-requests.tsx new file mode 100644 index 0000000..8f3c24c --- /dev/null +++ b/apps/dashboard/src/routes/_protected/pull-requests.tsx @@ -0,0 +1,68 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { githubMyPullsQueryOptions } from "#/lib/github.query"; +import type { PullSummary } from "#/lib/github.types"; + +export const Route = createFileRoute("/_protected/pull-requests")({ + loader: async ({ context }) => { + await context.queryClient.ensureQueryData( + githubMyPullsQueryOptions({ userId: context.user.id }), + ); + }, + component: PullRequestsPage, +}); + +function PullRequestsPage() { + const { user } = Route.useRouteContext(); + const { data } = useSuspenseQuery( + githubMyPullsQueryOptions({ userId: user.id }), + ); + + return ( +
+
+

+ Cached pull request groups +

+

Pull Requests

+
+ +
+ + + + + +
+
+ ); +} + +function PullGroup({ title, pulls }: { title: string; pulls: PullSummary[] }) { + return ( +
+
+

{title}

+ {pulls.length} +
+ {pulls.length === 0 ? ( +

+ No pull requests in this slice. +

+ ) : ( +
+ {pulls.map((pull) => ( +
+

+ #{pull.number} {pull.title} +

+

+ {pull.repository.fullName} +

+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/routes/_protected/reviews.tsx b/apps/dashboard/src/routes/_protected/reviews.tsx new file mode 100644 index 0000000..1757c37 --- /dev/null +++ b/apps/dashboard/src/routes/_protected/reviews.tsx @@ -0,0 +1,57 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { githubMyPullsQueryOptions } from "#/lib/github.query"; + +export const Route = createFileRoute("/_protected/reviews")({ + loader: async ({ context }) => { + await context.queryClient.ensureQueryData( + githubMyPullsQueryOptions({ userId: context.user.id }), + ); + }, + component: ReviewsPage, +}); + +function ReviewsPage() { + const { user } = Route.useRouteContext(); + const { data } = useSuspenseQuery( + githubMyPullsQueryOptions({ userId: user.id }), + ); + + return ( +
+
+

+ Review requests stay warm in the shared pull request cache. +

+

Reviews

+
+ +
+
+

Requested reviews

+ + {data.reviewRequested.length} + +
+ {data.reviewRequested.length === 0 ? ( +

+ No review requests found. +

+ ) : ( +
+ {data.reviewRequested.map((pull) => ( +
+

+ #{pull.number} {pull.title} +

+

+ {pull.repository.fullName} +

+
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/routes/api/auth/$.ts b/apps/dashboard/src/routes/api/auth/$.ts index 4fef678..cc179b0 100644 --- a/apps/dashboard/src/routes/api/auth/$.ts +++ b/apps/dashboard/src/routes/api/auth/$.ts @@ -1,5 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { getAuth } from "#/lib/auth"; +import { getAuth } from "#/lib/auth.server"; export const Route = createFileRoute("/api/auth/$")({ server: { diff --git a/apps/dashboard/src/routes/login.tsx b/apps/dashboard/src/routes/login.tsx index c72ef1b..9990189 100644 --- a/apps/dashboard/src/routes/login.tsx +++ b/apps/dashboard/src/routes/login.tsx @@ -2,8 +2,8 @@ import { GitHubLogo } from "@quickhub/icons"; import { Button } from "@quickhub/ui/components/button"; import { Logo } from "@quickhub/ui/components/logo"; import { createFileRoute, redirect } from "@tanstack/react-router"; -import { signIn } from "#/lib/auth.client"; import { getSession } from "#/lib/auth.functions"; +import { signInWithGitHub } from "#/lib/auth-actions"; export const Route = createFileRoute("/login")({ beforeLoad: async () => { @@ -42,7 +42,7 @@ function LoginPage() { className="space-y-3" onSubmit={(event) => { event.preventDefault(); - void signIn.social({ provider: "github" }); + void signInWithGitHub(); }} >