diff --git a/.fleet/design-review.json b/.fleet/design-review.json index 21f2bbb..e386eea 100644 --- a/.fleet/design-review.json +++ b/.fleet/design-review.json @@ -2,7 +2,7 @@ "$schema": "fleet.design-review.v1", "version": 1, "project": "starboard", - "target": "projects shared app shell", + "target": "core discovery hardening and shared repository shell", "mode": "preserve", "register": "product", "context": { @@ -14,29 +14,29 @@ "probes": [], "selected": "existing-design", "approval": "not-required", - "before": ".fleet/evidence/projects-shell/before-1440.png" + "before": ".fleet/evidence/core-discovery-hardening/before-1440.png" }, "evidence": { "screenshots": [ { "width": 390, - "path": ".fleet/evidence/projects-shell/after-390.png" + "path": ".fleet/evidence/core-discovery-hardening/after-390.png" }, { "width": 768, - "path": ".fleet/evidence/projects-shell/after-768.png" + "path": ".fleet/evidence/core-discovery-hardening/after-768.png" }, { "width": 1440, - "path": ".fleet/evidence/projects-shell/after-1440.png" + "path": ".fleet/evidence/core-discovery-hardening/after-1440.png" } ], "projectCheck": { - "command": "pnpm build", + "command": "pnpm build:cf", "status": "pass" }, "critique": { - "score": 32, + "score": 35, "maximum": 40 }, "audit": { @@ -53,7 +53,7 @@ } }, "ownerFeedback": { - "decision": "keep", - "note": "Owner approved the shared-shell Projects correction on 2026-08-09." + "decision": "delegated", + "note": "Owner delegated the final implementation and testability judgment by asking to fix the full audited surface before their own test pass." } } diff --git a/.fleet/evidence/core-discovery-hardening/after-1440.png b/.fleet/evidence/core-discovery-hardening/after-1440.png new file mode 100644 index 0000000..46f5e88 Binary files /dev/null and b/.fleet/evidence/core-discovery-hardening/after-1440.png differ diff --git a/.fleet/evidence/core-discovery-hardening/after-390.png b/.fleet/evidence/core-discovery-hardening/after-390.png new file mode 100644 index 0000000..dab4aad Binary files /dev/null and b/.fleet/evidence/core-discovery-hardening/after-390.png differ diff --git a/.fleet/evidence/core-discovery-hardening/after-768.png b/.fleet/evidence/core-discovery-hardening/after-768.png new file mode 100644 index 0000000..af7b4f1 Binary files /dev/null and b/.fleet/evidence/core-discovery-hardening/after-768.png differ diff --git a/.fleet/evidence/core-discovery-hardening/before-1440.png b/.fleet/evidence/core-discovery-hardening/before-1440.png new file mode 100644 index 0000000..dae08ed Binary files /dev/null and b/.fleet/evidence/core-discovery-hardening/before-1440.png differ diff --git a/.fleet/reviews/2026-08-09-projects-shared-app-shell.json b/.fleet/reviews/2026-08-09-projects-shared-app-shell.json new file mode 100644 index 0000000..21f2bbb --- /dev/null +++ b/.fleet/reviews/2026-08-09-projects-shared-app-shell.json @@ -0,0 +1,59 @@ +{ + "$schema": "fleet.design-review.v1", + "version": 1, + "project": "starboard", + "target": "projects shared app shell", + "mode": "preserve", + "register": "product", + "context": { + "product": "PRODUCT.md", + "design": "src/app/globals.css" + }, + "direction": { + "references": [], + "probes": [], + "selected": "existing-design", + "approval": "not-required", + "before": ".fleet/evidence/projects-shell/before-1440.png" + }, + "evidence": { + "screenshots": [ + { + "width": 390, + "path": ".fleet/evidence/projects-shell/after-390.png" + }, + { + "width": 768, + "path": ".fleet/evidence/projects-shell/after-768.png" + }, + { + "width": 1440, + "path": ".fleet/evidence/projects-shell/after-1440.png" + } + ], + "projectCheck": { + "command": "pnpm build", + "status": "pass" + }, + "critique": { + "score": 32, + "maximum": 40 + }, + "audit": { + "score": 18, + "maximum": 20 + }, + "unresolved": { + "p0": 0, + "p1": 0 + }, + "detector": { + "posture": "advisory", + "findings": [] + } + }, + "ownerFeedback": { + "decision": "keep", + "note": "Owner approved the shared-shell Projects correction on 2026-08-09." + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e97562..0a0bc04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,4 +26,8 @@ jobs: - run: pnpm test:coverage - - run: pnpm build + - run: pnpm build:e2e + + - run: pnpm exec playwright install --with-deps chromium + + - run: pnpm test:e2e diff --git a/AGENTS.md b/AGENTS.md index fad8b1f..19266ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,3 +244,13 @@ S69 Starboard — migrate deployment from Vercel/Turso to Cloudflare Workers via Access 284k tokens of past work via get_observations([IDs]) or mem-search skill. + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index d6a2384..d92c235 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -32,13 +32,13 @@ installation, alerts, reports, digest email, and stack generation. **Local dev:** `pnpm install && cp .env.example .env.local && pnpm dev` → http://localhost:3000 -**Key checks:** `pnpm test:coverage` · `pnpm build` · `pnpm build:cf` (Cloudflare path) +**Key checks:** `pnpm test:coverage` · `pnpm test:e2e` · `pnpm build:cf` (Cloudflare path) ``` GitHub OAuth (NextAuth) │ ▼ -Star sync + public project connection ──► D1 (users, repos, user_repos, user_projects, lists, comments, votes) +Star sync + public project connection ──► D1 (users, repos, user_repos, user_projects, lists) │ ├── Full-text + facet search (GET /api/stars) ├── Semantic search: knowledgebase Worker; lexical-only when shared RAG is unavailable @@ -72,6 +72,15 @@ provenance. The workflow is free and has no billing or entitlement gate. ## Timeline +- **2026-08-09 (core discovery hardening, local)** — Stabilized aborted filter + requests, added hybrid public Discover relevance with lexical fallback, + tightened peer/tool evidence thresholds, protected uncataloged previews with + the user's GitHub token, paginated Tool Intelligence evidence, moved + repository intelligence into the shared shell, and retired its discussion + controls while retaining legacy data. The production-accurate Playwright + suite now covers the Astro landing and core public journeys at desktop and + mobile widths. This work is locally testable and not yet released. + - **2026-08-09 (shared lint baseline)** — Adopted the Fleet Ultracite baseline for core TypeScript, React, Next.js, and Vitest code. Explicit compatibility exceptions preserve current behavior while 206 files pass with zero @@ -177,7 +186,7 @@ provenance. The workflow is free and has no billing or entitlement gate. | Phase | Milestone | |-------|-----------| | Foundation | GitHub OAuth (NextAuth v5), OpenNext Cloudflare deploy, core dashboard with sync, tags, collections, full-text search, virtual scroll | -| Repo intelligence | Repo detail (`/explore`), comments/votes, public shared lists, legal/marketing shell | +| Repo intelligence | Shared-shell repository detail (`/explore`), similar projects, tool evidence, public shared lists, legal/marketing shell | | Semantic search | knowledgebase Worker integration for relevance search; README-backed sync ingest; local embeddings retained for non-RAG Starboard features | | Connected projects | Shipped public GitHub project connections, public preview, GitHub picker, and evidence-based repository and tool recommendations | | Discovery & tools | Public Discover, daily bounded seed/enrich/embed with manual dispatch, stored growth sorting, and Tool Intelligence | @@ -212,7 +221,9 @@ provenance. The workflow is free and has no billing or entitlement gate. - GitHub star-list ingestion via HTML scraping where no official API exists. - Main dashboard: smart categories, custom colored tags, named collections, full-text search, language/category/tag/collection filters, sort (recently starred, most stars, recently updated, A-Z), grid/list toggle, virtual scroll for 1000+ repos. - URL-shareable filter/sort state through nuqs. -- Repo detail (`/explore`): comments, votes, likes, similar repos, list assignment, tag picker. +- Repo detail (`/explore`): public similar repositories, detected-tool evidence, + and stored star history in the shared application shell. Legacy discussion + data is retained but has no active product controls. - Public shared lists at `/lists/[slug]` with SSR and `list.json` export route. - Legal/marketing shell: about, privacy, terms, sitemap, robots, OG image, security.txt, humans.txt, PWA manifest. @@ -225,9 +236,9 @@ provenance. The workflow is free and has no billing or entitlement gate. credentials in GitHub. ### Connected projects and public preview -- Guests can preview a public GitHub repository without sign-in or a user-data - write, then carry the normalized repository through sign-in for explicit - connection. +- Guests can preview a cataloged public GitHub repository without sign-in or a + user-data write. Uncataloged lookups require the existing GitHub session token + and preserve the repository through sign-in for explicit connection. - Authenticated users can connect and disconnect public GitHub repositories by URL or an on-demand public-repository picker without a broader OAuth scope. - `user_projects` isolates project connections per user while reusing shared @@ -238,13 +249,15 @@ provenance. The workflow is free and has no billing or entitlement gate. ### Discovery and intelligence surfaces - Public Discover page and `/api/discover` for the seeded popular repository - corpus; authentication adds saved state and collection controls but is not - required to browse, search, sort, filter, paginate, or open repo details. + corpus; relevance search fuses bounded semantic and lexical candidates and + falls back honestly to lexical search. Authentication adds saved state and + collection controls but is not required to browse, search, sort, filter, + paginate, or open repo details. - Discover supports paginated 30-day growth ordering and detected-tool facets from indexed local snapshot/tool tables. - Daily bounded GitHub Actions seed/enrich popular repos in D1 and embed through native Worker bindings; manual dispatch remains available for operator checks. - Star history and fastest-grower APIs/surfaces: `/api/repos/[repoId]/star-history`, `/api/growth`, Discover growth sorting, and repo-detail mini history from stored `repo_star_snapshots`. -- Tool Intelligence: additive `repo_tools` index, `/api/tools`, `/api/repos/[repoId]/tools`, `/tools`, and `pnpm db:enrich-tools` for bounded SBOM/tree/manifest-based detection with source/confidence labels. Accuracy disclaimer is shown in-product because manifest/SBOM evidence is stronger than README/topic/metadata inference and C/C++ monorepos vary. +- Tool Intelligence: additive `repo_tools` index, `/api/tools`, `/api/repos/[repoId]/tools`, `/tools`, and `pnpm db:enrich-tools` for bounded SBOM/tree/manifest-based detection with source/confidence labels. Repository evidence is server-filtered and paginated in 48-item pages. Accuracy disclaimer is shown in-product because manifest/SBOM evidence is stronger than README/topic/metadata inference and C/C++ monorepos vary. - SaaS Maker feedback widget integrated; product analytics run directly through PostHog. - First-run UX, sample prioritized stars board, semantic search, GitHub permission trust note, and public discovery paths remain. @@ -253,7 +266,9 @@ provenance. The workflow is free and has no billing or entitlement gate. - Shared Ultracite lint baseline with a clean 206-file check. - Checked-in `.env.example` documents required local variables without secrets. -- Vitest unit tests with v8 coverage thresholds (80% lines/functions/statements, 70% branches) on `github-projects`, `project-recommendations`, `search`, and `starboard-rag-documents`; Playwright e2e path documented in README. +- Vitest unit tests with v8 coverage thresholds (80% lines/functions/statements, + 70% branches) on selected core logic; production-accurate Playwright journeys + cover the Astro landing and public app at desktop and mobile widths. - Pre-push lint hook. - TypeScript config and Astro landing tooling made self-contained for green Cloudflare builds. diff --git a/README.md b/README.md index 0a6e5ba..34ecf9b 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,9 @@ matches, and keeps personal stars searchable with tags and collections. ## Features -- **Public Project Preview** — paste a public GitHub repository and inspect a - read-only recommendation sample before sign-in +- **Public Project Preview** — inspect a read-only recommendation sample for a + cataloged repository before sign-in; uncataloged lookups use the user's + existing GitHub session - **GitHub OAuth** — Sign in and sync your starred repos - **Smart Categories** — Auto-categorize repos (AI/ML, Frontend, DevOps, etc.) - **Custom Tags** — Create and assign colored tags to repos diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md index 199982d..7c8305d 100644 --- a/docs/architecture/data-flow.md +++ b/docs/architecture/data-flow.md @@ -31,7 +31,8 @@ history and remains empty when insufficient samples exist. ```text public GitHub URL or owner/repository → normalize and check the local catalog - → resolve one public GitHub repository on a catalog miss + → require the existing GitHub session token on a catalog miss + → resolve one public GitHub repository with authenticated quota → public preview: no user-owned write → authenticated connection only after explicit confirmation → upsert shared repos row diff --git a/docs/development/commands.md b/docs/development/commands.md index 185d069..9cd3d4d 100644 --- a/docs/development/commands.md +++ b/docs/development/commands.md @@ -23,6 +23,7 @@ Source of truth: `scripts` in `package.json`. This page annotates intent; run | --- | --- | | `pnpm cf:build` | `next build --webpack` + inline critical CSS + `opennextjs-cloudflare build --skipNextBuild` + `populateCache local` + landing-astro build + overlay | | `pnpm build:cf` | Alias for `cf:build` | +| `pnpm build:e2e` | Credential-free OpenNext build plus Astro landing overlay for browser tests | | `pnpm preview:cf` | `build:cf` + `opennextjs-cloudflare preview` | | `pnpm deploy:cf` | `build:cf` + SHA-tagged `opennextjs-cloudflare deploy` (manual) | | `pnpm cf:typegen` | `wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts` | diff --git a/docs/development/testing.md b/docs/development/testing.md index bee6049..137c5fa 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -34,8 +34,19 @@ pnpm test:coverage # vitest run --coverage ## End-to-end (Playwright) - **Runner:** Playwright. Config in `playwright.config.ts`. Tests in `e2e/`. -- `pnpm test:e2e` — desktop project. -- `pnpm test:e2e:mobile` — mobile project. +- `pnpm test:e2e` — runs `build:e2e` locally to produce a credential-free + OpenNext artifact, overlays the real Astro landing, then exercises landing + and mocked public-product journeys at desktop and mobile widths. CI builds + that artifact once before starting Playwright. +- Playwright starts the artifact with `wrangler.e2e.jsonc`, a local-only + binding set and applies migrations to its disposable local D1 before the + preview starts. The config omits Cloudflare AI, Vectorize, and service + bindings, so the suite needs no operator credentials and cannot call those + production resources. +- Covered journeys: public project CTA, Discover search stability, bounded Tool + Intelligence pagination, the shared repository-intelligence shell, and the + uncataloged-preview sign-in boundary. +- `pnpm test:e2e:mobile` — the mobile Next.js application journeys only. ## Smoke checks @@ -47,7 +58,7 @@ pnpm test:coverage # vitest run --coverage ## CI - `.github/workflows/ci.yml` — push/PR: `pnpm install --frozen-lockfile` → - `lint` → `test:coverage` → `build`. + `lint` → `test:coverage` → `build:e2e` → Chromium install → `test:e2e`. - `.github/workflows/weekly.yml` — Mondays 09:00 UTC: lint, typecheck, test, build (catches drift that doesn't surface on push CI). - `.github/workflows/docs.yml` — push/PR on docs-touching paths: runs diff --git a/docs/operations/ci-cd.md b/docs/operations/ci-cd.md index 999ca14..64b1299 100644 --- a/docs/operations/ci-cd.md +++ b/docs/operations/ci-cd.md @@ -7,7 +7,11 @@ in [jobs.md](jobs.md); this page covers the push/PR and deploy pipelines. - **Triggers:** push to `main`/`master`; PRs against `main`/`master`. - **Steps:** checkout → pnpm setup → Node 22 → `pnpm install --frozen-lockfile` - → `pnpm lint` → `pnpm test:coverage` → `pnpm build`. + → `pnpm lint` → `pnpm test:coverage` → `pnpm build:e2e` → Chromium install + → `pnpm test:e2e`. `build:e2e` creates the OpenNext and Astro-overlaid + artifact without Cloudflare cache population or operator credentials. CI + previews that artifact so the browser suite verifies the landing and app + together. - **Permissions:** default. ## Deploy (`.github/workflows/deploy.yml`) diff --git a/docs/operations/data-map.md b/docs/operations/data-map.md index 0c46ff7..f3b4b04 100644 --- a/docs/operations/data-map.md +++ b/docs/operations/data-map.md @@ -32,7 +32,7 @@ retirement requires separate approval. | D1 `user_repos` (starred/saved state) | irreplaceable-user | GitHub sync via `/api/stars/sync` | Re-sync from GitHub starred list (ETag + HTML scrape) | ~seconds per user | 2026-08-02 | | D1 `user_projects` | irreplaceable-user | Project connection UI/API | Reconnect public GitHub repositories manually | ~seconds per user | 2026-08-08 | | D1 `user_lists`, `user_repo_lists` | irreplaceable-user | User UI actions | Not reconstructable — user-curated collections | n/a — export required | 2026-08-02 | -| D1 `comments`, `likes`, `comment_votes` | irreplaceable-user | User UI actions | Not reconstructable — user-generated content | n/a — export required | 2026-08-02 | +| D1 `comments`, `likes`, `comment_votes` | historical inactive user storage | Removed discussion UI; legacy user actions | Not reconstructable — retained for export until a separately approved retention migration | n/a — export required | 2026-08-09 | | D1 `repo_embeddings` hashes + Vectorize `starboard-repos` values | derived | Worker binding embedding jobs | Re-embed from `repos` + `repo_ai_metadata` text via Workers AI | ~minutes (Workers AI quota) | 2026-08-02 | | D1 `repo_ai_metadata` | derived | `scripts/enrich-repos.ts` (free-ai gateway) | Re-enrich from `repos` metadata via AI | ~minutes per batch | 2026-08-02 | | D1 `repo_tools` | derived | `scripts/enrich-tools.ts` | Re-detect from GH tree/manifest/SBOM | ~minutes per batch | 2026-08-02 | diff --git a/docs/product/features.md b/docs/product/features.md index 9462bf2..52c21d9 100644 --- a/docs/product/features.md +++ b/docs/product/features.md @@ -5,9 +5,10 @@ reasons, see [../architecture/decisions/](../architecture/decisions/). ## Project-aware discovery -- Guests can preview a public GitHub repository before sign-in. Cataloged - repositories use stored evidence; uncataloged repositories use one public - GitHub metadata lookup. Preview creates no user-owned row. +- Guests can preview a cataloged public GitHub repository before sign-in using + stored evidence. An uncataloged lookup requires the user's existing GitHub + session token so anonymous traffic cannot exhaust a shared quota. Preview + creates no user-owned row in either case. - Authenticated users can connect a public GitHub repository by URL or `owner/repository`, or choose from a bounded on-demand list of public GitHub repositories, without broadening the current OAuth scope. @@ -27,10 +28,13 @@ reasons, see [../architecture/decisions/](../architecture/decisions/). ## Public discovery and tool intelligence -- Discover is public and supports search, pagination, language facets, detected - tool facets, and stored 30-day growth ordering. +- Discover is public and supports hybrid semantic-plus-lexical relevance + search with lexical fallback, pagination, language facets, detected-tool + facets, and stored 30-day growth ordering. - Tool Intelligence aggregates normalized `repo_tools` records across the seeded corpus and, for authenticated users, the personal library. +- Tool evidence is paginated in bounded 48-repository pages and filtered on the + server, avoiding a 500-card initial render. - Tool detections preserve category, confidence, and source evidence. The UI states that detection is evidence-based but not guaranteed complete. - Repository detail pages include tool evidence, similar repositories, and @@ -59,8 +63,9 @@ reasons, see [../architecture/decisions/](../architecture/decisions/). - Daily and manually dispatched seed, metadata-enrichment, tool-enrichment, and embedding jobs with explicit per-run bounds. - Additive ordered D1 migrations; raw SQL with no ORM. -- Vitest unit/integration coverage, Playwright path, Biome checks, docs - validation, and OpenNext Cloudflare builds. +- Selective core-logic Vitest coverage, Playwright journeys against the actual + Astro-overlaid Cloudflare preview, Biome checks, docs validation, and + OpenNext Cloudflare builds. ## Deliberately removed diff --git a/docs/product/overview.md b/docs/product/overview.md index 3360043..6faf1bf 100644 --- a/docs/product/overview.md +++ b/docs/product/overview.md @@ -7,8 +7,10 @@ developer's public GitHub projects to a seeded open-source repository catalog, then explains which repositories and tools may fit the work being built. Similar repositories form the grounding layer: tool recommendations come only from normalized detections on those peers and name their repository sources. -Guests can preview this value with a public repository before sign-in; a durable -project connection remains an explicit authenticated action. +Guests can preview this value for repositories already in the public catalog. +An uncataloged repository lookup uses the user's existing authenticated GitHub +session rather than a shared anonymous quota. A durable project connection +remains a separate explicit action. The product also keeps a personal starred-repository library with search, lists, tags, saved state, and notes. Discover and Tool Intelligence remain diff --git a/docs/product/surfaces.md b/docs/product/surfaces.md index e93ff3d..0b9b965 100644 --- a/docs/product/surfaces.md +++ b/docs/product/surfaces.md @@ -5,7 +5,7 @@ | Route | Purpose | Access | | --- | --- | --- | | `/` | Product landing page | Public | -| `/project-preview` | Read-only project-context preview before sign-in | Public | +| `/project-preview` | Read-only project-context preview; catalog hits are public and uncataloged lookups require GitHub sign-in | Conditional public | | `/discover` | Search and filter the seeded repository corpus | Public | | `/explore/[...slug]` | Repository detail, tool evidence, history, and similar repos | Public read; signed-in actions | | `/tools` | Aggregate tool intelligence | Public | diff --git a/e2e/landing-mobile.spec.ts b/e2e/landing-mobile.spec.ts index 0fb2c92..386e2da 100644 --- a/e2e/landing-mobile.spec.ts +++ b/e2e/landing-mobile.spec.ts @@ -1,30 +1,24 @@ import { expect, test } from '@playwright/test'; -/** - * Mobile-viewport smoke test for the public landing page. - * - * Runs under both the `desktop` and `mobile` Playwright projects (see - * playwright.config.ts). The `mobile` project uses a 390px iPhone 13 - * viewport — the fleet mobile target — so layout regressions there fail CI. - * - * The primary signed-in flow (the virtualized repo grid) is verified manually - * against the mobile conventions doc. - */ -test.describe('landing page', () => { +test.describe('production Astro landing page', () => { test('renders the hero and key sections with no horizontal scroll', async ({ page }) => { await page.goto('/'); - // Hero value prop is visible. await expect( - page.getByRole('heading', { name: 'GitHub stars, ranked for the work you ship.', level: 1 }) + page.getByRole('heading', { + name: 'Start with your project. Find the tools that fit it.', + level: 1, + }) + ).toBeVisible(); + await expect( + page.getByText(/paste a public github repository\. starboard finds/i) ).toBeVisible(); - await expect(page.getByText(/connect a public github project/i)).toBeVisible(); - // The single primary CTA is reachable. - await expect(page.getByRole('button', { name: /continue with github/i })).toBeVisible(); + const repository = page.getByRole('textbox', { name: /public github repository/i }); + await expect(repository).toBeVisible(); + await expect(page.getByRole('button', { name: /preview project/i })).toBeVisible(); - // The guest alternative must lead to a genuinely public surface. - await expect(page.getByRole('link', { name: /browse public repos/i })).toHaveAttribute( + await expect(page.getByRole('link', { name: /browse the public catalog/i })).toHaveAttribute( 'href', '/discover' ); @@ -38,10 +32,17 @@ test.describe('landing page', () => { test('the primary CTA is a large enough touch target', async ({ page }) => { await page.goto('/'); - const cta = page.getByRole('button', { name: /continue with github/i }); + const cta = page.getByRole('button', { name: /preview project/i }); const box = await cta.boundingBox(); expect(box).not.toBeNull(); - // WCAG 2.5.5 / iOS HIG: tap targets must be at least 44x44px. expect(box!.height).toBeGreaterThanOrEqual(44); }); + + test('submits a repository through the public preview route', async ({ page }) => { + await page.goto('/'); + await page.getByRole('textbox', { name: /public github repository/i }).fill('acme/store'); + await page.getByRole('button', { name: /preview project/i }).click(); + + await expect(page).toHaveURL(/\/project-preview\?repository=acme%2Fstore$/); + }); }); diff --git a/e2e/public-app.spec.ts b/e2e/public-app.spec.ts new file mode 100644 index 0000000..84cb694 --- /dev/null +++ b/e2e/public-app.spec.ts @@ -0,0 +1,229 @@ +import { expect, type Page, test } from '@playwright/test'; + +const repo = { + id: 1, + name: 'starboard', + full_name: 'codevetter/starboard', + owner_login: 'codevetter', + owner_avatar: 'https://avatars.githubusercontent.com/u/1?v=4', + owner: { login: 'codevetter', avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4' }, + html_url: 'https://github.com/codevetter/starboard', + description: 'Project-aware open-source discovery and tool intelligence.', + language: 'TypeScript', + stargazers_count: 12_500, + archived: false, + topics: ['github', 'discovery'], + repo_created_at: '2026-01-01T00:00:00Z', + repo_updated_at: '2026-08-01T00:00:00Z', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', + list_id: null, + collection_ids: [], + tags: [], + notes: null, + starred_at: null, + is_starred: false, + is_saved: false, + star_growth_30d: 420, +}; + +async function mockGuestSession(page: Page) { + await page.route('**/api/auth/session', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: 'null' }) + ); +} + +test.beforeEach(async ({ page }) => { + await mockGuestSession(page); +}); + +test('Discover keeps the first search request alive and shows matching repositories', async ({ + page, +}) => { + await page.route('**/discover/data**', (route) => + route.fulfill({ + json: { + repos: [repo], + total: 1, + facets: { + languages: [['TypeScript', 1]], + lists: [], + tags: [], + tools: [{ key: 'nextjs', name: 'Next.js', count: 1 }], + }, + minStars: 5000, + }, + }) + ); + + await page.goto('/discover'); + await page.getByPlaceholder('Search repos...').fill('tool intelligence'); + + await expect(page.getByRole('link', { name: /codevetter\/starboard/i })).toBeVisible(); + await expect(page.getByText(/couldn't load discover results/i)).toHaveCount(0); + await expect(page).toHaveURL(/q=tool\+intelligence|q=tool%20intelligence/); + + if ((page.viewportSize()?.width ?? 0) >= 1024) { + const repositoryCount = page.getByRole('button', { + name: /how discover selects repositories/i, + }); + await repositoryCount.hover(); + await expect( + page.getByText(/discover starts with public github repositories at 5,000\+ stars/i) + ).toBeVisible(); + } +}); + +test('tool intelligence renders one bounded page and loads more on demand', async ({ page }) => { + await page.route('**/api/tools?**', async (route) => { + const url = new URL(route.request().url()); + const offset = Number(url.searchParams.get('offset') ?? 0); + const count = offset === 0 ? 48 : 1; + const repos = Array.from({ length: count }, (_, index) => { + const id = offset + index + 1; + return { + ...repo, + id, + name: `repo-${id}`, + full_name: `acme/repo-${id}`, + html_url: `https://github.com/acme/repo-${id}`, + tool: { + toolKey: 'vitest', + toolName: 'Vitest', + category: 'testing', + url: 'https://vitest.dev', + confidence: 96, + sources: ['package.json'], + }, + }; + }); + await route.fulfill({ + json: { + scope: 'discover', + disclaimer: 'Detections come from repository manifests and may need verification.', + tool: { + toolKey: 'vitest', + toolName: 'Vitest', + category: 'testing', + url: 'https://vitest.dev', + repoCount: 49, + avgConfidence: 96, + maxConfidence: 100, + }, + repos, + page: { offset, limit: 48, hasMore: offset === 0 }, + }, + }); + }); + + await page.goto('/tools/vitest'); + await expect(page.getByRole('heading', { name: 'Vitest', level: 1 })).toBeVisible(); + await expect(page.getByText('Showing 48 of 49 matching repositories.')).toBeVisible(); + await expect(page.getByRole('link', { name: /^acme\/repo-/ })).toHaveCount(48); + + await page.getByRole('button', { name: 'Load 48 more' }).click(); + await expect(page.getByText('Showing 49 of 49 matching repositories.')).toBeVisible(); + await expect(page.getByRole('link', { name: /^acme\/repo-/ })).toHaveCount(49); +}); + +test('repository intelligence reuses the app shell and omits discussion controls', async ({ + page, +}, testInfo) => { + await page.route('**/api/repos/lookup?**', (route) => route.fulfill({ json: { repo } })); + await page.route('**/api/repos/1/similar?**', (route) => + route.fulfill({ + json: { + similar: [ + { + ...repo, + id: 2, + name: 'peer', + full_name: 'acme/peer', + html_url: 'https://github.com/acme/peer', + similarity: 0.88, + }, + ], + }, + }) + ); + await page.route('**/api/repos/1/tools', (route) => + route.fulfill({ + json: { + disclaimer: 'Manifest evidence', + tools: [ + { + toolKey: 'vitest', + toolName: 'Vitest', + category: 'testing', + url: 'https://vitest.dev', + confidence: 96, + sources: ['package.json'], + }, + ], + }, + }) + ); + await page.route('**/api/repos/1/star-history?**', (route) => + route.fulfill({ + json: { + points: [ + { stargazersCount: 12_000, capturedAt: '2026-07-01T00:00:00Z' }, + { stargazersCount: 12_500, capturedAt: '2026-08-01T00:00:00Z' }, + ], + growth: { starsGained: 500, percentGrowth: 4.2, enoughHistory: true }, + }, + }) + ); + + await page.goto('/explore/codevetter/starboard'); + await expect(page.getByRole('heading', { name: 'Repository Intelligence' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'starboard', level: 1 })).toBeVisible(); + if ((page.viewportSize()?.width ?? 0) < 640) { + await expect(page.getByRole('button', { name: 'Open product navigation' })).toBeVisible(); + } else { + await expect(page.getByRole('link', { name: 'Discover', exact: true })).toBeVisible(); + } + await expect(page.getByRole('heading', { name: 'Similar projects' })).toBeVisible(); + await expect(page.getByText('Discussion', { exact: true })).toHaveCount(0); + + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth + ); + expect(overflow).toBe(false); + + if (!process.env.CI && testInfo.project.name === 'desktop') { + for (const width of [390, 768, 1440]) { + await page.setViewportSize({ width, height: 900 }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth + ) + ).toBe(false); + await page.screenshot({ + path: `.fleet/evidence/core-discovery-hardening/after-${width}.png`, + fullPage: true, + }); + } + } +}); + +test('uncataloged public preview asks for sign-in without presenting a rate-limit failure', async ({ + page, +}) => { + await page.route('**/api/project-preview?**', (route) => + route.fulfill({ + status: 401, + json: { + error: 'Sign in to preview a repository that is not in the Starboard catalog yet.', + loginRequired: true, + }, + }) + ); + + await page.goto('/project-preview?repository=acme%2Fprivate-to-catalog'); + await expect(page.locator('section[role="alert"]')).toContainText( + 'Sign in to preview a repository that is not in the Starboard catalog yet.' + ); + await expect(page.getByRole('link', { name: 'Sign in to preview' })).toBeVisible(); + await expect(page.getByText(/rate limit/i)).toHaveCount(0); +}); diff --git a/landing-astro/src/pages/index.astro b/landing-astro/src/pages/index.astro index 1f9b159..e0fd552 100644 --- a/landing-astro/src/pages/index.astro +++ b/landing-astro/src/pages/index.astro @@ -82,7 +82,7 @@ const DESCRIPTION =

- No sign-in to preview · public repositories only · nothing saved until you connect + Catalog matches preview without sign-in · uncataloged repositories use your signed-in GitHub session · nothing saved until you connect

@@ -194,7 +194,7 @@ const DESCRIPTION =
  • 1

    Preview a project

    -

    Paste any public GitHub repository. No account or write happens yet.

    +

    Paste a public GitHub repository. Catalog matches need no account; new lookups use your signed-in GitHub session. Nothing is saved yet.

  • 2 diff --git a/next.config.ts b/next.config.ts index d4e8e3f..296777a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -16,7 +16,7 @@ const securityHeaders = [ "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://us-assets.i.posthog.com", // unsafe-inline/eval required by Next.js "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https://avatars.githubusercontent.com https://github.com", - "connect-src 'self' https://api.github.com https://us.i.posthog.com https://us-assets.i.posthog.com", + "connect-src 'self' https://api.github.com https://us.i.posthog.com https://us-assets.i.posthog.com https://vitals.fleet.workers.dev", "frame-ancestors 'none'", ].join('; '), }, diff --git a/openspec/changes/prove-project-recommendation-value/.openspec.yaml b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/.openspec.yaml similarity index 100% rename from openspec/changes/prove-project-recommendation-value/.openspec.yaml rename to openspec/changes/archive/2026-08-09-prove-project-recommendation-value/.openspec.yaml diff --git a/openspec/changes/prove-project-recommendation-value/design.md b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/design.md similarity index 72% rename from openspec/changes/prove-project-recommendation-value/design.md rename to openspec/changes/archive/2026-08-09-prove-project-recommendation-value/design.md index 171ad66..bfff22f 100644 --- a/openspec/changes/prove-project-recommendation-value/design.md +++ b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/design.md @@ -66,11 +66,13 @@ not depend on Workers AI availability. The static landing submits a GET form to `/project-preview`. The Next.js preview surface calls a public GET API with the normalized public repository value. -Resolution checks the local catalog first; only a cache miss calls GitHub's -single-repository endpoint, with bounded caching and no token for guests. The -response never writes `user_projects` or other user-owned tables. The connect -CTA signs in and returns to `/projects?repository=...`, where the manual form is -prefilled for an explicit final connection. +Resolution checks the local catalog first. Catalog hits remain public. A cache +miss no longer calls GitHub anonymously from the shared Worker egress path: an +authenticated session token may resolve the repository, while a guest receives +a sign-in handoff that preserves the repository value. The response never +writes `user_projects` or other user-owned tables. The connect CTA signs in and +returns to `/projects?repository=...`, where the manual form is prefilled for an +explicit final connection. Alternative: connect automatically after OAuth. Rejected because the user should still see and confirm the durable write boundary. @@ -104,11 +106,11 @@ dashboard. - **Workers AI or Vectorize is temporarily unavailable** → Continue through FTS and language candidates, report the retrieval mode, and never turn the error into a failed project connection. -- **Unauthenticated GitHub quota is exhausted or preview is abused** → Resolve - cataloged repositories without GitHub, cache successful public resolution, - make exactly one repository request on a miss, and return a retryable state. - A broader edge rate limiter is explicitly outside this change because it - previously affected unrelated product requests. +- **Unauthenticated GitHub quota is exhausted or preview is abused** → Never + spend shared anonymous GitHub quota on a guest request. Resolve cataloged + repositories locally and require the existing minimal GitHub session for an + uncataloged lookup. A broader edge rate limiter remains outside this change + because it previously affected unrelated product requests. - **Minimal OAuth token returns fewer repositories than expected** → Present the picker as a convenience, label it public-only, and keep manual URL entry primary and fully functional. @@ -127,3 +129,33 @@ dashboard. and responsive visual review before release. 4. Roll back by reverting this change; it adds no schema, dependency, OAuth scope, secret, or production binding migration. + +## Hardening decisions after production audit + +### 7. Search requests own only their request lifetime + +The SWR fetcher aborts the previous request before installing a new controller. +Filter effects reset pagination but never abort the request that SWR just +started. Discover fuses bounded Vectorize matches with FTS matches and degrades +to lexical results when Workers AI or Vectorize is unavailable. + +### 8. Recommendation evidence has an admission floor + +Primary-language equality remains contextual evidence but cannot admit a peer +by itself. A peer needs a meaningful shared topic, exact tool, tool-area, or +multiple contextual signals. Tool additions exclude language metadata and +low-confidence detections, and need corroboration from at least two admitted +peers. + +### 9. Large evidence collections paginate at the API boundary + +Tool detail requests use a small bounded page with offset metadata and an +explicit Load more action. Filtering is server-side so pagination does not make +search incomplete. The DOM therefore grows only when the user asks for more. + +### 10. Core product routes share one shell + +Repository detail uses `AppShell` and `TopBar`, exposes a real main landmark and +page heading, and returns guests to Discover. The discussion UI is removed so +the surface focuses on repository evidence, similar projects, and detected +tools; existing database records are preserved. diff --git a/openspec/changes/prove-project-recommendation-value/proposal.md b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/proposal.md similarity index 78% rename from openspec/changes/prove-project-recommendation-value/proposal.md rename to openspec/changes/archive/2026-08-09-prove-project-recommendation-value/proposal.md index 56b6238..54c3946 100644 --- a/openspec/changes/prove-project-recommendation-value/proposal.md +++ b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/proposal.md @@ -25,6 +25,17 @@ and make the grounding set representative of the full eligible catalog. recommendation sentiment. No repository identity or query text is emitted. - Clarify tool cards with support strength, source confidence, and a direct path to the existing Tool Intelligence detail surface. +- Harden the shipped first-value path: prevent client searches from cancelling + themselves, combine semantic and lexical Discover candidates, and keep an + uncataloged guest preview from consuming shared unauthenticated GitHub quota. +- Tighten the recommendation contract so primary-language overlap alone cannot + admit a peer and language or weak one-source detections cannot become tool + recommendations. +- Paginate repository evidence on Tool Intelligence detail pages and bring the + core repository-detail route into the shared application shell. +- Remove the peripheral repository discussion UI, repair production telemetry, + and add browser journeys for the actual Astro landing plus core public app + surfaces. - Update active product/status documentation and add a deterministic retrieval evaluation fixture for the core recommendation path. @@ -53,4 +64,4 @@ and make the grounding set representative of the full eligible catalog. - Reuses the existing D1 full-text indexes, Cloudflare Vectorize binding, Workers AI embedding adapter, GitHub OAuth token, and UI components. - Adds no production dependency, billing/entitlement gate, private-repository - scope, raw-SQL proxy, deployment, or production configuration change. + scope, raw-SQL proxy, deployment, secret, or production binding change. diff --git a/openspec/changes/prove-project-recommendation-value/specs/github-project-connections/spec.md b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/github-project-connections/spec.md similarity index 100% rename from openspec/changes/prove-project-recommendation-value/specs/github-project-connections/spec.md rename to openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/github-project-connections/spec.md diff --git a/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/public-discovery/spec.md b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/public-discovery/spec.md new file mode 100644 index 0000000..52a8469 --- /dev/null +++ b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/public-discovery/spec.md @@ -0,0 +1,44 @@ +## MODIFIED Requirements + +### Requirement: Public landing handoff + +The Starboard landing page SHALL make a public project preview the primary +first-value action and SHALL preserve direct public Discover browsing as an +alternative. + +#### Scenario: Guest submits a cataloged public project + +- **WHEN** a visitor enters a cataloged GitHub URL or `owner/repository` on the + landing page +- **THEN** the visitor reaches a working read-only project preview without + first being required to sign in + +#### Scenario: Guest submits an uncataloged public project + +- **WHEN** a visitor enters a repository that is not yet in Starboard's catalog +- **THEN** Starboard preserves the repository and asks for GitHub sign-in before + making the external lookup + +#### Scenario: Guest follows the public CTA + +- **WHEN** a visitor selects the landing-page public browsing action +- **THEN** the visitor reaches `/discover` and can use the shared corpus + +## ADDED Requirements + +### Requirement: Public search is stable and meaning-aware + +Discover SHALL combine bounded semantic and lexical candidates for relevance +search and SHALL NOT surface an error merely because its own filter state +changed. + +#### Scenario: Visitor changes a search or filter + +- **WHEN** a new first-page request replaces an older Discover request +- **THEN** Starboard cancels only the stale request and renders the newest result + without requiring a manual retry + +#### Scenario: Semantic retrieval is unavailable + +- **WHEN** Workers AI or Vectorize cannot serve a Discover query +- **THEN** Starboard returns bounded lexical results and does not fail the page diff --git a/openspec/changes/prove-project-recommendation-value/specs/public-project-preview/spec.md b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/public-project-preview/spec.md similarity index 83% rename from openspec/changes/prove-project-recommendation-value/specs/public-project-preview/spec.md rename to openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/public-project-preview/spec.md index b93829d..df0dc4e 100644 --- a/openspec/changes/prove-project-recommendation-value/specs/public-project-preview/spec.md +++ b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/public-project-preview/spec.md @@ -20,8 +20,14 @@ preview without creating user-owned data. #### Scenario: Uncataloged public repository - **WHEN** a guest previews a valid public repository not yet in the catalog -- **THEN** Starboard resolves its public GitHub metadata and shows the best - available explained preview without persisting a project connection +- **THEN** Starboard asks for the existing minimal GitHub sign-in, preserves the + repository value, and performs no anonymous GitHub API lookup + +#### Scenario: Signed-in uncataloged public repository + +- **WHEN** a signed-in user previews a public repository not yet in the catalog +- **THEN** Starboard resolves it with the user's existing GitHub token and shows + the best available explained preview without persisting a project connection #### Scenario: Invalid or unavailable repository diff --git a/openspec/changes/prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md similarity index 76% rename from openspec/changes/prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md rename to openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md index 3f84dbc..4d833dc 100644 --- a/openspec/changes/prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md +++ b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md @@ -25,6 +25,17 @@ recommended tool only from normalized detections on those ranked peers. - **THEN** Starboard recommends those tools with the exact peer repositories and detection confidence that support the recommendation +#### Scenario: Only primary language overlaps + +- **WHEN** a candidate shares only the connected project's primary language +- **THEN** Starboard does not admit it as a grounded similar project + +#### Scenario: Tool evidence is weak or metadata-only + +- **WHEN** a peer detection is repository language metadata, below the grounded + confidence floor, or supported by only one peer +- **THEN** Starboard does not present it as a tool to evaluate + #### Scenario: Tool is already used by the connected project - **WHEN** a peer repository contains a tool already detected in the connected @@ -66,3 +77,14 @@ not useful without sending repository identity or free-form text to analytics. - **WHEN** a user selects useful or not useful - **THEN** Starboard records only recommendation kind, sentiment, rank bucket, retrieval mode, and aggregate evidence buckets + +### Requirement: Tool evidence collections remain bounded + +Tool Intelligence SHALL paginate repository evidence and SHALL apply repository +filtering before pagination. + +#### Scenario: A tool appears in hundreds of repositories + +- **WHEN** a user opens that tool's detail page +- **THEN** Starboard renders one bounded page and offers an explicit way to load + more without placing the full collection in the DOM diff --git a/openspec/changes/prove-project-recommendation-value/tasks.md b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/tasks.md similarity index 61% rename from openspec/changes/prove-project-recommendation-value/tasks.md rename to openspec/changes/archive/2026-08-09-prove-project-recommendation-value/tasks.md index ea51f25..8d8d4d1 100644 --- a/openspec/changes/prove-project-recommendation-value/tasks.md +++ b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/tasks.md @@ -27,4 +27,17 @@ - [x] 5.1 Update current product, status, architecture, route, and analytics documentation; remove remaining shipped-state and digest-era inconsistencies. - [x] 5.2 Run targeted tests, typecheck, lint, docs checks, strict OpenSpec validation, and the production Cloudflare build. -- [ ] 5.3 Complete preserve-lane responsive review at 390, 768, and 1440 pixels, critique, polish, audit, detector review, and the design receipt. +- [x] 5.3 Complete preserve-lane responsive review at 390, 768, and 1440 pixels, critique, polish, audit, detector review, and the design receipt. + +## 6. Production-audit hardening + +- [x] 6.1 Fix Discover and Library request cancellation and add a regression guard for stable filter changes. +- [x] 6.2 Fuse bounded semantic and lexical candidates for public Discover with an honest lexical fallback. +- [x] 6.3 Require meaningful peer evidence, exclude language and weak detections, and require corroboration for grounded tool recommendations. +- [x] 6.4 Keep catalog previews public while requiring the existing GitHub session token for uncataloged lookups; preserve the post-login repository handoff. +- [x] 6.5 Add bounded API pagination and server-side filtering to Tool Intelligence repository evidence. +- [x] 6.6 Move repository detail into the shared shell, repair landmarks/headings/back navigation, and remove the peripheral discussion UI without deleting stored data. +- [x] 6.7 Prevent the signed-out Projects workspace flash and repair Web Vitals CSP/version observability. +- [x] 6.8 Expand Playwright coverage to the actual Astro landing and mocked core public journeys at desktop and mobile widths. +- [x] 6.9 Remove strict-Knip findings and the non-compliant bounce easing advisory. +- [x] 6.10 Run targeted tests after each lane, then full lint, typecheck, coverage, E2E, docs, strict Knip, Cloudflare build, diff validation, and production-profile browser review. diff --git a/openspec/changes/prove-project-recommendation-value/specs/public-discovery/spec.md b/openspec/changes/prove-project-recommendation-value/specs/public-discovery/spec.md deleted file mode 100644 index 902d85a..0000000 --- a/openspec/changes/prove-project-recommendation-value/specs/public-discovery/spec.md +++ /dev/null @@ -1,19 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Public landing handoff - -The Starboard landing page SHALL make a public project preview the primary -first-value action and SHALL preserve direct public Discover browsing as an -alternative. - -#### Scenario: Guest submits a public project - -- **WHEN** a visitor enters a GitHub URL or `owner/repository` on the landing - page -- **THEN** the visitor reaches a working read-only project preview without - first being required to sign in - -#### Scenario: Guest follows the public browsing alternative - -- **WHEN** a visitor selects the landing-page public browsing action -- **THEN** the visitor reaches `/discover` and can use the shared corpus diff --git a/openspec/specs/github-project-connections/spec.md b/openspec/specs/github-project-connections/spec.md index d1e510b..68ee4f3 100644 --- a/openspec/specs/github-project-connections/spec.md +++ b/openspec/specs/github-project-connections/spec.md @@ -4,9 +4,7 @@ Define how developers connect public GitHub repositories as durable project context for project-aware repository and tool discovery. - ## Requirements - ### Requirement: Connect a public GitHub project Starboard SHALL allow an authenticated user to connect a public GitHub @@ -88,3 +86,32 @@ entitlement checks. - **WHEN** an authenticated user connects a supported public repository - **THEN** the complete project-intelligence workflow is available without a checkout, plan selection, usage credit, or premium lock + +### Requirement: Choose a public repository from GitHub + +Starboard SHALL let an authenticated user load and select public repositories +available through the existing GitHub token while retaining manual URL entry. + +#### Scenario: User opens the repository picker + +- **WHEN** a signed-in user asks to choose from GitHub +- **THEN** Starboard fetches a bounded list of public repositories using the + existing OAuth token and does not request a broader scope + +#### Scenario: Picker is unavailable + +- **WHEN** GitHub cannot return the repository list +- **THEN** Starboard keeps manual public URL entry usable and shows a recovery + message for the picker + +### Requirement: Privacy-safe project activation measurement + +Starboard SHALL measure the project-connection and recommendation activation +funnel without emitting repository names, repository identifiers, query text, +or GitHub access tokens. + +#### Scenario: Project is connected + +- **WHEN** a signed-in user successfully connects a project +- **THEN** Starboard records the connection source as picker or manual entry + without recording repository identity diff --git a/openspec/specs/public-discovery/spec.md b/openspec/specs/public-discovery/spec.md index 65771e0..fc2643b 100644 --- a/openspec/specs/public-discovery/spec.md +++ b/openspec/specs/public-discovery/spec.md @@ -4,9 +4,7 @@ Define the unauthenticated Starboard discovery experience and the boundary between public repository browsing and signed-in personalization. - ## Requirements - ### Requirement: Public Discover browsing Starboard SHALL allow unauthenticated visitors to browse, search, sort, filter, @@ -54,12 +52,26 @@ authenticated while preserving the same public Discover corpus for guests. ### Requirement: Public landing handoff -The Starboard landing page SHALL link guests directly to the working public -Discover surface as the alternative to connecting GitHub. +The Starboard landing page SHALL make a public project preview the primary +first-value action and SHALL preserve direct public Discover browsing as an +alternative. + +#### Scenario: Guest submits a cataloged public project + +- **WHEN** a visitor enters a cataloged GitHub URL or `owner/repository` on the + landing page +- **THEN** the visitor reaches a working read-only project preview without + first being required to sign in + +#### Scenario: Guest submits an uncataloged public project + +- **WHEN** a visitor enters a repository that is not yet in Starboard's catalog +- **THEN** Starboard preserves the repository and asks for GitHub sign-in before + making the external lookup #### Scenario: Guest follows the public CTA -- **WHEN** a visitor selects the landing-page public browsing CTA +- **WHEN** a visitor selects the landing-page public browsing action - **THEN** the visitor reaches `/discover` and can use the shared corpus ### Requirement: Connected project discovery handoff @@ -78,3 +90,20 @@ visible. - **WHEN** a user selects a project recommendation - **THEN** Starboard opens the existing public repository detail surface + +### Requirement: Public search is stable and meaning-aware + +Discover SHALL combine bounded semantic and lexical candidates for relevance +search and SHALL NOT surface an error merely because its own filter state +changed. + +#### Scenario: Visitor changes a search or filter + +- **WHEN** a new first-page request replaces an older Discover request +- **THEN** Starboard cancels only the stale request and renders the newest result + without requiring a manual retry + +#### Scenario: Semantic retrieval is unavailable + +- **WHEN** Workers AI or Vectorize cannot serve a Discover query +- **THEN** Starboard returns bounded lexical results and does not fail the page diff --git a/openspec/specs/public-project-preview/spec.md b/openspec/specs/public-project-preview/spec.md new file mode 100644 index 0000000..cdafc05 --- /dev/null +++ b/openspec/specs/public-project-preview/spec.md @@ -0,0 +1,63 @@ +# public-project-preview Specification + +## Purpose +Let a visitor test Starboard's project-aware discovery with public repository +context before creating an account or persisting a project connection. +## Requirements +### Requirement: Preview a public GitHub project before sign-in + +Starboard SHALL accept a public GitHub URL or `owner/repository` from an +unauthenticated visitor and return a bounded, read-only project-intelligence +preview without creating user-owned data. + +#### Scenario: Cataloged public repository + +- **WHEN** a guest previews a public repository already present in the catalog +- **THEN** Starboard uses the cataloged metadata and evidence to show explained + similar projects without requiring sign-in + +#### Scenario: Uncataloged public repository + +- **WHEN** a guest previews a valid public repository not yet in the catalog +- **THEN** Starboard asks for the existing minimal GitHub sign-in, preserves the + repository value, and performs no anonymous GitHub API lookup + +#### Scenario: Signed-in uncataloged public repository + +- **WHEN** a signed-in user previews a public repository not yet in the catalog +- **THEN** Starboard resolves it with the user's existing GitHub token and shows + the best available explained preview without persisting a project connection + +#### Scenario: Invalid or unavailable repository + +- **WHEN** the supplied value is invalid, private, missing, or unavailable +- **THEN** Starboard returns a recovery message that states the accepted public + repository format and does not imply private-repository access + +### Requirement: Preview preserves an honest connection boundary + +Starboard MUST distinguish transient preview context from a durable connected +project and SHALL require GitHub sign-in before saving the project. + +#### Scenario: Guest wants to keep the previewed project + +- **WHEN** a guest selects the connect action from a successful preview +- **THEN** Starboard sends the guest through GitHub sign-in and returns them to + Projects with the public repository value ready to connect + +#### Scenario: Guest only inspects the preview + +- **WHEN** a guest views recommendations and leaves without signing in +- **THEN** Starboard creates no user project, list, note, or saved-repository row + +### Requirement: Preview work is bounded and degradable + +Starboard SHALL bound external resolution and recommendation work so a preview +cannot trigger bulk GitHub or unbounded catalog operations. + +#### Scenario: Recommendation infrastructure is partially unavailable + +- **WHEN** semantic retrieval or GitHub resolution is temporarily unavailable +- **THEN** Starboard either uses available catalog evidence or returns a clear + retry state without fabricating recommendations + diff --git a/openspec/specs/repo-tool-intelligence/spec.md b/openspec/specs/repo-tool-intelligence/spec.md index 6bb45ae..bb2efa9 100644 --- a/openspec/specs/repo-tool-intelligence/spec.md +++ b/openspec/specs/repo-tool-intelligence/spec.md @@ -82,9 +82,21 @@ recommendation context while preserving confidence and evidence limitations. ### Requirement: Similar repositories ground tool recommendations -The system SHALL rank similar repositories before recommending tools and SHALL -derive each recommended tool only from normalized detections on those ranked -peer repositories. +The system SHALL retrieve candidate peers from the eligible public catalog, +rank similar repositories before recommending tools, and derive each +recommended tool only from normalized detections on those ranked peers. + +#### Scenario: Semantic and lexical evidence are available + +- **WHEN** a project has enough public context for both retrieval paths +- **THEN** Starboard combines bounded semantic and lexical candidates from the + eligible catalog before deterministic evidence reranking + +#### Scenario: Semantic retrieval is unavailable + +- **WHEN** the semantic index or embedding path cannot serve a project +- **THEN** Starboard uses bounded lexical and structured catalog candidates and + labels any broad fallback honestly #### Scenario: Peer repositories contain additional tools @@ -93,6 +105,17 @@ peer repositories. - **THEN** Starboard recommends those tools with the exact peer repositories and detection confidence that support the recommendation +#### Scenario: Only primary language overlaps + +- **WHEN** a candidate shares only the connected project's primary language +- **THEN** Starboard does not admit it as a grounded similar project + +#### Scenario: Tool evidence is weak or metadata-only + +- **WHEN** a peer detection is repository language metadata, below the grounded + confidence floor, or supported by only one peer +- **THEN** Starboard does not present it as a tool to evaluate + #### Scenario: Tool is already used by the connected project - **WHEN** a peer repository contains a tool already detected in the connected @@ -104,3 +127,42 @@ peer repositories. - **WHEN** no project-specific similarity signal exists - **THEN** Starboard may show broad repository discovery but SHALL NOT present tools from those repositories as grounded recommendations + +### Requirement: Explain retrieval and recommendation strength + +Starboard SHALL expose enough non-sensitive context for a user to distinguish a +specific grounded recommendation from a degraded or broad result. + +#### Scenario: Grounded tool is shown + +- **WHEN** Starboard recommends a tool from similar repositories +- **THEN** the interface shows peer support, source confidence, and a direct + path to inspect the existing Tool Intelligence detail + +#### Scenario: Candidate retrieval degrades + +- **WHEN** one retrieval source is unavailable +- **THEN** the result describes the retrieval mode without claiming a stronger + semantic match than the system produced + +### Requirement: Privacy-safe recommendation feedback + +Starboard SHALL let a user mark repository and tool recommendations useful or +not useful without sending repository identity or free-form text to analytics. + +#### Scenario: User rates a recommendation + +- **WHEN** a user selects useful or not useful +- **THEN** Starboard records only recommendation kind, sentiment, rank bucket, + retrieval mode, and aggregate evidence buckets + +### Requirement: Tool evidence collections remain bounded + +Tool Intelligence SHALL paginate repository evidence and SHALL apply repository +filtering before pagination. + +#### Scenario: A tool appears in hundreds of repositories + +- **WHEN** a user opens that tool's detail page +- **THEN** Starboard renders one bounded page and offers an explicit way to load + more without placing the full collection in the DOM diff --git a/package.json b/package.json index 10c7c3d..4429d91 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "db:enrich-tools": "tsx scripts/enrich-tools.ts", "cf:build": "next build --webpack && node scripts/run-inline-critical-css.mjs && opennextjs-cloudflare build --skipNextBuild && opennextjs-cloudflare populateCache local && pnpm --filter ./landing-astro build && node scripts/run-overlay-astro-landing.mjs", "build:cf": "next build --webpack && node scripts/run-inline-critical-css.mjs && opennextjs-cloudflare build --skipNextBuild && opennextjs-cloudflare populateCache local && pnpm --filter ./landing-astro build && node scripts/run-overlay-astro-landing.mjs", + "build:e2e": "next build --webpack && node scripts/run-inline-critical-css.mjs && opennextjs-cloudflare build --skipNextBuild && pnpm --filter ./landing-astro build && node scripts/run-overlay-astro-landing.mjs", "preview:cf": "pnpm build:cf && opennextjs-cloudflare preview", "deploy:cf": "pnpm build:cf && opennextjs-cloudflare deploy --tag \"$(git rev-parse HEAD)\"", "cf:typegen": "wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts", diff --git a/playwright.config.ts b/playwright.config.ts index 4598fd3..2556034 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,37 +1,47 @@ /** - * Playwright config — desktop + mobile-viewport projects. - * - * The `mobile` project uses the iPhone 13 device descriptor (390px wide — the - * fleet Wave 4 mobile target) so mobile-layout regressions are caught in CI - * alongside the `desktop` baseline. - * - * Run only the mobile project: pnpm exec playwright test --project=mobile - * - * NOTE: `@playwright/test` is not yet a dependency. Install it before running: - * pnpm add -D @playwright/test && pnpm exec playwright install --with-deps + * Browser coverage for both production surfaces: + * - the Astro landing page that is overlaid at `/` during `build:cf` + * - the Next.js application shell and public product journeys */ import { defineConfig, devices } from '@playwright/test'; +const appURL = 'http://127.0.0.1:8787'; +const browserPreview = + 'pnpm exec wrangler d1 migrations apply starboard-e2e --local --config wrangler.e2e.jsonc && pnpm exec wrangler dev --config wrangler.e2e.jsonc --port 8787 --var AUTH_SECRET:starboard-browser-test-secret-at-least-32-characters --var AUTH_GITHUB_ID:browser-test-client --var AUTH_GITHUB_SECRET:browser-test-secret'; + export default defineConfig({ testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: 'list', - use: { - baseURL: 'http://localhost:3000', - trace: 'on-first-retry', - }, + use: { trace: 'on-first-retry' }, webServer: { - command: 'pnpm dev', - url: 'http://localhost:3000', + command: process.env.CI ? browserPreview : `pnpm build:e2e && ${browserPreview}`, + url: appURL, reuseExistingServer: !process.env.CI, - timeout: 120_000, + timeout: 240_000, }, projects: [ - // Desktop baseline. - { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, - // Mobile-viewport project — iPhone 13 is 390px wide, the mobile target. - { name: 'mobile', use: { ...devices['iPhone 13'] } }, + { + name: 'desktop', + testMatch: /public-app\.spec\.ts/, + use: { ...devices['Desktop Chrome'], baseURL: appURL }, + }, + { + name: 'mobile', + testMatch: /public-app\.spec\.ts/, + use: { ...devices['iPhone 13'], baseURL: appURL, browserName: 'chromium' }, + }, + { + name: 'landing-desktop', + testMatch: /landing-mobile\.spec\.ts/, + use: { ...devices['Desktop Chrome'], baseURL: appURL }, + }, + { + name: 'landing-mobile', + testMatch: /landing-mobile\.spec\.ts/, + use: { ...devices['iPhone 13'], baseURL: appURL, browserName: 'chromium' }, + }, ], }); diff --git a/public/api-ai.json b/public/api-ai.json index 048b72b..7a10f31 100644 --- a/public/api-ai.json +++ b/public/api-ai.json @@ -30,7 +30,7 @@ "url": "https://starboard.codevetter.com/project-preview", "md": "https://starboard.codevetter.com/project-preview.md", "kind": "static", - "description": "Try project-aware recommendations before sign-in without saving data" + "description": "Preview cataloged recommendations before sign-in without saving data; uncataloged lookups require GitHub sign-in" }, { "id": "tools", diff --git a/public/index.md b/public/index.md index 3bb605a..5afad98 100644 --- a/public/index.md +++ b/public/index.md @@ -4,7 +4,7 @@ Project-aware GitHub repository discovery and tool intelligence. ## Public without sign-in -- Preview a public GitHub project without creating an account or saving data +- Preview a cataloged public GitHub project without creating an account or saving data - Browse and search the seeded Discover corpus - Inspect detected tool and framework intelligence - Read the product history, scope, privacy policy, and terms diff --git a/public/llms-full.txt b/public/llms-full.txt index b011225..1b63f40 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -10,7 +10,7 @@ Project-aware GitHub repository discovery and tool intelligence. ## Public without sign-in -- Preview a public GitHub project without creating an account or saving data +- Preview a cataloged public GitHub project without creating an account or saving data - Browse and search the seeded Discover corpus - Inspect detected tool and framework intelligence - Read the product history, scope, privacy policy, and terms @@ -30,7 +30,7 @@ Project-aware GitHub repository discovery and tool intelligence. ## Product links - Home: https://starboard.codevetter.com/ — Product -- Project Preview: https://starboard.codevetter.com/project-preview — Try project-aware recommendations before sign-in +- Project Preview: https://starboard.codevetter.com/project-preview — Preview cataloged recommendations before sign-in; new lookups require GitHub sign-in - Discover: https://starboard.codevetter.com/discover — Browse and search the seeded public repository corpus - Tools: https://starboard.codevetter.com/tools — Detected tool and framework intelligence - Changelog: https://starboard.codevetter.com/changelog — Verified product history diff --git a/public/llms.txt b/public/llms.txt index e2200ed..1d87fd9 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -5,7 +5,7 @@ ## Product - [Home](https://starboard.codevetter.com/): Product -- [Project Preview](https://starboard.codevetter.com/project-preview): Try project-aware recommendations before sign-in +- [Project Preview](https://starboard.codevetter.com/project-preview): Preview cataloged recommendations before sign-in; new lookups require GitHub sign-in - [Discover](https://starboard.codevetter.com/discover): Browse and search the seeded public repository corpus - [Tools](https://starboard.codevetter.com/tools): Detected tool and framework intelligence - [Changelog](https://starboard.codevetter.com/changelog): Verified product history diff --git a/public/project-preview.md b/public/project-preview.md index 979f782..be21d8c 100644 --- a/public/project-preview.md +++ b/public/project-preview.md @@ -1,13 +1,14 @@ # Public Project Preview Paste a public GitHub URL or `owner/repository` at - to inspect a bounded sample of -similar projects and peer-grounded tools before sign-in. + to inspect a bounded sample +of similar projects and peer-grounded tools. Cataloged repositories preview +before sign-in; uncataloged lookups use the user's existing GitHub session. The preview checks Starboard's public catalog first and may resolve one public -GitHub repository on a catalog miss. It creates no project connection, list, -note, or saved repository. GitHub sign-in is required only when the visitor -chooses to connect the project. +GitHub repository on an authenticated catalog miss. It creates no project +connection, list, note, or saved repository. Connecting remains a separate +explicit action. Recommendations combine available semantic, catalog text, and language evidence before deterministic reranking. Degraded and broad-fallback states are labeled. diff --git a/src/__tests__/abortable-fetch.test.ts b/src/__tests__/abortable-fetch.test.ts new file mode 100644 index 0000000..65ae7c9 --- /dev/null +++ b/src/__tests__/abortable-fetch.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { replaceAbortableJsonRequest } from '@/lib/abortable-fetch'; + +describe('replaceAbortableJsonRequest', () => { + it('aborts only the stale request and leaves the newest request alive', async () => { + const pending: Array<{ + signal: AbortSignal; + resolve: (value: { ok: boolean; json: () => Promise }) => void; + }> = []; + vi.stubGlobal( + 'fetch', + vi.fn( + (_url: string, init?: RequestInit) => + new Promise((resolve) => pending.push({ signal: init!.signal as AbortSignal, resolve })) + ) + ); + const ref = { current: null as AbortController | null }; + + const first = replaceAbortableJsonRequest(ref, '/first'); + const second = replaceAbortableJsonRequest<{ page: number }>(ref, '/second'); + + expect(pending[0].signal.aborted).toBe(true); + expect(pending[1].signal.aborted).toBe(false); + + pending[1].resolve({ ok: true, json: async () => ({ page: 2 }) }); + await expect(second).resolves.toEqual({ page: 2 }); + expect(ref.current).toBeNull(); + + pending[0].resolve({ ok: true, json: async () => ({ page: 1 }) }); + await expect(first).resolves.toEqual({ page: 1 }); + }); +}); diff --git a/src/__tests__/discover-api.test.ts b/src/__tests__/discover-api.test.ts index 2233476..0151375 100644 --- a/src/__tests__/discover-api.test.ts +++ b/src/__tests__/discover-api.test.ts @@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ auth: vi.fn(), + embed: vi.fn(), execute: vi.fn(), batch: vi.fn(), + vectorQuery: vi.fn(), })); vi.mock('@/lib/auth', () => ({ auth: mocks.auth })); @@ -14,6 +16,10 @@ vi.mock('@/db', () => ({ batch: mocks.batch, }, })); +vi.mock('@/lib/embeddings', () => ({ generateEmbeddings: mocks.embed })); +vi.mock('@/lib/repo-vectors', () => ({ + repoVectors: () => ({ query: mocks.vectorQuery }), +})); import { GET } from '@/app/api/discover/route'; @@ -21,6 +27,8 @@ describe('GET /api/discover', () => { beforeEach(() => { vi.clearAllMocks(); mocks.auth.mockResolvedValue({ user: { githubId: 'user-1' } }); + mocks.embed.mockResolvedValue([[0.1, 0.2]]); + mocks.vectorQuery.mockResolvedValue([]); mocks.execute.mockResolvedValue({ rows: [ { @@ -118,6 +126,36 @@ describe('GET /api/discover', () => { expect(mocks.batch).not.toHaveBeenCalled(); }); + it('fuses semantic candidates ahead of lexical-only matches for relevance search', async () => { + mocks.vectorQuery.mockResolvedValue([{ repoId: 2, distance: 0.2 }]); + + const response = await GET( + new NextRequest('http://localhost/api/discover?q=vector%20database&sort=relevance') + ); + + expect(response.status).toBe(200); + expect(mocks.embed).toHaveBeenCalledWith(['vector database vector database']); + const mainQuery = mocks.execute.mock.calls[1]?.[0] as { sql: string; args: unknown[] }; + expect(mainQuery.sql).toContain('CASE r.id WHEN 2 THEN 0 WHEN 1 THEN 1'); + expect(mainQuery.args).toContain(JSON.stringify([2, 1])); + }); + + it('degrades to lexical Discover results when semantic retrieval fails', async () => { + mocks.embed.mockRejectedValueOnce(new Error('AI unavailable')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const response = await GET(new NextRequest('http://localhost/api/discover?q=database')); + + expect(response.status).toBe(200); + const mainQuery = mocks.execute.mock.calls[1]?.[0] as { sql: string; args: unknown[] }; + expect(mainQuery.args).toContain(JSON.stringify([1])); + expect(warn).toHaveBeenCalledWith( + 'Discover semantic retrieval unavailable; using lexical search', + expect.any(Error) + ); + warn.mockRestore(); + }); + // Regression guard: the eligibility filter must use the index-friendly // IN (UNION) form, not the OR EXISTS correlated subquery that forced // O(|repos| × |user_repos|) row reads per request (the 500M-row burn). diff --git a/src/__tests__/project-activation-ui.test.ts b/src/__tests__/project-activation-ui.test.ts index 0e7b36f..15a935d 100644 --- a/src/__tests__/project-activation-ui.test.ts +++ b/src/__tests__/project-activation-ui.test.ts @@ -12,7 +12,8 @@ describe('project-first activation wiring', () => { expect(landing).toContain('action="/project-preview"'); expect(landing).toContain('name="repository"'); - expect(landing).toContain('No sign-in to preview'); + expect(landing).toContain('Catalog matches preview without sign-in'); + expect(landing).toContain('uncataloged repositories use your signed-in GitHub session'); expect(landing).not.toContain('Library · this week'); expect(landing).not.toContain('>Hot<'); expect(landing).not.toContain('>Watch<'); @@ -41,6 +42,27 @@ describe('project-first activation wiring', () => { expect(topBar).toContain('className="size-11 shrink-0 sm:hidden"'); }); + it('reuses the shared shell and navigation across discovery surfaces', () => { + const discover = source('src/app/discover/discover-client.tsx'); + const discoverLayout = source('src/app/discover/layout.tsx'); + const tools = source('src/app/tools/page.tsx'); + const toolDetail = source('src/app/tools/[toolKey]/page.tsx'); + const toolsLayout = source('src/app/tools/layout.tsx'); + const topBar = source('src/components/top-bar.tsx'); + + expect(discoverLayout).toContain(''); + expect(toolsLayout).toContain(''); + expect(tools).toContain(' { const preview = source('src/components/project-preview-workspace.tsx'); const login = source('src/app/login/page.tsx'); diff --git a/src/__tests__/project-preview-api.test.ts b/src/__tests__/project-preview-api.test.ts index e040c33..1c0e57b 100644 --- a/src/__tests__/project-preview-api.test.ts +++ b/src/__tests__/project-preview-api.test.ts @@ -2,18 +2,21 @@ import { NextRequest } from 'next/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ + auth: vi.fn(), resolve: vi.fn(), retrieve: vi.fn(), })); vi.mock('@/lib/project-preview', () => ({ resolveProjectPreview: mocks.resolve })); vi.mock('@/lib/project-intelligence', () => ({ retrieveProjectIntelligence: mocks.retrieve })); +vi.mock('@/lib/auth', () => ({ auth: mocks.auth })); import { GET } from '@/app/api/project-preview/route'; describe('public project preview API', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.auth.mockResolvedValue(null); mocks.retrieve.mockResolvedValue({ similarProjects: [], recommendedTools: [], @@ -54,4 +57,26 @@ describe('public project preview API', () => { expect(payload).toMatchObject({ project, source: 'catalog', retrieval: { mode: 'hybrid' } }); expect(mocks.retrieve).toHaveBeenCalledWith(project, 12); }); + + it('does not spend anonymous GitHub quota on an uncataloged guest preview', async () => { + mocks.resolve.mockResolvedValue({ status: 'auth-required' }); + + const response = await GET( + new NextRequest('http://localhost/api/project-preview?repository=acme%2Fnew-repo') + ); + + expect(response.status).toBe(401); + expect(await response.json()).toMatchObject({ loginRequired: true }); + expect(mocks.resolve).toHaveBeenCalledWith('acme/new-repo', undefined); + expect(mocks.retrieve).not.toHaveBeenCalled(); + }); + + it('passes the existing GitHub token to an uncataloged lookup', async () => { + mocks.auth.mockResolvedValue({ accessToken: 'github-token' }); + mocks.resolve.mockResolvedValue({ status: 'unavailable' }); + + await GET(new NextRequest('http://localhost/api/project-preview?repository=acme%2Fmissing')); + + expect(mocks.resolve).toHaveBeenCalledWith('acme/missing', 'github-token'); + }); }); diff --git a/src/__tests__/project-preview.test.ts b/src/__tests__/project-preview.test.ts index 65e35e3..3c1a0db 100644 --- a/src/__tests__/project-preview.test.ts +++ b/src/__tests__/project-preview.test.ts @@ -49,7 +49,16 @@ describe('public project preview resolution', () => { expect(fetchProject).not.toHaveBeenCalled(); }); - it('resolves one public GitHub repository on a catalog miss without persisting it', async () => { + it('requires authentication before an uncataloged GitHub request', async () => { + const execute = vi.fn().mockResolvedValue(result([])); + const fetchProject = vi.fn(); + const resolve = createProjectPreviewResolver({ database: { execute }, fetchProject }); + + await expect(resolve('acme/sdk')).resolves.toEqual({ status: 'auth-required' }); + expect(fetchProject).not.toHaveBeenCalled(); + }); + + it('resolves one public GitHub repository with the existing session token', async () => { const execute = vi.fn().mockResolvedValue(result([])); const fetchProject = vi.fn().mockResolvedValue({ id: 2, @@ -68,12 +77,15 @@ describe('public project preview resolution', () => { }); const resolve = createProjectPreviewResolver({ database: { execute }, fetchProject }); - await expect(resolve('acme/sdk')).resolves.toMatchObject({ + await expect(resolve('acme/sdk', 'github-token')).resolves.toMatchObject({ status: 'resolved', source: 'github', project: { id: 2, fullName: 'acme/sdk', tools: [] }, }); expect(execute).toHaveBeenCalledTimes(1); - expect(fetchProject).toHaveBeenCalledTimes(1); + expect(fetchProject).toHaveBeenCalledWith( + { owner: 'acme', repo: 'sdk', fullName: 'acme/sdk' }, + 'github-token' + ); }); }); diff --git a/src/__tests__/project-recommendations.test.ts b/src/__tests__/project-recommendations.test.ts index a5fb70c..edec69c 100644 --- a/src/__tests__/project-recommendations.test.ts +++ b/src/__tests__/project-recommendations.test.ts @@ -79,17 +79,35 @@ describe('rankProjectRecommendations', () => { ).toEqual([]); }); + it('does not admit a peer from primary-language overlap alone', () => { + const project = repo({ id: 1, fullName: 'acme/app', language: 'TypeScript' }); + const unrelated = repo({ + id: 2, + fullName: 'oss/unrelated', + language: 'TypeScript', + stargazersCount: 100_000, + tools: [{ key: 'java', name: 'Java', category: 'language', confidence: 98 }], + }); + + const result = rankProjectRecommendations(project, [unrelated]); + + expect(result.fallback).toBe(true); + expect(result.recommendedTools).toEqual([]); + }); + it('derives new tools from similar repositories with exact provenance', () => { const project = repo({ id: 1, fullName: 'acme/webapp', language: 'TypeScript', + topics: ['testing'], tools: [{ key: 'nextjs', name: 'Next.js', category: 'framework', confidence: 98 }], }); const peerOne = repo({ id: 2, fullName: 'oss/peer-one', language: 'TypeScript', + topics: ['testing'], tools: [ { key: 'nextjs', name: 'Next.js', category: 'framework', confidence: 96 }, { key: 'vitest', name: 'Vitest', category: 'testing', confidence: 94 }, @@ -99,6 +117,7 @@ describe('rankProjectRecommendations', () => { id: 3, fullName: 'oss/peer-two', language: 'TypeScript', + topics: ['testing'], tools: [{ key: 'vitest', name: 'Vitest', category: 'testing', confidence: 88 }], }); @@ -158,6 +177,7 @@ describe('rankProjectRecommendations', () => { fullName: 'acme/app', language: 'TypeScript', topics: ['payments'], + tools: [{ key: 'stripe', name: 'Stripe', category: 'payments', confidence: 98 }], }); const strongest = repo({ id: 2, @@ -165,17 +185,20 @@ describe('rankProjectRecommendations', () => { language: 'TypeScript', topics: ['payments'], stargazersCount: 1, + tools: [{ key: 'stripe', name: 'Stripe', category: 'payments', confidence: 95 }], }); const popular = repo({ id: 3, fullName: 'oss/popular', language: 'TypeScript', + topics: ['payments'], stargazersCount: 100, }); const alphabetical = repo({ id: 4, fullName: 'oss/alphabetical', language: 'TypeScript', + topics: ['payments'], stargazersCount: 100, }); @@ -186,17 +209,24 @@ describe('rankProjectRecommendations', () => { ).toEqual(['oss/strongest', 'oss/alphabetical', 'oss/popular']); }); - it('orders grounded tools by peer support, score, and name while deduplicating evidence', () => { - const project = repo({ id: 1, fullName: 'acme/app', language: 'TypeScript' }); + it('requires corroborated, confident, non-language tool evidence', () => { + const project = repo({ + id: 1, + fullName: 'acme/app', + language: 'TypeScript', + topics: ['testing'], + }); const peerOne = repo({ id: 2, fullName: 'oss/one', language: 'TypeScript', + topics: ['testing'], tools: [ { key: 'alpha', name: 'Alpha', category: 'testing', confidence: 150 }, { key: 'alpha', name: 'Alpha', category: 'testing', confidence: 150 }, { key: 'beta', name: 'Beta', category: 'testing', confidence: 90 }, { key: 'delta', name: 'Delta', category: 'testing', confidence: 90 }, + { key: 'java', name: 'Java', category: 'language', confidence: 100 }, { key: '', name: 'Invalid', category: 'testing', confidence: 100 }, ], }); @@ -204,19 +234,64 @@ describe('rankProjectRecommendations', () => { id: 3, fullName: 'oss/two', language: 'TypeScript', + topics: ['testing'], tools: [ - { key: 'alpha', name: 'Alpha', category: 'testing', confidence: -5 }, + { key: 'alpha', name: 'Alpha', category: 'testing', confidence: 70 }, { key: 'gamma', name: 'Gamma', category: 'testing', confidence: 10 }, ], }); const tools = rankProjectRecommendations(project, [peerOne, peerTwo]).recommendedTools; - expect(tools.map((tool) => tool.name)).toEqual(['Alpha', 'Beta', 'Delta', 'Gamma']); + expect(tools.map((tool) => tool.name)).toEqual(['Alpha']); expect(tools[0].supportCount).toBe(2); expect(tools[0].sources).toHaveLength(2); }); + it('does not recommend a competing framework when the project already uses one', () => { + const project = repo({ + id: 1, + fullName: 'acme/react-app', + topics: ['frontend'], + tools: [{ key: 'react', name: 'React', category: 'framework', confidence: 98 }], + }); + const peers = [2, 3].map((id) => + repo({ + id, + fullName: `oss/peer-${id}`, + topics: ['frontend'], + tools: [{ key: 'angular', name: 'Angular', category: 'framework', confidence: 98 }], + }) + ); + + expect(rankProjectRecommendations(project, peers).recommendedTools).toEqual([]); + }); + + it('orders grounded tools by support, evidence score, then stable name', () => { + const project = repo({ id: 1, fullName: 'acme/app', topics: ['testing'] }); + const peers = [2, 3, 4].map((id) => + repo({ + id, + fullName: `oss/peer-${id}`, + topics: ['testing'], + tools: [ + { key: 'zulu', name: 'Zulu', category: 'testing', confidence: 90 }, + ...(id < 4 + ? [ + { key: 'beta', name: 'Beta', category: 'testing', confidence: 90 }, + { key: 'gamma', name: 'Gamma', category: 'testing', confidence: 80 }, + { key: 'alpha', name: 'Alpha', category: 'testing', confidence: 80 }, + ] + : []), + ], + }) + ); + + expect( + rankProjectRecommendations(project, peers).recommendedTools.map((tool) => tool.name) + ).toEqual(['Zulu', 'Beta', 'Alpha', 'Gamma']); + }); + it('handles empty metadata and clamps the requested peer limit', () => { const project = repo({ id: 1, fullName: 'acme/empty', name: '' }); const peers = Array.from({ length: 60 }, (_, index) => diff --git a/src/__tests__/similar-repos-api.test.ts b/src/__tests__/similar-repos-api.test.ts new file mode 100644 index 0000000..a8b0587 --- /dev/null +++ b/src/__tests__/similar-repos-api.test.ts @@ -0,0 +1,87 @@ +import { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + execute: vi.fn(), + queryByRepoId: vi.fn(), +})); + +vi.mock('@/lib/auth', () => ({ auth: mocks.auth })); +vi.mock('@/db', () => ({ db: { execute: mocks.execute } })); +vi.mock('@/lib/repo-vectors', () => ({ + repoVectors: () => ({ queryByRepoId: mocks.queryByRepoId }), +})); + +import { GET } from '@/app/api/repos/[repoId]/similar/route'; + +describe('GET /api/repos/[repoId]/similar', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.auth.mockResolvedValue(null); + mocks.queryByRepoId.mockResolvedValue([{ repoId: 2, distance: 0.2 }]); + mocks.execute + .mockResolvedValueOnce({ + rows: [ + { + name: 'app', + full_name: 'acme/app', + description: 'A TypeScript commerce application', + language: 'TypeScript', + topics: '["commerce","nextjs"]', + }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { + id: 2, + name: 'store', + full_name: 'peer/store', + owner_login: 'peer', + owner_avatar: 'https://example.com/peer.png', + html_url: 'https://github.com/peer/store', + description: 'A TypeScript commerce platform', + language: 'TypeScript', + stargazers_count: 12_000, + archived: 0, + topics: '["commerce"]', + repo_updated_at: '2026-08-01T00:00:00Z', + }, + ], + }); + }); + + it('serves global similarity to a guest', async () => { + const response = await GET( + new NextRequest('http://localhost/api/repos/1/similar?scope=global'), + { params: Promise.resolve({ repoId: '1' }) } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + similar: [{ id: 2, full_name: 'peer/store', similarity: 0.8 }], + }); + expect(mocks.queryByRepoId).toHaveBeenCalledWith(1, 100); + }); + + it('fails closed only when a guest explicitly requests user scope', async () => { + const response = await GET(new NextRequest('http://localhost/api/repos/1/similar?scope=user'), { + params: Promise.resolve({ repoId: '1' }), + }); + + expect(response.status).toBe(401); + expect(mocks.execute).not.toHaveBeenCalled(); + expect(mocks.queryByRepoId).not.toHaveBeenCalled(); + }); + + it('keeps the public path available when optional auth lookup fails', async () => { + mocks.auth.mockRejectedValueOnce(new Error('Auth unavailable')); + + const response = await GET(new NextRequest('http://localhost/api/repos/1/similar'), { + params: Promise.resolve({ repoId: '1' }), + }); + + expect(response.status).toBe(200); + }); +}); diff --git a/src/__tests__/swr-fetcher.test.ts b/src/__tests__/swr-fetcher.test.ts index ce4cf95..6db115d 100644 --- a/src/__tests__/swr-fetcher.test.ts +++ b/src/__tests__/swr-fetcher.test.ts @@ -21,12 +21,14 @@ describe('jsonFetcher', () => { ok: false, status: 429, headers: { get: (name: string) => (name === 'Retry-After' ? '3' : null) }, + json: async () => ({ error: 'Try again shortly.' }), }) ); await expect(jsonFetcher('/x')).rejects.toMatchObject({ name: 'FetchHttpError', status: 429, retryAfterMs: 3000, + message: 'Try again shortly.', }); }); }); diff --git a/src/__tests__/tools-api.test.ts b/src/__tests__/tools-api.test.ts new file mode 100644 index 0000000..84441cb --- /dev/null +++ b/src/__tests__/tools-api.test.ts @@ -0,0 +1,89 @@ +import { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + execute: vi.fn(), +})); + +vi.mock('@/lib/auth', () => ({ auth: mocks.auth })); +vi.mock('@/db', () => ({ db: { execute: mocks.execute } })); + +import { GET } from '@/app/api/tools/route'; + +describe('GET /api/tools tool evidence pagination', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.auth.mockResolvedValue(null); + mocks.execute + .mockResolvedValueOnce({ + rows: [ + { + tool_key: 'react', + tool_name: 'React', + category: 'framework', + repo_count: 120, + avg_confidence: 95, + max_confidence: 98, + }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { + id: 51, + name: 'app', + full_name: 'acme/app', + owner_login: 'acme', + owner_avatar: '', + html_url: 'https://github.com/acme/app', + description: 'A dashboard', + language: 'TypeScript', + stargazers_count: 20_000, + archived: 0, + topics: '[]', + repo_created_at: '2025-01-01T00:00:00Z', + repo_updated_at: '2026-01-01T00:00:00Z', + tool_key: 'react', + tool_name: 'React', + category: 'framework', + confidence: 98, + sources: '["package.json"]', + }, + ], + }); + }); + + it('filters before applying a bounded limit and offset', async () => { + const response = await GET( + new NextRequest('http://localhost/api/tools?tool=react&q=TypeScript&limit=500&offset=48') + ); + + expect(response.status).toBe(200); + const resultQuery = mocks.execute.mock.calls[1][0] as { sql: string; args: unknown[] }; + expect(resultQuery.sql).toContain("LOWER(r.full_name) LIKE ? ESCAPE '\\'"); + expect(resultQuery.sql).toContain('LIMIT ? OFFSET ?'); + expect(resultQuery.args.slice(-5)).toEqual([ + '%typescript%', + '%typescript%', + '%typescript%', + 100, + 48, + ]); + + expect(await response.json()).toMatchObject({ + tool: { repoCount: 120 }, + repos: [{ id: 51 }], + page: { offset: 48, limit: 100, hasMore: true }, + }); + }); + + it('keeps personal tool evidence protected', async () => { + mocks.execute.mockReset(); + + const response = await GET(new NextRequest('http://localhost/api/tools?tool=react&scope=user')); + + expect(response.status).toBe(401); + expect(mocks.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/discover/route.ts b/src/app/api/discover/route.ts index 43676bd..e78a92f 100644 --- a/src/app/api/discover/route.ts +++ b/src/app/api/discover/route.ts @@ -3,9 +3,13 @@ import { type NextRequest, NextResponse } from 'next/server'; import { db } from '@/db'; import { auth } from '@/lib/auth'; -import { ftsSearchQuery } from '@/lib/search'; +import { generateEmbeddings } from '@/lib/embeddings'; +import { repoVectors } from '@/lib/repo-vectors'; +import { expandedSearchQuery, ftsSearchQuery, rrfFuse } from '@/lib/search'; const MIN_STARS_FLOOR = 5000; +const SEMANTIC_TOP_K = 100; +const SEMANTIC_DISTANCE_MAX = 0.7; // Index-friendly eligibility filter. The previous form // (r.stargazers_count >= ? OR EXISTS (SELECT 1 FROM user_repos ...)) // forced SQLite to evaluate a correlated subquery against user_repos for @@ -51,7 +55,7 @@ export async function GET(request: NextRequest) { .map((value) => value.trim().toLowerCase()) .filter((value) => /^[a-z0-9][a-z0-9-]{0,63}$/.test(value)) || []; const listId = params.get('list_id'); - const sort = params.get('sort') || 'stars'; + const sort = params.get('sort') || (q ? 'relevance' : 'stars'); const limit = Math.min(Math.max(parseInt(params.get('limit') || '50', 10) || 50, 1), 200); const offset = Math.max(parseInt(params.get('offset') || '0', 10) || 0, 0); @@ -61,8 +65,8 @@ export async function GET(request: NextRequest) { let rankedRepoIds: number[] | null = null; if (q) { const lexicalQuery = ftsSearchQuery(q); - const lexIds = lexicalQuery - ? await db + const lexIdsPromise = lexicalQuery + ? db .execute({ sql: `SELECT r.id, MIN(rank) AS best_rank @@ -85,12 +89,29 @@ export async function GET(request: NextRequest) { args: [lexicalQuery, lexicalQuery, MIN_STARS_FLOOR], }) .then((result) => result.rows.map((r) => r.id as number)) - : []; + : Promise.resolve([] as number[]); - if (lexIds.length > 0) { - rankedRepoIds = lexIds; + const semanticIdsPromise = (async () => { + try { + const [embedding] = await generateEmbeddings([expandedSearchQuery(q)]); + if (!embedding) return []; + const matches = await repoVectors().query(embedding, SEMANTIC_TOP_K); + return matches + .filter((match) => match.distance <= SEMANTIC_DISTANCE_MAX) + .map((match) => match.repoId); + } catch (error) { + console.warn('Discover semantic retrieval unavailable; using lexical search', error); + return []; + } + })(); + + const [lexIds, semanticIds] = await Promise.all([lexIdsPromise, semanticIdsPromise]); + const searchIds = rrfFuse([semanticIds, lexIds]); + + if (searchIds.length > 0) { + rankedRepoIds = searchIds; whereClauses.push('r.id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))'); - whereArgs.push(JSON.stringify(lexIds)); + whereArgs.push(JSON.stringify(searchIds)); } else { whereClauses.push('0 = 1'); } diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index afec577..4453d84 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,7 +1,23 @@ +import { getCloudflareContext } from '@opennextjs/cloudflare'; import { NextResponse } from 'next/server'; import { db } from '@/db'; +interface WorkerVersionMetadata { + id?: string; + tag?: string; + timestamp?: string; +} + +function workerVersionMetadata(): WorkerVersionMetadata | null { + try { + const { env } = getCloudflareContext(); + return (env as { CF_VERSION_METADATA?: WorkerVersionMetadata }).CF_VERSION_METADATA ?? null; + } catch { + return null; + } +} + /** * Public health endpoint for the Starboard Cloudflare Worker. * @@ -38,7 +54,13 @@ export async function GET() { console.error('Starboard search health probe failed', err); } - const revision = process.env.CF_PAGES_COMMIT_SHA || process.env.NEXT_PUBLIC_REVISION || 'unknown'; + const version = workerVersionMetadata(); + const revision = + process.env.CF_PAGES_COMMIT_SHA || + process.env.NEXT_PUBLIC_REVISION || + version?.tag || + version?.id || + 'unknown'; const ragConfigured = Boolean(process.env.RAG_SERVICE_KEY && process.env.STARBOARD_RAG_INDEX_ID); @@ -48,7 +70,7 @@ export async function GET() { build: { name: 'starboard', revision, - branch: process.env.CF_PAGES_BRANCH ?? 'unknown', + branch: process.env.CF_PAGES_BRANCH ?? (version ? 'worker' : 'unknown'), }, live: true, revision, diff --git a/src/app/api/project-preview/route.ts b/src/app/api/project-preview/route.ts index f8b6507..fffae82 100644 --- a/src/app/api/project-preview/route.ts +++ b/src/app/api/project-preview/route.ts @@ -1,13 +1,21 @@ import { type NextRequest, NextResponse } from 'next/server'; import { GitHubProjectApiError } from '@/lib/github-projects'; +import { auth } from '@/lib/auth'; import { retrieveProjectIntelligence } from '@/lib/project-intelligence'; import { resolveProjectPreview } from '@/lib/project-preview'; export async function GET(request: NextRequest) { const repository = request.nextUrl.searchParams.get('repository') ?? ''; try { - const resolution = await resolveProjectPreview(repository); + let accessToken: string | undefined; + try { + accessToken = (await auth())?.accessToken; + } catch { + // Catalog previews are public and must remain available when optional + // session hydration is unavailable. + } + const resolution = await resolveProjectPreview(repository, accessToken); if (resolution.status === 'invalid') { return NextResponse.json( { error: 'Enter a public GitHub URL or owner/repository.' }, @@ -20,6 +28,15 @@ export async function GET(request: NextRequest) { { status: 404 } ); } + if (resolution.status === 'auth-required') { + return NextResponse.json( + { + error: 'Sign in with GitHub to preview a repository outside the public catalog.', + loginRequired: true, + }, + { status: 401 } + ); + } const recommendations = await retrieveProjectIntelligence(resolution.project, 12); return NextResponse.json( diff --git a/src/app/api/repos/[repoId]/route.ts b/src/app/api/repos/[repoId]/route.ts index 097a4c3..a413a5a 100644 --- a/src/app/api/repos/[repoId]/route.ts +++ b/src/app/api/repos/[repoId]/route.ts @@ -1,8 +1,6 @@ -import type { InStatement } from '@/db/client'; import { type NextRequest, NextResponse } from 'next/server'; import { db } from '@/db'; -import { auth } from '@/lib/auth'; import { resolveRepoId } from '../resolve'; @@ -111,27 +109,6 @@ export async function GET( const row = repoResult.rows[0]; - const session = await auth(); - const userId = session?.user?.githubId ?? null; - - const countQueries: InStatement[] = [ - { sql: 'SELECT COUNT(*) as count FROM likes WHERE repo_id = ?', args: [repoId] }, - { sql: 'SELECT COUNT(*) as count FROM comments WHERE repo_id = ?', args: [repoId] }, - ]; - - if (userId) { - countQueries.push({ - sql: 'SELECT 1 as liked FROM likes WHERE user_id = ? AND repo_id = ?', - args: [userId, repoId], - }); - } - - const batchResults = await db.batch(countQueries); - - const likeCount = batchResults[0].rows[0].count as number; - const commentCount = batchResults[1].rows[0].count as number; - const userLiked = userId ? batchResults[2].rows.length > 0 : false; - return NextResponse.json({ repo: { id: row.id as number, @@ -148,9 +125,6 @@ export async function GET( repo_created_at: row.repo_created_at as string | null, repo_updated_at: row.repo_updated_at as string | null, }, - likeCount, - commentCount, - userLiked, }); } catch (error) { console.error('Failed to fetch repo detail:', error); diff --git a/src/app/api/repos/[repoId]/similar/route.ts b/src/app/api/repos/[repoId]/similar/route.ts index 4c789fb..4b697e8 100644 --- a/src/app/api/repos/[repoId]/similar/route.ts +++ b/src/app/api/repos/[repoId]/similar/route.ts @@ -34,11 +34,12 @@ export async function GET( request: NextRequest, { params }: { params: Promise<{ repoId: string }> } ) { - const session = await auth(); - if (!session?.user?.githubId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + let userId: string | null = null; + try { + userId = (await auth())?.user?.githubId ?? null; + } catch { + // Global similarity is public. Only the explicit user scope fails closed. } - const userId = session.user.githubId; const { repoId: rawId } = await params; const repoId = parseInt(rawId, 10); @@ -49,6 +50,9 @@ export async function GET( const limitParam = request.nextUrl.searchParams.get('limit'); const limit = Math.min(Math.max(parseInt(limitParam || '', 10) || DEFAULT_LIMIT, 1), 30); const scope = request.nextUrl.searchParams.get('scope') || 'global'; // "user" | "global" + if (scope === 'user' && !userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } try { // 1. Confirm the relational vector metadata exists and load lightweight @@ -104,7 +108,7 @@ export async function GET( FROM user_repos ur JOIN repos r ON r.id = ur.repo_id WHERE ur.user_id = ? AND r.id IN (${placeholders})`; - return db.execute({ sql, args: scope === 'global' ? chunk : [userId, ...chunk] }); + return db.execute({ sql, args: scope === 'global' ? chunk : [userId!, ...chunk] }); }) ); const hydratedRows = hydratedResults.flatMap((result) => result.rows); diff --git a/src/app/api/tools/route.ts b/src/app/api/tools/route.ts index 39d3208..d7751ea 100644 --- a/src/app/api/tools/route.ts +++ b/src/app/api/tools/route.ts @@ -20,6 +20,10 @@ function parseScope(value: string | null): ToolScope { return value === 'user' || value === 'all' ? value : 'discover'; } +function likePattern(value: string): string { + return `%${value.toLowerCase().replace(/[\\%_]/g, '\\$&')}%`; +} + function scopeClause(scope: ToolScope, userId: string | null, minStars: number) { if (scope === 'user') { if (!userId) return null; @@ -66,8 +70,14 @@ export async function GET(request: NextRequest) { 100 ); const minStars = Math.max(parseInt(params.get('min_stars') || '10000', 10) || 10000, 0); - const limit = Math.min(Math.max(parseInt(params.get('limit') || '80', 10) || 80, 1), 500); const tool = params.get('tool')?.trim() || null; + const limitCeiling = tool ? 100 : 500; + const limit = Math.min( + Math.max(parseInt(params.get('limit') || '80', 10) || 80, 1), + limitCeiling + ); + const offset = Math.max(parseInt(params.get('offset') || '0', 10) || 0, 0); + const query = params.get('q')?.trim().slice(0, 120) || null; const scopeSql = scopeClause(scope, session?.user?.githubId ?? null, minStars); if (!scopeSql) { @@ -75,6 +85,14 @@ export async function GET(request: NextRequest) { } if (tool) { + const querySql = query + ? `AND ( + LOWER(r.full_name) LIKE ? ESCAPE '\\' + OR LOWER(COALESCE(r.description, '')) LIKE ? ESCAPE '\\' + OR LOWER(COALESCE(r.language, '')) LIKE ? ESCAPE '\\' + )` + : ''; + const queryArgs = query ? Array(3).fill(likePattern(query)) : []; const summary = await db.execute({ sql: `SELECT rt.tool_key, rt.tool_name, @@ -89,8 +107,9 @@ export async function GET(request: NextRequest) { AND rt.confidence >= ? AND rt.category != 'language' AND ${scopeSql.where} + ${querySql} GROUP BY rt.tool_key, rt.tool_name, rt.category`, - args: [...scopeSql.joinArgs, tool, minConfidence, ...scopeSql.whereArgs], + args: [...scopeSql.joinArgs, tool, minConfidence, ...scopeSql.whereArgs, ...queryArgs], }); const summaryRow = summary.rows[0]; const definition = getToolDefinition(tool); @@ -120,10 +139,20 @@ export async function GET(request: NextRequest) { AND rt.confidence >= ? AND rt.category != 'language' AND ${scopeSql.where} + ${querySql} ORDER BY rt.confidence DESC, r.stargazers_count DESC - LIMIT ?`, - args: [...scopeSql.joinArgs, tool, minConfidence, ...scopeSql.whereArgs, limit], + LIMIT ? OFFSET ?`, + args: [ + ...scopeSql.joinArgs, + tool, + minConfidence, + ...scopeSql.whereArgs, + ...queryArgs, + limit, + offset, + ], }); + const repoCount = (summaryRow?.repo_count as number | undefined) ?? 0; return json({ scope, @@ -135,7 +164,7 @@ export async function GET(request: NextRequest) { toolName: (summaryRow?.tool_name as string | undefined) ?? definition?.name ?? tool, category: (summaryRow?.category as string | undefined) ?? definition?.category ?? 'library', url: getToolUrl((summaryRow?.tool_key as string | undefined) ?? definition?.key ?? tool), - repoCount: (summaryRow?.repo_count as number | undefined) ?? 0, + repoCount, avgConfidence: summaryRow ? Math.round(summaryRow.avg_confidence as number) : 0, maxConfidence: (summaryRow?.max_confidence as number | undefined) ?? 0, }, @@ -169,6 +198,11 @@ export async function GET(request: NextRequest) { sources: JSON.parse((row.sources as string) || '[]') as string[], }, })), + page: { + offset, + limit, + hasMore: offset + result.rows.length < repoCount, + }, }); } diff --git a/src/app/discover/discover-client.tsx b/src/app/discover/discover-client.tsx index 859e638..aeceba4 100644 --- a/src/app/discover/discover-client.tsx +++ b/src/app/discover/discover-client.tsx @@ -116,7 +116,9 @@ function DiscoverContent({ const [searchQuery, setSearchQuery] = useQueryState('q', parseAsString.withDefault('')); const [sortBy, setSortBy] = useQueryState( 'sort', - parseAsStringLiteral(sortOptions).withDefault('most-stars') + parseAsStringLiteral(sortOptions).withDefault( + initialUrl.includes('q=') ? 'relevance' : 'most-stars' + ) ); const [selectedLanguages, setSelectedLanguages] = useQueryState( 'lang', @@ -216,6 +218,17 @@ function DiscoverContent({ setSelectedListId(null); }, [setSearchQuery, setSelectedLanguages, setSelectedTools, setSelectedListId]); + const handleSearchChange = useCallback( + (value: string) => { + const wasEmpty = searchQuery.trim().length === 0; + const isEmpty = value.trim().length === 0; + setSearchQuery(value); + if (wasEmpty && !isEmpty && sortBy === 'most-stars') setSortBy('relevance'); + if (!wasEmpty && isEmpty && sortBy === 'relevance') setSortBy('most-stars'); + }, + [searchQuery, setSearchQuery, setSortBy, sortBy] + ); + const handleLanguageToggle = useCallback( (language: string) => { setSelectedLanguages((prev) => @@ -293,7 +306,7 @@ function DiscoverContent({ <> { if (sort !== 'recently-starred') setSortBy(sort); @@ -303,6 +316,7 @@ function DiscoverContent({ onViewModeChange={setViewMode} onMenuClick={() => setSidebarOpen(true)} repoCount={total} + repoCountDescription="This count shows repositories matching your current search and filters. Discover starts with public GitHub repositories at 5,000+ stars, added and refreshed by a bounded daily job." hasActiveFilters={hasActiveFilters} onClearFilters={clearFilters} /> diff --git a/src/app/discover/layout.tsx b/src/app/discover/layout.tsx new file mode 100644 index 0000000..1ef186f --- /dev/null +++ b/src/app/discover/layout.tsx @@ -0,0 +1,5 @@ +import { AppShell } from '@/components/app-shell'; + +export default function DiscoverLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/app/explore/[...slug]/page.tsx b/src/app/explore/[...slug]/page.tsx index c0fbb4d..d36752d 100644 --- a/src/app/explore/[...slug]/page.tsx +++ b/src/app/explore/[...slug]/page.tsx @@ -7,11 +7,8 @@ import { ExternalLink, GitFork, Info, - MessageSquare, Sparkles, Star, - ThumbsDown, - ThumbsUp, TrendingUp, Wrench, } from 'lucide-react'; @@ -24,6 +21,7 @@ import useSWR from 'swr'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; +import { TopBar } from '@/components/top-bar'; import { useRepoDetail } from '@/hooks/use-repo-detail'; import { useSimilarRepos } from '@/hooks/use-similar-repos'; import { getAvatarImageAttrs } from '@/lib/avatar'; @@ -154,45 +152,51 @@ function MiniStarHistory({ history }: { history: StarHistoryResponse }) { function PageSkeleton() { return ( -
    - -
    -
    -
    - -
    - - +
    + +
    + +
    +
    +
    + +
    + + +
    + +
    + + +
    + + +
    +
    + + +
    - -
    - - -
    - - -
    -
    - - -
    -
    -
    - {[1, 2].map((i) => ( -
    -
    - - - +
    + {[1, 2].map((i) => ( +
    +
    + + + +
    + +
    - - -
    - ))} + ))} +
    -
    +
    ); } @@ -203,8 +207,7 @@ export default function RepoDetailPage() { const { status } = useSession(); const isAuthenticated = status === 'authenticated'; - const { repo, commentCount, comments, isLoading, error, addComment, voteComment } = - useRepoDetail(repoSlug); + const { repo, isLoading, error } = useRepoDetail(repoSlug); // Stagger secondary requests after the main repo payload so a single page // open does not fire 4 concurrent /api/* calls (Cloudflare edge 429s). @@ -230,37 +233,17 @@ export default function RepoDetailPage() { { revalidateOnFocus: false, dedupingInterval: 60_000 } ); - const [commentBody, setCommentBody] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [votingId, setVotingId] = useState(null); - - const handleSubmitComment = async (e: React.FormEvent) => { - e.preventDefault(); - if (!commentBody.trim() || submitting) return; - setSubmitting(true); - try { - await addComment(commentBody.trim()); - setCommentBody(''); - } finally { - setSubmitting(false); - } - }; - - const handleVote = async (commentId: number, value: 1 | -1) => { - if (!isAuthenticated || votingId === commentId) return; - setVotingId(commentId); - try { - await voteComment(commentId, value); - } finally { - setVotingId(null); - } - }; - if (!repoSlug) { return ( -
    -

    Invalid repository path. Use /explore/owner/repo

    -
    +
    + +
    +

    Invalid repository path. Use /explore/owner/repo

    +
    +
    ); } @@ -268,370 +251,261 @@ export default function RepoDetailPage() { if (error || !repo) { return ( -
    - - - Back - -
    -

    - {error ? 'Failed to load repository.' : 'Repository not found.'} -

    +
    + +
    + + + Back + +
    +

    + {error ? 'Failed to load repository.' : 'Repository not found.'} +

    +
    -
    + ); } const langColor = repo.language ? (languageColors[repo.language] ?? '#8b8b8b') : null; const ownerAvatar = getAvatarImageAttrs(repo.owner_avatar, 40); + const backHref = isAuthenticated ? '/stars' : '/discover'; + const backLabel = isAuthenticated ? 'Back to Library' : 'Back to Discover'; return ( -
    - {/* Back link */} - - - Back to stars - +
    + +
    + {/* Back link */} + + + {backLabel} + - {/* Repo header card */} -
    -
    - {/* Owner + name */} -
    - {/* eslint-disable-next-line @next/next/no-img-element */} - {repo.owner_login} -
    -

    {repo.owner_login}

    -

    {repo.name}

    + {/* Repo header card */} +
    +
    + {/* Owner + name */} +
    + {/* eslint-disable-next-line @next/next/no-img-element */} + {repo.owner_login} +
    +

    {repo.owner_login}

    +

    {repo.name}

    +
    -
    - {/* GitHub CTA */} - - - -
    - - {/* Description */} - {repo.description && ( -

    {repo.description}

    - )} - - {/* Topics */} - {repo.topics.length > 0 && ( -
    - {repo.topics.map((topic) => ( - - {topic} - - ))} + {/* GitHub CTA */} + + +
    - )} - {/* Stats row */} -
    -
    - - - {formatStarCount(repo.stargazers_count)} - - stars -
    - {repo.language && ( -
    - - {repo.language} -
    + {/* Description */} + {repo.description && ( +

    {repo.description}

    )} -
    - - - {commentCount} {commentCount === 1 ? 'comment' : 'comments'} - -
    - {repo.archived && ( -
    - - Archived -
    - )} - {repo.repo_updated_at && ( -
    - - Updated {timeAgo(repo.repo_updated_at)} + + {/* Topics */} + {repo.topics.length > 0 && ( +
    + {repo.topics.map((topic) => ( + + {topic} + + ))}
    )} - {repo.repo_created_at && ( + + {/* Stats row */} +
    - - Created {formatDate(repo.repo_created_at)} + + + {formatStarCount(repo.stargazers_count)} + + stars
    - )} + {repo.language && ( +
    + + {repo.language} +
    + )} + {repo.archived && ( +
    + + Archived +
    + )} + {repo.repo_updated_at && ( +
    + + Updated {timeAgo(repo.repo_updated_at)} +
    + )} + {repo.repo_created_at && ( +
    + + Created {formatDate(repo.repo_created_at)} +
    + )} +
    -
    - {(starHistory || (repoTools?.tools.length ?? 0) > 0) && ( -
    -
    -

    - - Star history -

    - {starHistory ? ( - - ) : ( -

    No snapshots available yet.

    - )} + {(starHistory || (repoTools?.tools.length ?? 0) > 0) && ( +
    +
    +

    + + Star history +

    + {starHistory ? ( + + ) : ( +

    No snapshots available yet.

    + )} +
    + +
    +

    + + Detected tools +

    + {repoTools?.tools.length ? ( + <> +
    + {repoTools.tools.slice(0, 12).map((tool) => ( + + + {tool.toolName} + + + ))} +
    +
    + + {repoTools.disclaimer} +
    + + ) : ( +

    No detected tools yet.

    + )} +
    + )} -
    + {/* Similar repos */} + {(similarLoading || similar.length > 0) && ( +

    - - Detected tools + + Similar projects

    - {repoTools?.tools.length ? ( - <> -
    - {repoTools.tools.slice(0, 12).map((tool) => ( - - - {tool.toolName} - - - ))} -
    -
    - - {repoTools.disclaimer} -
    - + {similarLoading ? ( +
    + {[1, 2, 3, 4].map((i) => ( + + ))} +
    ) : ( -

    No detected tools yet.

    - )} -
    -
    - )} - - {/* Similar repos */} - {(similarLoading || similar.length > 0) && ( -
    -

    - - Similar in your stars -

    - {similarLoading ? ( -
    - {[1, 2, 3, 4].map((i) => ( - - ))} -
    - ) : ( -
    - {similar.map((s) => { - const langColor = s.language ? (languageColors[s.language] ?? '#8b8b8b') : null; - const avatar = getAvatarImageAttrs(s.owner.avatar_url, 24); - return ( - -
    - {/* eslint-disable-next-line @next/next/no-img-element */} - {s.owner.login} -
    -

    - {s.owner.login}/ - {s.name} -

    - {s.description && ( -

    - {s.description} +

    + {similar.map((s) => { + const langColor = s.language ? (languageColors[s.language] ?? '#8b8b8b') : null; + const avatar = getAvatarImageAttrs(s.owner.avatar_url, 24); + return ( + +
    + {/* eslint-disable-next-line @next/next/no-img-element */} + {s.owner.login} +
    +

    + {s.owner.login}/ + {s.name}

    - )} -
    - {s.language && ( - - - {s.language} - + {s.description && ( +

    + {s.description} +

    )} - - - {formatStarCount(s.stargazers_count)} - - - {Math.round(s.similarity * 100)}% - +
    + {s.language && ( + + + {s.language} + + )} + + + {formatStarCount(s.stargazers_count)} + + + {Math.round(s.similarity * 100)}% + +
    -
    - - ); - })} -
    - )} -
    - )} - - {/* Comments section */} -
    -

    - Discussion - {commentCount > 0 && ( - ({commentCount}) - )} -

    - - {/* Comment input at top */} -
    - {isAuthenticated ? ( -
    -