diff --git a/.cursor/debug.log b/.cursor/debug.log new file mode 100644 index 00000000..414e2bd6 --- /dev/null +++ b/.cursor/debug.log @@ -0,0 +1,2 @@ +{"location":"map-storage-commit-detail.ts:buildCommitDetailPayload","message":"pierre first file raw prefix","data":{"path":"test.ts","state":"deleted","rawLen":134,"rawPrefix":"diff --git a/test.ts b/test.ts\ndeleted file mode 100644\nindex 54b82a0..0000000\n--- a/test.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-const a = 1;"},"timestamp":1774205454126,"hypothesisId":"H1-H4"} +{"location":"map-storage-commit-detail.ts:buildCommitDetailPayload","message":"pierre first file raw prefix","data":{"path":"test.ts","state":"deleted","rawLen":134,"rawPrefix":"diff --git a/test.ts b/test.ts\ndeleted file mode 100644\nindex 54b82a0..0000000\n--- a/test.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-const a = 1;"},"timestamp":1774205459285,"hypothesisId":"H1-H4"} diff --git a/AGENTS.md b/AGENTS.md index 6451af31..8b3aeb55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,95 @@ -# Better Hub AGENTS.md +# Better Hub -- Agent Documentation -## Production Information +Better Hub is a reimagined GitHub UI for code collaboration, built by the Better Auth team. It is a Next.js 16 / React 19 monorepo that proxies the GitHub API, adds AI features (Ghost assistant), and provides a faster, keyboard-driven experience for repos, PRs, issues, and CI/CD. -- The origin for better-hub is: https://better-hub.com - in +## Production + +- Live site: https://better-hub.com ## Design -- Try to follow the design of the rest of the site as much as possible. -- Avoid loading spinners and prefer skeleton UI for loading states. +- Match the design language of the rest of the app. +- Prefer skeleton UI over loading spinners for loading states. + +## How to Use These Docs + +The `agent-docs/` directory contains detailed documentation organized by topic. Start with the section most relevant to your task. If you need a broad understanding, read **architecture/overview.md** first. + +## Keeping Docs Up to Date + +When you make changes to the codebase that affect architecture, patterns, configuration, or conventions documented in `agent-docs/`, update the relevant doc files as part of the same change. Examples of when to update: + +- Adding or removing a page, API route, or component directory -- update `architecture/project-structure.md` and `frontend/components.md` +- Changing the database schema -- update `data-layer/database.md` +- Adding or modifying environment variables -- update `infrastructure/environment.md` +- Changing the auth flow, caching strategy, or data-fetching patterns -- update the relevant feature or data-layer doc +- Introducing a new major dependency or tool -- update `architecture/overview.md` +- Adding a new feature area -- consider creating a new doc file under the appropriate directory + +If a change is significant enough that it would surprise a future agent reading the current docs, the docs need updating. Keep descriptions concise and factual -- document what exists and how it works, not aspirational plans. + +## Documentation Index + +### Architecture + +- [agent-docs/architecture/overview.md](agent-docs/architecture/overview.md) -- Tech stack, monorepo layout, key dependencies, how systems connect +- [agent-docs/architecture/project-structure.md](agent-docs/architecture/project-structure.md) -- Full directory tree with annotations for every folder and key file +- [agent-docs/architecture/data-flow.md](agent-docs/architecture/data-flow.md) -- Request lifecycle, the `localFirstGitRead` caching pattern, AI data flow, mutation flow + +### Features + +- [agent-docs/features/ghost-ai.md](agent-docs/features/ghost-ai.md) -- Ghost AI assistant: model routing, ~30 tools, conversation persistence, semantic search, E2B sandboxes +- [agent-docs/features/github-integration.md](agent-docs/features/github-integration.md) -- Octokit REST client, ~30 sync job types, shared vs per-user cache security, rate limiting, OAuth scopes +- [agent-docs/features/pr-reviews.md](agent-docs/features/pr-reviews.md) -- PR review system: diff viewing, inline comments, AI summaries, merge panel, conflict resolution, CI checks +- [agent-docs/features/billing.md](agent-docs/features/billing.md) -- Stripe metered billing, credit system, spending limits, AI model pricing, usage tracking + +### Data Layer + +- [agent-docs/data-layer/database.md](agent-docs/data-layer/database.md) -- Prisma schema (20 models), connection pool config, migration commands +- [agent-docs/data-layer/caching.md](agent-docs/data-layer/caching.md) -- Multi-tier Redis caching: per-user, shared, repo data (TTL tiers), Vercel cache, DB fallback +- [agent-docs/data-layer/github-sync.md](agent-docs/data-layer/github-sync.md) -- Background sync job system: job lifecycle, deduplication, draining, ETag support + +### Authentication + +- [agent-docs/auth/authentication.md](agent-docs/auth/authentication.md) -- better-auth config, GitHub OAuth, `getServerSession()`, session/cookie handling, PAT sign-in, scopes + +### Frontend + +- [agent-docs/frontend/routing.md](agent-docs/frontend/routing.md) -- GitHub-compatible URL rewriting, middleware, git protocol redirects, route groups +- [agent-docs/frontend/components.md](agent-docs/frontend/components.md) -- Component organization by domain (~150 components), server vs client patterns +- [agent-docs/frontend/ui-patterns.md](agent-docs/frontend/ui-patterns.md) -- TailwindCSS 4, Radix UI, CVA, Shiki, TipTap, theming, keyboard shortcuts, hooks + +### Infrastructure + +- [agent-docs/infrastructure/development.md](agent-docs/infrastructure/development.md) -- Local setup, Docker Compose, dev scripts, linting, TypeScript config, testing +- [agent-docs/infrastructure/deployment.md](agent-docs/infrastructure/deployment.md) -- Vercel hosting, GitHub Actions CI, Sentry, security headers, build process +- [agent-docs/infrastructure/environment.md](agent-docs/infrastructure/environment.md) -- All environment variables categorized with descriptions + +## Quick Reference + +### Critical Files + +| File | Purpose | +| ---------------------------------------- | ----------------------------------------------------- | +| `apps/web/src/lib/github.ts` | All GitHub API data fetching (~7300 lines) | +| `apps/web/src/lib/auth.ts` | Authentication config and `getServerSession()` | +| `apps/web/src/lib/db.ts` | Database client and connection pool | +| `apps/web/src/proxy.ts` | Middleware (auth + URL rewriting) | +| `apps/web/src/app/api/ai/ghost/route.ts` | Ghost AI endpoint (~3500 lines) | +| `apps/web/prisma/schema/` | Database schema (multi-file: auth.prisma, app.prisma) | +| `apps/web/next.config.ts` | Next.js configuration | +| `apps/web/.env.example` | Environment variable template | + +### Common Tasks + +**Adding a new page**: Create `page.tsx` in `apps/web/src/app/(app)/your-route/`. It will automatically get the app layout (navbar, Ghost, auth). + +**Adding a new API route**: Create `route.ts` in `apps/web/src/app/api/your-endpoint/`. Use `getServerSession()` for auth and `getOctokitFromSession()` for GitHub API access. + +**Adding a new component**: Place in the appropriate feature directory under `apps/web/src/components/`. Use `"use client"` only if the component needs interactivity. + +**Fetching GitHub data**: Add a function in `apps/web/src/lib/github.ts` using the `localFirstGitRead` pattern. Define the cache key, job type, and remote fetcher. + +**Adding a database model**: Add to the appropriate file in `apps/web/prisma/schema/` (`auth.prisma` for better-auth tables, `app.prisma` for everything else), run `bunx prisma migrate dev --name your_migration`, then `bunx prisma generate`. + +**Running checks before PR**: Run `bun check` from the repo root (lint + format + typecheck). diff --git a/agent-docs/architecture/data-flow.md b/agent-docs/architecture/data-flow.md new file mode 100644 index 00000000..fffd4ba7 --- /dev/null +++ b/agent-docs/architecture/data-flow.md @@ -0,0 +1,153 @@ +# Data Flow + +This document describes how data moves through Better Hub from an incoming HTTP request to a rendered page. + +## Request Lifecycle + +### 1. Middleware (`src/proxy.ts`) + +Every request first passes through Next.js middleware which handles three concerns: + +**Git protocol redirect** -- If the URL matches a git service path (`info/refs?service=git-upload-pack`, `git-receive-pack`), the request is redirected to `github.com` with a 307. This lets `git clone` and `git push` work transparently. + +**Authentication** -- Public paths (`/`, `/api/auth`, `/api/inngest`) are allowed through. All other paths require a `better-auth` session cookie. Missing sessions redirect to `/`. + +**URL rewriting** -- GitHub-compatible URLs are rewritten to internal App Router paths: +- `/:owner/:repo` -> `/repos/:owner/:repo` +- `/:owner/:repo/pull/:number` -> `/repos/:owner/:repo/pulls/:number` +- `/:owner/:repo/commit/:sha` -> `/repos/:owner/:repo/commits/:sha` +- `/:owner/:repo/compare/base...head` -> `/repos/:owner/:repo/pulls/new?base=&head=` + +The `APP_ROUTES` set prevents rewriting known first-segment routes (`dashboard`, `repos`, `api`, `_next`, etc.). + +### 2. App Layout (`src/app/(app)/layout.tsx`) + +The `(app)` route group layout runs for every authenticated page: + +1. Calls `getServerSession()` which is wrapped in React `cache()` for request deduplication +2. If no session exists, redirects to `/` with a `?redirect=` parameter +3. Fetches notifications via `getNotifications()` +4. Checks onboarding status and star state for first-run overlay +5. Wraps children in providers: `NuqsAdapter`, `GlobalChatProvider`, `MutationEventProvider`, `ColorThemeProvider`, `GitHubLinkInterceptor`, `TooltipProvider` +6. Renders: navbar, navigation progress bar, nav-aware content area, Ghost chat panel, onboarding overlay + +### 3. Repo Layout (`src/app/(app)/repos/[owner]/[repo]/layout.tsx`) + +For repository pages, a nested layout provides: + +1. Fetches repo page data via `getRepoPageData()` (repo metadata, nav counts, star status, org membership, latest commit) +2. Loads cached data in parallel: file tree, contributor avatars, languages, branches, tags +3. Prefetches PR data in the background via `waitUntil(prefetchPRData())` +4. Renders: sidebar (description, stats, contributors, languages), repo nav tabs, code content wrapper with file tree and branch selector + +### 4. Page Components + +Individual pages fetch their specific data using functions from `src/lib/github.ts`. These all use the `localFirstGitRead` pattern described below. + +## GitHub Data Fetching: `localFirstGitRead` Pattern + +The core data-fetching pattern prioritizes local cache for speed while keeping data fresh via background sync: + +``` +1. Check Redis cache (gh:{userId}:{cacheKey}) + ├── HIT with fresh data → return immediately + └── MISS or stale + │ +2. Check shared cache (for public data types) + ├── HIT → return + enqueue background sync job + └── MISS + │ +3. Fetch from GitHub API (Octokit) + ├── SUCCESS → update cache, return data + └── FAILURE (rate limit, network) + │ +4. Fall back to DB cache (github_cache_entries table) + ├── HIT → return stale data + └── MISS → return fallback value +``` + +**Security model**: Only certain data types are shareable across users (branches, tags, releases, issues, PRs, contributors, etc. -- defined in `SHAREABLE_CACHE_TYPES`). Private repo data and user-specific data is always scoped to the requesting user's cache key. + +### Background Sync Jobs + +When data is served from cache and may be stale, a sync job is enqueued: + +1. Jobs are deduplicated by `(userId, dedupeKey)` -- only one pending job per user per data type +2. The `drainGithubSyncJobs()` function claims and processes jobs for a user +3. Jobs are processed with the user's GitHub token, updating both Redis and DB caches +4. Failed jobs are retried up to 8 times with backoff +5. Running jobs have a 10-minute timeout to prevent stuck jobs + +## AI Data Flow + +### Ghost Chat (`/api/ai/ghost`) + +``` +Client message + │ + ├── Check usage limits (credits, spending cap) + ├── Resolve model (user preference or "auto" → default model) + ├── Load/create conversation from DB + │ + ▼ +streamText() with tools + │ + ├── GitHub tools (via user's Octokit) + │ ├── get_repo_info, list_issues, list_prs + │ ├── get_issue, get_pull_request, get_file_content + │ ├── create_issue, create_pr, add_comment + │ ├── merge_pr, create_branch, update_file + │ └── ... (~30 tools) + │ + ├── Search tools + │ ├── search_repos, search_code, search_issues + │ └── semantic_search (Mixedbread embeddings + reranking) + │ + ├── Code execution (E2B sandbox) + │ + └── Navigation tools (generate Better Hub URLs) + │ + ▼ +Stream response to client + │ + ├── Save messages to DB (chat_messages) + ├── Log token usage (ai_call_logs + usage_logs) + └── Report to Stripe (metered billing) +``` + +### Embedding Pipeline (Background) + +``` +User views PR/Issue + │ + ▼ +Inngest event: app/content.viewed + │ + ▼ +embedContent function + ├── Embed title + body (Mixedbread mxbai-embed-large-v1) + ├── Embed comments in batches of 20 + ├── Embed reviews + └── Store in search_embeddings table (with content hash for dedup) +``` + +## Caching Tiers + +| Tier | TTL | Use Case | Key Pattern | +|---|---|---|---| +| Per-user Redis | Varies | User-specific GitHub data | `gh:{userId}:{cacheKey}` | +| Shared Redis | Varies | Public repo data (branches, tags, etc.) | `shared:{cacheKey}` | +| Repo data cache | 24h / 1h / 5min | Languages, branches, file tree, events | `repo_*:{owner}/{repo}` | +| Vercel cache | `unstable_cache` | Server component data revalidation | Function-based | +| DB cache | Permanent | Fallback when GitHub API is unavailable | `github_cache_entries` table | +| README cache | Medium TTL | Rendered README content | `readme:{owner}/{repo}` | + +## Mutation Flow + +When users perform write actions (create issue, merge PR, add comment), the flow is: + +1. Client calls a mutation function (often via `use-mutation.ts` hook) +2. The function calls the GitHub API directly via Octokit +3. On success, relevant caches are invalidated (`invalidateIssueCache`, `invalidatePullRequestCache`, etc.) +4. A mutation event is dispatched via `MutationEventProvider` to update other components on the page +5. `use-mutation-subscription.ts` hooks in other components react to the event and refetch data diff --git a/agent-docs/architecture/overview.md b/agent-docs/architecture/overview.md new file mode 100644 index 00000000..e8c31c39 --- /dev/null +++ b/agent-docs/architecture/overview.md @@ -0,0 +1,95 @@ +# Architecture Overview + +Better Hub is a reimagined GitHub UI for code collaboration, built by the Better Auth team. It provides a faster, more pleasant experience for browsing repos, reviewing PRs, triaging issues, and interacting with an AI assistant (Ghost). + +Production URL: `https://www.better-hub.com` + +## Tech Stack + +| Layer | Technology | Version | +|---|---|---| +| Framework | Next.js (App Router) | 16 | +| UI | React | 19 | +| Styling | TailwindCSS | 4 | +| Component primitives | Radix UI | - | +| ORM | Prisma | 7 | +| Database | PostgreSQL | 16 | +| Cache | Redis via Upstash REST | 7 | +| Package manager | Bun | 1.3.5 | +| Linter | oxlint | - | +| Formatter | oxfmt | - | +| Language | TypeScript (strict) | 5.7+ | +| Error tracking | Sentry | 10 | +| Hosting | Vercel | - | + +## Monorepo Layout + +The repo uses Bun workspaces with three packages: + +- **`apps/web`** -- The main Next.js application. Contains all pages, API routes, components, and server-side logic. +- **`packages/chrome-extension`** -- Chrome Manifest V3 extension that adds "Open in Better Hub" buttons on GitHub pages and optionally redirects GitHub URLs. +- **`packages/firefox-extension`** -- Firefox equivalent of the Chrome extension. + +## Key Dependencies + +### GitHub Integration +- `@octokit/rest` -- REST client for all GitHub API calls +- `better-auth` -- Authentication library (built by the same team) with GitHub OAuth + +### AI / ML +- `@openrouter/ai-sdk-provider` + `ai` (Vercel AI SDK) -- Model routing and streaming for Ghost AI +- `@ai-sdk/anthropic` -- Anthropic provider for specific AI tasks +- `@mixedbread-ai/sdk` -- Embedding generation and reranking for semantic search +- `supermemory` -- Long-term AI conversation memory +- `e2b` -- Sandboxed code execution environments + +### Billing +- `stripe` -- Metered billing and subscriptions +- `@better-auth/stripe` -- Stripe plugin for better-auth + +### Background Jobs +- `inngest` -- Durable background functions (embedding content, retrying Stripe usage reports) + +### UI +- `radix-ui` -- Accessible UI primitives (dialog, dropdown, tooltip, popover, etc.) +- `cmdk` -- Command palette (`Cmd+K`) +- `shiki` -- Syntax highlighting for code blocks and diffs +- `@tiptap/*` -- Rich text editor for comments and markdown +- `motion` -- Animations +- `lucide-react` -- Icons +- `react-markdown` + remark/rehype plugins -- Markdown rendering +- `nuqs` -- URL query state management +- `next-themes` -- Theme switching + +### Data +- `@prisma/client` + `@prisma/adapter-pg` -- ORM with native PostgreSQL adapter +- `@upstash/redis` -- Redis REST client for caching +- `pg` -- PostgreSQL connection pool +- `zod` -- Schema validation + +## How They Connect + +``` +Browser ──► Next.js Middleware (proxy.ts) + │ + ├── URL rewriting (GitHub-compatible routes) + ├── Authentication check (better-auth session cookie) + │ + ▼ + App Router (React Server Components) + │ + ├── getServerSession() ──► better-auth ──► PostgreSQL + ├── GitHub data fetching ──► localFirstGitRead pattern + │ │ + │ ├── Redis cache (Upstash) + │ ├── DB cache (github_cache_entries) + │ └── GitHub API (Octokit) + background sync jobs + │ + ├── AI endpoints ──► OpenRouter / Anthropic + │ │ + │ ├── Tool calls (GitHub API via Octokit) + │ ├── E2B sandbox (code execution) + │ └── Semantic search (Mixedbread embeddings) + │ + └── Billing ──► Stripe (metered usage) +``` diff --git a/agent-docs/architecture/project-structure.md b/agent-docs/architecture/project-structure.md new file mode 100644 index 00000000..58a5e42f --- /dev/null +++ b/agent-docs/architecture/project-structure.md @@ -0,0 +1,276 @@ +# Project Structure + +## Root + +``` +better-hub/ +├── AGENTS.md # Agent documentation entry point +├── CONTRIBUTING.md # Contributor guide +├── README.md # Project readme +├── SECURITY.md # Security policy +├── LICENSE # MIT license +├── package.json # Root workspace config (scripts, devDependencies) +├── tsconfig.json # Base TypeScript config (strict mode) +├── oxlint.json # Linter config (plugins: typescript, import, promise, unicorn) +├── bunfig.toml # Bun configuration +├── docker-compose.yml # Local dev services (Postgres, Redis, Redis REST proxy) +├── bun.lock / pnpm-lock.yaml # Lock files +├── apps/ # Application packages +├── packages/ # Shared/extension packages +└── agent-docs/ # Agent documentation (this directory) +``` + +## `apps/web/` -- Main Next.js Application + +``` +apps/web/ +├── package.json # App dependencies and scripts +├── next.config.ts # Next.js config (rewrites, images, headers, Sentry) +├── prisma/ +│ ├── schema.prisma # Database schema (20 models) +│ └── migrations/ # Prisma migrations +├── public/ +│ ├── extension/ # Extension download assets +│ └── fonts/ # Custom fonts +├── scripts/ +│ └── generate-openrouter-models.mts # Fetches model pricing from OpenRouter API +└── src/ + ├── proxy.ts # Next.js middleware (auth, URL rewrites, git redirects) + ├── app/ # App Router pages and API routes + ├── components/ # React components organized by feature + ├── generated/ # Auto-generated code (Prisma client) + ├── hooks/ # Custom React hooks + └── lib/ # Core business logic, utilities, stores +``` + +## `src/app/` -- App Router + +### Page Routes (`src/app/(app)/`) + +The `(app)` route group wraps all authenticated pages with a shared layout that provides the navbar, session, notifications, Ghost AI chat panel, and theming. + +``` +(app)/ +├── layout.tsx # Auth gate, navbar, Ghost chat, theme provider +├── dashboard/page.tsx # User dashboard (activity feed, repos) +├── repos/page.tsx # Repository listing +├── s/[owner]/[repo]/ # Better Hub git storage (Pierre) — same chrome as GitHub repos +│ ├── layout.tsx # RepoLayoutWrapper, RepoNav (shared tabs), CodeContentWrapper, StorageRepoSidebar +│ ├── page.tsx # Overview placeholder (`/s/:owner/:repo`) +│ ├── code/page.tsx # Code tab (matches GitHub code page; FileList + README) +│ ├── tree/[ref]/[[...path]]/# Directory listing +│ ├── blob/[ref]/[...path]/ # File viewer +│ └── commits, pulls, issues, prompts, actions, releases, tags, security, activity, insights, settings/ +│ # Tab placeholders (empty) +├── repos/[owner]/[repo]/ # Repository detail (has its own layout) +│ ├── layout.tsx # Repo sidebar, nav tabs, file tree, branch selector +│ ├── page.tsx # Repo overview (README, file list) +│ ├── blob/[...path]/ # File viewer +│ ├── tree/[...path]/ # Directory viewer +│ ├── pulls/ # Pull requests list and detail +│ │ ├── page.tsx # PR list +│ │ ├── new/ # Create PR +│ │ └── [number]/ # PR detail (conversation, diff, files, checks) +│ ├── issues/ # Issues list and detail +│ │ ├── page.tsx +│ │ └── [number]/ +│ ├── actions/ # CI/CD workflow runs +│ │ ├── page.tsx +│ │ ├── compare/ # Compare workflow runs +│ │ ├── [runId]/ # Run detail +│ │ └── workflows/[...path]/ +│ ├── commits/ # Commit history and detail +│ ├── discussions/ # Repository discussions +│ ├── releases/ # Releases and tags +│ ├── tags/ # Tag listing +│ ├── security/ # Security advisories +│ ├── people/ # Contributors and org members +│ ├── insights/ # Repository insights +│ ├── settings/ # Repo settings +│ ├── activity/ # Activity feed +│ ├── code/ # Code search within repo +│ └── prompts/ # Prompt requests +├── issues/page.tsx # Cross-repo issues view +├── pulls/page.tsx # Cross-repo PRs view +├── stars/ # Starred repos +├── trending/page.tsx # Trending repositories +├── search/page.tsx # Global search +├── notifications/page.tsx # Notification center +├── orgs/ # Organizations listing and detail +├── users/[username]/ # User profile +├── extension/page.tsx # Browser extension download page +└── [owner]/page.tsx # Owner profile (redirected via rewrites) +``` + +### API Routes (`src/app/api/`) + +``` +api/ +├── auth/[...all]/ # better-auth catch-all handler +├── ai/ +│ ├── ghost/ # Ghost AI chat (main endpoint + stream resume) +│ ├── ghost-tabs/ # Ghost tab management +│ ├── chat-history/ # Chat history retrieval +│ ├── commit-message/ # AI commit message generation +│ ├── pr-overview/ # AI PR analysis/summary +│ ├── command/ # AI command execution +│ └── rewrite-prompt/ # AI prompt rewriting +├── billing/ +│ ├── balance/ # Credit balance check +│ ├── spending-limit/ # Spending limit management +│ └── welcome/ # Welcome credit grant +├── search-*/ # Search endpoints (repos, issues, PRs, code, users) +├── repo-files/ # Repository file listing +├── file-content/ # File content retrieval +├── highlight-code/ # Server-side syntax highlighting +├── highlight-diff/ # Diff syntax highlighting +├── workflow-runs/ # CI/CD workflow data +├── check-status/ # PR check status +├── compare-runs/ # Compare workflow runs +├── merge-conflicts/ # PR merge conflict detection +├── job-logs/ # CI job log streaming +├── user-*/ # User data endpoints (profile, repos, scopes, settings) +├── org-repos/ # Organization repos +├── rate-limit/ # GitHub rate limit status +├── upload/ # File upload (images) +├── github-image/ # GitHub image proxy +├── extension-download/ # Extension download handler +├── og/ # OpenGraph image generation +└── inngest/ # Inngest webhook handler +``` + +## `src/components/` -- UI Components + +Components are organized by feature domain: + +``` +components/ +├── layout/ # App shell (navbar, nav-aware-content, notification sheet) +├── repo/ # Repository views (sidebar, nav, code viewer, file tree, etc.) ~38 files +├── pr/ # Pull request UI (diff viewer, conversation, merge, reviews) ~30 files +├── issue/ # Issue detail views +├── issues/ # Issue listing +├── prs/ # PR listing +├── dashboard/ # Dashboard widgets +├── search/ # Search UI +├── settings/ # User settings +│ └── tabs/ # Settings tab panels +├── shared/ # Cross-cutting components ~32 files +│ ├── ai-chat.tsx # AI chat interface +│ ├── global-chat-panel.tsx # Ghost panel (slide-out) +│ ├── markdown-renderer.tsx # Server-side markdown +│ ├── highlighted-code-block.tsx +│ ├── comment.tsx / comment-thread.tsx +│ └── github-link-interceptor.tsx # Rewrites github.com links to Better Hub +├── ui/ # Base UI primitives (button, dialog, badge, etc.) ~14 files +├── actions/ # CI/CD action views +├── discussion/ # Discussion views +├── notifications/ # Notification UI +├── onboarding/ # First-run onboarding overlay +├── orgs/ # Organization views +├── people/ # People/contributors views +├── security/ # Security advisory views +├── trending/ # Trending repos +├── users/ # User profile views +│ └── activity-timeline/ # Activity timeline components +├── providers/ # React context providers +├── extension/ # Extension promo +├── prompt-request/ # Prompt request UI +├── repos/ # Repos listing +├── pwa/ # PWA support +└── theme/ # Theme provider and selector +``` + +## `src/lib/` -- Core Logic + +``` +lib/ +├── auth.ts # better-auth server config, getServerSession() +├── auth-client.ts # better-auth React client +├── ai-auth.ts # Helper to get Octokit from session for AI routes +├── db.ts # Prisma client with PG pool configuration +├── redis.ts # Upstash Redis client +├── github.ts # GitHub API layer (~7300 lines, all data fetching) +├── github-types.ts # TypeScript interfaces for GitHub entities +├── github-utils.ts # Utility functions (language colors, URL helpers) +├── github-scopes.ts # OAuth scope groups and definitions +├── github-sync-store.ts # DB/Redis sync job persistence layer +├── github-user-attachments.ts # User attachment handling +├── inngest.ts # Background job definitions (embed content, retry billing) +├── mixedbread.ts # Embedding and reranking client (Mixedbread AI) +├── embedding-store.ts # Semantic search embedding CRUD +├── chat-store.ts # Ghost AI conversation persistence +├── repo-data-cache.ts # Redis cache helpers for repo data +├── repo-data-cache-vc.ts # Vercel cache layer for repo data +├── readme-cache.ts # README content cache +├── user-settings-store.ts # User preferences persistence +├── prompt-request-store.ts # Prompt request CRUD +├── pinned-repos.ts # Pinned repos management +├── pinned-items-store.ts # Pinned items (issues, PRs) management +├── recent-views.ts # Recently viewed items tracking +├── pr-overview-store.ts # PR AI analysis cache +├── contributor-score.ts # Contributor scoring algorithm +├── user-profile-score.ts # User profile scoring +├── file-tree.ts # File tree builder from git tree +├── shiki.ts / shiki-client.ts # Syntax highlighter (server and client) +├── diff-preferences.ts # Diff view preferences +├── extract-snippet.ts # Code snippet extraction +├── three-way-merge.ts # Three-way merge for conflict resolution +├── commit-utils.ts # Commit-related utilities +├── image-upload.ts # Image upload helpers +├── image-upload-r2.ts # R2 storage upload +├── resumable-stream.ts # Resumable AI stream support +├── live-tick.ts # Live duration ticker +├── mutation-events.ts # Client-side mutation event system +├── tiptap-mention.ts # TipTap mention plugin config +├── theme-script.ts # Theme initialization script +├── utils.ts # General utilities (cn, etc.) +├── storage/ +│ └── index.ts # Git storage client (@pierre/storage) +├── billing/ +│ ├── config.ts # Billing constants (welcome credit, cost units, etc.) +│ ├── ai-models.ts # AI model registry and pricing calculations +│ ├── ai-models.server.ts # Server-side model helpers +│ ├── openrouter-models.generated.ts # Auto-generated model catalog +│ ├── credit.ts # Credit ledger operations +│ ├── spending-limit.ts # Spending limit management +│ ├── stripe.ts # Stripe client and metered usage reporting +│ ├── token-usage.ts # Token usage logging +│ └── usage-limit.ts # Usage limit checks +├── themes/ +│ ├── index.ts # Theme exports +│ ├── themes.tsx # Theme definitions +│ ├── types.ts # Theme type definitions +│ └── border-radius.ts # Border radius presets +├── auth-plugins/ +│ └── pat-signin.ts # PAT (Personal Access Token) sign-in plugin +├── schemas/ # (empty — Zod schemas inline) +└── og/ # OpenGraph image generation helpers +``` + +## `src/hooks/` -- Custom React Hooks + +``` +hooks/ +├── use-readme.ts # README content fetching +├── use-is-mobile.ts # Mobile viewport detection +├── use-server-initial-data.ts # Hydrate server data into client state +├── use-mutation.ts # Optimistic mutation helper +├── use-mutation-subscription.ts # Subscribe to mutation events +├── use-infinite-scroll.ts # Infinite scroll pagination +└── use-click-outside.ts # Click-outside detection +``` + +## `packages/` -- Browser Extensions + +Both extensions share the same architecture: + +``` +packages/chrome-extension/ # Manifest V3 +packages/firefox-extension/ # Manifest V3 (Firefox-compatible) +├── manifest.json # Extension manifest +├── background.js # Service worker (URL redirect rules) +├── popup.html / popup.js # Extension popup UI +├── rules.json # Declarative redirect rules +└── icons/ # Extension icons +``` diff --git a/agent-docs/auth/authentication.md b/agent-docs/auth/authentication.md new file mode 100644 index 00000000..cbe0869b --- /dev/null +++ b/agent-docs/auth/authentication.md @@ -0,0 +1,178 @@ +# Authentication + +Better Hub uses the `better-auth` library (built by the same team) for authentication, with GitHub as the sole OAuth provider. + +## Key Files + +- `apps/web/src/lib/auth.ts` -- Server-side auth configuration and `getServerSession()` +- `apps/web/src/lib/auth-client.ts` -- Client-side auth hooks (`useSession`, `signIn`, `signOut`) +- `apps/web/src/lib/ai-auth.ts` -- Helper to extract Octokit/token for AI routes +- `apps/web/src/lib/auth-plugins/pat-signin.ts` -- Personal Access Token sign-in plugin +- `apps/web/src/lib/github-scopes.ts` -- OAuth scope groups and descriptions +- `apps/web/src/app/api/auth/[...all]/route.ts` -- better-auth catch-all API handler +- `apps/web/src/proxy.ts` -- Middleware with auth checks + +## Server Configuration (`auth.ts`) + +The `betterAuth()` instance is configured with: + +### Database Adapter +```typescript +database: prismaAdapter(prisma, { provider: "postgresql" }) +``` + +### Plugins +- `dash()` -- Dashboard/admin panel with activity tracking +- `sentinel()` -- Rate limiting and abuse protection +- `admin()` -- Admin user management +- `patSignIn()` -- Custom plugin for Personal Access Token authentication +- `stripe()` -- Stripe billing integration (conditional on `STRIPE_SECRET_KEY` being set) +- `oAuthProxy()` -- OAuth proxy for Vercel preview deployments (production URL: `https://www.better-hub.com`) + +### GitHub OAuth +```typescript +socialProviders: { + github: { + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + scope: ["read:user", "user:email", "public_repo"], + mapProfileToUser(profile) { + return { githubLogin: profile.login }; + }, + }, +}, +``` + +Default scopes are minimal. Users can opt into additional scopes (private repos, notifications, etc.) through the sign-in UI. + +### Session Configuration +```typescript +session: { + cookieCache: { + enabled: true, + maxAge: 60 * 60 * 24 * 7, // 7 days + strategy: "jwe", // JSON Web Encryption + }, +}, +``` + +Sessions are cached in the cookie itself using JWE encryption. This means most auth checks don't require a DB lookup. + +### Account Settings +```typescript +account: { + encryptOAuthTokens: true, // OAuth tokens encrypted at rest + storeAccountCookie: true, // Account data cached in cookie + updateAccountOnSignIn: true, // Sync scopes on each sign-in +}, +``` + +### User Fields +Custom fields added to the User model: +- `githubPat` (string, optional) -- Personal Access Token for enhanced API access +- `onboardingDone` (boolean, optional) -- Whether the user completed onboarding + +### Trusted Origins +```typescript +trustedOrigins: [ + "https://www.better-hub.com", + "https://better-hub-*-better-auth.vercel.app", + "https://beta.better-hub.com", +], +``` + +## `getServerSession()` + +This is the primary function for getting the authenticated user in server components and API routes. It is wrapped in React `cache()` for request deduplication. + +```typescript +export const getServerSession = cache(async () => { + // 1. Get session from better-auth + // 2. Get GitHub access token + // 3. Fetch GitHub user data (cached in Redis for 1 hour) + // 4. Return { user, session, githubUser: { ...githubData, accessToken } } +}); +``` + +Returns `null` if: +- No valid session cookie exists +- The session has expired +- The GitHub access token is invalid + +The `$Session` type is exported for use throughout the app: +```typescript +export type $Session = NonNullable>>; +``` + +## Client-Side Auth (`auth-client.ts`) + +```typescript +export const authClient = createAuthClient({ + plugins: [ + inferAdditionalFields(), + dashClient(), + sentinelClient(), + stripeClient({ subscription: true }), + ], +}); + +export const { signIn, signOut, useSession } = authClient; +``` + +- `useSession()` -- React hook for accessing session state +- `signIn()` -- Initiate GitHub OAuth flow +- `signOut()` -- End the session + +## AI Route Auth (`ai-auth.ts`) + +Helper functions for AI API routes: + +```typescript +// Get an authenticated Octokit client from the session +getOctokitFromSession(): Promise + +// Get just the GitHub token +getGitHubToken(): Promise +``` + +## Middleware Auth (`proxy.ts`) + +The middleware checks authentication before any protected route: + +1. Public paths bypass auth: `/`, `/api/auth`, `/api/inngest` +2. All other paths require a valid session cookie (`getSessionCookie(request.headers)`) +3. Missing sessions redirect to `/` (the landing page with sign-in) + +## OAuth Scopes + +Scopes are defined in `github-scopes.ts` as groups: + +| Group | Scopes | Required? | +|---|---|---| +| `profile` | `user`, `user:email`, `user:follow` | Yes | +| `public_repos` | `public_repo`, `repo:status`, `repo_deployment`, `read:org` | Yes | +| `private_repos` | `repo` | No | +| `notifications` | `notifications` | No | +| `gist` | `gist` | No | +| `admin` | `admin:repo_hook`, `admin:org` | No | +| `workflow` | `workflow` | No | +| `delete_repo` | `delete_repo` | No | + +Each group has a label, description, and reason shown to the user during scope selection. + +## GitHub User Data Caching + +When a session is established, the GitHub user profile is fetched and cached: + +1. Hash the access token with SHA-256 +2. Check Redis for `github_user:{tokenHash}` +3. If miss, call `octokit.users.getAuthenticated()` +4. Cache result in Redis with 1-hour TTL +5. Caching is fire-and-forget via `waitUntil()` to not block the response + +## PAT Sign-In + +The `patSignIn()` plugin allows users to authenticate with a GitHub Personal Access Token instead of OAuth. This is useful for: +- CI/CD integrations +- Automated testing +- Users who prefer token-based auth diff --git a/agent-docs/data-layer/caching.md b/agent-docs/data-layer/caching.md new file mode 100644 index 00000000..13ca17ea --- /dev/null +++ b/agent-docs/data-layer/caching.md @@ -0,0 +1,137 @@ +# Caching + +Better Hub uses a multi-tier caching strategy to minimize GitHub API calls and maximize page load speed. + +## Key Files + +- `apps/web/src/lib/redis.ts` -- Upstash Redis client +- `apps/web/src/lib/repo-data-cache.ts` -- Redis cache helpers for repository data (write path) +- `apps/web/src/lib/repo-data-cache-vc.ts` -- Vercel `unstable_cache` wrappers (read path) +- `apps/web/src/lib/github-sync-store.ts` -- Per-user GitHub cache entries (Redis + DB) +- `apps/web/src/lib/readme-cache.ts` -- README content cache +- `apps/web/src/lib/github.ts` -- Contains the `localFirstGitRead` function that orchestrates all cache layers + +## Redis Client + +```typescript +import { Redis } from "@upstash/redis"; + +export const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, +}); +``` + +In local development, Docker Compose provides Redis + a serverless-redis-http proxy on port 8079 that emulates the Upstash REST API. + +## Cache Tiers + +### Tier 1: Per-User GitHub Cache (Redis) + +**Key pattern**: `gh:{userId}:{cacheKey}` + +Used for user-specific GitHub data (repos, notifications, etc.). Each user gets their own cache namespace to prevent private data leakage. + +Operations in `github-sync-store.ts`: +- `getGithubCacheEntry(userId, cacheKey)` -- Read from Redis +- `upsertGithubCacheEntry(userId, cacheKey, type, data, etag)` -- Write to Redis +- `touchGithubCacheEntrySyncedAt(userId, cacheKey)` -- Update timestamp +- `deleteGithubCacheByPrefix(userId, prefix)` -- Invalidate by prefix + +### Tier 2: Shared Cache (Redis) + +**Key pattern**: `shared:{cacheKey}` + +Used for public data types that are safe to share across users (see `SHAREABLE_CACHE_TYPES` in `github.ts`). This dramatically reduces API calls for popular repos. + +Operations: +- `getSharedCacheEntry(cacheKey)` -- Read +- `upsertSharedCacheEntry(cacheKey, type, data, etag)` -- Write +- `touchSharedCacheEntrySyncedAt(cacheKey)` -- Update timestamp +- `deleteSharedCacheByPrefix(prefix)` -- Invalidate + +### Tier 3: Repo Data Cache (Redis with TTL) + +**Key pattern**: `{suffix}:{owner}/{repo}` (e.g., `repo_languages:vercel/next.js`) + +Purpose-built cache for specific repo data with TTL tiers: + +| TTL Tier | Duration | Data Types | +|---|---|---| +| `slow` | 24 hours | Languages, contributor avatars | +| `medium` | 1 hour | Branches, tags, file tree, page data | +| `fast` | 5 minutes | PRs, issues, events, CI status | + +Cache functions in `repo-data-cache.ts`: +- `setCachedRepoLanguages(owner, repo, data)` +- `setCachedContributorAvatars(owner, repo, data)` +- `setCachedBranches(owner, repo, data)` +- `setCachedTags(owner, repo, data)` +- `setCachedRepoTree(owner, repo, data)` +- `setCachedAuthorDossier(owner, repo, login, data)` +- And corresponding `getCached*` functions + +Some cache entries also support per-user scoping: `{suffix}:{userId}:{owner}/{repo}`. + +### Tier 4: Vercel Cache (Server Component Caching) + +Read wrappers in `repo-data-cache-vc.ts` use Next.js `unstable_cache` with revalidation tags: + +```typescript +getCachedRepoTree(owner, repo) // tag: "repo-tree:{owner}/{repo}" +getCachedContributorAvatars(owner, repo) +getCachedRepoLanguages(owner, repo) +getCachedBranches(owner, repo) +getCachedTags(owner, repo) +``` + +These read from the Redis cache but add a Vercel-level cache layer for server components, reducing Redis calls during page renders. + +### Tier 5: DB Cache (PostgreSQL -- Fallback) + +The `github_cache_entries` table serves as the last-resort fallback. If Redis is down or the entry has been evicted, the system falls back to the DB. + +This tier has no TTL -- data persists until explicitly overwritten. It ensures the app can still render (with stale data) even when GitHub's API is completely unavailable. + +## Cache Invalidation + +### Automatic (TTL-based) +Redis entries expire automatically based on their TTL tier. + +### Manual (After Mutations) +When users perform write operations, specific caches are invalidated: + +```typescript +// After creating/updating an issue +invalidateIssueCache(owner, repo, issueNumber); +invalidateRepoIssuesCache(owner, repo); + +// After merging/updating a PR +invalidatePullRequestCache(owner, repo, pullNumber); +invalidateRepoPullRequestsCache(owner, repo); +``` + +These functions delete the relevant cache entries from both per-user and shared caches. + +### Force Refresh +The `forceRefresh` flag on `GitHubAuthContext` bypasses all caches and fetches directly from the GitHub API. This is triggered by explicit user refresh actions. + +## Cache Warming + +The repo layout pre-warms caches for data that will be needed: + +1. `getCachedRepoTree` -- File tree for the sidebar +2. `getCachedContributorAvatars` -- Contributor faces +3. `getCachedRepoLanguages` -- Language breakdown +4. `getCachedBranches` / `getCachedTags` -- Branch/tag selectors +5. `prefetchPRData()` -- Background warmup of PR and issue caches via `waitUntil()` + +## GitHub User Data Cache + +GitHub user profile data is cached in Redis per access token: + +```typescript +redis.set(`github_user:${tokenHash}`, JSON.stringify(userData), { ex: 3600 }); +``` + +This avoids repeated `/user` API calls during a session. The token is hashed before being used as a cache key. diff --git a/agent-docs/data-layer/database.md b/agent-docs/data-layer/database.md new file mode 100644 index 00000000..4700b532 --- /dev/null +++ b/agent-docs/data-layer/database.md @@ -0,0 +1,124 @@ +# Database + +Better Hub uses PostgreSQL as its primary database, accessed through Prisma ORM with a native `pg` adapter for connection pooling. + +## Key Files + +- `apps/web/prisma/schema/` -- Database schema (multi-file: `base.prisma` for config, `auth.prisma` for better-auth tables, `app.prisma` for app tables) +- `apps/web/prisma/migrations/` -- Prisma migrations +- `apps/web/src/lib/db.ts` -- Prisma client initialization and connection pool config +- `apps/web/src/generated/prisma/` -- Auto-generated Prisma client + +## Connection Pool + +The pool is configured in `db.ts` with environment-aware settings: + +| Setting | Development | Production | +| ------------------------- | ------------- | ---------- | +| `max` connections | 20 | 5 | +| `idleTimeoutMillis` | 10,000 | 30,000 | +| `connectionTimeoutMillis` | 0 (unlimited) | 5,000 | + +Development uses a larger pool because Next.js spawns 10-15 child processes, each needing its own connections. Docker Compose sets `max_connections=300` on PostgreSQL. Production sits behind a managed pooler (PgBouncer/Neon). + +The pool is attached to `process` as a singleton (`_proc.__dbPool`) to survive HMR in development. + +## Schema Overview + +### Authentication (managed by better-auth) + +| Model | Purpose | +| -------------- | ------------------------------------------------------------------------------------------------------------ | +| `User` | User accounts. Extended with `githubPat`, `onboardingDone`, `aiMessageCount`, `stripeCustomerId`, ban fields | +| `Session` | Active sessions with token, expiry, IP, user agent. Supports impersonation (`impersonatedBy`) | +| `Account` | OAuth accounts (GitHub). Stores encrypted access/refresh tokens, scopes | +| `Verification` | Email/token verification records | + +### GitHub Data Sync + +| Model | Purpose | +| ------------------ | ---------------------------------------------------------------------------------------------------------- | +| `GithubCacheEntry` | DB-level cache for GitHub API responses. Keyed by `(userId, cacheKey)`. Stores JSON data + etag + syncedAt | +| `GithubSyncJob` | Background sync job queue. Deduplicated by `(userId, dedupeKey)`. Tracks status, attempts, errors | + +### AI / Chat + +| Model | Purpose | +| ------------------ | ----------------------------------------------------------------------------------------------------------- | +| `ChatConversation` | Ghost AI conversations. Keyed by `(userId, contextKey)`. Tracks active stream ID for resumability | +| `ChatMessage` | Individual messages in a conversation. Stores role, content, and `partsJson` for multi-part AI SDK messages | +| `GhostTab` | Ghost panel tab state (tab ID, label, position) | +| `GhostTabState` | Per-user active tab and counter | + +### Search + +| Model | Purpose | +| ----------------- | ------------------------------------------------------------------------------------------------------------ | +| `SearchEmbedding` | Semantic search embeddings for viewed PRs/issues. Stores embedding vectors as JSON, content hashes for dedup | + +### User Preferences + +| Model | Purpose | +| ----------------- | ------------------------------------------------------------------------------------------------ | +| `UserSettings` | User preferences: theme, color theme, Ghost model, code theme, font, API keys, onboarding status | +| `CustomCodeTheme` | User-created custom code syntax themes | +| `PinnedItem` | Pinned issues/PRs per repo per user | + +### Billing + +| Model | Purpose | +| --------------- | --------------------------------------------------------------------------------- | +| `Subscription` | Stripe subscription state (plan, status, period, cancellation) | +| `UsageLog` | Per-AI-call billing records. Links to `AiCallLog`. Tracks Stripe reporting status | +| `AiCallLog` | Detailed AI call logs: provider, model, token counts, cost breakdown | +| `CreditLedger` | Credit transactions (grants and expirations) | +| `SpendingLimit` | Per-user monthly spending cap | + +### Prompt Requests + +| Model | Purpose | +| ----------------------- | --------------------------------------------------------------------------------------- | +| `PromptRequest` | Community prompt requests tied to repos. Has title, body, status (open/accepted/closed) | +| `PromptRequestComment` | Comments on prompt requests | +| `PromptRequestReaction` | Reactions (emoji) on prompt requests | + +### PR Analysis + +| Model | Purpose | +| -------------------- | --------------------------------------------------------------------------------------------------------- | +| `PrOverviewAnalysis` | Cached AI-generated PR analysis. Keyed by `(owner, repo, pullNumber)`, invalidated when `headSha` changes | + +## Prisma Commands + +```bash +cd apps/web + +# Generate Prisma client (run after schema changes) +bunx prisma generate + +# Create a migration +bunx prisma migrate dev --name your_migration_name + +# Apply migrations +bunx prisma migrate dev + +# Reset database (destructive) +bunx prisma migrate reset + +# Open Prisma Studio +bunx prisma studio +``` + +The `postinstall` script in `apps/web/package.json` runs `prisma generate` automatically on `bun install`. The `build` script also runs `prisma generate` before `next build`. + +## Accessing the Database + +Always import the Prisma client from `@/lib/db`: + +```typescript +import { prisma } from "@/lib/db"; + +const user = await prisma.user.findUnique({ where: { id: userId } }); +``` + +Never create a new `PrismaClient` instance directly -- the singleton in `db.ts` manages the connection pool. diff --git a/agent-docs/data-layer/github-sync.md b/agent-docs/data-layer/github-sync.md new file mode 100644 index 00000000..8fc95110 --- /dev/null +++ b/agent-docs/data-layer/github-sync.md @@ -0,0 +1,141 @@ +# GitHub Data Synchronization + +The sync system keeps locally cached GitHub data fresh by running background jobs that fetch updated data from the API. + +## Key Files + +- `apps/web/src/lib/github-sync-store.ts` -- CRUD for sync jobs and cache entries +- `apps/web/src/lib/github.ts` -- Contains `localFirstGitRead()`, `drainGithubSyncJobs()`, and job enqueue logic + +## How It Works + +### Job Lifecycle + +``` +Data requested (page load) + │ + ├── Cache HIT (fresh) → return cached data, done + │ + └── Cache MISS or stale + │ + ├── Fetch from GitHub API directly + ├── Update cache with result + └── Enqueue sync job for next refresh + │ + ▼ + GithubSyncJob created + (status: "pending", dedupeKey prevents duplicates) + │ + ▼ + drainGithubSyncJobs(userId) picks up job + │ + ├── Claims job (status: "running") + ├── Fetches data from GitHub API + ├── Updates cache (Redis + optionally DB) + ├── Marks job succeeded (deletes it) + │ + └── On failure: + ├── Increments attempts counter + ├── Records error message + └── Schedules next attempt (backoff) +``` + +### Job Deduplication + +Jobs are deduplicated by `(userId, dedupeKey)` via a unique constraint on the `github_sync_jobs` table. If a job already exists for the same user and data type, a new one is not created. This prevents flooding the job queue when a user rapidly navigates pages. + +### Job Draining + +The `drainGithubSyncJobs()` function: + +1. Claims a batch of pending jobs for the user (`claimDueGithubSyncJobs`) +2. Processes each job by calling the appropriate GitHub API +3. On success, marks the job completed and updates the cache +4. On failure, marks the job failed with error details +5. Uses a per-user lock (`githubSyncDrainingUsers` Set) to prevent concurrent drains for the same user + +Draining is triggered by `waitUntil()` during page loads -- it runs in the background after the response is sent. + +## Sync Store Operations + +### Cache Entry Operations + +```typescript +// Read +getGithubCacheEntry(userId, cacheKey): GithubCacheEntry | null +getSharedCacheEntry(cacheKey): GithubCacheEntry | null + +// Write +upsertGithubCacheEntry(userId, cacheKey, cacheType, data, etag?) +upsertSharedCacheEntry(cacheKey, cacheType, data, etag?) + +// Touch (update syncedAt without changing data) +touchGithubCacheEntrySyncedAt(userId, cacheKey) +touchSharedCacheEntrySyncedAt(cacheKey) + +// Delete +deleteGithubCacheByPrefix(userId, prefix) +deleteSharedCacheByPrefix(prefix) +``` + +Cache entries are stored in Redis with the structure: + +```typescript +interface GithubCacheEntry { + data: T; + syncedAt: string; // ISO timestamp + etag: string | null; +} +``` + +### Sync Job Operations + +```typescript +// Enqueue +enqueueGithubSyncJob(userId, dedupeKey, jobType, payload) + +// Claim (for processing) +claimDueGithubSyncJobs(userId, limit): GithubSyncJob[] + +// Complete +markGithubSyncJobSucceeded(jobId) +markGithubSyncJobFailed(jobId, error) +``` + +## Configuration + +| Constant | Value | Purpose | +|---|---|---| +| `MAX_ATTEMPTS` | 8 | Maximum retry attempts before giving up | +| `RUNNING_JOB_TIMEOUT_MS` | 10 minutes | Stuck job detection threshold | + +## Job Payload + +All sync jobs carry a typed payload: + +```typescript +interface GitDataSyncJobPayload { + owner?: string; + repo?: string; + sort?: RepoSort; + perPage?: number; + path?: string; + ref?: string; + treeSha?: string; + recursive?: boolean; + username?: string; + orgName?: string; + state?: "open" | "closed" | "all"; + query?: string; + issueNumber?: number; + pullNumber?: number; + language?: string; + since?: "daily" | "weekly" | "monthly"; +} +``` + +The payload contains all the parameters needed to re-fetch the data from the GitHub API when the job runs. + +## ETag Support + +Cache entries store GitHub's ETag header value. When refreshing data, the sync job can send the ETag in an `If-None-Match` header. If GitHub returns 304 (Not Modified), the job simply touches the `syncedAt` timestamp without updating the data. This saves bandwidth and API quota. diff --git a/agent-docs/features/billing.md b/agent-docs/features/billing.md new file mode 100644 index 00000000..f97dc062 --- /dev/null +++ b/agent-docs/features/billing.md @@ -0,0 +1,127 @@ +# Billing System + +Better Hub uses Stripe for metered billing of AI usage. Users receive welcome credits and can set spending limits. The system tracks every AI call with token-level granularity. + +## Key Files + +- `apps/web/src/lib/billing/config.ts` -- Constants: error codes, welcome credit amount, cost-to-units ratio, Stripe config +- `apps/web/src/lib/billing/ai-models.ts` -- AI model registry with pricing per million tokens +- `apps/web/src/lib/billing/ai-models.server.ts` -- Server-side model helpers +- `apps/web/src/lib/billing/openrouter-models.generated.ts` -- Auto-generated model catalog (from `scripts/generate-openrouter-models.mts`) +- `apps/web/src/lib/billing/credit.ts` -- Credit ledger operations (grant, check balance) +- `apps/web/src/lib/billing/spending-limit.ts` -- Per-user spending limit management +- `apps/web/src/lib/billing/stripe.ts` -- Stripe client, metered usage reporting +- `apps/web/src/lib/billing/token-usage.ts` -- Token usage logging and cost calculation +- `apps/web/src/lib/billing/usage-limit.ts` -- Pre-request usage limit checks +- `apps/web/src/lib/inngest.ts` -- Background job: `retryUnreportedUsage` (cron every 10 min) +- `apps/web/src/app/api/billing/balance/route.ts` -- Credit balance endpoint +- `apps/web/src/app/api/billing/spending-limit/route.ts` -- Spending limit CRUD +- `apps/web/src/app/api/billing/welcome/route.ts` -- Welcome credit grant + +## Billing Flow + +``` +AI Request + │ + ├── checkUsageLimit(userId) + │ ├── Check credit balance (credit_ledger, minus usage_logs) + │ ├── Check spending limit (spending_limit table) + │ └── Return: { allowed, creditExhausted?, spendingLimitReached? } + │ + ├── If not allowed → return BILLING_ERROR code + │ ├── MESSAGE_LIMIT_REACHED + │ ├── CREDIT_EXHAUSTED + │ └── SPENDING_LIMIT_REACHED + │ + ▼ (request proceeds) + │ + ├── AI model processes request + │ + ▼ (response complete) + │ + ├── logTokenUsage(userId, model, usage) + │ ├── Calculate cost via calculateCostUsd() + │ ├── Insert ai_call_logs record + │ ├── Insert usage_logs record + │ └── Deduct from credits if applicable + │ + └── reportUsageToStripe(usageLogId, userId, costUsd) + ├── Convert cost to units (1 USD = 10,000 units) + └── Report to Stripe Meter API +``` + +## Credit System + +### Welcome Credits +- New users receive **$10.00 USD** in credits upon signup +- Credits expire after **30 days** +- Granted via `grantSignupCredits()` called from the Stripe `onCustomerCreate` hook + +### Credit Ledger +The `credit_ledger` table tracks all credit transactions: +- `amount` -- Credit amount (positive = grant, negative = usage) +- `type` -- Transaction type (e.g., `welcome_credit`) +- `expiresAt` -- Optional expiration date + +### Balance Calculation +Available balance = sum of non-expired credits minus sum of usage costs. + +## AI Model Pricing + +Models are registered in `ai-models.ts` with pricing per million tokens: + +```typescript +interface ModelPricing { + inputPerM: number; // Cost per 1M input tokens + outputPerM: number; // Cost per 1M output tokens + cacheReadMultiplier?: number; // Discount for cache reads (e.g., 0.1 = 90% off) + cacheWriteMultiplier?: number; // Premium for cache writes +} +``` + +Cost calculation handles: +- Standard input/output token costs +- Cache read discounts (subtract cache reads from input, apply multiplier) +- Cache write premiums + +The model catalog is auto-generated from the OpenRouter API via `scripts/generate-openrouter-models.mts`. Run `bun generate:models` to refresh. + +## Stripe Integration + +### Metered Billing +- 1 USD = 10,000 Stripe meter units (`COST_TO_UNITS`) +- The Stripe meter price must be set to $0.0001 per unit +- Usage is reported per AI call via `reportUsageToStripe()` + +### Subscription +- Plan: `base` with a metered line item +- Active statuses: `active`, `trialing` +- Configured via `STRIPE_BASE_PRICE_ID` and `STRIPE_METERED_PRICE_ID` env vars + +### Stripe is Optional +Stripe features are disabled when `STRIPE_SECRET_KEY` is not set. A console warning is logged. This allows local development without Stripe. + +## Spending Limits + +Users can set a monthly spending cap via the settings UI: +- Stored in the `spending_limit` table (`monthlyCapUsd`, default $10.00) +- Minimum cap: $0.01 +- Checked before each AI request in `checkUsageLimit()` + +## Failure Recovery + +The `retryUnreportedUsage` Inngest function runs every 10 minutes: + +1. **Expire old entries**: Usage logs older than 35 days (Stripe meter API limit) that were never reported are marked as reported and logged as permanent loss +2. **Retry pending entries**: Unreported usage logs (at least 1 minute old) are retried in batches of 25 +3. This ensures no revenue leakage from transient Stripe API failures + +## Database Tables + +| Table | Purpose | +|---|---| +| `ai_call_logs` | Per-call token usage, model, provider, cost | +| `usage_logs` | Billing usage records, links to ai_call_logs, Stripe report status | +| `credit_ledger` | Credit grants and expirations | +| `spending_limit` | Per-user monthly spending cap | +| `subscription` | Stripe subscription state | diff --git a/agent-docs/features/ghost-ai.md b/agent-docs/features/ghost-ai.md new file mode 100644 index 00000000..5fad90b7 --- /dev/null +++ b/agent-docs/features/ghost-ai.md @@ -0,0 +1,119 @@ +# Ghost AI Assistant + +Ghost is Better Hub's built-in AI assistant. Users toggle it with `Cmd+I`. It can review PRs, navigate code, triage issues, write commit messages, and execute code in sandboxes. + +## Key Files + +- `apps/web/src/app/api/ai/ghost/route.ts` -- Main Ghost chat endpoint (~3500 lines) +- `apps/web/src/app/api/ai/ghost/[id]/stream/route.ts` -- Stream resume endpoint +- `apps/web/src/app/api/ai/ghost-tabs/route.ts` -- Ghost tab CRUD +- `apps/web/src/app/api/ai/commit-message/route.ts` -- AI commit message generation +- `apps/web/src/app/api/ai/pr-overview/route.ts` -- AI PR analysis/summary +- `apps/web/src/app/api/ai/command/route.ts` -- AI command execution +- `apps/web/src/app/api/ai/rewrite-prompt/route.ts` -- Prompt rewriting +- `apps/web/src/app/api/ai/chat-history/route.ts` -- Chat history retrieval +- `apps/web/src/lib/chat-store.ts` -- Conversation and message persistence (PostgreSQL) +- `apps/web/src/lib/resumable-stream.ts` -- Resumable stream support for AI responses +- `apps/web/src/lib/ai-auth.ts` -- Helper to get Octokit/token from the user's session +- `apps/web/src/components/shared/ai-chat.tsx` -- Chat UI component +- `apps/web/src/components/shared/global-chat-panel.tsx` -- Slide-out Ghost panel +- `apps/web/src/components/shared/global-chat-provider.tsx` -- Chat context provider +- `apps/web/src/components/shared/floating-ghost-button.tsx` -- Floating toggle button +- `apps/web/src/components/shared/chat-page-activator.tsx` -- Sets chat context per page + +## Model Configuration + +Ghost uses a two-tier model approach: + +```typescript +const GHOST_MODELS = { + default: process.env.GHOST_MODEL || "moonshotai/kimi-k2.5", + mergeConflict: process.env.GHOST_MERGE_MODEL || "google/gemini-2.5-pro-preview", +}; +``` + +- **"auto" mode** (default): The system picks the model based on the task type. Users see "auto" in settings. +- **User-selected model**: Users can pick a specific model from settings or the command palette. The choice is stored in `UserSettings.ghostModel`. +- **BYOK (Bring Your Own Key)**: Users can provide their own OpenRouter API key via settings (`UserSettings.openrouterApiKey`). When `useOwnApiKey` is true, their key is used instead of the platform key. + +All model routing goes through OpenRouter via `@openrouter/ai-sdk-provider`. + +## Tool System + +Ghost has ~30 tools organized into categories. All tool `execute` functions are wrapped with `withSafeTools()` which catches errors so a single tool failure doesn't crash the stream. + +### GitHub Tools (require user's Octokit) +- `get_repo_info` -- Fetch repository metadata +- `list_issues` / `get_issue` -- Browse and read issues +- `list_pull_requests` / `get_pull_request` -- Browse and read PRs +- `get_pull_request_diff` -- Fetch PR diff +- `get_file_content` -- Read file contents from a repo +- `list_repo_files` -- List files in a directory +- `create_issue` -- Create a new issue +- `create_pull_request` -- Create a new PR +- `add_comment` -- Comment on issues/PRs +- `merge_pull_request` -- Merge a PR +- `create_branch` -- Create a new branch +- `update_file` -- Commit file changes +- `create_review` -- Submit a PR review +- `manage_labels` -- Add/remove labels + +### Search Tools +- `search_repos` -- Search GitHub repositories +- `search_code` -- Search code across repos +- `search_issues` -- Search issues and PRs +- `semantic_search` -- Search user's viewed content using Mixedbread embeddings and reranking + +### Code Execution +- `execute_code` -- Run code in an E2B sandbox. Creates a `Sandbox` instance, writes files, runs commands, and returns output. Billed as a fixed cost. + +### Navigation +- `navigate` -- Generate Better Hub URLs for the user to click + +## Conversation Persistence + +Conversations are stored in PostgreSQL via `chat-store.ts`: + +- `ChatConversation` -- Identified by `(userId, contextKey)`. The `contextKey` ties a conversation to a specific context (e.g., `owner/repo` for repo chats, a specific PR URL for PR chats). +- `ChatMessage` -- Messages within a conversation, stored with `role`, `content`, and `partsJson` (for multi-part AI SDK messages). +- `GhostTab` / `GhostTabState` -- Tab management for the Ghost panel (multiple conversations). + +The `activeStreamId` on conversations supports resumable streams -- if a response is interrupted, the client can resume from where it left off. + +## Semantic Search + +Ghost can search the user's previously viewed content: + +1. When a user views a PR or issue, an Inngest event (`app/content.viewed`) is fired +2. The `embedContent` function embeds the title, body, comments, and reviews using Mixedbread's `mxbai-embed-large-v1` model +3. Embeddings are stored in the `search_embeddings` table with content hashes for deduplication +4. When Ghost's `semantic_search` tool is called, it: + - Embeds the query with Mixedbread + - Searches embeddings using cosine similarity + - Reranks results with Mixedbread's `mxbai-rerank-large-v1` + - Returns the top results with snippets + +Additionally, SuperMemory (`supermemory` package) provides long-term conversation memory across sessions. + +## Usage Tracking + +Every Ghost interaction is tracked for billing: + +1. `logTokenUsage()` records input/output tokens, model, and calculated cost in `ai_call_logs` +2. Cost is calculated using the model pricing registry in `src/lib/billing/ai-models.ts` +3. A corresponding `usage_logs` entry is created +4. Usage is reported to Stripe as metered billing events +5. Before each request, `checkUsageLimit()` verifies the user has credits and hasn't hit their spending cap + +## Cache Invalidation + +When Ghost performs write operations (create issue, merge PR, etc.), it invalidates relevant caches: + +```typescript +invalidateIssueCache(owner, repo, issueNumber); +invalidateRepoIssuesCache(owner, repo); +invalidatePullRequestCache(owner, repo, prNumber); +invalidateRepoPullRequestsCache(owner, repo); +``` + +This ensures the UI reflects changes immediately after Ghost acts. diff --git a/agent-docs/features/github-integration.md b/agent-docs/features/github-integration.md new file mode 100644 index 00000000..479ad33e --- /dev/null +++ b/agent-docs/features/github-integration.md @@ -0,0 +1,182 @@ +# GitHub Integration + +Better Hub is a client for the GitHub API. All repository data, issues, PRs, notifications, and user information comes from GitHub's REST API via Octokit. + +## Key Files + +- `apps/web/src/lib/github.ts` -- Primary GitHub API layer (~7300 lines). Contains all data fetching functions. +- `apps/web/src/lib/github-types.ts` -- TypeScript interfaces for GitHub entities (IssueItem, RepoItem, NotificationItem, ActivityEvent, etc.) +- `apps/web/src/lib/github-utils.ts` -- Utility functions: language colors, URL converters (GitHub <-> Better Hub), formatting helpers +- `apps/web/src/lib/github-scopes.ts` -- OAuth scope groups and their descriptions +- `apps/web/src/lib/github-sync-store.ts` -- Persistence layer for cache entries and sync jobs +- `apps/web/src/lib/github-user-attachments.ts` -- User attachment handling +- `apps/web/src/lib/ai-auth.ts` -- Helper to get authenticated Octokit from session + +## Authentication Context + +Every GitHub API call requires a `GitHubAuthContext`: + +```typescript +interface GitHubAuthContext { + userId: string; + token: string; + octokit: Octokit; + forceRefresh: boolean; + githubUser: $Session["githubUser"]; +} +``` + +This is constructed from the user's session. The `token` is the OAuth access token stored (encrypted) in the `account` table by better-auth. + +## Data Sync Job Types + +The system defines ~30 job types for background data synchronization: + +| Job Type | Description | +|---|---| +| `user_repos` | User's repositories | +| `repo` | Repository metadata | +| `repo_contents` | Repository file listing | +| `repo_tree` | Git tree (recursive) | +| `repo_branches` | Branch listing | +| `repo_tags` | Tag listing | +| `repo_releases` | Release listing | +| `file_content` | Single file content | +| `repo_readme` | Repository README | +| `repo_issues` | Issue listing | +| `repo_pull_requests` | PR listing | +| `issue` | Single issue detail | +| `issue_comments` | Issue comments | +| `pull_request` | Single PR detail | +| `pull_request_files` | PR changed files | +| `pull_request_comments` | PR review comments | +| `pull_request_reviews` | PR reviews | +| `pull_request_commits` | PR commit history | +| `pr_bundle` | Bundled PR data fetch | +| `repo_contributors` | Contributor listing | +| `repo_workflows` | CI/CD workflow definitions | +| `repo_workflow_runs` | Workflow run history | +| `repo_nav_counts` | Counts for nav tabs (open issues, PRs, active runs) | +| `repo_discussions` | Discussion listing | +| `authenticated_user` | Current user profile | +| `user_orgs` | User's organizations | +| `org` | Organization detail | +| `org_repos` | Organization repositories | +| `org_members` | Organization members | +| `notifications` | User notifications | +| `search_issues` | Issue/PR search | +| `user_events` | User activity events | +| `starred_repos` | Starred repositories | +| `contributions` | Contribution data | +| `trending_repos` | Trending repositories | +| `user_profile` | Public user profile | +| `user_public_repos` | User's public repos | +| `user_public_orgs` | User's public orgs | +| `person_repo_activity` | Person's activity in a repo | + +## Shared vs Per-User Caching + +The caching system has a critical security distinction: + +**Per-user cache**: Data that may contain private information is cached per-user. The cache key includes the user ID: `gh:{userId}:{cacheKey}`. This prevents data from one user's private repos leaking to another user. + +**Shared cache**: Public data types defined in `SHAREABLE_CACHE_TYPES` can be shared across users. These include: +- `repo_branches`, `repo_tags`, `repo_releases` +- `repo_issues`, `repo_pull_requests` +- `issue`, `issue_comments` +- `pull_request`, `pull_request_files`, `pull_request_comments`, `pull_request_reviews`, `pull_request_commits` +- `repo_contributors`, `repo_workflows`, `repo_workflow_runs`, `repo_nav_counts` +- `user_profile`, `user_public_repos`, `user_public_orgs`, `user_events` +- `org`, `org_repos`, `org_members` +- `trending_repos` + +This distinction is enforced by `isShareableCacheType()`. Any data from repos (code, contents, trees) is **excluded** from the shared cache because private-repo data fetched by one authorized user would leak to others. + +## The `localFirstGitRead` Pattern + +This is the core data-fetching pattern used by all GitHub data functions: + +```typescript +interface LocalFirstGitReadOptions { + authCtx: GitHubAuthContext | null; + cacheKey: string; + cacheType: string; + fallback: T; + jobType: GitDataSyncJobType; + jobPayload: GitDataSyncJobPayload; + fetchRemote: (octokit: Octokit) => Promise; +} +``` + +The function: +1. Checks Redis for cached data (per-user, then shared if applicable) +2. If cache miss, fetches from GitHub API directly +3. Updates the cache on successful fetch +4. Enqueues a background sync job for future freshness +5. If the API call fails (rate limit, etc.), falls back to the DB cache +6. If all else fails, returns the provided `fallback` value + +## Rate Limit Handling + +The `GitHubRateLimitError` class captures rate limit details: + +```typescript +class GitHubRateLimitError extends Error { + readonly resetAt: number; // unix timestamp (seconds) + readonly limit: number; + readonly used: number; +} +``` + +When a 403 rate limit response is received, the error is thrown and caught at the page level. The `/api/rate-limit` endpoint exposes the current rate limit status to the client. + +## GitHub OAuth Scopes + +Scopes are organized into groups in `github-scopes.ts`: + +- **profile** (required): `user`, `user:email`, `user:follow` +- **public_repos** (required): `public_repo`, `repo:status`, `repo_deployment`, `read:org` +- **private_repos** (optional): `repo` (full access to private repositories) +- **notifications** (optional): `notifications` +- **gist** (optional): `gist` +- **admin** (optional): `admin:repo_hook`, `admin:org` +- **workflow** (optional): `workflow` +- **delete_repo** (optional): `delete_repo` + +Users can opt into additional scopes during sign-in or later in settings. The sign-in UI shows each group with its description and reason. + +## Repository Permissions + +The `extractRepoPermissions()` function normalizes GitHub's permission object: + +```typescript +type RepoPermissions = { + admin: boolean; + push: boolean; + pull: boolean; + maintain: boolean; + triage: boolean; +}; +``` + +These permissions drive UI decisions like showing merge buttons, edit controls, and settings access. + +## Key Data Fetching Functions + +The most important functions exported from `github.ts` (non-exhaustive): + +- `getRepoPageData(owner, repo)` -- Fetches everything needed for the repo layout +- `getRepoTree(owner, repo, ref, recursive)` -- Git tree for file explorer +- `getFileContent(owner, repo, path, ref)` -- Single file content +- `getRepoPullRequests(owner, repo, state, page)` -- PR listing +- `getPullRequest(owner, repo, number)` -- PR detail with full data +- `getPullRequestFiles(owner, repo, number)` -- Changed files in a PR +- `getRepoIssues(owner, repo, state, page)` -- Issue listing +- `getIssue(owner, repo, number)` -- Issue detail +- `getNotifications(perPage)` -- User notifications +- `getUserRepos(sort, perPage)` -- User's repositories +- `getStarredRepos(username, page)` -- Starred repos +- `getTrendingRepos(language, since)` -- Trending repos +- `prefetchPRData(owner, repo, opts)` -- Background prefetch for PR data +- `checkIsStarred(owner, repo)` -- Check if user starred a repo +- `getForkSyncStatus(owner, repo, branch)` -- Fork behind/ahead status diff --git a/agent-docs/features/pr-reviews.md b/agent-docs/features/pr-reviews.md new file mode 100644 index 00000000..292407e1 --- /dev/null +++ b/agent-docs/features/pr-reviews.md @@ -0,0 +1,110 @@ +# Pull Request Reviews + +The PR review experience is one of Better Hub's core differentiators. It includes inline diffs, AI-powered summaries, threaded review comments, merge conflict resolution, and CI status integration. + +## Key Files + +### Pages +- `apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/page.tsx` -- PR listing +- `apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/[number]/page.tsx` -- PR detail +- `apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/[number]/[...sub]/page.tsx` -- PR sub-pages (files, commits, checks) +- `apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/new/page.tsx` -- Create PR + +### Components (`src/components/pr/`) + +**Layout and Navigation** +- `pr-detail-layout.tsx` -- Main PR detail page layout +- `pr-header.tsx` -- PR title, status, metadata header +- `editable-pr-title.tsx` -- Inline-editable PR title +- `editable-base-branch.tsx` -- Change base branch +- `pr-status-indicator.tsx` -- Open/merged/closed status badge + +**Diff Viewing** +- `pr-diff-viewer.tsx` -- Main diff rendering component +- `pr-diff-list.tsx` -- List of changed files with diffs +- `pr-files-list.tsx` -- File listing for "Files changed" tab +- `diff-file-tree.tsx` -- Tree view of changed files +- `diff-snippet-table.tsx` -- Individual diff snippet rendering + +**Conversation and Comments** +- `pr-conversation.tsx` -- Full PR conversation timeline +- `pr-comment-form.tsx` -- Comment input (uses TipTap rich text editor) +- `pr-optimistic-comments-provider.tsx` -- Optimistic UI for new comments +- `deleted-comments-context.tsx` -- Tracks deleted comments +- `chat-message-wrapper.tsx` -- Wraps individual messages +- `message-actions-menu.tsx` -- Actions menu on comments (edit, delete, quote) +- `collapsible-description.tsx` -- Collapsible PR description +- `collapsible-review-card.tsx` -- Collapsible review summary card + +**Reviews** +- `pr-reviews-panel.tsx` -- Review submissions panel +- `pr-review-form.tsx` -- Submit review form (approve, request changes, comment) + +**Merge and CI** +- `pr-merge-panel.tsx` -- Merge controls (merge, squash, rebase) +- `pr-conflict-resolver.tsx` -- Merge conflict resolution UI +- `pr-checks-panel.tsx` -- CI check status listing +- `check-status-badge.tsx` -- Individual check status badge + +**AI Integration** +- `pr-overview-panel.tsx` -- AI-generated PR summary/analysis +- `pr-author-dossier.tsx` -- AI-generated author context +- `pr-author-dossier-lazy.tsx` -- Lazy-loaded variant + +**Activity Groups** +- `commit-activity-group.tsx` -- Grouped commit activity in timeline +- `bot-activity-group.tsx` -- Grouped bot activity in timeline + +## PR Data Flow + +### Loading a PR Detail Page + +1. The page component calls `getPullRequest(owner, repo, number)` which fetches: + - PR metadata (title, body, state, author, labels, assignees) + - PR files (changed files with patches) + - PR reviews and review comments + - PR commits + - PR check status +2. Data is fetched using the `localFirstGitRead` pattern (cache-first) +3. The `prefetchPRData()` function is called in the repo layout to warm caches + +### PR Overview (AI Analysis) + +The AI PR overview is generated via `/api/ai/pr-overview`: + +1. Client requests an overview for a specific PR +2. Server checks the `pr_overview_analyses` table for a cached analysis matching the current `headSha` +3. If no cache or SHA mismatch, generates a new analysis using the AI model +4. The analysis is streamed to the client and cached in the DB +5. The `pr-overview-panel.tsx` component displays: summary, key changes, risk assessment, and suggested reviewers + +### Creating a PR + +The create PR flow (`pulls/new/page.tsx`) supports: +- GitHub Desktop / `gh pr create` compatible URLs via middleware rewriting of `/compare/base...head` +- Pre-filling title and body from URL query parameters +- Branch comparison view with diff preview + +### Merge Panel + +The `pr-merge-panel.tsx` supports three merge strategies: +- **Merge commit** -- Standard merge +- **Squash and merge** -- Squash all commits +- **Rebase and merge** -- Rebase onto base branch + +It also shows merge requirements (required reviews, CI checks) and handles merge conflicts. + +### Conflict Resolution + +The `pr-conflict-resolver.tsx` provides an in-browser conflict resolution experience: +- Uses `three-way-merge.ts` for merge conflict detection +- Can invoke Ghost AI (using the `mergeConflict` model, defaulting to Gemini 2.5 Pro) to suggest resolutions +- Presents a side-by-side view of conflicting changes + +## Related API Routes + +- `/api/merge-conflicts` -- Fetches merge conflict data for a PR +- `/api/check-status` -- Fetches CI check status for a PR +- `/api/highlight-diff` -- Server-side syntax highlighting for diff content +- `/api/ai/pr-overview` -- AI-generated PR analysis +- `/api/ai/commit-message` -- AI commit message suggestions diff --git a/agent-docs/frontend/components.md b/agent-docs/frontend/components.md new file mode 100644 index 00000000..b5b5598b --- /dev/null +++ b/agent-docs/frontend/components.md @@ -0,0 +1,211 @@ +# Component Organization + +Components live in `apps/web/src/components/` and are organized by feature domain. The app uses React Server Components by default (Next.js App Router), with client components marked explicitly with `"use client"`. + +## Directory Structure + +### `layout/` -- App Shell (3 files) + +- `navbar.tsx` -- Top navigation bar with user menu, notifications, command palette trigger +- `nav-aware-content.tsx` -- Content wrapper that adjusts for navbar visibility +- `notification-sheet.tsx` -- Slide-out notification panel + +### `repo/` -- Repository Views (39 files) + +The largest component group, covering all repository UI: + +**Layout** + +- `repo-layout-wrapper.tsx` -- Outer wrapper managing sidebar + content split +- `repo-sidebar.tsx` -- Left sidebar: description, stats, topics, license, links, contributors, languages +- `repo-sidebar-identity.tsx` -- Shared repo header (breadcrumb, owner avatar image, description, badges) used by `repo-sidebar.tsx` and `storage-repo-sidebar.tsx` +- `storage-repo-sidebar.tsx` -- Git storage (`/s/...`) sidebar; same identity block as GitHub repos, plus storage-specific info +- `repo-nav.tsx` -- Tab navigation (Code, Issues, PRs, Actions, Discussions, etc.) +- `code-content-wrapper.tsx` -- Wraps code views with file tree and branch selector + +**Code Browsing** + +- `code-viewer.tsx` / `code-viewer-client.tsx` -- File content display with syntax highlighting +- `file-list.tsx` -- Directory listing (table of files) +- `file-explorer-tree.tsx` -- Sidebar file tree +- `markdown-blob-view.tsx` -- Rendered markdown file view +- `notebook-viewer.tsx` / `notebook-viewer-client.tsx` -- Jupyter notebook rendering +- `document-outline.tsx` -- Markdown heading outline + +**Repository Info** + +- `repo-overview.tsx` -- Main repo page (README + file list) +- `repo-badge.tsx` -- Repo visibility badge (public/private) +- `repo-breadcrumb.tsx` / `breadcrumb-nav.tsx` -- Path breadcrumbs +- `repo-settings.tsx` -- Repo settings view +- `repo-activity-view.tsx` -- Activity feed +- `insights-view.tsx` -- Repository insights + +**Branch/Tag Management** + +- `branch-selector.tsx` -- Branch/tag dropdown +- `sidebar-branch-switcher.tsx` -- Sidebar branch switcher +- `tags-list.tsx` -- Tags listing page + +**Releases and Commits** + +- `releases-list.tsx` -- Releases listing +- `release-detail.tsx` -- Individual release view +- `commits-list.tsx` -- Commit history +- `commit-detail.tsx` -- Individual commit view +- `latest-commit-section.tsx` -- Latest commit display in code view + +**Sidebar Sections** + +- `sidebar-contributors.tsx` -- Top contributors with avatars +- `sidebar-languages.tsx` -- Language breakdown bar +- `sidebar-used-by.tsx` -- "Used by" section + +**Actions** + +- `star-button.tsx` -- Star/unstar repo +- `fork-button.tsx` -- Fork repo +- `fork-sync-button.tsx` -- Sync fork with upstream +- `pin-button.tsx` -- Pin repo +- `create-repo-dialog.tsx` -- Create new repo dialog +- `code-toolbar.tsx` -- Code view toolbar +- `readme-toolbar.tsx` -- README view toolbar + +**Utilities** + +- `repo-revalidator.tsx` -- Triggers background data revalidation + +### `pr/` -- Pull Request UI (30 files) + +See [features/pr-reviews.md](../features/pr-reviews.md) for detailed documentation. + +### `issue/` -- Issue Detail + +Components for viewing and interacting with individual issues. + +### `issues/` -- Issue Listing + +Components for the issues list view with filtering and sorting. + +### `prs/` -- PR Listing + +Components for the pull requests list view. + +### `shared/` -- Cross-Cutting Components (32 files) + +Reusable components used across multiple features: + +**AI** + +- `ai-chat.tsx` -- Chat interface component +- `global-chat-panel.tsx` -- Ghost AI slide-out panel +- `global-chat-provider.tsx` -- Chat state context provider +- `floating-ghost-button.tsx` -- Floating Ghost toggle +- `chat-page-activator.tsx` -- Sets chat context based on current page + +**Markdown** + +- `markdown-renderer.tsx` -- Server-side markdown rendering +- `client-markdown.tsx` -- Client-side markdown rendering +- `markdown-editor.tsx` -- TipTap-based markdown editor +- `markdown-mention-tooltips.tsx` -- @mention tooltips in markdown +- `markdown-copy-handler.tsx` -- Copy-to-clipboard for code blocks in markdown +- `github-emoji.tsx` -- GitHub emoji rendering + +**Code** + +- `highlighted-code-block.tsx` -- Syntax-highlighted code block +- `reactive-code-blocks.tsx` -- Interactive code blocks with copy buttons + +**Comments** + +- `comment.tsx` -- Individual comment component +- `comment-thread.tsx` -- Threaded comment display + +**GitHub UI** + +- `github-avatar.tsx` -- GitHub user avatar with fallback +- `github-link-interceptor.tsx` -- Rewrites github.com links to Better Hub +- `user-tooltip.tsx` -- User info tooltip on hover +- `label-badge.tsx` -- Issue/PR label badge +- `permission-badge.tsx` -- Repo permission badge +- `reaction-display.tsx` -- Emoji reaction display and picker + +**Navigation** + +- `navigation-progress.tsx` -- Top loading bar +- `nav-visibility-provider.tsx` -- Navbar visibility context + +**Actions** + +- `refresh-button.tsx` -- Force refresh button +- `copy-link-button.tsx` -- Copy URL to clipboard +- `pin-button.tsx` -- Pin item button +- `track-view.tsx` -- View tracking component + +**Utilities** + +- `list-controls.tsx` -- List filtering/sorting controls +- `mention-suggestion.tsx` -- @mention autocomplete +- `mutation-event-provider.tsx` -- Mutation event bus context +- `commit-dialog.tsx` -- Commit creation dialog +- `file-icon.tsx` -- File type icon + +### `ui/` -- Base Primitives (14 files) + +Low-level UI components, mostly wrapping Radix UI: + +- `button.tsx` -- Button with variants (CVA) +- `badge.tsx` -- Badge component +- `dialog.tsx` -- Modal dialog (Radix) +- `sheet.tsx` -- Slide-out sheet (Radix) +- `dropdown-menu.tsx` -- Dropdown menu (Radix) +- `tooltip.tsx` -- Tooltip (Radix) +- `command.tsx` -- Command palette (cmdk) +- `resize-handle.tsx` -- Draggable resize handle +- `time-ago.tsx` -- Relative time display +- `live-duration.tsx` -- Live updating duration +- `logo.tsx` -- Better Hub logo +- `agent-icon.tsx` -- AI agent icon +- `github-background.tsx` -- GitHub-style background pattern +- `halftone-background.tsx` -- Halftone dot background + +### Other Feature Directories + +| Directory | Purpose | +| -------------------------- | ------------------------------- | +| `dashboard/` | Dashboard page widgets | +| `search/` | Search UI and results | +| `settings/` | User settings panels | +| `settings/tabs/` | Individual settings tab content | +| `actions/` | CI/CD workflow views | +| `discussion/` | Discussion UI | +| `notifications/` | Notification list and items | +| `onboarding/` | First-run onboarding overlay | +| `orgs/` | Organization views | +| `people/` | People/contributors views | +| `security/` | Security advisory views | +| `trending/` | Trending repos view | +| `users/` | User profile views | +| `users/activity-timeline/` | Activity timeline components | +| `repos/` | Repository listing views | +| `prompt-request/` | Prompt request UI | +| `extension/` | Browser extension promo | +| `providers/` | React context providers | +| `pwa/` | PWA support | +| `theme/` | Theme provider and selector | + +## Component Patterns + +### Server vs Client Components + +- **Server components** (default): Used for data fetching, database access, rendering with session context. No `"use client"` directive. +- **Client components**: Used for interactivity (event handlers, state, effects). Marked with `"use client"` at the top. + +### Data Passing + +Server components fetch data and pass it as props to client components. The `use-server-initial-data.ts` hook pattern hydrates server-fetched data into client state without refetching. + +### Optimistic Updates + +The `use-mutation.ts` hook provides optimistic update support. `MutationEventProvider` and `use-mutation-subscription.ts` enable cross-component communication after mutations. diff --git a/agent-docs/frontend/routing.md b/agent-docs/frontend/routing.md new file mode 100644 index 00000000..b093c2a5 --- /dev/null +++ b/agent-docs/frontend/routing.md @@ -0,0 +1,114 @@ +# Routing + +Better Hub implements GitHub-compatible URLs so users can swap `github.com` with `better-hub.com` in any URL. This is achieved through middleware URL rewriting. + +## Key Files + +- `apps/web/src/proxy.ts` -- Next.js middleware (auth + URL rewriting) +- `apps/web/next.config.ts` -- `rewrites()` config as a fallback layer + +## URL Rewriting Strategy + +There are two layers of URL rewriting, both achieving the same goal: + +### Layer 1: Middleware (`proxy.ts`) + +The middleware handles specific GitHub URL patterns that require transformation beyond simple path prefixing: + +| GitHub URL | Better Hub Internal Route | +| ----------------------------------- | ------------------------------------------- | +| `/:owner/:repo` | `/repos/:owner/:repo` | +| `/:owner/:repo/pull/:number` | `/repos/:owner/:repo/pulls/:number` | +| `/:owner/:repo/commit/:sha` | `/repos/:owner/:repo/commits/:sha` | +| `/:owner/:repo/actions/runs/:runId` | `/repos/:owner/:repo/actions/:runId` | +| `/:owner/:repo/compare/base...head` | `/repos/:owner/:repo/pulls/new?base=&head=` | + +The middleware uses `NextResponse.rewrite()` for transparent rewrites (URL stays the same in the browser) and `NextResponse.redirect()` for the compare URL (which changes the URL). + +### Layer 2: Next.js Config Rewrites (`next.config.ts`) + +The `beforeFiles` rewrites in `next.config.ts` handle the generic case: + +```typescript +rewrites: { + beforeFiles: [ + // /:owner/:repo/:path* → /repos/:owner/:repo/:path* + // /:owner/:repo → /repos/:owner/:repo + // Only when first segment is NOT a known route + ], +} +``` + +### Known Routes Exclusion + +Both layers maintain a list of first-segment routes that should NOT be rewritten: + +**Middleware (`APP_ROUTES`)**: +`dashboard`, `s`, `repos`, `issues`, `prs`, `stars`, `settings`, `search`, `trending`, `notifications`, `orgs`, `users`, `api`, `debug`, `_next` + +The `s` segment is reserved for **git storage** repositories (Better Hub–hosted repos) at `/s/:owner/:repo/...`, not GitHub-style `/:owner/:repo` rewrites. + +**Next.js Config (`KNOWN_ROUTES`)**: +`api`, `dashboard`, `debug`, `extension`, `issues`, `notifications`, `orgs`, `prompt`, `repos`, `s`, `search`, `stars`, `trending`, `users`, `_next` + +If the first URL segment matches a known route, the URL is passed through unmodified. Otherwise, it's assumed to be an `/:owner/:repo` pattern and gets rewritten. + +## Git Protocol Handling + +The middleware detects git client requests and redirects them to GitHub: + +```typescript +// Detects: /:owner/:repo/info/refs?service=git-upload-pack +// Detects: /:owner/:repo/git-upload-pack +// Detects: /:owner/:repo/git-receive-pack +// → Redirects to https://github.com/... (307) +``` + +This ensures `git clone`, `git fetch`, and `git push` work correctly when pointed at Better Hub URLs. + +## GitHub Link Interception + +The `GitHubLinkInterceptor` component (`src/components/shared/github-link-interceptor.tsx`) intercepts clicks on `github.com` links within the app and rewrites them to Better Hub URLs. Links with `data-no-github-intercept` attribute bypass this behavior. + +The `toAppUrl()` function in `github-utils.ts` converts GitHub URLs to Better Hub internal URLs. + +## Route Groups + +The App Router uses route groups for layout organization: + +- `(app)/` -- All authenticated pages. Wrapped in the app layout with navbar, Ghost panel, providers. +- `debug/` -- Debug pages (outside the app group). + +## Dynamic Segments + +Key dynamic route segments: + +| Segment | Purpose | +| ------------ | --------------------------------------------------- | +| `[owner]` | GitHub user or organization login | +| `[repo]` | Repository name | +| `[number]` | Issue or PR number | +| `[sha]` | Commit SHA | +| `[...path]` | File or directory path (catch-all) | +| `[runId]` | CI/CD workflow run ID | +| `[jobId]` | CI/CD job ID | +| `[tag]` | Release tag | +| `[ghsaId]` | GitHub Security Advisory ID | +| `[username]` | GitHub username | +| `[org]` | Organization name | +| `[id]` | Generic ID (prompt requests) | +| `[...sub]` | Sub-pages (catch-all for PR tabs, new PR sub-views) | + +## Middleware Matcher + +```typescript +export const config = { + matcher: ["/((?!api|_next/static|_next/image|favicon\\.ico|[^/]+\\.[^/]+$).*)"], +}; +``` + +The middleware runs on all paths except: + +- `/api/*` (API routes handle their own auth) +- `/_next/static/*` and `/_next/image/*` (static assets) +- `/favicon.ico` and other root-level files with extensions diff --git a/agent-docs/frontend/ui-patterns.md b/agent-docs/frontend/ui-patterns.md new file mode 100644 index 00000000..8327b7fa --- /dev/null +++ b/agent-docs/frontend/ui-patterns.md @@ -0,0 +1,165 @@ +# UI Patterns and Libraries + +## Styling + +### TailwindCSS 4 +The app uses TailwindCSS 4 with PostCSS integration (`@tailwindcss/postcss`). Utility classes are the primary styling method. The `tw-animate-css` package provides animation utilities. + +### Class Merging +The `cn()` utility function (from `src/lib/utils.ts`) combines `clsx` and `tailwind-merge`: + +```typescript +import { cn } from "@/lib/utils"; + +
+``` + +Always use `cn()` when merging class names to avoid Tailwind class conflicts. + +### Class Variance Authority (CVA) +Component variants are defined using `class-variance-authority`: + +```typescript +import { cva } from "class-variance-authority"; + +const buttonVariants = cva("base-classes", { + variants: { + variant: { default: "...", destructive: "...", outline: "..." }, + size: { default: "...", sm: "...", lg: "..." }, + }, + defaultVariants: { variant: "default", size: "default" }, +}); +``` + +## Component Libraries + +### Radix UI +Unstyled, accessible primitives used for: +- `Dialog` -- Modal dialogs +- `DropdownMenu` -- Context menus and dropdown menus +- `Popover` -- Popovers +- `Tooltip` -- Tooltips +- `Avatar` -- User avatars with fallback +- `Slot` -- Component composition via slot pattern +- `VisuallyHidden` -- Screen reader only content + +All Radix components are re-exported with Better Hub styling from `src/components/ui/`. + +### cmdk +Command palette component (`src/components/ui/command.tsx`), triggered with `Cmd+K`. Provides fuzzy search across repos, navigation, theme switching, and actions. + +### Motion (Framer Motion) +Used for animations throughout the app. Common patterns: +- Page transitions +- Panel slide-in/out (Ghost chat, notification sheet) +- List item animations +- Loading states + +## Syntax Highlighting + +### Shiki +Two Shiki configurations exist: + +- `src/lib/shiki.ts` -- Server-side highlighter for rendering code blocks during SSR +- `src/lib/shiki-client.ts` -- Client-side highlighter for dynamic code highlighting + +Code themes are configurable per user: +- `codeThemeLight` -- Light mode theme (default: `vitesse-light`) +- `codeThemeDark` -- Dark mode theme (default: `vitesse-black`) +- Custom themes can be created and stored in `custom_code_themes` table + +### Diff Highlighting +The `/api/highlight-diff` endpoint provides server-side syntax highlighting for PR diffs. The `pr-diff-viewer.tsx` component renders highlighted diffs inline. + +## Rich Text Editing + +### TipTap +Used for comment and markdown editing: + +- `@tiptap/react` -- React integration +- `@tiptap/starter-kit` -- Basic editing features +- `@tiptap/extension-link` -- Link support +- `@tiptap/extension-mention` -- @mention support +- `@tiptap/extension-placeholder` -- Placeholder text +- `@tiptap/suggestion` -- Autocomplete suggestions +- `tiptap-markdown` -- Markdown input/output + +The mention suggestion system (`src/lib/tiptap-mention.ts`, `src/components/shared/mention-suggestion.tsx`) provides @username autocomplete with GitHub user search. + +## Markdown Rendering + +Multiple rendering approaches: + +- **Server-side**: `react-markdown` with `remark-gfm`, `remark-rehype`, `rehype-raw`, `rehype-sanitize`, `rehype-stringify` in `markdown-renderer.tsx` +- **Client-side**: `client-markdown.tsx` for dynamic markdown updates +- Both support GitHub-Flavored Markdown (tables, task lists, strikethrough, autolinks) + +## Theming + +### Color Themes (`src/lib/themes/`) + +The app supports multiple color themes beyond standard light/dark: + +- `themes.tsx` -- Theme definitions (colors, backgrounds) +- `types.ts` -- Theme type interfaces +- `border-radius.ts` -- Border radius presets +- `index.ts` -- Theme exports + +Themes are applied via CSS custom properties. The `ColorThemeProvider` component sets the theme based on user preferences. + +### System Theme +`next-themes` handles system-level light/dark mode detection. The user can choose: system, light, or dark mode (`UserSettings.colorMode`). + +### Code Font +Users can select from preset code fonts and font sizes (`UserSettings.codeFont`, `UserSettings.codeFontSize`). + +## Keyboard Shortcuts + +`@tanstack/react-hotkeys` provides keyboard shortcut handling. Key shortcuts: +- `Cmd+K` -- Command palette +- `Cmd+I` -- Toggle Ghost AI +- Various navigation shortcuts within views + +## URL State Management + +`nuqs` manages URL query parameters as React state: + +```typescript +import { useQueryState } from "nuqs"; +const [tab, setTab] = useQueryState("tab"); +``` + +The `NuqsAdapter` is mounted in the app layout to enable query state throughout the app. + +## Icons + +`lucide-react` provides the icon set. Icons are tree-shaken at build time. + +## Custom Hooks (`src/hooks/`) + +| Hook | Purpose | +|---|---| +| `use-readme.ts` | Fetches and caches README content | +| `use-is-mobile.ts` | Detects mobile viewport | +| `use-server-initial-data.ts` | Hydrates server-fetched data into client state | +| `use-mutation.ts` | Optimistic mutation with rollback | +| `use-mutation-subscription.ts` | Subscribe to mutation events across components | +| `use-infinite-scroll.ts` | Infinite scroll pagination | +| `use-click-outside.ts` | Detect clicks outside an element | + +## Data Fetching Patterns + +### Server Components +Data is fetched directly in server components using functions from `src/lib/github.ts`. No client-side fetching needed for initial renders. + +### React Query +`@tanstack/react-query` is used for client-side data fetching where real-time updates or polling are needed. + +### Mutation Events +The `MutationEventProvider` / `use-mutation-subscription.ts` pattern enables cross-component communication: + +1. Component A performs a mutation (e.g., merges a PR) +2. Component A dispatches a mutation event +3. Components B and C subscribe to that event type and refetch their data + +This avoids prop drilling and keeps components loosely coupled. diff --git a/agent-docs/infrastructure/deployment.md b/agent-docs/infrastructure/deployment.md new file mode 100644 index 00000000..699cb374 --- /dev/null +++ b/agent-docs/infrastructure/deployment.md @@ -0,0 +1,111 @@ +# Deployment + +## Hosting + +Better Hub is deployed on **Vercel**. The production URL is `https://www.better-hub.com`. + +## CI/CD + +### GitHub Actions (`.github/workflows/ci.yml`) + +The CI pipeline runs on pushes to `main` and on pull requests: + +``` +lint ──┐ +format ├──► build +typecheck┘ +``` + +| Job | Command | Purpose | +|---|---|---| +| `Lint` | `bun lint` | oxlint checks | +| `Format` | `bun fmt:check` | oxfmt formatting verification | +| `Typecheck` | `bun typecheck` | TypeScript type checking | +| `Build` | `bun run build` | Full production build (depends on all above passing) | + +All jobs use: +- `actions/checkout@v4` +- `oven-sh/setup-bun@v2` +- `bun install --frozen-lockfile` + +The build job sets `SKIP_ENV_VALIDATION=true` to allow building without real environment variables. + +## Error Tracking + +### Sentry (`@sentry/nextjs` v10) + +Configured in `next.config.ts` via `withSentryConfig()`: + +- **Org**: `better-hub` +- **Project**: `javascript-nextjs` +- **Tunnel route**: `/monitoring` -- Routes browser Sentry requests through Next.js to circumvent ad-blockers +- **Source maps**: Uploaded with `widenClientFileUpload: true` for better stack traces +- **Silent mode**: Only prints source map upload logs in CI (`!process.env.CI`) +- **Tree-shaking**: Debug logging is removed from production bundles + +### Vercel Cron Monitors +`automaticVercelMonitors: true` enables automatic instrumentation of Vercel Cron jobs. + +## Security Headers + +All responses include security headers (configured in `next.config.ts`): + +| Header | Value | +|---|---| +| `X-Content-Type-Options` | `nosniff` | +| `X-Frame-Options` | `DENY` | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | +| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | +| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload` | + +## Image Optimization + +Remote image patterns are allowlisted in `next.config.ts`: + +- `avatars.githubusercontent.com` +- `*.githubusercontent.com` +- `github.com` +- `opengraph.githubassets.com` +- `raw.githubusercontent.com` +- `user-images.githubusercontent.com` +- `repository-images.githubusercontent.com` +- `better-hub.com` +- `images.better-auth.com` + +Image optimization timeout: 3 seconds (`imgOptTimeoutInSeconds`). + +## Caching Configuration + +Next.js experimental stale times: +- `dynamic`: 300 seconds (5 minutes) +- `static`: 180 seconds (3 minutes) + +These control how long dynamically and statically rendered pages can be served from the Vercel edge cache before revalidation. + +## OAuth Proxy + +For Vercel preview deployments, the `oAuthProxy` plugin redirects OAuth callbacks through the production URL: + +```typescript +oAuthProxy({ productionURL: "https://www.better-hub.com" }) +``` + +This is only enabled when `process.env.VERCEL` is set. + +## Build Process + +The web app build command: + +```bash +prisma generate && next build +``` + +1. Generate the Prisma client from the schema +2. Run the Next.js production build (compiles pages, API routes, generates static assets) + +## Background Jobs + +Inngest handles background job processing. The webhook endpoint is at `/api/inngest`. In production, Inngest calls this endpoint to trigger functions. The two functions defined: + +1. `embed-content` -- Embed PR/issue content when viewed (event-driven) +2. `retry-unreported-usage` -- Retry failed Stripe usage reports (cron: every 10 minutes) diff --git a/agent-docs/infrastructure/development.md b/agent-docs/infrastructure/development.md new file mode 100644 index 00000000..42ca5817 --- /dev/null +++ b/agent-docs/infrastructure/development.md @@ -0,0 +1,165 @@ +# Development Setup + +## Prerequisites + +- **Node.js** 22+ +- **Bun** (package manager, v1.3.5+) +- **Docker** (for PostgreSQL and Redis) +- A **GitHub OAuth App** ([create one here](https://github.com/settings/developers)) + +## Local Setup + +```bash +# 1. Clone the repo +git clone https://github.com/better-auth/better-hub.git +cd better-hub + +# 2. Use the repo Node version +nvm use + +# 3. Start PostgreSQL and Redis +docker compose up -d + +# 4. Configure environment +cp apps/web/.env.example apps/web/.env +# Fill in required values (see environment.md) + +# 5. Install dependencies +bun install + +# 6. Run database migrations +cd apps/web && bunx prisma migrate dev && bunx prisma generate && cd ../.. + +# 7. Start dev server +bun dev +``` + +The app will be available at `http://localhost:3000`. + +## Docker Compose Services + +Defined in `docker-compose.yml`: + +| Service | Image | Port | Purpose | +|---|---|---|---| +| `postgres` | `postgres:16-alpine` | `127.0.0.1:54320` | PostgreSQL database (`max_connections=300`) | +| `redis` | `redis:7-alpine` | (internal) | Redis cache | +| `redis-rest` | `hiett/serverless-redis-http` | `127.0.0.1:8079` | Upstash-compatible REST API proxy for Redis | + +The Redis REST proxy emulates the Upstash REST API locally, so the same `@upstash/redis` client works in both development and production. + +Default connection strings: +- PostgreSQL: `postgresql://postgres:postgres@localhost:54320/better_hub` +- Redis REST: `http://localhost:8079` with token `local_token` + +## Development Scripts + +Run from the repo root: + +| Command | Description | +|---|---| +| `bun dev` | Start all apps in dev mode (Next.js dev server) | +| `bun lint` | Run oxlint across `apps/` and `packages/` | +| `bun lint:fix` | Run oxlint with auto-fix | +| `bun fmt` | Format with oxfmt | +| `bun fmt:check` | Check formatting (CI mode) | +| `bun typecheck` | TypeScript type checking (`tsc --noEmit`) | +| `bun check` | Run lint + fmt:check + typecheck (full CI check) | +| `bun fix` | Run lint:fix + fmt (fix everything) | +| `bun build` | Build all workspaces | + +### Web App Scripts (`apps/web/`) + +| Command | Description | +|---|---| +| `bun dev` | Next.js dev server | +| `bun build` | `prisma generate && next build` | +| `bun start` | Next.js production server | +| `bun test` | Run tests with Vitest | +| `bun generate:models` | Refresh AI model catalog from OpenRouter API | +| `bun generate:models:check` | Verify model catalog is up to date | + +## Pre-Commit Hooks + +Configured via `simple-git-hooks` + `lint-staged`: + +```json +{ + "pre-commit": "bun lint-staged" +} +``` + +Lint-staged runs on staged files: +- `*.{ts,tsx,js,jsx}` -> `oxfmt --write` + `oxlint --fix` +- `*.json` -> `oxfmt --write` + +After cloning, run `bun prepare` (or `bun install`, which triggers `postinstall`) to set up the git hooks. + +## Linting and Formatting + +### oxlint (`oxlint.json`) +- Plugins: `typescript`, `import`, `promise`, `unicorn` +- Key rules: `eqeqeq: error`, `no-var: error`, `prefer-const: error`, `no-unused-vars: warn` +- Ignores: `node_modules`, `dist`, `.git`, `.next` + +### oxfmt +- Configured via `.oxfmtignore` for file exclusions +- Handles TypeScript, JavaScript, JSON, and YAML files + +## TypeScript Configuration + +Root `tsconfig.json` sets strict defaults: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "noEmit": true + } +} +``` + +Notable strict settings: +- `noUncheckedIndexedAccess` -- Array/object index access returns `T | undefined` +- `exactOptionalPropertyTypes` -- Distinguishes between `undefined` and missing +- `verbatimModuleSyntax` -- Requires explicit `type` imports + +## Database Management + +```bash +cd apps/web + +# Create a new migration +bunx prisma migrate dev --name migration_name + +# Apply pending migrations +bunx prisma migrate dev + +# Generate Prisma client (after schema changes) +bunx prisma generate + +# Open Prisma Studio (database GUI) +bunx prisma studio + +# Reset database (drops all data) +bunx prisma migrate reset +``` + +## Testing + +Tests use Vitest (`vitest` v4): + +```bash +cd apps/web +bun test +``` + +Test files follow the `*.test.ts` convention (e.g., `extract-snippet.test.ts`). diff --git a/agent-docs/infrastructure/environment.md b/agent-docs/infrastructure/environment.md new file mode 100644 index 00000000..4ca2e0a3 --- /dev/null +++ b/agent-docs/infrastructure/environment.md @@ -0,0 +1,115 @@ +# Environment Variables + +All environment variables are defined in `apps/web/.env`. See `apps/web/.env.example` for a template. + +## Required Variables + +### Authentication + +| Variable | Description | +| ---------------------- | ---------------------------------------------------------------------------------- | +| `GITHUB_CLIENT_ID` | GitHub OAuth App client ID | +| `GITHUB_CLIENT_SECRET` | GitHub OAuth App client secret | +| `BETTER_AUTH_SECRET` | 32-char random string for session encryption. Generate with `openssl rand -hex 16` | +| `BETTER_AUTH_URL` | Base URL of the app (e.g., `http://localhost:3000`) | +| `NEXT_PUBLIC_APP_URL` | Public-facing app URL (same as above for local dev) | + +### Database + +| Variable | Description | +| -------------- | ----------------------------------------------------------------------------------------------------------------- | +| `DATABASE_URL` | PostgreSQL connection string. Docker Compose default: `postgresql://postgres:postgres@localhost:54320/better_hub` | + +### Redis + +| Variable | Description | +| -------------------------- | --------------------------------------------------------------------------- | +| `UPSTASH_REDIS_REST_URL` | Upstash Redis REST API URL. Docker Compose default: `http://localhost:8079` | +| `UPSTASH_REDIS_REST_TOKEN` | Upstash Redis REST API token. Docker Compose default: `local_token` | + +## AI (Required for AI Features) + +| Variable | Description | Default | +| --------------------- | --------------------------------------------- | ------------------------------- | +| `OPEN_ROUTER_API_KEY` | OpenRouter API key for Ghost AI | (none) | +| `GHOST_MODEL` | Default Ghost model ID | `moonshotai/kimi-k2.5` | +| `GHOST_MERGE_MODEL` | Model for merge conflict resolution | `google/gemini-2.5-pro-preview` | +| `ANTHROPIC_API_KEY` | Anthropic API key for specific tasks | (none) | +| `OPENAPI_KEY` | OpenAI API key (optional, for specific tasks) | (none) | + +## Billing (Optional) + +| Variable | Description | +| ------------------------- | ------------------------------------------------------------ | +| `STRIPE_SECRET_KEY` | Stripe API key. If unset, all billing features are disabled. | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | +| `STRIPE_BASE_PRICE_ID` | Stripe Price ID for the base subscription plan | +| `STRIPE_METERED_PRICE_ID` | Stripe Price ID for metered usage | + +## Code Execution (Optional) + +| Variable | Description | +| -------------- | --------------------------------------------------------- | +| `E2B_API_KEY` | E2B API key for sandboxed code execution | +| `E2B_TEMPLATE` | Custom E2B template ID (falls back to default base image) | + +## Background Jobs (Optional) + +| Variable | Description | +| --------------------- | ----------------------------------------------- | +| `INNGEST_EVENT_KEY` | Inngest event key for background job processing | +| `INNGEST_SIGNING_KEY` | Inngest webhook signing key | + +## Search and Memory (Optional) + +| Variable | Description | +| ---------------------- | ----------------------------------------------- | +| `MIXEDBREAD_API_KEY` | Mixedbread API key for embeddings and reranking | +| `SUPER_MEMORY_API_KEY` | SuperMemory API key for AI conversation memory | + +## Git Storage (Optional) + +| Variable | Description | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GIT_STORAGE_PRIVATE_KEY` | PKCS8 private key for Pierre (`@pierre/storage`) JWT signing, used inside the **`@better-hub/storage`** Better Auth plugin. Required to create repos and to back **`/s/:owner/:repo`** browsing; the web app calls storage through **`auth.api`** (plugin routes), not by importing Pierre directly. Optional override: `GIT_STORAGE_NAME` (defaults to `better-hub`). | + +## Integrations (Optional) + +| Variable | Description | +| --------------------- | ------------------------------------- | +| `SLACK_CLIENT_ID` | Slack app client ID for notifications | +| `SLACK_CLIENT_SECRET` | Slack app client secret | +| `VERCEL_OIDC_TOKEN` | Vercel OIDC token for deployment auth | + +## Error Tracking (Optional) + +| Variable | Description | +| ------------------- | ---------------------------------------- | +| `SENTRY_DSN` | Sentry Data Source Name | +| `SENTRY_AUTH_TOKEN` | Sentry auth token for source map uploads | + +## Docker Compose Variables + +The Docker Compose file uses these with defaults: + +| Variable | Default | Purpose | +| ------------------- | ------------- | ---------------------- | +| `POSTGRES_PASSWORD` | `postgres` | PostgreSQL password | +| `SRH_TOKEN` | `local_token` | Redis REST proxy token | + +## Minimal Local Setup + +For basic local development, you need at minimum: + +```env +GITHUB_CLIENT_ID=your_github_oauth_app_client_id +GITHUB_CLIENT_SECRET=your_github_oauth_app_client_secret +BETTER_AUTH_SECRET=any_32_character_random_string_here +BETTER_AUTH_URL=http://localhost:3000 +NEXT_PUBLIC_APP_URL=http://localhost:3000 +DATABASE_URL=postgresql://postgres:postgres@localhost:54320/better_hub +UPSTASH_REDIS_REST_URL=http://localhost:8079 +UPSTASH_REDIS_REST_TOKEN=local_token +``` + +AI features require `OPEN_ROUTER_API_KEY` at minimum. All other variables are optional and enable additional features when provided. diff --git a/apps/web/.env.example b/apps/web/.env.example index ef5b241e..7ca9496a 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -78,6 +78,14 @@ ANTHROPIC_API_KEY=your_anthropic_api_key # SuperMemory for AI conversation memory # SUPER_MEMORY_API_KEY=your_super_memory_api_key +# ────────────────────────────────────────────── +# Git storage — Better Hub hosted repos /s/... (optional) +# ────────────────────────────────────────────── +# Same key as packages/storage; required for create-repo and /s code browser +# GIT_STORAGE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" +# Optional; defaults to better-hub (Pierre storage client name). +# GIT_STORAGE_NAME="better-hub" + # ────────────────────────────────────────────── # Integrations (optional) # ────────────────────────────────────────────── diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index ded790ca..391d61f1 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -8,6 +8,7 @@ import type { NextConfig } from "next"; const KNOWN_ROUTES = [ "api", "dashboard", + "s", "debug", "extension", "issues", diff --git a/apps/web/package.json b/apps/web/package.json index 9d314203..fc08b5a4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,8 +19,9 @@ "@aws-sdk/s3-request-presigner": "^3.998.0", "@base-ui/react": "^1.3.0", "@better-auth/infra": "0.1.9-beta.1", - "@better-auth/stripe": "1.5.1", + "@better-auth/stripe": "1.5.5", "@better-auth/utils": "^0.3.1", + "@better-hub/storage": "workspace:*", "@mixedbread-ai/sdk": "^2.2.11", "@octokit/rest": "^22.0.1", "@openrouter/ai-sdk-provider": "^2.2.3", @@ -31,6 +32,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@sentry/nextjs": "^10", @@ -48,7 +50,7 @@ "ai": "^6.0.97", "auth": "1.5.0-beta.18", "better-all": "^0.0.7", - "better-auth": "1.5.1", + "better-auth": "catalog:", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -56,7 +58,7 @@ "e2b": "^2.12.1", "foxact": "^0.2.52", "inngest": "^3.52.3", - "lucide-react": "^0.575.0", + "lucide-react": "^1.0.1", "motion": "^12.34.3", "next": "16.1.6", "next-themes": "^0.4.6", @@ -67,6 +69,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-markdown": "^10.1.0", + "recharts": "3.8.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", diff --git a/apps/web/prisma.config.ts b/apps/web/prisma.config.ts index 76f03ef1..fd02e59e 100644 --- a/apps/web/prisma.config.ts +++ b/apps/web/prisma.config.ts @@ -2,7 +2,7 @@ import "dotenv/config"; import { defineConfig } from "prisma/config"; export default defineConfig({ - schema: "prisma/schema.prisma", + schema: "prisma/schema", migrations: { path: "prisma/migrations", }, diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema/app.prisma similarity index 70% rename from apps/web/prisma/schema.prisma rename to apps/web/prisma/schema/app.prisma index 44e25f23..eca72482 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema/app.prisma @@ -1,116 +1,3 @@ - -generator client { - provider = "prisma-client" - output = "../src/generated/prisma" -} - -datasource db { - provider = "postgresql" -} - -model User { - id String @id - name String - email String - githubPat String? - onboardingDone Boolean @default(false) - emailVerified Boolean - image String? - createdAt DateTime - updatedAt DateTime - - sessions Session[] - accounts Account[] - usageLogs UsageLog[] - aiCallLogs AiCallLog[] - creditLedger CreditLedger[] - - aiMessageCount Int @default(0) - lastActiveAt DateTime? - role String? - banned Boolean? @default(false) - banReason String? - banExpires DateTime? - - stripeCustomerId String? - - @@map("user") - @@index([githubPat, email]) -} - -model Session { - id String @id - expiresAt DateTime - token String @unique - createdAt DateTime - updatedAt DateTime - ipAddress String? - userAgent String? - userId String - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - impersonatedBy String? - - @@map("session") - @@index([userId, expiresAt]) -} - -model Account { - id String @id - accountId String - providerId String - userId String - accessToken String? - refreshToken String? - idToken String? - accessTokenExpiresAt DateTime? - refreshTokenExpiresAt DateTime? - scope String? - password String? - createdAt DateTime - updatedAt DateTime - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@map("account") - @@index([userId, providerId]) -} - -model Verification { - id String @id - identifier String - value String - expiresAt DateTime - createdAt DateTime? - updatedAt DateTime? - - @@map("verification") - @@index([identifier, expiresAt]) -} - -model Subscription { - id String @id - plan String - referenceId String - stripeCustomerId String? - stripeSubscriptionId String? - status String? @default("incomplete") - periodStart DateTime? - periodEnd DateTime? - trialStart DateTime? - trialEnd DateTime? - cancelAtPeriodEnd Boolean? @default(false) - cancelAt DateTime? - canceledAt DateTime? - endedAt DateTime? - seats Int? - billingInterval String? - stripeScheduleId String? - - @@map("subscription") -} - model GithubCacheEntry { userId String cacheKey String @@ -263,14 +150,14 @@ model PinnedItem { } model UsageLog { - id String @id @default(cuid()) - userId String - taskType String - costUsd Decimal @default(0) @db.Decimal(10, 6) - creditUsed Decimal @default(0) @db.Decimal(10, 6) - aiCallLogId Int? @unique - stripeReported Boolean @default(false) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + userId String + taskType String + costUsd Decimal @default(0) @db.Decimal(10, 6) + creditUsed Decimal @default(0) @db.Decimal(10, 6) + aiCallLogId Int? @unique + stripeReported Boolean @default(false) + createdAt DateTime @default(now()) aiCallLog AiCallLog? @relation(fields: [aiCallLogId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -319,14 +206,13 @@ model CreditLedger { model SpendingLimit { userId String @id - monthlyCapUsd Decimal @db.Decimal(10, 2) @default(10.00) + monthlyCapUsd Decimal @default(10.00) @db.Decimal(10, 2) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("spending_limit") } - model PromptRequest { id String @id userId String @@ -349,7 +235,7 @@ model PromptRequest { } model PromptRequestComment { - id String @id + id String @id promptRequestId String userId String userLogin String? @@ -365,7 +251,7 @@ model PromptRequestComment { } model PromptRequestReaction { - id String @id + id String @id promptRequestId String userId String userLogin String? diff --git a/apps/web/prisma/schema/auth.prisma b/apps/web/prisma/schema/auth.prisma new file mode 100644 index 00000000..def5382a --- /dev/null +++ b/apps/web/prisma/schema/auth.prisma @@ -0,0 +1,191 @@ +model User { + id String @id + name String + email String + emailVerified Boolean @default(false) + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastActiveAt DateTime? + username String? + displayUsername String? + role String? + banned Boolean? @default(false) + banReason String? + banExpires DateTime? + stripeCustomerId String? + githubPat String? + githubLogin String? + onboardingDone Boolean? + aiMessageCount Int? + sessions Session[] + accounts Account[] + members Member[] + invitations Invitation[] + repositorymembers RepositoryMember[] + usageLogs UsageLog[] + aiCallLogs AiCallLog[] + creditLedgers CreditLedger[] + + @@unique([email]) + @@unique([username]) + @@map("user") +} + +model Session { + id String @id + expiresAt DateTime + token String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ipAddress String? + userAgent String? + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + impersonatedBy String? + activeOrganizationId String? + + @@unique([token]) + @@index([userId]) + @@map("session") +} + +model Account { + id String @id + accountId String + providerId String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? + refreshToken String? + idToken String? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? + password String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@map("account") +} + +model Verification { + id String @id + identifier String + value String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([identifier]) + @@map("verification") +} + +model Organization { + id String @id + name String + slug String + logo String? + createdAt DateTime + metadata String? + members Member[] + invitations Invitation[] + + @@unique([slug]) + @@map("organization") +} + +model Member { + id String @id + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role String @default("member") + createdAt DateTime + + @@index([organizationId]) + @@index([userId]) + @@map("member") +} + +model Invitation { + id String @id + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + email String + role String? + status String @default("pending") + expiresAt DateTime + createdAt DateTime @default(now()) + inviterId String + user User @relation(fields: [inviterId], references: [id], onDelete: Cascade) + + @@index([organizationId]) + @@index([email]) + @@map("invitation") +} + +model Repository { + id String @id + name String + slug String + createdAt DateTime + updatedAt DateTime? + description String? + visibility String + repositorymembers RepositoryMember[] + + @@unique([slug]) + @@map("repository") +} + +model RepositoryMember { + id String @id + repositoryId String + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime + updatedAt DateTime? + + @@map("repositoryMember") +} + +model DeviceCode { + id String @id + deviceCode String + userCode String + userId String? + expiresAt DateTime + status String + lastPolledAt DateTime? + pollingInterval Int? + clientId String? + scope String? + + @@map("deviceCode") +} + +model Subscription { + id String @id + plan String + referenceId String + stripeCustomerId String? + stripeSubscriptionId String? + status String? @default("incomplete") + periodStart DateTime? + periodEnd DateTime? + trialStart DateTime? + trialEnd DateTime? + cancelAtPeriodEnd Boolean? @default(false) + cancelAt DateTime? + canceledAt DateTime? + endedAt DateTime? + seats Int? + billingInterval String? + stripeScheduleId String? + + @@map("subscription") +} diff --git a/apps/web/prisma/schema/base.prisma b/apps/web/prisma/schema/base.prisma new file mode 100644 index 00000000..bc49346e --- /dev/null +++ b/apps/web/prisma/schema/base.prisma @@ -0,0 +1,8 @@ +generator client { + provider = "prisma-client" + output = "../../src/generated/prisma" +} + +datasource db { + provider = "postgresql" +} diff --git a/apps/web/src/app-routes.ts b/apps/web/src/app-routes.ts index 0e525ea6..cd30b74d 100644 --- a/apps/web/src/app-routes.ts +++ b/apps/web/src/app-routes.ts @@ -1,6 +1,7 @@ export const APP_ROUTES = new Set([ "dashboard", "repos", + "s", "issues", "theme-store", "prs", diff --git a/apps/web/src/app/(app)/[owner]/page.tsx b/apps/web/src/app/(app)/[owner]/page.tsx index 6af180c9..9a82cc35 100644 --- a/apps/web/src/app/(app)/[owner]/page.tsx +++ b/apps/web/src/app/(app)/[owner]/page.tsx @@ -9,10 +9,12 @@ import { getUserOrgTopRepos, getContributionData, getUserEvents, + getUserProfileReadme, } from "@/lib/github"; import { ogImageUrl, ogImages } from "@/lib/og/og-utils"; import { OrgDetailContent } from "@/components/orgs/org-detail-content"; import { UserProfileContent } from "@/components/users/user-profile-content"; +import { UserProfileReadmePanel } from "@/components/users/user-profile-readme-panel"; export async function generateMetadata({ params, @@ -116,16 +118,23 @@ export default async function OwnerPage({ params }: { params: Promise<{ owner: s let contributionData: Awaited> = null; let orgTopRepos: Awaited> = []; let activityEvents: Awaited> = []; + let profileReadme: Awaited> = null; if (!isBot) { try { - const [reposResult, orgsResult, contributionsResult, eventsResult] = - await Promise.allSettled([ - getUserPublicRepos(userData.login, 100), - getUserPublicOrgs(userData.login), - getContributionData(userData.login), - getUserEvents(userData.login, 100), - ]); + const [ + reposResult, + orgsResult, + contributionsResult, + eventsResult, + profileReadmeResult, + ] = await Promise.allSettled([ + getUserPublicRepos(userData.login, 100), + getUserPublicOrgs(userData.login), + getContributionData(userData.login), + getUserEvents(userData.login, 100), + getUserProfileReadme(userData.login), + ]); if (reposResult.status === "fulfilled") reposData = reposResult.value; if (orgsResult.status === "fulfilled") orgsData = orgsResult.value; if (contributionsResult.status === "fulfilled") { @@ -133,6 +142,9 @@ export default async function OwnerPage({ params }: { params: Promise<{ owner: s } if (eventsResult.status === "fulfilled") activityEvents = eventsResult.value; + if (profileReadmeResult.status === "fulfilled") { + profileReadme = profileReadmeResult.value; + } if (orgsData.length > 0) { orgTopRepos = await getUserOrgTopRepos( orgsData.map((o) => o.login), @@ -143,8 +155,20 @@ export default async function OwnerPage({ params }: { params: Promise<{ owner: s } } + const hasProfileReadme = profileReadme !== null; + return ( + ) : null + } user={{ login: userData.login, name: userData.name ?? null, diff --git a/apps/web/src/app/(app)/error.tsx b/apps/web/src/app/(app)/error.tsx index fe3ebcb3..38dd4958 100644 --- a/apps/web/src/app/(app)/error.tsx +++ b/apps/web/src/app/(app)/error.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation"; import { Gauge, RefreshCw, - Github, Clock, Zap, ShieldAlert, @@ -16,6 +15,7 @@ import { Check, ArrowRight, } from "lucide-react"; +import { GithubIcon } from "@/components/shared/icons/github-icon"; import { cn } from "@/lib/utils"; import { signOut } from "@/lib/auth-client"; @@ -250,7 +250,7 @@ function RateLimitUI({ reset }: { reset: () => void }) { {/* Info card */}
- + GitHub API ·{" "} {rateLimitInfo diff --git a/apps/web/src/app/(app)/repos/[owner]/[repo]/commits/actions.ts b/apps/web/src/app/(app)/repos/[owner]/[repo]/commits/actions.ts index 5f799c70..8c5d4330 100644 --- a/apps/web/src/app/(app)/repos/[owner]/[repo]/commits/actions.ts +++ b/apps/web/src/app/(app)/repos/[owner]/[repo]/commits/actions.ts @@ -104,6 +104,7 @@ export async function fetchCommitDetail( owner: string, repo: string, sha: string, + _branch?: string, ): Promise<{ commit: CommitDetailData | null; highlightData: Record>; diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/actions/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/actions/page.tsx new file mode 100644 index 00000000..06007ac3 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/actions/page.tsx @@ -0,0 +1,5 @@ +import { StorageRepoTabPlaceholder } from "@/components/repo/storage-repo-tab-placeholder"; + +export default function StorageActionsPage() { + return ; +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/activity/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/activity/page.tsx new file mode 100644 index 00000000..1f77e4d5 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/activity/page.tsx @@ -0,0 +1,5 @@ +import { StorageRepoTabPlaceholder } from "@/components/repo/storage-repo-tab-placeholder"; + +export default function StorageActivityPage() { + return ; +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/blob/[ref]/[...path]/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/blob/[ref]/[...path]/page.tsx new file mode 100644 index 00000000..daf5f887 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/blob/[ref]/[...path]/page.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { CodeViewer } from "@/components/repo/code-viewer"; +import { + StorageRepoNoBranchesState, + StorageRepoRefNotFoundState, +} from "@/components/repo/storage-repo-empty-state"; +import { getServerSession } from "@/lib/auth"; +import { getMemberStorageRepository, getStorageFileText } from "@/lib/storage-git"; + +export const runtime = "nodejs"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ owner: string; repo: string; ref: string; path: string[] }>; +}): Promise { + const { owner, repo, path } = await params; + const basename = path[path.length - 1] ?? "file"; + return { title: `${basename} · ${owner}/${repo}` }; +} + +export default async function StorageRepoBlobPage({ + params, +}: { + params: Promise<{ owner: string; repo: string; ref: string; path: string[] }>; +}) { + const { owner, repo, ref, path } = await params; + if (!path?.length) notFound(); + + const session = await getServerSession(); + if (!session?.user) notFound(); + + const record = await getMemberStorageRepository(owner, repo, session.user.id); + if (!record) notFound(); + + const filePath = path.join("/"); + const filename = path[path.length - 1] ?? filePath; + + const file = await getStorageFileText(owner, repo, filePath, ref); + if (file === null) notFound(); + if (file.kind === "file_not_found") notFound(); + + if (file.kind === "file") { + return ( + + ); + } + + if (file.kind === "no_branches") { + return ; + } + + return ; +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/code/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/code/page.tsx new file mode 100644 index 00000000..b92cf77e --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/code/page.tsx @@ -0,0 +1,165 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { BranchSelector } from "@/components/repo/branch-selector"; +import { CodeToolbar } from "@/components/repo/code-toolbar"; +import { FileList } from "@/components/repo/file-list"; +import { + StorageRepoNoBranchesState, + StorageRepoRefNotFoundState, +} from "@/components/repo/storage-repo-empty-state"; +import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; +import { TrackView } from "@/components/shared/track-view"; +import { getServerSession } from "@/lib/auth"; +import { + getMemberStorageRepository, + getStorageFileText, + getStorageGitMeta, + listStorageDirectory, +} from "@/lib/storage-git"; + +export const runtime = "nodejs"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ owner: string; repo: string }>; +}): Promise { + const { owner, repo } = await params; + return { title: `Code · ${owner}/${repo}` }; +} + +export default async function StorageRepoCodePage({ + params, +}: { + params: Promise<{ owner: string; repo: string }>; +}) { + const { owner, repo } = await params; + const session = await getServerSession(); + if (!session?.user) notFound(); + + const record = await getMemberStorageRepository(owner, repo, session.user.id); + if (!record) notFound(); + + const repoBasePath = `/s/${owner}/${repo}`; + + const [listed, gitMeta] = await Promise.all([ + listStorageDirectory(owner, repo, { pathPrefix: "" }), + getStorageGitMeta(owner, repo), + ]); + + if (listed === null || !gitMeta) notFound(); + + const branches = gitMeta.branches; + const tags: { name: string }[] = []; + const defaultBranch = gitMeta.defaultBranch; + + const toolbar = ( +
+ +
+ ({ name: b.name }))} + defaultBranch={defaultBranch} + showCloneControls={false} + /> +
+
+ ); + + if (!listed.ok) { + return ( +
+ + {toolbar} + {listed.reason === "no_branches" ? ( + + ) : ( + + )} +
+ ); + } + + const items = listed.items; + const hasReadme = items.some( + (i) => + i.type === "file" && + typeof i.name === "string" && + i.name.toLowerCase().startsWith("readme"), + ); + + let readme: { content: string } | null = null; + if (hasReadme) { + const readmeItem = items.find( + (i) => i.type === "file" && i.name.toLowerCase().startsWith("readme"), + ); + if (readmeItem) { + const file = await getStorageFileText( + owner, + repo, + readmeItem.path, + listed.resolvedRef, + ); + if (file?.kind === "file") readme = { content: file.content }; + } + } + + return ( +
+ + {toolbar} + + {readme && ( +
+
+ + README.md + +
+
+ +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/commits/[sha]/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/commits/[sha]/page.tsx new file mode 100644 index 00000000..5c44823b --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/commits/[sha]/page.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { CommitDetail } from "@/components/repo/commit-detail"; +import { getServerSession } from "@/lib/auth"; +import { getMemberStorageRepository } from "@/lib/storage-git"; +import { fetchStorageCommitDetail } from "../actions"; + +export const runtime = "nodejs"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ owner: string; repo: string; sha: string }>; +}): Promise { + const { owner, repo, sha } = await params; + const shortSha = sha.slice(0, 7); + return { title: `Commit ${shortSha} · ${owner}/${repo}` }; +} + +export default async function StorageCommitDetailPage({ + params, + searchParams, +}: { + params: Promise<{ owner: string; repo: string; sha: string }>; + searchParams: Promise<{ branch?: string }>; +}) { + const { owner, repo: repoName, sha } = await params; + const { branch } = await searchParams; + + const session = await getServerSession(); + if (!session?.user) notFound(); + + const record = await getMemberStorageRepository(owner, repoName, session.user.id); + if (!record) notFound(); + + const { commit, highlightData } = await fetchStorageCommitDetail( + owner, + repoName, + sha, + branch, + ); + + if (!commit) { + return ( +
+

+ Commit not found +

+
+ ); + } + + return ( + + ); +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/commits/actions.ts b/apps/web/src/app/(app)/s/[owner]/[repo]/commits/actions.ts new file mode 100644 index 00000000..9eb7b774 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/commits/actions.ts @@ -0,0 +1,75 @@ +"use server"; + +import { listStorageCommits, getStorageCommitDetailPayload } from "@/lib/storage-git"; +import { highlightDiffLines, type SyntaxToken } from "@/lib/shiki"; +import type { CommitDetailData } from "@/app/(app)/repos/[owner]/[repo]/commits/actions"; +import type { StorageCommitListRow } from "@/lib/storage-git"; + +export async function fetchStorageCommitsByDate( + owner: string, + repo: string, + _since?: string, + _until?: string, + branch?: string, +): Promise<{ commits: StorageCommitListRow[]; nextCursor: string | null; hasMore: boolean }> { + return ( + (await listStorageCommits(owner, repo, { branch, limit: 30 })) ?? { + commits: [], + nextCursor: null, + hasMore: false, + } + ); +} + +export async function fetchStorageCommitsNext( + owner: string, + repo: string, + branch: string, + cursor: string, + _since?: string, + _until?: string, +): Promise<{ commits: StorageCommitListRow[]; nextCursor: string | null; hasMore: boolean }> { + return ( + (await listStorageCommits(owner, repo, { branch, cursor, limit: 30 })) ?? { + commits: [], + nextCursor: null, + hasMore: false, + } + ); +} + +export async function fetchStorageCommitDetail( + owner: string, + repo: string, + sha: string, + branch?: string, +): Promise<{ + commit: CommitDetailData | null; + highlightData: Record>; +}> { + const commit = await getStorageCommitDetailPayload(owner, repo, sha, branch); + if (!commit) { + return { commit: null, highlightData: {} }; + } + + const highlightData: Record> = {}; + if (commit.files && commit.files.length > 0) { + await Promise.all( + commit.files.map(async (file: { filename: string; patch?: string }) => { + if (file.patch) { + try { + highlightData[file.filename] = + await highlightDiffLines( + file.patch, + file.filename, + ); + } catch { + // silent + } + } + }), + ); + } + + return { commit, highlightData }; +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/commits/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/commits/page.tsx new file mode 100644 index 00000000..e075049b --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/commits/page.tsx @@ -0,0 +1,65 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { CommitsList } from "@/components/repo/commits-list"; +import type { Commit } from "@/components/repo/commits-list"; +import { getServerSession } from "@/lib/auth"; +import { + getMemberStorageRepository, + getStorageGitMeta, + listStorageCommits, +} from "@/lib/storage-git"; +import { + fetchStorageCommitDetail, + fetchStorageCommitsByDate, + fetchStorageCommitsNext, +} from "./actions"; + +export const runtime = "nodejs"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ owner: string; repo: string }>; +}): Promise { + const { owner, repo } = await params; + return { title: `Commits · ${owner}/${repo}` }; +} + +export default async function StorageCommitsPage({ + params, +}: { + params: Promise<{ owner: string; repo: string }>; +}) { + const { owner, repo: repoName } = await params; + const session = await getServerSession(); + if (!session?.user) notFound(); + + const record = await getMemberStorageRepository(owner, repoName, session.user.id); + if (!record) notFound(); + + const [gitMeta, data] = await Promise.all([ + getStorageGitMeta(owner, repoName), + listStorageCommits(owner, repoName, { limit: 30 }), + ]); + if (!gitMeta) notFound(); + if (!data) notFound(); + + return ( + + ); +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/insights/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/insights/page.tsx new file mode 100644 index 00000000..34c3e669 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/insights/page.tsx @@ -0,0 +1,5 @@ +import { StorageRepoTabPlaceholder } from "@/components/repo/storage-repo-tab-placeholder"; + +export default function StorageInsightsPage() { + return ; +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/issues/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/issues/page.tsx new file mode 100644 index 00000000..256e6b94 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/issues/page.tsx @@ -0,0 +1,5 @@ +import { StorageRepoTabPlaceholder } from "@/components/repo/storage-repo-tab-placeholder"; + +export default function StorageIssuesPage() { + return ; +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/layout.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/layout.tsx new file mode 100644 index 00000000..c0750293 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/layout.tsx @@ -0,0 +1,135 @@ +import { cookies } from "next/headers"; +import { notFound } from "next/navigation"; +import { RepoLayoutWrapper } from "@/components/repo/repo-layout-wrapper"; +import { RepoNav } from "@/components/repo/repo-nav"; +import { CodeContentWrapper } from "@/components/repo/code-content-wrapper"; +import { StorageRepoSidebar } from "@/components/repo/storage-repo-sidebar"; +import { getServerSession } from "@/lib/auth"; +import { getUser } from "@/lib/github"; +import { getMemberStorageRepository, getStorageGitMeta } from "@/lib/storage-git"; +import { buildStorageFileTree } from "@/lib/storage-file-tree"; +import { + REPO_SIDEBAR_COOKIE, + type RepoSidebarState, +} from "@/components/repo/repo-sidebar-constants"; +import type { FileTreeNode } from "@/lib/file-tree"; + +export const runtime = "nodejs"; + +export default async function StorageRepoLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ owner: string; repo: string }>; +}) { + const { owner, repo: repoName } = await params; + const session = await getServerSession(); + if (!session?.user) notFound(); + + const [record, gitMeta, ownerProfile] = await Promise.all([ + getMemberStorageRepository(owner, repoName, session.user.id), + getStorageGitMeta(owner, repoName), + getUser(owner), + ]); + if (!record) notFound(); + if (!gitMeta) notFound(); + + const gh = session.githubUser; + const ghLogin = gh && typeof gh.login === "string" ? gh.login : null; + const avatarFromMatchingSession = + gh && + typeof gh.avatar_url === "string" && + ghLogin?.toLowerCase() === owner.toLowerCase() + ? gh.avatar_url + : undefined; + + let ownerAvatarUrl = + (typeof ownerProfile?.avatar_url === "string" + ? ownerProfile.avatar_url + : undefined) ?? avatarFromMatchingSession; + + if (!ownerAvatarUrl && typeof session.user.image === "string") { + ownerAvatarUrl = session.user.image; + } + if (!ownerAvatarUrl) { + ownerAvatarUrl = `https://github.com/identicons/${encodeURIComponent(owner)}.png`; + } + + const ownerType = ownerProfile?.type === "Organization" ? "Organization" : "User"; + + const initialBranches = gitMeta.branches; + + let tree: FileTreeNode[] | null = null; + if (gitMeta.branches.length > 0 && gitMeta.files) { + tree = buildStorageFileTree(gitMeta.files); + } + + const cookieStore = await cookies(); + const sidebarCookie = cookieStore.get(REPO_SIDEBAR_COOKIE); + let sidebarState: RepoSidebarState | null = null; + if (sidebarCookie?.value) { + try { + sidebarState = JSON.parse(sidebarCookie.value); + } catch {} + } + + const repoBasePath = `/s/${owner}/${repoName}`; + const visibility = record.visibility === "private" ? "private" : "public"; + + return ( +
+ + } + > +
+ +
+ + {children} + +
+
+ ); +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/page.tsx new file mode 100644 index 00000000..f359fa17 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ owner: string; repo: string }>; +}): Promise { + const { owner, repo } = await params; + return { title: `${owner}/${repo}` }; +} + +export default async function StorageRepoOverviewPage() { + return ( +
+

+ Overview — coming soon. +

+
+ ); +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/prompts/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/prompts/page.tsx new file mode 100644 index 00000000..78cbc7a8 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/prompts/page.tsx @@ -0,0 +1,5 @@ +import { StorageRepoTabPlaceholder } from "@/components/repo/storage-repo-tab-placeholder"; + +export default function StoragePromptsPage() { + return ; +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/pulls/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/pulls/page.tsx new file mode 100644 index 00000000..97bc3459 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/pulls/page.tsx @@ -0,0 +1,5 @@ +import { StorageRepoTabPlaceholder } from "@/components/repo/storage-repo-tab-placeholder"; + +export default function StoragePullsPage() { + return ; +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/releases/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/releases/page.tsx new file mode 100644 index 00000000..6dfaffb6 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/releases/page.tsx @@ -0,0 +1,5 @@ +import { StorageRepoTabPlaceholder } from "@/components/repo/storage-repo-tab-placeholder"; + +export default function StorageReleasesPage() { + return ; +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/security/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/security/page.tsx new file mode 100644 index 00000000..fb1cf4e7 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/security/page.tsx @@ -0,0 +1,5 @@ +import { StorageRepoTabPlaceholder } from "@/components/repo/storage-repo-tab-placeholder"; + +export default function StorageSecurityPage() { + return ; +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/settings/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/settings/page.tsx new file mode 100644 index 00000000..d9ab70d5 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/settings/page.tsx @@ -0,0 +1,5 @@ +import { StorageRepoTabPlaceholder } from "@/components/repo/storage-repo-tab-placeholder"; + +export default function StorageSettingsPage() { + return ; +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/tags/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/tags/page.tsx new file mode 100644 index 00000000..53d04461 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/tags/page.tsx @@ -0,0 +1,5 @@ +import { StorageRepoTabPlaceholder } from "@/components/repo/storage-repo-tab-placeholder"; + +export default function StorageTagsPage() { + return ; +} diff --git a/apps/web/src/app/(app)/s/[owner]/[repo]/tree/[ref]/[[...path]]/page.tsx b/apps/web/src/app/(app)/s/[owner]/[repo]/tree/[ref]/[[...path]]/page.tsx new file mode 100644 index 00000000..59b3e555 --- /dev/null +++ b/apps/web/src/app/(app)/s/[owner]/[repo]/tree/[ref]/[[...path]]/page.tsx @@ -0,0 +1,58 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { FileList } from "@/components/repo/file-list"; +import { + StorageRepoNoBranchesState, + StorageRepoRefNotFoundState, +} from "@/components/repo/storage-repo-empty-state"; +import { getServerSession } from "@/lib/auth"; +import { getMemberStorageRepository, listStorageDirectory } from "@/lib/storage-git"; + +export const runtime = "nodejs"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ owner: string; repo: string; ref: string; path?: string[] }>; +}): Promise { + const { owner, repo, ref } = await params; + return { title: `Tree · ${owner}/${repo} @ ${ref}` }; +} + +export default async function StorageRepoTreePage({ + params, +}: { + params: Promise<{ owner: string; repo: string; ref: string; path?: string[] }>; +}) { + const { owner, repo, ref, path: pathSegs } = await params; + const session = await getServerSession(); + if (!session?.user) notFound(); + + const record = await getMemberStorageRepository(owner, repo, session.user.id); + if (!record) notFound(); + + const pathPrefix = pathSegs?.length ? pathSegs.join("/") : ""; + const listed = await listStorageDirectory(owner, repo, { + ref, + pathPrefix, + }); + if (listed === null) notFound(); + + if (!listed.ok) { + return listed.reason === "no_branches" ? ( + + ) : ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/src/app/(app)/users/[username]/page.tsx b/apps/web/src/app/(app)/users/[username]/page.tsx index 5a55bffd..06bcf9c1 100644 --- a/apps/web/src/app/(app)/users/[username]/page.tsx +++ b/apps/web/src/app/(app)/users/[username]/page.tsx @@ -6,9 +6,11 @@ import { getUserOrgTopRepos, getContributionData, getUserEvents, + getUserProfileReadme, } from "@/lib/github"; import { ogImageUrl, ogImages } from "@/lib/og/og-utils"; import { UserProfileContent } from "@/components/users/user-profile-content"; +import { UserProfileReadmePanel } from "@/components/users/user-profile-readme-panel"; import { ExternalLink, User } from "lucide-react"; function UnknownUserPage({ username }: { username: string }) { @@ -74,6 +76,7 @@ export default async function UserProfilePage({ let contributionData: Awaited> = null; let orgTopRepos: Awaited> = []; let activityEvents: Awaited> = []; + let profileReadme: Awaited> = null; try { userData = await getUser(username); @@ -89,13 +92,19 @@ export default async function UserProfilePage({ if (!isBot) { try { const resolvedLogin = userData.login; - const [reposResult, orgsResult, contributionsResult, eventsResult] = - await Promise.allSettled([ - getUserPublicRepos(resolvedLogin, 100), - getUserPublicOrgs(resolvedLogin), - getContributionData(resolvedLogin), - getUserEvents(resolvedLogin, 100), - ]); + const [ + reposResult, + orgsResult, + contributionsResult, + eventsResult, + profileReadmeResult, + ] = await Promise.allSettled([ + getUserPublicRepos(resolvedLogin, 100), + getUserPublicOrgs(resolvedLogin), + getContributionData(resolvedLogin), + getUserEvents(resolvedLogin, 100), + getUserProfileReadme(resolvedLogin), + ]); if (reposResult.status === "fulfilled") reposData = reposResult.value; if (orgsResult.status === "fulfilled") orgsData = orgsResult.value; @@ -104,6 +113,9 @@ export default async function UserProfilePage({ } if (eventsResult.status === "fulfilled") activityEvents = eventsResult.value; + if (profileReadmeResult.status === "fulfilled") { + profileReadme = profileReadmeResult.value; + } // Fetch top repos from the user's orgs (for scoring) if (orgsData.length > 0) { @@ -116,8 +128,20 @@ export default async function UserProfilePage({ } } + const hasProfileReadme = profileReadme !== null; + return ( + ) : null + } user={{ login: userData.login, name: userData.name ?? null, diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts index 7cbe91bb..7a660c03 100644 --- a/apps/web/src/app/api/auth/[...all]/route.ts +++ b/apps/web/src/app/api/auth/[...all]/route.ts @@ -1,4 +1,4 @@ import { auth } from "@/lib/auth"; import { toNextJsHandler } from "better-auth/next-js"; -export const { POST, GET } = toNextJsHandler(auth); +export const { POST, GET, DELETE, PATCH, PUT } = toNextJsHandler(auth); diff --git a/apps/web/src/app/device/page.tsx b/apps/web/src/app/device/page.tsx new file mode 100644 index 00000000..2bc53489 --- /dev/null +++ b/apps/web/src/app/device/page.tsx @@ -0,0 +1,371 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef, Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import { authClient, useSession } from "@/lib/auth-client"; +import { LoadingSpinner } from "@/components/shared/icons/loading-spinner"; +import { cn } from "@/lib/utils"; +import { CheckCircle2, Monitor, ShieldAlert, XCircle } from "lucide-react"; + +type Phase = "enter-code" | "approve" | "success" | "denied" | "error"; + +const LOGO_SVG_PATH = + "M25.3906 16.25C25.3908 14.8872 24.9992 13.5531 24.2627 12.4066C23.5261 11.26 22.4755 10.3494 21.236 9.78298C19.9965 9.21661 18.6203 9.01841 17.2714 9.21199C15.9224 9.40557 14.6576 9.98277 13.6274 10.8749C12.5972 11.7669 11.8451 12.9363 11.4606 14.2437C11.0762 15.5512 11.0756 16.9415 11.459 18.2492C11.8424 19.557 12.5935 20.727 13.623 21.6199C14.6524 22.5128 15.9169 23.091 17.2656 23.2857V41.7142C15.4867 41.971 13.871 42.8921 12.7438 44.2921C11.6165 45.6921 11.0614 47.467 11.1901 49.2598C11.3189 51.0526 12.1218 52.7301 13.4375 53.9547C14.7532 55.1793 16.4839 55.8601 18.2813 55.8601C20.0787 55.8601 21.8093 55.1793 23.125 53.9547C24.4407 52.7301 25.2437 51.0526 25.3724 49.2598C25.5011 47.467 24.946 45.6921 23.8188 44.2921C22.6915 42.8921 21.0758 41.971 19.2969 41.7142V23.2857C20.9888 23.0415 22.5361 22.1959 23.6552 20.9037C24.7744 19.6116 25.3905 17.9594 25.3906 16.25ZM13.2031 16.25C13.2031 15.2456 13.501 14.2638 14.059 13.4287C14.6169 12.5936 15.41 11.9428 16.3379 11.5584C17.2659 11.1741 18.2869 11.0735 19.272 11.2694C20.257 11.4654 21.1619 11.949 21.872 12.6592C22.5822 13.3694 23.0659 14.2742 23.2618 15.2593C23.4578 16.2444 23.3572 17.2654 22.9728 18.1933C22.5885 19.1212 21.9376 19.9143 21.1025 20.4723C20.2674 21.0303 19.2856 21.3281 18.2813 21.3281C16.9345 21.3281 15.6428 20.7931 14.6905 19.8408C13.7382 18.8884 13.2031 17.5968 13.2031 16.25ZM23.3594 48.75C23.3594 49.7543 23.0616 50.7362 22.5036 51.5712C21.9456 52.4063 21.1525 53.0572 20.2246 53.4416C19.2967 53.8259 18.2756 53.9265 17.2906 53.7305C16.3055 53.5346 15.4007 53.051 14.6905 52.3408C13.9803 51.6306 13.4967 50.7257 13.3007 49.7407C13.1048 48.7556 13.2053 47.7346 13.5897 46.8067C13.974 45.8788 14.6249 45.0857 15.46 44.5277C16.2951 43.9697 17.2769 43.6719 18.2813 43.6719C18.9481 43.6719 19.6085 43.8032 20.2246 44.0584C20.8407 44.3136 21.4005 44.6877 21.872 45.1592C22.3436 45.6308 22.7176 46.1906 22.9728 46.8067C23.228 47.4228 23.3594 48.0831 23.3594 48.75ZM51.7969 41.7142V28.0896C51.7985 27.4222 51.6678 26.761 51.4124 26.1444C51.157 25.5277 50.782 24.9678 50.309 24.4969L39.0152 13.2031H48.75C49.0194 13.2031 49.2777 13.0961 49.4682 12.9056C49.6586 12.7152 49.7656 12.4568 49.7656 12.1875C49.7656 11.9181 49.6586 11.6598 49.4682 11.4693C49.2777 11.2789 49.0194 11.1719 48.75 11.1719H36.5625C36.2932 11.1719 36.0348 11.2789 35.8444 11.4693C35.6539 11.6598 35.5469 11.9181 35.5469 12.1875V24.375C35.5469 24.6443 35.6539 24.9027 35.8444 25.0931C36.0348 25.2836 36.2932 25.3906 36.5625 25.3906C36.8319 25.3906 37.0902 25.2836 37.2807 25.0931C37.4711 24.9027 37.5781 24.6443 37.5781 24.375V14.6402L48.8744 25.934C49.1573 26.2171 49.3816 26.5533 49.5345 26.9231C49.6874 27.293 49.766 27.6894 49.7656 28.0896V41.7142C47.9867 41.971 46.371 42.8921 45.2438 44.2921C44.1165 45.6921 43.5614 47.467 43.6901 49.2598C43.8189 51.0526 44.6219 52.7301 45.9375 53.9547C47.2532 55.1793 48.9839 55.8601 50.7813 55.8601C52.5787 55.8601 54.3093 55.1793 55.625 53.9547C56.9407 52.7301 57.7437 51.0526 57.8724 49.2598C58.0011 47.467 57.446 45.6921 56.3187 44.2921C55.1915 42.8921 53.5758 41.971 51.7969 41.7142ZM50.7813 53.8281C49.7769 53.8281 48.7951 53.5303 47.96 52.9723C47.1249 52.4143 46.474 51.6212 46.0897 50.6933C45.7053 49.7654 45.6048 48.7444 45.8007 47.7593C45.9967 46.7742 46.4803 45.8694 47.1905 45.1592C47.9007 44.449 48.8055 43.9654 49.7906 43.7694C50.7756 43.5735 51.7967 43.6741 52.7246 44.0584C53.6525 44.4428 54.4456 45.0936 55.0036 45.9287C55.5616 46.7638 55.8594 47.7456 55.8594 48.75C55.8594 50.0968 55.3244 51.3884 54.372 52.3408C53.4197 53.2931 52.1281 53.8281 50.7813 53.8281Z"; + +function DeviceAuthorizationInner() { + const searchParams = useSearchParams(); + const { data: session, isPending: sessionLoading } = useSession(); + + const [phase, setPhase] = useState("enter-code"); + const [userCode, setUserCode] = useState(""); + const [verifying, setVerifying] = useState(false); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + const urlCode = searchParams.get("user_code"); + + useEffect(() => { + if (urlCode) { + setUserCode(urlCode.trim().replace(/-/g, "").toUpperCase()); + } + }, [urlCode]); + + useEffect(() => { + if (phase === "enter-code" && !urlCode) { + inputRef.current?.focus(); + } + }, [phase, urlCode]); + + const verifyCode = useCallback(async (code: string) => { + setVerifying(true); + setError(null); + try { + const formatted = code.trim().replace(/-/g, "").toUpperCase(); + const response = await authClient.device({ + query: { user_code: formatted }, + }); + if (response.data) { + setUserCode(formatted); + setPhase("approve"); + } else { + setError("Invalid or expired code. Please check and try again."); + } + } catch { + setError("Invalid or expired code. Please check and try again."); + } finally { + setVerifying(false); + } + }, []); + + // Auto-verify if code comes from URL and user is authenticated + const autoVerified = useRef(false); + useEffect(() => { + if (urlCode && session && !autoVerified.current && phase === "enter-code") { + autoVerified.current = true; + verifyCode(urlCode); + } + }, [urlCode, session, phase, verifyCode]); + + function handleSubmitCode(e: React.FormEvent) { + e.preventDefault(); + if (!userCode.trim() || verifying) return; + verifyCode(userCode); + } + + async function handleApprove() { + setProcessing(true); + setError(null); + try { + await authClient.device.approve({ userCode }); + setPhase("success"); + } catch { + setError("Failed to approve. The code may have expired."); + setProcessing(false); + } + } + + async function handleDeny() { + setProcessing(true); + setError(null); + try { + await authClient.device.deny({ userCode }); + setPhase("denied"); + } catch { + setError("Failed to deny. The code may have expired."); + setProcessing(false); + } + } + + function formatDisplayCode(code: string): string { + const clean = code.replace(/-/g, "").toUpperCase(); + if (clean.length === 8) return `${clean.slice(0, 4)}-${clean.slice(4)}`; + return clean; + } + + if (sessionLoading) { + return ( + + + + ); + } + + if (!session) { + const returnUrl = urlCode + ? `/device?user_code=${encodeURIComponent(urlCode)}` + : "/device"; + return ( + +
+
+ +
+
+

+ Device Authorization +

+

+ Sign in to your account to authorize a + device. +

+
+ + Sign in to continue + +
+
+ ); + } + + return ( + + {phase === "enter-code" && ( +
+
+ +
+
+

+ Authorize Device +

+

+ Enter the code shown on your device to grant + access. +

+
+ { + setUserCode( + e.target.value + .toUpperCase() + .replace(/[^A-Z0-9]/g, ""), + ); + setError(null); + }} + placeholder="ABCD1234" + maxLength={8} + spellCheck={false} + autoComplete="off" + className="w-full max-w-[220px] text-center tracking-[0.3em] font-mono text-2xl font-semibold bg-transparent border border-foreground/15 rounded-lg px-4 py-3 text-foreground placeholder:text-foreground/20 focus:outline-none focus:border-foreground/40 focus:ring-1 focus:ring-foreground/20 transition-all" + /> + {error &&

{error}

} + +
+ )} + + {phase === "approve" && ( +
+
+ +
+
+

+ Confirm Authorization +

+

+ A device is requesting access to your + account. +

+
+
+
+ + Device Code + + + {formatDisplayCode(userCode)} + +
+
+ + Account + + + {session.user.name || + session.user.email} + +
+
+ {error &&

{error}

} +
+ + +
+
+ )} + + {phase === "success" && ( +
+
+ +
+
+

+ Device Authorized +

+

+ The device has been granted access. You can + close this tab and return to your terminal. +

+
+
+ )} + + {phase === "denied" && ( +
+
+ +
+
+

+ Authorization Denied +

+

+ The device was not granted access. You can + close this tab. +

+
+
+ )} + + {phase === "error" && ( +
+
+ +
+
+

+ Something went wrong +

+

+ {error ?? "An unexpected error occurred."} +

+
+ +
+ )} +
+ ); +} + +export default function DeviceAuthorizationPage() { + return ( + + + + } + > + + + ); +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+ {/* Logo */} +
+ + + + + BETTER-HUB. + +
+ +
{children}
+
+ ); +} diff --git a/apps/web/src/components/dashboard/contribution-chart.tsx b/apps/web/src/components/dashboard/contribution-chart.tsx index 514d7257..cadb7d23 100644 --- a/apps/web/src/components/dashboard/contribution-chart.tsx +++ b/apps/web/src/components/dashboard/contribution-chart.tsx @@ -1,7 +1,7 @@ "use client"; import { cn } from "@/lib/utils"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type Ref } from "react"; interface ContributionDay { contributionCount: number; @@ -18,11 +18,16 @@ interface ContributionData { weeks: ContributionWeek[]; } +export type ContributionChartStreak = + | { count: number; kind: "current" } + | { count: number; kind: "best" }; + const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const SHOW_DAYS = [1, 3, 5]; -function getLevel(count: number): number { +/** Same buckets as the contribution heatmap cells (0 = none, 4 = busiest). */ +export function getContributionHeatLevel(count: number): 0 | 1 | 2 | 3 | 4 { if (count === 0) return 0; if (count <= 3) return 1; if (count <= 6) return 2; @@ -56,13 +61,33 @@ function getMonthFromDate(date: string): number { return parsed.getUTCMonth(); } -export function ContributionChart({ data }: { data: ContributionData }) { +/** Matches profile contribution grid dates (UTC YYYY-MM-DD from `toISOString`). */ +function utcTodayDateString(): string { + return new Date().toISOString().slice(0, 10); +} + +function isFutureContributionDay(date: string): boolean { + return date > utcTodayDateString(); +} + +export function ContributionChart({ + data, + streak = null, + calendarMeasureRef, +}: { + data: ContributionData; + streak?: ContributionChartStreak | null; + /** Width of the calendar block; used to cap the activity year strip on large screens. */ + calendarMeasureRef?: Ref; +}) { const [hovered, setHovered] = useState(null); const [tooltipX, setTooltipX] = useState(0); const scrollContainerRef = useRef(null); const hoveredCellRef = useRef(null); const tooltipRef = useRef(null); + const tooltipDay = hovered && !isFutureContributionDay(hovered.date) ? hovered : null; + const monthPositions = useMemo(() => { const positions: { label: string; col: number }[] = []; let last = -1; @@ -119,7 +144,7 @@ export function ContributionChart({ data }: { data: ContributionData }) { }, []); useEffect(() => { - if (!hovered || !hoveredCellRef.current) return; + if (!tooltipDay || !hoveredCellRef.current) return; const update = () => { if (!hoveredCellRef.current) return; @@ -138,177 +163,259 @@ export function ContributionChart({ data }: { data: ContributionData }) { parent?.removeEventListener("scroll", update); window.removeEventListener("resize", update); }; - }, [hovered, updateTooltipPosition]); + }, [tooltipDay, updateTooltipPosition]); + + const levelLegend = ( +
+ Less + {[0, 1, 2, 3, 4].map((l) => ( +
+ ))} + More +
+ ); return (
- {/* Header */} -
-
- - {data.totalContributions.toLocaleString()} - - - contributions this year - -
-
- Less - {[0, 1, 2, 3, 4].map((l) => ( +
+
+
- ))} - More -
-
- - {/* Chart */} -
-
- {hovered && ( -
-
-
- - { - hovered.contributionCount - } - {" "} - contribution - {hovered.contributionCount !== - 1 - ? "s" - : ""} -
-
- {new Date( - hovered.date, - ).toLocaleDateString( - "en-US", - { - weekday: "short", - month: "short", - day: "numeric", - }, - )} + style={{ left: tooltipX }} + > + {tooltipDay && ( +
+
+
+ + { + tooltipDay.contributionCount + } + {" "} + contribution + {tooltipDay.contributionCount !== + 1 + ? "s" + : ""} +
+
+ {new Date( + tooltipDay.date, + ).toLocaleDateString( + "en-US", + { + weekday: "short", + month: "short", + day: "numeric", + }, + )} +
+
-
+ )}
- )} -
-
-
- {/* Day labels column */}
- {DAYS.map((day, i) => ( +
+ {/* Day labels column */}
- {SHOW_DAYS.includes(i) && ( - - {day} - - )} + {DAYS.map((day, i) => ( +
+ {SHOW_DAYS.includes( + i, + ) && ( + + { + day + } + + )} +
+ ))}
- ))} -
- {/* Grid column */} -
- {/* Month labels — absolutely positioned so they don't clip */} -
- {visibleMonthPositions.map((m) => ( - - {m.label} - - ))} -
+ {/* Grid column */} +
+ {/* Month labels — absolutely positioned so they don't clip */} +
+ {visibleMonthPositions.map( + (m) => ( + + { + m.label + } + + ), + )} +
- {/* Cells */} -
- {data.weeks.map((week, wi) => ( + {/* Cells */}
- {week.contributionDays.map( - (day) => ( + {data.weeks.map( + ( + week, + wi, + ) => (
{ - hoveredCellRef.current = - e.currentTarget; - setHovered( + > + {week.contributionDays.map( + ( day, - ); - updateTooltipPosition( - e.currentTarget, - ); - }} - onMouseLeave={() => { - hoveredCellRef.current = - null; - setHovered( - null, - ); - }} - /> + di, + ) => { + const future = + isFutureContributionDay( + day.date, + ); + return ( +
{ + if ( + future + ) { + hoveredCellRef.current = + null; + setHovered( + null, + ); + return; + } + hoveredCellRef.current = + e.currentTarget; + setHovered( + day, + ); + updateTooltipPosition( + e.currentTarget, + ); + }} + onMouseLeave={() => { + if ( + future + ) + return; + hoveredCellRef.current = + null; + setHovered( + null, + ); + }} + /> + ); + }, + )} +
), )}
- ))} +
+
+
+ + {data.totalContributions.toLocaleString()} + + + contributions this year + +
+
+ {streak ? ( +
+ + + {streak.count}{" "} + {streak.kind === + "current" + ? "day streak" + : "day best streak"} + +
+ ) : null} +
+
+ {levelLegend} +
+
diff --git a/apps/web/src/components/extension/extension-install-dialog.tsx b/apps/web/src/components/extension/extension-install-dialog.tsx index 66ef7939..ba1d6862 100644 --- a/apps/web/src/components/extension/extension-install-dialog.tsx +++ b/apps/web/src/components/extension/extension-install-dialog.tsx @@ -3,7 +3,7 @@ import { ArrowRight, Check, - Chrome, + Globe, Download, ExternalLink, Puzzle, @@ -44,7 +44,7 @@ const QUICK_STEPS = [ detail: "Toggle in top-right", }, { - icon: Chrome, + icon: Globe, label: '"Load unpacked"', detail: "Select the unzipped folder", }, @@ -66,7 +66,7 @@ export function ExtensionInstallDialog({ open, onOpenChange }: ExtensionInstallD
- +
diff --git a/apps/web/src/components/extension/extension-page-content.tsx b/apps/web/src/components/extension/extension-page-content.tsx index 8083b829..f635d239 100644 --- a/apps/web/src/components/extension/extension-page-content.tsx +++ b/apps/web/src/components/extension/extension-page-content.tsx @@ -3,7 +3,7 @@ import { ArrowRight, Check, - Chrome, + Globe, Download, Flame, Puzzle, @@ -50,7 +50,7 @@ const STEPS = [ title: "Load the extension", description: 'Click "Load unpacked" in the top-left area, then select the unzipped folder. The Better Hub extension will appear in your extensions list.', - icon: Chrome, + icon: Globe, }, { number: "06", @@ -95,7 +95,7 @@ const FIREFOX_STEPS = [ title: "Load temporary add-on", description: 'Click "Load Temporary Add-on" and select the manifest in the unzipped folder. The Better Hub extension will appear in your add-ons list.', - icon: Chrome, + icon: Globe, }, { number: "06", @@ -143,7 +143,7 @@ export function ExtensionPageContent() { {/* Extension icon */}
{browser === "chrome" ? ( - + ) : ( )} @@ -172,7 +172,7 @@ export function ExtensionPageContent() { : "bg-muted text-muted-foreground hover:bg-muted/80", )} > - + Chrome
); } diff --git a/apps/web/src/components/repo/branch-selector.tsx b/apps/web/src/components/repo/branch-selector.tsx index b1b75562..3a7339a2 100644 --- a/apps/web/src/components/repo/branch-selector.tsx +++ b/apps/web/src/components/repo/branch-selector.tsx @@ -8,6 +8,8 @@ import { cn } from "@/lib/utils"; interface BranchSelectorProps { owner: string; repo: string; + /** Defaults to `/${owner}/${repo}` */ + repoBasePath?: string; currentRef: string; branches: { name: string }[]; tags: { name: string }[]; @@ -19,6 +21,7 @@ interface BranchSelectorProps { export function BranchSelector({ owner, repo, + repoBasePath, currentRef, branches, tags, @@ -26,6 +29,7 @@ export function BranchSelector({ pathType = "tree", defaultBranch, }: BranchSelectorProps) { + const base = repoBasePath ?? `/${owner}/${repo}`; const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const [tab, setTab] = useState<"branches" | "tags">("branches"); @@ -52,9 +56,9 @@ export function BranchSelector({ setSearch(""); const pathSuffix = currentPath ? `/${currentPath}` : ""; if (currentPath) { - router.push(`/${owner}/${repo}/${pathType}/${ref}${pathSuffix}`); + router.push(`${base}/${pathType}/${ref}${pathSuffix}`); } else { - router.push(`/${owner}/${repo}/tree/${ref}`); + router.push(`${base}/tree/${ref}`); } } diff --git a/apps/web/src/components/repo/breadcrumb-nav.tsx b/apps/web/src/components/repo/breadcrumb-nav.tsx index eb1fdde5..db07f34c 100644 --- a/apps/web/src/components/repo/breadcrumb-nav.tsx +++ b/apps/web/src/components/repo/breadcrumb-nav.tsx @@ -4,22 +4,32 @@ import { encodeFilePath } from "@/lib/github-utils"; interface BreadcrumbNavProps { owner: string; repo: string; + /** Defaults to `/${owner}/${repo}` */ + repoBasePath?: string; currentRef: string; path: string; isFile?: boolean; } -export function BreadcrumbNav({ owner, repo, currentRef, path, isFile }: BreadcrumbNavProps) { +export function BreadcrumbNav({ + owner, + repo, + repoBasePath, + currentRef, + path, + isFile, +}: BreadcrumbNavProps) { if (!path) return null; + const base = repoBasePath ?? `/${owner}/${repo}`; const segments = path.split("/").filter(Boolean); const crumbs = segments.map((segment, i) => { const partialPath = segments.slice(0, i + 1).join("/"); const isLast = i === segments.length - 1; const href = isLast && isFile - ? `/${owner}/${repo}/blob/${currentRef}/${encodeFilePath(partialPath)}` - : `/${owner}/${repo}/tree/${currentRef}/${encodeFilePath(partialPath)}`; + ? `${base}/blob/${currentRef}/${encodeFilePath(partialPath)}` + : `${base}/tree/${currentRef}/${encodeFilePath(partialPath)}`; return { label: segment, href, isLast }; }); @@ -27,7 +37,7 @@ export function BreadcrumbNav({ owner, repo, currentRef, path, isFile }: Breadcr return (
@@ -321,6 +334,7 @@ export function CodeContentWrapper({
)} diff --git a/apps/web/src/components/repo/code-toolbar.tsx b/apps/web/src/components/repo/code-toolbar.tsx index 5ed7a104..a1001bc1 100644 --- a/apps/web/src/components/repo/code-toolbar.tsx +++ b/apps/web/src/components/repo/code-toolbar.tsx @@ -25,9 +25,13 @@ interface EnrichedBranch { interface CodeToolbarProps { owner: string; repo: string; + /** Defaults to `/${owner}/${repo}` — used for GitHub clone/ZIP URLs. */ + repoBasePath?: string; currentRef: string; branches: EnrichedBranch[]; defaultBranch: string; + /** @default true — set false for hosted storage (no GitHub clone UI). */ + showCloneControls?: boolean; onDeleteBranch?: ( owner: string, repo: string, @@ -38,9 +42,11 @@ interface CodeToolbarProps { export function CodeToolbar({ owner, repo, + repoBasePath, currentRef, branches, defaultBranch, + showCloneControls = true, onDeleteBranch, }: CodeToolbarProps) { const [showClone, setShowClone] = useState(false); @@ -52,6 +58,7 @@ export function CodeToolbar({ const [isPending, startTransition] = useTransition(); const [cloneProtocol, setCloneProtocol] = useState<"https" | "ssh">("https"); + const isGithubPath = !repoBasePath || repoBasePath === `/${owner}/${repo}`; const cloneUrl = cloneProtocol === "https" ? `https://github.com/${owner}/${repo}.git` @@ -272,32 +279,34 @@ export function CodeToolbar({ )}
-
-
- - - - ZIP - + {showCloneControls && isGithubPath && ( +
+
+ + + + ZIP + +
-
+ )}
{/* Clone dropdown */} - {showClone && ( + {showCloneControls && isGithubPath && showClone && ( <>
>; } @@ -89,7 +91,14 @@ const DEFAULT_SIDEBAR_WIDTH = 300; const MIN_SIDEBAR_WIDTH = 140; const MAX_SIDEBAR_WIDTH = 1000; -export function CommitDetail({ owner, repo, commit, highlightData }: CommitDetailProps) { +export function CommitDetail({ + owner, + repo, + repoBasePath, + commit, + highlightData, +}: CommitDetailProps) { + const base = repoBasePath ?? `/${owner}/${repo}`; const [activeIndex, setActiveIndex] = useState(0); const [wordWrap, setWordWrap] = useState(true); const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH); @@ -153,7 +162,7 @@ export function CommitDetail({ owner, repo, commit, highlightData }: CommitDetai }; const copyLink = () => { - const url = `${window.location.origin}/${owner}/${repo}/commits/${commit.sha}`; + const url = `${window.location.origin}${base}/commits/${commit.sha}`; navigator.clipboard.writeText(url); setCopiedLink(true); setTimeout(() => setCopiedLink(false), 2000); @@ -306,7 +315,7 @@ export function CommitDetail({ owner, repo, commit, highlightData }: CommitDetai {commit.parents.map((p) => ( {p.sha.slice(0, 7)} diff --git a/apps/web/src/components/repo/commits-list.tsx b/apps/web/src/components/repo/commits-list.tsx index f78ca2e4..e7d893fa 100644 --- a/apps/web/src/components/repo/commits-list.tsx +++ b/apps/web/src/components/repo/commits-list.tsx @@ -18,9 +18,9 @@ import { cn } from "@/lib/utils"; import { TimeAgo } from "@/components/ui/time-ago"; import { ResizeHandle } from "@/components/ui/resize-handle"; import { - fetchCommitsByDate, - fetchCommitsPage, - fetchCommitDetail, + fetchCommitsByDate as defaultFetchCommitsByDate, + fetchCommitsPage as defaultFetchCommitsPage, + fetchCommitDetail as defaultFetchCommitDetail, type CommitDetailData, } from "@/app/(app)/repos/[owner]/[repo]/commits/actions"; import { useMutationSubscription } from "@/hooks/use-mutation-subscription"; @@ -38,7 +38,7 @@ const MIN_SHEET_WIDTH = 400; const MAX_SHEET_WIDTH_RATIO = 0.9; // Types -type Commit = { +export type Commit = { sha: string; commit: { message: string; @@ -57,12 +57,47 @@ type Commit = { html_url: string; }; +export type CommitsListCursorPagination = { + initialNextCursor: string | null; + initialHasMore: boolean; + fetchByBranch: ( + owner: string, + repo: string, + branch: string, + since?: string, + until?: string, + ) => Promise<{ commits: Commit[]; nextCursor: string | null; hasMore: boolean }>; + fetchMore: ( + owner: string, + repo: string, + branch: string, + cursor: string, + since?: string, + until?: string, + ) => Promise<{ commits: Commit[]; nextCursor: string | null; hasMore: boolean }>; + fetchCommitDetail: ( + owner: string, + repo: string, + sha: string, + branch?: string, + ) => Promise<{ + commit: CommitDetailData | null; + highlightData: Record>; + }>; +}; + interface CommitsListProps { owner: string; repo: string; commits: Commit[]; defaultBranch: string; branches: { name: string }[]; + /** e.g. `/s/owner/repo` for Code.Storage repos */ + repoBasePath?: string; + /** Cursor-based pagination (Pierre); omit for GitHub page-based pagination */ + cursorPagination?: CommitsListCursorPagination; + /** Hide date inputs when the backend does not support since/until */ + enableDateFilter?: boolean; } // Utility functions @@ -233,6 +268,7 @@ function CommitsToolbar({ since, until, hasDateFilter, + showDateFilters, onBranchChange, onSearchChange, onSinceChange, @@ -246,6 +282,7 @@ function CommitsToolbar({ since: string; until: string; hasDateFilter: boolean; + showDateFilters: boolean; onBranchChange: (branch: string) => void; onSearchChange: (search: string) => void; onSinceChange: (since: string) => void; @@ -283,28 +320,32 @@ function CommitsToolbar({
- onSinceChange(e.target.value)} - title="Since date" - className="h-9 rounded-md border border-border bg-background px-3 font-mono text-xs text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" - /> - onUntilChange(e.target.value)} - title="Until date" - className="h-9 rounded-md border border-border bg-background px-3 font-mono text-xs text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" - /> - {hasDateFilter && ( - + {showDateFilters && ( + <> + onSinceChange(e.target.value)} + title="Since date" + className="h-9 rounded-md border border-border bg-background px-3 font-mono text-xs text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> + onUntilChange(e.target.value)} + title="Until date" + className="h-9 rounded-md border border-border bg-background px-3 font-mono text-xs text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> + {hasDateFilter && ( + + )} + )}
); @@ -314,6 +355,7 @@ function CommitRow({ commit, owner, repo, + repoLinkBase, isFirst, isExpanded, copiedSha, @@ -325,6 +367,7 @@ function CommitRow({ commit: Commit; owner: string; repo: string; + repoLinkBase: string; isFirst: boolean; isExpanded: boolean; copiedSha: string | null; @@ -380,7 +423,7 @@ function CommitRow({
{isMobile === undefined || isMobile ? ( {firstLine} @@ -477,6 +520,7 @@ function CommitDateGroup({ commits, owner, repo, + repoLinkBase, expandedShas, copiedSha, isMobile, @@ -488,6 +532,7 @@ function CommitDateGroup({ commits: Commit[]; owner: string; repo: string; + repoLinkBase: string; expandedShas: Set; copiedSha: string | null; isMobile: boolean | undefined; @@ -507,6 +552,7 @@ function CommitDateGroup({ commit={commit} owner={owner} repo={repo} + repoLinkBase={repoLinkBase} isFirst={i === 0} isExpanded={expandedShas.has(commit.sha)} copiedSha={copiedSha} @@ -526,6 +572,7 @@ function CommitDetailSheet({ onOpenChange, owner, repo, + repoBasePath, selectedCommitSha, commitDetail, highlightData, @@ -540,6 +587,7 @@ function CommitDetailSheet({ onOpenChange: (open: boolean) => void; owner: string; repo: string; + repoBasePath: string; selectedCommitSha: string | null; commitDetail: CommitDetailData | null; highlightData: Record>; @@ -574,7 +622,7 @@ function CommitDetailSheet({
{selectedCommitSha && ( @@ -600,6 +648,7 @@ function CommitDetailSheet({ @@ -619,7 +668,21 @@ function CommitDetailSheet({ const PER_PAGE = 30; // Main component -export function CommitsList({ owner, repo, commits, defaultBranch, branches }: CommitsListProps) { +export function CommitsList({ + owner, + repo, + commits, + defaultBranch, + branches, + repoBasePath: repoBasePathProp, + cursorPagination, + enableDateFilter = true, +}: CommitsListProps) { + const repoLinkBase = repoBasePathProp ?? `/${owner}/${repo}`; + const initialCommitsRef = useRef(commits); + const initialNextCursorRef = useRef(cursorPagination?.initialNextCursor ?? null); + const initialHasMoreRef = useRef(cursorPagination?.initialHasMore ?? false); + // Filter state const [search, setSearch] = useState(""); const [since, setSince] = useState(""); @@ -628,9 +691,14 @@ export function CommitsList({ owner, repo, commits, defaultBranch, branches }: C const [displayedCommits, setDisplayedCommits] = useState(commits); const [isPending, startTransition] = useTransition(); - // Infinite scroll state + // Infinite scroll state (GitHub: page; storage: cursor) const [page, setPage] = useState(1); - const [hasMore, setHasMore] = useState(commits.length >= PER_PAGE); + const [nextCursor, setNextCursor] = useState( + cursorPagination?.initialNextCursor ?? null, + ); + const [hasMore, setHasMore] = useState( + cursorPagination ? cursorPagination.initialHasMore : commits.length >= PER_PAGE, + ); const [isLoadingMore, setIsLoadingMore] = useState(false); const loadMoreRef = useRef(null); @@ -663,27 +731,57 @@ export function CommitsList({ owner, repo, commits, defaultBranch, branches }: C } }, []); - // Infinite scroll: load next page + // Infinite scroll: load next page (GitHub) or next cursor page (storage) const loadMore = useCallback(async () => { if (isLoadingMore || !hasMore) return; setIsLoadingMore(true); - const nextPage = page + 1; - const result = await fetchCommitsPage( - owner, - repo, - nextPage, - currentBranch, - since || undefined, - until || undefined, - ); - const newCommits = result as Commit[]; - if (newCommits.length < PER_PAGE) { - setHasMore(false); + if (cursorPagination) { + if (!nextCursor) { + setHasMore(false); + setIsLoadingMore(false); + return; + } + const result = await cursorPagination.fetchMore( + owner, + repo, + currentBranch, + nextCursor, + since || undefined, + until || undefined, + ); + setDisplayedCommits((prev) => [...prev, ...result.commits]); + setNextCursor(result.nextCursor); + setHasMore(result.hasMore); + } else { + const nextPage = page + 1; + const result = await defaultFetchCommitsPage( + owner, + repo, + nextPage, + currentBranch, + since || undefined, + until || undefined, + ); + const newCommits = result as Commit[]; + if (newCommits.length < PER_PAGE) { + setHasMore(false); + } + setDisplayedCommits((prev) => [...prev, ...newCommits]); + setPage(nextPage); } - setDisplayedCommits((prev) => [...prev, ...newCommits]); - setPage(nextPage); setIsLoadingMore(false); - }, [isLoadingMore, hasMore, page, owner, repo, currentBranch, since, until]); + }, [ + isLoadingMore, + hasMore, + page, + owner, + repo, + currentBranch, + since, + until, + cursorPagination, + nextCursor, + ]); // IntersectionObserver for infinite scroll useEffect(() => { @@ -734,7 +832,27 @@ export function CommitsList({ owner, repo, commits, defaultBranch, branches }: C const fetchCommits = useCallback( (branch: string, newSince?: string, newUntil?: string) => { startTransition(async () => { - const result = await fetchCommitsByDate( + if (cursorPagination) { + const result = await cursorPagination.fetchByBranch( + owner, + repo, + branch, + newSince + ? new Date(newSince).toISOString() + : undefined, + newUntil + ? new Date( + newUntil + "T23:59:59", + ).toISOString() + : undefined, + ); + setDisplayedCommits(result.commits); + setNextCursor(result.nextCursor); + setHasMore(result.hasMore); + setPage(1); + return; + } + const result = await defaultFetchCommitsByDate( owner, repo, newSince ? new Date(newSince).toISOString() : undefined, @@ -749,7 +867,7 @@ export function CommitsList({ owner, repo, commits, defaultBranch, branches }: C setHasMore(data.length >= PER_PAGE); }); }, - [owner, repo], + [owner, repo, cursorPagination], ); // Subscribe to mutations @@ -771,55 +889,77 @@ export function CommitsList({ owner, repo, commits, defaultBranch, branches }: C (branch: string) => { setCurrentBranch(branch); if (branch === defaultBranch && !since && !until) { - setDisplayedCommits(commits); + setDisplayedCommits(initialCommitsRef.current); setPage(1); - setHasMore(commits.length >= PER_PAGE); + if (cursorPagination) { + setNextCursor(initialNextCursorRef.current); + setHasMore(initialHasMoreRef.current); + } else { + setHasMore(initialCommitsRef.current.length >= PER_PAGE); + } } else { fetchCommits(branch, since, until); } }, - [defaultBranch, since, until, commits, fetchCommits], + [defaultBranch, since, until, fetchCommits, cursorPagination], ); const handleSinceChange = useCallback( (newSince: string) => { setSince(newSince); if (!newSince && !until && currentBranch === defaultBranch) { - setDisplayedCommits(commits); + setDisplayedCommits(initialCommitsRef.current); setPage(1); - setHasMore(commits.length >= PER_PAGE); + if (cursorPagination) { + setNextCursor(initialNextCursorRef.current); + setHasMore(initialHasMoreRef.current); + } else { + setHasMore(initialCommitsRef.current.length >= PER_PAGE); + } } else { fetchCommits(currentBranch, newSince, until); } }, - [until, currentBranch, defaultBranch, commits, fetchCommits], + [until, currentBranch, defaultBranch, fetchCommits, cursorPagination], ); const handleUntilChange = useCallback( (newUntil: string) => { setUntil(newUntil); if (!since && !newUntil && currentBranch === defaultBranch) { - setDisplayedCommits(commits); + setDisplayedCommits(initialCommitsRef.current); setPage(1); - setHasMore(commits.length >= PER_PAGE); + if (cursorPagination) { + setNextCursor(initialNextCursorRef.current); + setHasMore(initialHasMoreRef.current); + } else { + setHasMore(initialCommitsRef.current.length >= PER_PAGE); + } } else { fetchCommits(currentBranch, since, newUntil); } }, - [since, currentBranch, defaultBranch, commits, fetchCommits], + [since, currentBranch, defaultBranch, fetchCommits, cursorPagination], ); const clearDates = useCallback(() => { setSince(""); setUntil(""); if (currentBranch === defaultBranch) { - setDisplayedCommits(commits); + setDisplayedCommits(initialCommitsRef.current); setPage(1); - setHasMore(commits.length >= PER_PAGE); + if (cursorPagination) { + setNextCursor(initialNextCursorRef.current); + setHasMore(initialHasMoreRef.current); + } else { + setHasMore(initialCommitsRef.current.length >= PER_PAGE); + } } else { fetchCommits(currentBranch); } - }, [currentBranch, defaultBranch, commits, fetchCommits]); + }, [currentBranch, defaultBranch, fetchCommits, cursorPagination]); + + const fetchDetailFn = cursorPagination?.fetchCommitDetail ?? defaultFetchCommitDetail; const handleCommitClick = useCallback( async (sha: string) => { @@ -829,12 +969,12 @@ export function CommitsList({ owner, repo, commits, defaultBranch, branches }: C setCommitDetail(null); setHighlightData({}); - const result = await fetchCommitDetail(owner, repo, sha); + const result = await fetchDetailFn(owner, repo, sha, currentBranch); setCommitDetail(result.commit); setHighlightData(result.highlightData); setIsLoadingDetail(false); }, - [owner, repo], + [owner, repo, currentBranch, fetchDetailFn], ); const copySha = useCallback((sha: string) => { @@ -871,6 +1011,7 @@ export function CommitsList({ owner, repo, commits, defaultBranch, branches }: C since={since} until={until} hasDateFilter={hasDateFilter} + showDateFilters={enableDateFilter} onBranchChange={handleBranchChange} onSearchChange={setSearch} onSinceChange={handleSinceChange} @@ -897,6 +1038,7 @@ export function CommitsList({ owner, repo, commits, defaultBranch, branches }: C commits={dateCommits} owner={owner} repo={repo} + repoLinkBase={repoLinkBase} expandedShas={expandedShas} copiedSha={copiedSha} isMobile={isMobile} @@ -923,6 +1065,7 @@ export function CommitsList({ owner, repo, commits, defaultBranch, branches }: C onOpenChange={setSheetOpen} owner={owner} repo={repo} + repoBasePath={repoLinkBase} selectedCommitSha={selectedCommitSha} commitDetail={commitDetail} highlightData={highlightData} diff --git a/apps/web/src/components/repo/file-explorer-tree.tsx b/apps/web/src/components/repo/file-explorer-tree.tsx index a36c5098..c389f643 100644 --- a/apps/web/src/components/repo/file-explorer-tree.tsx +++ b/apps/web/src/components/repo/file-explorer-tree.tsx @@ -13,6 +13,8 @@ interface FileExplorerTreeProps { tree: FileTreeNode[]; owner: string; repo: string; + /** Defaults to `/${owner}/${repo}` */ + repoBasePath?: string; defaultBranch: string; } @@ -45,13 +47,11 @@ function buildSearchIndex(nodes: FileTreeNode[]): SearchEntry[] { function FileSearchBar({ searchIndex, - owner, - repo, + repoBasePath, defaultBranch, }: { searchIndex: SearchEntry[]; - owner: string; - repo: string; + repoBasePath: string; defaultBranch: string; }) { const router = useRouter(); @@ -116,10 +116,10 @@ function FileSearchBar({ setInputValue(""); setSuggestions([]); router.push( - `/${owner}/${repo}/blob/${defaultBranch}/${encodeFilePath(filePath)}`, + `${repoBasePath}/blob/${defaultBranch}/${encodeFilePath(filePath)}`, ); }, - [router, owner, repo, defaultBranch], + [router, repoBasePath, defaultBranch], ); const handleKeyDown = useCallback( @@ -223,22 +223,52 @@ function FileSearchBar({ // ── Main component ────────────────────────────────────────────────── -export function FileExplorerTree({ tree, owner, repo, defaultBranch }: FileExplorerTreeProps) { +function refAndSubpathFromCodePath( + pathname: string, + base: string, + fallbackRef: string, +): { ref: string; filePath: string | null } { + for (const kind of ["blob", "tree"] as const) { + const prefix = `${base}/${kind}/`; + if (!pathname.startsWith(prefix)) continue; + const rest = pathname.slice(prefix.length); + const slash = rest.indexOf("/"); + const ref = slash === -1 ? rest : rest.slice(0, slash); + const tail = slash === -1 ? "" : rest.slice(slash + 1); + if (!ref) continue; + try { + return { + ref, + filePath: tail ? decodeURIComponent(tail) : null, + }; + } catch { + return { ref, filePath: tail || null }; + } + } + return { ref: fallbackRef, filePath: null }; +} + +export function FileExplorerTree({ + tree, + owner, + repo, + repoBasePath, + defaultBranch, +}: FileExplorerTreeProps) { const pathname = usePathname(); + const base = repoBasePath ?? `/${owner}/${repo}`; const [expandedPaths, setExpandedPaths] = useState>(new Set()); const searchIndex = useMemo(() => buildSearchIndex(tree), [tree]); + const pathRef = useMemo( + () => refAndSubpathFromCodePath(pathname, base, defaultBranch).ref, + [pathname, base, defaultBranch], + ); + const currentPath = useMemo(() => { - const base = `/${owner}/${repo}`; - const blobPrefix = `${base}/blob/${defaultBranch}/`; - const treePrefix = `${base}/tree/${defaultBranch}/`; - if (pathname.startsWith(blobPrefix)) - return decodeURIComponent(pathname.slice(blobPrefix.length)); - if (pathname.startsWith(treePrefix)) - return decodeURIComponent(pathname.slice(treePrefix.length)); - return null; - }, [pathname, owner, repo, defaultBranch]); + return refAndSubpathFromCodePath(pathname, base, defaultBranch).filePath; + }, [pathname, base, defaultBranch]); useEffect(() => { if (!currentPath) return; @@ -264,9 +294,8 @@ export function FileExplorerTree({ tree, owner, repo, defaultBranch }: FileExplo
{tree.map((node) => ( @@ -274,9 +303,8 @@ export function FileExplorerTree({ tree, owner, repo, defaultBranch }: FileExplo key={node.path} node={node} depth={0} - owner={owner} - repo={repo} - defaultBranch={defaultBranch} + repoBasePath={base} + defaultBranch={pathRef} currentPath={currentPath} expandedPaths={expandedPaths} onToggle={toggleExpand} @@ -292,8 +320,7 @@ export function FileExplorerTree({ tree, owner, repo, defaultBranch }: FileExplo interface TreeNodeProps { node: FileTreeNode; depth: number; - owner: string; - repo: string; + repoBasePath: string; defaultBranch: string; currentPath: string | null; expandedPaths: Set; @@ -303,8 +330,7 @@ interface TreeNodeProps { const TreeNode = memo(function TreeNode({ node, depth, - owner, - repo, + repoBasePath, defaultBranch, currentPath, expandedPaths, @@ -360,8 +386,7 @@ const TreeNode = memo(function TreeNode({ key={child.path} node={child} depth={depth + 1} - owner={owner} - repo={repo} + repoBasePath={repoBasePath} defaultBranch={defaultBranch} currentPath={currentPath} expandedPaths={expandedPaths} @@ -376,7 +401,7 @@ const TreeNode = memo(function TreeNode({ return ( { if (a.type === "dir" && b.type !== "dir") return -1; if (a.type !== "dir" && b.type === "dir") return 1; @@ -33,13 +35,15 @@ export function FileList({ items, owner, repo, currentRef }: FileListProps) { ); } + const pathPrefix = linkBase === "storage" ? `/s/${owner}/${repo}` : `/${owner}/${repo}`; + return (
{sorted.map((item) => { const href = item.type === "dir" - ? `/${owner}/${repo}/tree/${currentRef}/${encodeFilePath(item.path)}` - : `/${owner}/${repo}/blob/${currentRef}/${encodeFilePath(item.path)}`; + ? `${pathPrefix}/tree/${currentRef}/${encodeFilePath(item.path)}` + : `${pathPrefix}/blob/${currentRef}/${encodeFilePath(item.path)}`; return ( (null); @@ -42,7 +45,7 @@ export function RepoBreadcrumb({ const fetchedRef = useRef(false); const inputRef = useRef(null); - const isOrg = ownerType === "Organization"; + const isOrg = ownerType === "Organization" && !repoBasePath; const fetchOrgRepos = useCallback(async () => { if (fetchedRef.current) return; @@ -97,12 +100,18 @@ export function RepoBreadcrumb({ className="rounded-sm border border-border" /> )} - - {owner} - + {repoBasePath ? ( + + {owner} + + ) : ( + + {owner} + + )} / {isOrg ? ( @@ -215,7 +224,7 @@ export function RepoBreadcrumb({ ) : ( {repoName} diff --git a/apps/web/src/components/repo/repo-layout-wrapper.tsx b/apps/web/src/components/repo/repo-layout-wrapper.tsx index 712b180f..f072dd6c 100644 --- a/apps/web/src/components/repo/repo-layout-wrapper.tsx +++ b/apps/web/src/components/repo/repo-layout-wrapper.tsx @@ -15,6 +15,8 @@ interface RepoLayoutWrapperProps { repo: string; ownerType: string; ownerAvatarUrl?: string; + /** Passed to collapsed breadcrumb (storage: `/s/owner/repo`). */ + repoBasePath?: string; initialCollapsed?: boolean; initialWidth?: number; } @@ -34,6 +36,7 @@ export function RepoLayoutWrapper({ initialWidth = DEFAULT_WIDTH, ownerType, ownerAvatarUrl, + repoBasePath, }: RepoLayoutWrapperProps) { const pathname = usePathname(); const isPrPage = pathname.includes("/pulls/"); @@ -245,6 +248,7 @@ export function RepoLayoutWrapper({ repoName={repo} ownerType={ownerType} ownerAvatarUrl={ownerAvatarUrl} + repoBasePath={repoBasePath} />
, repoNavSlot, diff --git a/apps/web/src/components/repo/repo-nav.tsx b/apps/web/src/components/repo/repo-nav.tsx index b16830e6..3d9a50bd 100644 --- a/apps/web/src/components/repo/repo-nav.tsx +++ b/apps/web/src/components/repo/repo-nav.tsx @@ -11,6 +11,10 @@ import { useNavVisibility } from "@/components/shared/nav-visibility-provider"; interface RepoNavProps { owner: string; repo: string; + /** URL prefix for this repo, e.g. `/owner/repo` (GitHub) or `/s/owner/repo` (storage). */ + basePath?: string; + /** When false, skip live PR/issue count adjustments (storage repos). @default true */ + subscribeToRepoMutations?: boolean; openIssuesCount?: number; openPrsCount?: number; activeRunsCount?: number; @@ -24,6 +28,8 @@ interface RepoNavProps { export function RepoNav({ owner, repo, + basePath, + subscribeToRepoMutations = true, openIssuesCount, openPrsCount, activeRunsCount, @@ -34,7 +40,7 @@ export function RepoNav({ showPeopleTab, }: RepoNavProps) { const pathname = usePathname(); - const base = `/${owner}/${repo}`; + const base = basePath ?? `/${owner}/${repo}`; const containerRef = useRef(null); const [indicator, setIndicator] = useState({ left: 0, width: 0 }); const [hasAnimated, setHasAnimated] = useState(false); @@ -45,20 +51,22 @@ export function RepoNav({ }, [openPrsCount, openIssuesCount, promptRequestsCount]); useMutationSubscription( - [ - "pr:merged", - "pr:closed", - "pr:reopened", - "issue:closed", - "issue:reopened", - "issue:created", - "prompt:created", - "prompt:accepted", - "prompt:closed", - "prompt:reopened", - ], + subscribeToRepoMutations + ? [ + "pr:merged", + "pr:closed", + "pr:reopened", + "issue:closed", + "issue:reopened", + "issue:created", + "prompt:created", + "prompt:accepted", + "prompt:closed", + "prompt:reopened", + ] + : [], (event: MutationEvent) => { - if (!isRepoEvent(event, owner, repo)) return; + if (!subscribeToRepoMutations || !isRepoEvent(event, owner, repo)) return; setCountAdjustments((prev) => { switch (event.type) { case "pr:merged": diff --git a/apps/web/src/components/repo/repo-sidebar-identity.tsx b/apps/web/src/components/repo/repo-sidebar-identity.tsx new file mode 100644 index 00000000..f89d455e --- /dev/null +++ b/apps/web/src/components/repo/repo-sidebar-identity.tsx @@ -0,0 +1,58 @@ +import Image from "next/image"; +import { RepoBreadcrumb } from "@/components/repo/repo-breadcrumb"; +import { RepoBadge, type RepoBadgeProps } from "@/components/repo/repo-badge"; + +export function RepoSidebarIdentity({ + owner, + repoName, + ownerType, + ownerAvatarUrl, + description, + badges, + repoBasePath, + children, +}: { + owner: string; + repoName: string; + ownerType: string; + ownerAvatarUrl: string; + description: string | null; + badges: Array>; + repoBasePath?: string; + children?: React.ReactNode; +}) { + return ( +
+ + + {description ? ( +

+ {description} +

+ ) : null} +
+ {badges.map((b, i) => ( + + ))} +
+ {children} +
+ ); +} diff --git a/apps/web/src/components/repo/repo-sidebar.tsx b/apps/web/src/components/repo/repo-sidebar.tsx index b22d9288..7e0cf234 100644 --- a/apps/web/src/components/repo/repo-sidebar.tsx +++ b/apps/web/src/components/repo/repo-sidebar.tsx @@ -1,4 +1,3 @@ -import Image from "next/image"; import Link from "next/link"; import { GitFork, Eye, Scale, HardDrive, LinkIcon } from "lucide-react"; import { formatNumber } from "@/lib/utils"; @@ -13,7 +12,7 @@ import { SidebarLanguages } from "@/components/repo/sidebar-languages"; import { SidebarContributors } from "@/components/repo/sidebar-contributors"; import { LatestCommitSection } from "@/components/repo/latest-commit-section"; -import { RepoBreadcrumb } from "@/components/repo/repo-breadcrumb"; +import { RepoSidebarIdentity } from "@/components/repo/repo-sidebar-identity"; import type { ContributorAvatarsData } from "@/lib/repo-data-cache"; import type { ForkSyncStatus } from "@/lib/github"; @@ -97,37 +96,15 @@ export function RepoSidebar({ return ( <> {/* Desktop sidebar */} -
+ ); +} + +export function StorageRepoRefNotFoundState({ ref }: { ref: string }) { + return ( +
+

+ Branch or ref not found. +

+

+ {ref} does not exist in + this repository. +

+
+ ); +} diff --git a/apps/web/src/components/repo/storage-repo-sidebar.tsx b/apps/web/src/components/repo/storage-repo-sidebar.tsx new file mode 100644 index 00000000..45cb75dc --- /dev/null +++ b/apps/web/src/components/repo/storage-repo-sidebar.tsx @@ -0,0 +1,66 @@ +import Link from "next/link"; +import { RepoSidebarIdentity } from "@/components/repo/repo-sidebar-identity"; + +export function StorageRepoSidebar({ + owner, + repoName, + ownerType, + ownerAvatarUrl, + description, + visibility, + defaultBranch, + repoBasePath, +}: { + owner: string; + repoName: string; + ownerType: string; + ownerAvatarUrl: string; + description: string | null; + visibility: "public" | "private"; + defaultBranch: string; + repoBasePath: string; +}) { + const badges = [ + { type: visibility === "private" ? ("private" as const) : ("public" as const) }, + ]; + + return ( + + ); +} diff --git a/apps/web/src/components/repo/storage-repo-tab-placeholder.tsx b/apps/web/src/components/repo/storage-repo-tab-placeholder.tsx new file mode 100644 index 00000000..eac6bea0 --- /dev/null +++ b/apps/web/src/components/repo/storage-repo-tab-placeholder.tsx @@ -0,0 +1,10 @@ +export function StorageRepoTabPlaceholder({ label }: { label: string }) { + return ( +
+

{label}

+

+ Not available for git storage yet. +

+
+ ); +} diff --git a/apps/web/src/components/settings/tabs/account-tab.tsx b/apps/web/src/components/settings/tabs/account-tab.tsx index 8e94ee78..589289f3 100644 --- a/apps/web/src/components/settings/tabs/account-tab.tsx +++ b/apps/web/src/components/settings/tabs/account-tab.tsx @@ -4,7 +4,6 @@ import { useState, useEffect } from "react"; import { LogOut, Trash2, - Github, Shield, ExternalLink, MapPin, @@ -12,6 +11,7 @@ import { Link as LinkIcon, Calendar, } from "lucide-react"; +import { GithubIcon } from "@/components/shared/icons/github-icon"; import { cn } from "@/lib/utils"; import { signIn, signOut } from "@/lib/auth-client"; import { SCOPE_GROUPS, scopesToGroupIds } from "@/lib/github-scopes"; @@ -137,7 +137,7 @@ export function AccountTab({ user, settings, onUpdate, githubProfile }: AccountT /> ) : (
- +
)}
@@ -218,7 +218,7 @@ export function AccountTab({ user, settings, onUpdate, githubProfile }: AccountT {/* GitHub */}
- + GitHub diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx new file mode 100644 index 00000000..b1e638a1 --- /dev/null +++ b/apps/web/src/components/ui/card.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/apps/web/src/components/ui/chart.tsx b/apps/web/src/components/ui/chart.tsx new file mode 100644 index 00000000..2008522c --- /dev/null +++ b/apps/web/src/components/ui/chart.tsx @@ -0,0 +1,390 @@ +"use client"; + +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; +import type { TooltipValueType } from "recharts"; + +import { cn } from "@/lib/utils"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const; +type TooltipNameType = number | string; + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +>; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps["children"]; + initialDimension?: { + width: number; + height: number; + }; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme ?? config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( +