From 83c0fcc406e4bd99ec74a7c456c2b96cefcd819a Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sun, 9 Aug 2026 01:07:01 +0530 Subject: [PATCH 1/2] prove project recommendation value --- PROJECT_STATUS.md | 61 ++- README.md | 17 +- docs/architecture/data-flow.md | 17 +- docs/operations/data-map.md | 5 +- docs/operations/foundry.md | 19 +- docs/product/features.md | 18 +- docs/product/overview.md | 7 +- docs/product/surfaces.md | 3 + landing-astro/src/pages/index.astro | 110 ++-- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/github-project-connections/spec.md | 0 .../specs/public-discovery/spec.md | 0 .../specs/repo-tool-intelligence/spec.md | 0 .../specs/star-momentum-insights/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 2 + .../design.md | 129 +++++ .../proposal.md | 56 +++ .../specs/github-project-connections/spec.md | 30 ++ .../specs/public-discovery/spec.md | 19 + .../specs/public-project-preview/spec.md | 57 +++ .../specs/repo-tool-intelligence/spec.md | 68 +++ .../tasks.md | 30 ++ .../specs/github-project-connections/spec.md | 90 ++++ openspec/specs/public-discovery/spec.md | 17 + openspec/specs/repo-tool-intelligence/spec.md | 43 ++ openspec/specs/star-momentum-insights/spec.md | 10 +- public/api-ai.json | 7 + public/catalog-updates.md | 2 +- public/index.md | 5 +- public/llms-full.txt | 6 +- public/llms.txt | 3 +- public/project-preview.md | 13 + src/__tests__/analytics.test.ts | 44 +- src/__tests__/github-projects-api.test.ts | 41 ++ src/__tests__/github-projects.test.ts | 40 +- src/__tests__/project-activation-ui.test.ts | 45 ++ src/__tests__/project-intelligence.test.ts | 148 ++++++ src/__tests__/project-preview-api.test.ts | 57 +++ src/__tests__/project-preview.test.ts | 79 +++ src/__tests__/projects-api.test.ts | 86 ++-- src/__tests__/sitemap.test.ts | 1 + src/app/api/github/projects/route.ts | 28 ++ src/app/api/project-preview/route.ts | 50 ++ .../projects/[slug]/recommendations/route.ts | 64 +-- src/app/api/projects/route.ts | 21 +- src/app/login/page.tsx | 48 +- src/app/project-preview/page.tsx | 11 + src/app/projects/page.tsx | 10 +- src/app/sitemap.ts | 6 + src/components/project-preview-workspace.tsx | 269 ++++++++++ .../project-recommendation-cards.tsx | 240 +++++++++ src/components/projects-workspace.tsx | 474 ++++++++++++------ src/lib/analytics.ts | 129 +++-- src/lib/catalog-updates.ts | 2 +- src/lib/connected-projects.ts | 14 +- src/lib/github-projects.ts | 71 ++- src/lib/project-intelligence.ts | 289 +++++++++++ src/lib/project-preview.ts | 95 ++++ 61 files changed, 2794 insertions(+), 412 deletions(-) rename openspec/changes/{focus-project-discovery => archive/2026-08-09-focus-project-discovery}/.openspec.yaml (100%) rename openspec/changes/{focus-project-discovery => archive/2026-08-09-focus-project-discovery}/design.md (100%) rename openspec/changes/{focus-project-discovery => archive/2026-08-09-focus-project-discovery}/proposal.md (100%) rename openspec/changes/{focus-project-discovery => archive/2026-08-09-focus-project-discovery}/specs/github-project-connections/spec.md (100%) rename openspec/changes/{focus-project-discovery => archive/2026-08-09-focus-project-discovery}/specs/public-discovery/spec.md (100%) rename openspec/changes/{focus-project-discovery => archive/2026-08-09-focus-project-discovery}/specs/repo-tool-intelligence/spec.md (100%) rename openspec/changes/{focus-project-discovery => archive/2026-08-09-focus-project-discovery}/specs/star-momentum-insights/spec.md (100%) rename openspec/changes/{focus-project-discovery => archive/2026-08-09-focus-project-discovery}/tasks.md (100%) create mode 100644 openspec/changes/prove-project-recommendation-value/.openspec.yaml create mode 100644 openspec/changes/prove-project-recommendation-value/design.md create mode 100644 openspec/changes/prove-project-recommendation-value/proposal.md create mode 100644 openspec/changes/prove-project-recommendation-value/specs/github-project-connections/spec.md create mode 100644 openspec/changes/prove-project-recommendation-value/specs/public-discovery/spec.md create mode 100644 openspec/changes/prove-project-recommendation-value/specs/public-project-preview/spec.md create mode 100644 openspec/changes/prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md create mode 100644 openspec/changes/prove-project-recommendation-value/tasks.md create mode 100644 openspec/specs/github-project-connections/spec.md create mode 100644 public/project-preview.md create mode 100644 src/__tests__/github-projects-api.test.ts create mode 100644 src/__tests__/project-activation-ui.test.ts create mode 100644 src/__tests__/project-intelligence.test.ts create mode 100644 src/__tests__/project-preview-api.test.ts create mode 100644 src/__tests__/project-preview.test.ts create mode 100644 src/app/api/github/projects/route.ts create mode 100644 src/app/api/project-preview/route.ts create mode 100644 src/app/project-preview/page.tsx create mode 100644 src/components/project-preview-workspace.tsx create mode 100644 src/components/project-recommendation-cards.tsx create mode 100644 src/lib/project-intelligence.ts create mode 100644 src/lib/project-preview.ts diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 518c7d8..93dcd48 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -1,16 +1,17 @@ # starboard — PROJECT STATUS -Last updated: 2026-08-08 +Last updated: 2026-08-09 ## Why/What Starboard is project-aware tool intelligence for GitHub: connect a public project, discover relevant open-source repositories with visible evidence, and -keep a searchable personal library of starred repositories. The focused local -implementation removes Fleet project coupling, Alerts, Reports, Stack Builder, -standalone Radar, and weekly digest. Live remains -[starboard.codevetter.com](https://starboard.codevetter.com); this local change -has not been deployed. +keep a searchable personal library of starred repositories. The focused product +has removed Fleet project coupling, Alerts, Reports, Stack Builder, standalone +Radar, and weekly digest. Live at +[starboard.codevetter.com](https://starboard.codevetter.com), the current +release adds public project preview, full-catalog hybrid retrieval, and a +GitHub project picker. Out of scope: private repositories until the permission model is chosen, organization/team dashboards, non-GitHub providers, automated dependency @@ -42,15 +43,19 @@ Star sync + public project connection ──► D1 (users, repos, user_repos, us ├── Full-text + facet search (GET /api/stars) ├── Semantic search: knowledgebase Worker; lexical-only when shared RAG is unavailable ├── Public catalog + repo_tools → Discover and Tool Intelligence - └── Connected project context → explained repository recommendations + └── Public preview or connected project context + → Vectorize + FTS + language candidates + → deterministic evidence reranking + → explained repository and tool recommendations ``` **Embedding contract:** `EMBEDDING_DIM=768` in `src/lib/embeddings.ts` matches the `starboard-repos` Vectorize index. D1 stores only repository IDs and text hashes; dimension changes require a deliberate replacement index and re-embedding. **Data model highlights:** tags stored as JSON arrays on `user_repos`; `user_projects` connects a user to a shared public `repos` row; tool evidence -lives in `repo_tools`; similar-project ranking uses visible language, topic, -metadata, and tool matches with explicit fallback labeling. Additional tools +lives in `repo_tools`; similar-project retrieval combines bounded Vectorize, +full-catalog FTS, and language candidates before visible language, topic, +metadata, and tool reranking with explicit fallback labeling. Additional tools are recommended only when detected in those grounded peers, with repository provenance. The workflow is free and has no billing or entitlement gate. @@ -67,6 +72,15 @@ provenance. The workflow is free and has no billing or entitlement gate. ## Timeline +- **2026-08-09 (project-value hardening released)** — Archived the + completed project-focus change after syncing its delta specs. Replaced the + 500-most-starred recommendation pool with bounded hybrid candidate retrieval + across the eligible catalog, added a read-only public project preview and + on-demand GitHub public-repository picker, made login project-first, and + replaced digest-era analytics with identity-free recommendation evidence. + Tests, typecheck, lint, docs, strict OpenSpec validation, and the production + Cloudflare build pass. + - **2026-08-08 (free project discovery shipped)** — Removed Alerts, Reports, Stack Builder, standalone Radar, weekly digest, and the checked-in Fleet project catalog. Added user-owned public GitHub project connections and @@ -160,8 +174,8 @@ provenance. The workflow is free and has no billing or entitlement gate. | 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 | | Semantic search | knowledgebase Worker integration for relevance search; README-backed sync ingest; local embeddings retained for non-RAG Starboard features | -| Connected projects (local, pending deploy) | Public GitHub project connections and evidence-based repository recommendations | -| Discovery & tools | Public Discover, manually dispatched seed/enrich/embed, stored growth sorting, and Tool Intelligence | +| 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 | | Removed 2026-08-08 | Fleet project catalog, Alerts, Reports, Stack Builder, standalone Radar, weekly digest | | Ops hardening (2026-06-20) | `.env.example`, Vitest + Playwright path, pre-push lint, self-contained TypeScript/Astro landing for green CF builds | @@ -170,11 +184,13 @@ provenance. The workflow is free and has no billing or entitlement gate. **Live:** [starboard.codevetter.com](https://starboard.codevetter.com) **Primary routes:** `/stars` (library) · `/explore/[...slug]` (repo detail) · -`/discover` · `/projects` · `/projects/[slug]` · `/lists/[slug]` · `/tools` +`/discover` · `/project-preview` · `/projects` · `/projects/[slug]` · +`/lists/[slug]` · `/tools` **Primary API:** `/api/stars` · `/api/stars/sync` · -`/api/repos/[repoId]/*` · `/api/lists/*` · `/api/projects/*` · `/api/growth` · -`/api/tools` · `/api/embeddings/generate` +`/api/repos/[repoId]/*` · `/api/lists/*` · `/api/project-preview` · +`/api/projects/*` · `/api/github/projects` · `/api/growth` · `/api/tools` · +`/api/embeddings/generate` | Surface | Role | |---------|------| @@ -203,22 +219,25 @@ provenance. The workflow is free and has no billing or entitlement gate. so Workers AI, Vectorize, and D1 use native bindings without broad Vectorize credentials in GitHub. -### Connected projects (implemented locally; not deployed) +### 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. - Authenticated users can connect and disconnect public GitHub repositories - without a broader OAuth scope. + by URL or an on-demand public-repository picker without a broader OAuth scope. - `user_projects` isolates project connections per user while reusing shared repository metadata. -- Deterministic recommendations explain language, topic, metadata, and tool - matches; sparse context is labeled as broad discovery. +- Bounded Vectorize, full-catalog FTS, and language candidates feed deterministic + recommendations that explain language, topic, metadata, and tool matches; + sparse context is labeled as broad discovery. ### 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. - Discover supports paginated 30-day growth ordering and detected-tool facets from indexed local snapshot/tool tables. -- Manually dispatched GitHub Actions seed/enrich popular repos in D1 and embed - through native Worker bindings; automatic seeding remains paused pending an - explicit operating budget. +- 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. - SaaS Maker feedback widget integrated; product analytics run directly through PostHog. diff --git a/README.md b/README.md index a820c9f..0a6e5ba 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ 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 - **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 @@ -37,8 +39,9 @@ matches, and keeps personal stars searchable with tags and collections. - **Search** — Full-text search across name, description, and topics - **Filter** — By language, category, tag, or collection - **Sort** — Recently starred, most stars, recently updated, A-Z -- **Projects** — connect public GitHub repositories for explained recommendations -- **Similar Projects** — ground recommendations in comparable repositories +- **Projects** — connect by URL or an on-demand public GitHub repository picker +- **Similar Projects** — hybrid full-catalog candidates with deterministic, + visible-evidence reranking - **Grounded Tools** — see which tools peers use and the exact repository evidence - **Tool Intelligence** — inspect detected tools, confidence, and repository evidence - **Discover** — search and filter a seeded public repository catalog @@ -89,6 +92,7 @@ AUTH_TRUST_HOST=true ```text src/app/stars/ main dashboard src/app/explore/ repo detail and discovery pages +src/app/project-preview public read-only project intelligence src/app/projects/ connected GitHub projects and repository recommendations src/app/lists/ public shared list pages src/app/api/ auth, stars, lists, repo interactions @@ -135,9 +139,12 @@ live in Vectorize. - GitHub star sync uses ETag caching to avoid unnecessary API calls. - GitHub star lists are scraped from GitHub HTML because there is no official API for that surface. - Projects are public GitHub repositories connected per user in D1. The current OAuth scope is not broadened for private access. -- Similar projects use visible language, topic, metadata, and tool evidence; - grounded tool recommendations then name the exact peer repositories that use - each tool. Sparse results are labeled as broad discovery. +- Similar projects combine bounded Vectorize, full-catalog FTS, and language + candidate lanes before visible language, topic, metadata, and tool reranking; + grounded tool recommendations then name the exact peers that use each tool. + Sparse results are labeled as broad discovery. +- Product analytics use categorical recommendation and evidence buckets only; + repository identity and query text are excluded. - Filter and sort state lives in the URL through `nuqs`, so dashboard links are shareable. - Scheduled GitHub Actions seed and enrich popular repositories for discovery. - SaaS Maker supplies the feedback widget only; product analytics run directly through PostHog. diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md index 779b154..199982d 100644 --- a/docs/architecture/data-flow.md +++ b/docs/architecture/data-flow.md @@ -30,12 +30,16 @@ history and remains empty when insufficient samples exist. ```text public GitHub URL or owner/repository - → normalize and resolve through GitHub + → normalize and check the local catalog + → resolve one public GitHub repository on a catalog miss + → public preview: no user-owned write + → authenticated connection only after explicit confirmation → upsert shared repos row → insert user_projects ownership relation → load project metadata + repo_tools - → rank a bounded public-catalog candidate set - → return recommendations with evidence + → retrieve bounded Vectorize + full-catalog FTS + language candidates + → reciprocal-rank fusion + deterministic evidence reranking + → return recommendations, retrieval mode, and peer-grounded tools ``` The route revalidates that the repository is publicly accessible. Project list, @@ -46,5 +50,8 @@ project context produces an explicitly labeled broad-discovery fallback. Repository similarity remains a separate Vectorize path. D1 stores only `repo_embeddings(repo_id, text_hash)`; vector values live in the -`starboard-repos` index. Similar-project recommendations currently use deterministic -visible evidence so every reason can be explained. +`starboard-repos` index. Project-aware recommendations use Vectorize as one +candidate lane, then fuse it with full-catalog lexical and structured lanes. +Deterministic visible evidence remains the final ranker so every reason can be +explained. When semantic retrieval is unavailable, lexical and structured lanes +remain usable; an all-lanes-empty state becomes an explicit broad fallback. diff --git a/docs/operations/data-map.md b/docs/operations/data-map.md index a7e7b67..0c46ff7 100644 --- a/docs/operations/data-map.md +++ b/docs/operations/data-map.md @@ -133,8 +133,9 @@ returns 503 and must not report global health. See ## Search activation evidence Privacy-safe aggregate activation counters are emitted to Foundry (PostHog) -on successful search result inspection and saved/organized actions. No raw -query text, repo IDs, repo full names, or user identifiers are sent. See +on search and project recommendation views, inspections, feedback, and +saved/organized actions. No raw query text, repo IDs, repo full names, or user +identifiers are sent. See [`foundry.md`](foundry.md) for the sanitization contract and [`src/lib/analytics.ts`](../../src/lib/analytics.ts) `trackSearchOutcome`. diff --git a/docs/operations/foundry.md b/docs/operations/foundry.md index e414874..841db96 100644 --- a/docs/operations/foundry.md +++ b/docs/operations/foundry.md @@ -21,7 +21,11 @@ never sends: | --- | --- | --- | | `signup` | first session after account creation | `project_id` | | `activated` | first successful star sync | `project_id` | -| `core_action` | each sync / list-created action | `project_id`, `action` (`repos_synced`/`list_created`) | +| `core_action` | each sync, list-created, or project-connected action | `project_id`, `action` (`repos_synced`/`list_created`/`project_connected`) | +| `project_connected` | a public project is durably connected | `project_id`, `source` (`manual`/`picker`) | +| `recommendation_set_viewed` | project recommendations reach the user | `project_id`, `retrieval_mode`, `result_count_bucket`, `fallback` | +| `recommendation_inspected` | a repository or tool recommendation is opened | `project_id`, `kind`, `rank_bucket`, `retrieval_mode` | +| `recommendation_feedback` | useful/not-useful is selected | `project_id`, `kind`, `sentiment`, `rank_bucket`, `retrieval_mode`, `support_bucket`, `confidence_bucket` | | `returned` | return session by a user with prior activity | `project_id` | | `search_outcome` | every `/api/stars` search with a query | `project_id`, `surface` (`lexical`/`semantic`/`discover`), `result_count_bucket` (`zero`/`1-5`/`6-20`/`21+`) | | `result_inspection` | a user opens a repo detail from search results | `project_id`, `surface` (`repo_detail`) | @@ -45,7 +49,8 @@ never sends: - The inactive historical `insight_reports` table is not an event source. - Foundry activation events carry **no** repo identity. The `search_outcome` event has only the surface and result-count bucket; `result_inspection` - has only the surface name. + has only the surface name. Project recommendation events use categorical + retrieval, rank, support, confidence, and sentiment buckets only. - The knowledgebase RAG index stores `full_name` in document metadata for result-to-repo mapping; this is the search backend (user-scoped via `user_id` in the index), not Foundry, and is not covered by this @@ -53,8 +58,8 @@ never sends: ## Verification -A future audit task (deferred — not blocking this capability) should grep -all `track*` and `emit*` call sites to confirm no PII / query text / repo -identity is passed. The current call sites are limited to -[`src/lib/analytics.ts`](../../src/lib/analytics.ts) exports and -[`src/app/api/stars/route.ts`](../../src/app/api/stars/route.ts). +Audit `track*` and `emit*` call sites during analytics changes to confirm no PII, +query text, or repository identity is passed. Current project event call sites +live in the project connection API and the project recommendation components; +their payloads are constrained by typed categorical helpers in +[`src/lib/analytics.ts`](../../src/lib/analytics.ts). diff --git a/docs/product/features.md b/docs/product/features.md index cc08d06..9462bf2 100644 --- a/docs/product/features.md +++ b/docs/product/features.md @@ -5,19 +5,25 @@ 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. - Authenticated users can connect a public GitHub repository by URL or - `owner/repository` without broadening the current OAuth scope. + `owner/repository`, or choose from a bounded on-demand list of public GitHub + repositories, without broadening the current OAuth scope. - Connections are user-owned D1 relations; disconnecting a project does not delete shared repository metadata. -- Projects receive deterministic similar-repository results from the seeded - public catalog using visible language, topic, metadata, and detected-tool - evidence. +- Projects retrieve bounded Vectorize, full-catalog FTS, and language candidate + lanes before deterministic reranking with visible language, topic, metadata, + and detected-tool evidence. - Similar repositories ground tool recommendations. Each recommended tool lists the exact peer repositories and detection confidence that support it. - Recommendation cards state the matching evidence. Sparse-context results are explicitly labeled as broad discovery fallbacks and do not generate tool recommendations. - The complete workflow is free and has no billing or entitlement gate. +- Recommendation views, inspections, and useful/not-useful feedback emit only + categorical buckets; repository identity and query text are excluded. ## Public discovery and tool intelligence @@ -50,8 +56,8 @@ reasons, see [../architecture/decisions/](../architecture/decisions/). ## Catalog and operations -- Manually dispatched seed, metadata-enrichment, tool-enrichment, and embedding - jobs with explicit per-run bounds. +- 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. diff --git a/docs/product/overview.md b/docs/product/overview.md index 249af35..3360043 100644 --- a/docs/product/overview.md +++ b/docs/product/overview.md @@ -7,6 +7,8 @@ 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. The product also keeps a personal starred-repository library with search, lists, tags, saved state, and notes. Discover and Tool Intelligence remain @@ -38,8 +40,9 @@ See [surfaces.md](surfaces.md) for the route and API map. ## Scope -**In scope:** public repository discovery; evidence-aware tool intelligence; -public GitHub project connections; project-aware repository recommendations; +**In scope:** public repository discovery; public project preview; +evidence-aware tool intelligence; public GitHub project connections; +project-aware repository recommendations; starred-repository sync, organization, search, and public lists; repository details and stored star history. diff --git a/docs/product/surfaces.md b/docs/product/surfaces.md index 725e34b..e93ff3d 100644 --- a/docs/product/surfaces.md +++ b/docs/product/surfaces.md @@ -5,6 +5,7 @@ | Route | Purpose | Access | | --- | --- | --- | | `/` | Product landing page | Public | +| `/project-preview` | Read-only project-context preview before sign-in | 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 | @@ -26,6 +27,8 @@ ownership checks remain user-scoped regardless of URL knowledge. | `/api/auth/*` | NextAuth GitHub OAuth | | `/api/stars`, `/api/stars/sync` | Personal library reads and GitHub sync | | `/api/discover`, `/discover/data` | Public repository discovery | +| `/api/project-preview` | Catalog-first, non-persistent public project recommendations | +| `/api/github/projects` | On-demand public repository choices from the existing signed-in GitHub token | | `/api/projects` | List and connect user projects | | `/api/projects/[slug]` | Disconnect an owned project | | `/api/projects/[slug]/recommendations` | Similar repositories plus tools grounded in exact peer-repository detections | diff --git a/landing-astro/src/pages/index.astro b/landing-astro/src/pages/index.astro index 22d6834..1f9b159 100644 --- a/landing-astro/src/pages/index.astro +++ b/landing-astro/src/pages/index.astro @@ -2,9 +2,9 @@ import Layout from '@/layouts/Layout.astro'; // Static Astro landing overlaid at `/` during build:cf. -// Zero client JS. CTAs → /login and /discover. +// Zero client JS. Public preview submits through a native GET form. -const TITLE = 'Starboard — GitHub stars that stay useful'; +const TITLE = 'Starboard — tool intelligence for your GitHub projects'; const DESCRIPTION = 'Connect your GitHub projects, search open source by meaning, and discover tools that fit the work you are shipping.'; --- @@ -51,30 +51,38 @@ const DESCRIPTION =

- Your GitHub stars, - ranked for the work you ship. + Start with your project. + Find the tools that fit it.

- GitHub gives you a flat list. Starboard finds similar repositories for the project you - are building, then shows which tools those peers actually use and where the evidence came from. + Paste a public GitHub repository. Starboard finds similar projects across the catalog, + then shows which tools those peers use and the repository evidence behind each suggestion.

-
- - - Continue with GitHub - - - Browse public Discover - -
+
+ + + +
+ + + Or browse the public catalog +

- Scope read:user - only · no write access · revoke anytime in GitHub settings + No sign-in to preview · public repositories only · nothing saved until you connect

@@ -90,41 +98,40 @@ const DESCRIPTION = - Library · this week - ★ 1,247 + acme/checkout · similar projects + evidence match
- Hot + Peer 1
-

vercel/next.js

-

TypeScript · Next.js · matches project context

+

medusajs/medusa

+

TypeScript · shared commerce topics

- 128k + 32k
- Match + Peer 2
-

cloudflare/workers-sdk

-

Shared tools · Cloudflare · deployment

+

saleor/saleor

+

Shared API context · commerce platform

- 3.1k + 21k
- Watch + Tool
-

langchain-ai/langchain

-

Semantic match · agent orchestration

+

Vitest

+

Used by 3 grounded peers · 94% confidence

- 98k + inspect
-
- Stale +
+ Why
-

old-org/forgotten-tool

-

Broad discovery · sparse project evidence

+

Repository evidence stays visible

+

Peer source · detection confidence · direct links

- 420
@@ -139,10 +146,10 @@ const DESCRIPTION =

What you get

- A library for tools you already trust. + Tool intelligence grounded in projects.

- Built for developers who star first and remember later — then need the list to behave like product infrastructure. + Start with a repository, compare credible peers, and inspect the evidence before adding a tool to your stack.

@@ -181,23 +188,23 @@ const DESCRIPTION =

How it works

-

Three calm steps.

+

From project to evidence in three steps.

  1. 1 -

    Connect GitHub

    -

    One click. Read-only profile + stars. Never write scopes.

    +

    Preview a project

    +

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

  2. 2 -

    Sync the library

    -

    Stars and GitHub lists land in one searchable place — built for 1,000+.

    +

    Compare grounded peers

    +

    Multiple catalog signals find similar projects, then visible evidence reranks them.

  3. 3 -

    Connect a project

    -

    Get explained recommendations from its language, topics, metadata, and detected tools.

    +

    Evaluate the tools

    +

    See peer support and detection confidence, then inspect the source repositories.

@@ -285,17 +292,14 @@ const DESCRIPTION =

- Ready when your star list isn't. + Start with the project in front of you.

Free. No paid plans or premium locks. Similar repositories ground every tool recommendation in open-source evidence you can inspect.

- - - Sign in with GitHub + + Preview a public project
diff --git a/openspec/changes/focus-project-discovery/.openspec.yaml b/openspec/changes/archive/2026-08-09-focus-project-discovery/.openspec.yaml similarity index 100% rename from openspec/changes/focus-project-discovery/.openspec.yaml rename to openspec/changes/archive/2026-08-09-focus-project-discovery/.openspec.yaml diff --git a/openspec/changes/focus-project-discovery/design.md b/openspec/changes/archive/2026-08-09-focus-project-discovery/design.md similarity index 100% rename from openspec/changes/focus-project-discovery/design.md rename to openspec/changes/archive/2026-08-09-focus-project-discovery/design.md diff --git a/openspec/changes/focus-project-discovery/proposal.md b/openspec/changes/archive/2026-08-09-focus-project-discovery/proposal.md similarity index 100% rename from openspec/changes/focus-project-discovery/proposal.md rename to openspec/changes/archive/2026-08-09-focus-project-discovery/proposal.md diff --git a/openspec/changes/focus-project-discovery/specs/github-project-connections/spec.md b/openspec/changes/archive/2026-08-09-focus-project-discovery/specs/github-project-connections/spec.md similarity index 100% rename from openspec/changes/focus-project-discovery/specs/github-project-connections/spec.md rename to openspec/changes/archive/2026-08-09-focus-project-discovery/specs/github-project-connections/spec.md diff --git a/openspec/changes/focus-project-discovery/specs/public-discovery/spec.md b/openspec/changes/archive/2026-08-09-focus-project-discovery/specs/public-discovery/spec.md similarity index 100% rename from openspec/changes/focus-project-discovery/specs/public-discovery/spec.md rename to openspec/changes/archive/2026-08-09-focus-project-discovery/specs/public-discovery/spec.md diff --git a/openspec/changes/focus-project-discovery/specs/repo-tool-intelligence/spec.md b/openspec/changes/archive/2026-08-09-focus-project-discovery/specs/repo-tool-intelligence/spec.md similarity index 100% rename from openspec/changes/focus-project-discovery/specs/repo-tool-intelligence/spec.md rename to openspec/changes/archive/2026-08-09-focus-project-discovery/specs/repo-tool-intelligence/spec.md diff --git a/openspec/changes/focus-project-discovery/specs/star-momentum-insights/spec.md b/openspec/changes/archive/2026-08-09-focus-project-discovery/specs/star-momentum-insights/spec.md similarity index 100% rename from openspec/changes/focus-project-discovery/specs/star-momentum-insights/spec.md rename to openspec/changes/archive/2026-08-09-focus-project-discovery/specs/star-momentum-insights/spec.md diff --git a/openspec/changes/focus-project-discovery/tasks.md b/openspec/changes/archive/2026-08-09-focus-project-discovery/tasks.md similarity index 100% rename from openspec/changes/focus-project-discovery/tasks.md rename to openspec/changes/archive/2026-08-09-focus-project-discovery/tasks.md diff --git a/openspec/changes/prove-project-recommendation-value/.openspec.yaml b/openspec/changes/prove-project-recommendation-value/.openspec.yaml new file mode 100644 index 0000000..d77f64e --- /dev/null +++ b/openspec/changes/prove-project-recommendation-value/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-09 diff --git a/openspec/changes/prove-project-recommendation-value/design.md b/openspec/changes/prove-project-recommendation-value/design.md new file mode 100644 index 0000000..171ad66 --- /dev/null +++ b/openspec/changes/prove-project-recommendation-value/design.md @@ -0,0 +1,129 @@ +## Context + +See `proposal.md` for motivation. Connected-project recommendations currently +hydrate the 500 most-starred eligible repositories and apply deterministic +evidence scoring in memory. The repository already has D1 FTS5 indexes, +project-owned Vectorize, a 768-dimensional Workers AI embedding adapter, and a +semantic similar-repository route, but the Projects path does not reuse them. +The Astro landing is static by design, while authenticated product surfaces are +Next.js client/server routes. + +## Goals / Non-Goals + +**Goals:** + +- Retrieve a bounded candidate set from the full eligible catalog rather than a + popularity prefix, then retain deterministic evidence reranking and tool + provenance. +- Let guests prove value with transient public project context and carry that + context through sign-in without writing user data first. +- Preserve minimal GitHub scope, existing visual language, and degraded states. +- Emit useful activation and feedback events without repository identity. + +**Non-Goals:** + +- Private repository access, GitHub App installation, broader OAuth scopes, or + organization administration. +- A learned ranker, automated model training, personalized recommendations, or + storing free-form feedback. +- Billing, entitlements, background alerts, reports, Radar, Stack Builder, or + weekly digest behavior. +- A new design system or landing-page visual overhaul. + +## Decisions + +### 1. One recommendation service owns candidate retrieval and reranking + +Extract the route's candidate work into a server-only project intelligence +service. It accepts project metadata plus a result limit and returns the current +ranked result with retrieval metadata. Authenticated and preview routes use the +same service so preview quality cannot drift from the saved-project path. + +Alternative: call the existing similar-repository HTTP route. Rejected because +it requires an authenticated request, assumes an existing repo vector, returns +a different data shape, and would create an internal HTTP hop. + +### 2. Candidate generation is bounded hybrid retrieval + +Build project text from public metadata. Request up to 100 Vectorize neighbors, +using the existing vector by repository id when available and a transient +Workers AI embedding otherwise. Independently query the existing repository and +AI-metadata FTS indexes across all eligible catalog rows, plus an indexed +same-language candidate lane. Fuse candidate ids with reciprocal rank fusion, +cap hydration, then apply the existing evidence scorer. If semantic work fails, +continue with lexical/structured candidates. If all specific lanes are empty, +hydrate a small popularity fallback and mark it broad. + +Alternative: scan and hydrate the entire catalog. Rejected because it moves +unbounded data through a user-facing Worker and will become less safe as the +catalog grows. + +Alternative: persist a new vector synchronously for every preview. Rejected +because a transient preview must not mutate catalog state and connection should +not depend on Workers AI availability. + +### 3. Preview is a cacheable, read-only API and a dedicated product surface + +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. + +Alternative: connect automatically after OAuth. Rejected because the user +should still see and confirm the durable write boundary. + +### 4. GitHub repository choices load only on demand + +Add an authenticated endpoint that requests one bounded page of public +repositories through the existing session token. The Projects form loads it +only after the user opens the picker and retains URL entry as the fallback. +No new permission is requested. + +### 5. Analytics is categorical and identity-free + +Replace digest-era event types with project activation, recommendation-set, +inspection, and binary feedback events. Properties are categorical buckets: +manual versus picker, repository versus tool, result/rank/support/confidence +buckets, fallback state, and retrieval mode. Repository ids, names, URLs, +descriptions, topics, and query strings are forbidden in these events. + +### 6. Frontend work stays in the preserve lane + +Keep the current dark technical visual language, tokens, typography, components, +and navigation. Recompose the landing hero around a real repository input and +replace the obsolete Hot/Watch/Stale library mock with a project-to-peers-to-tool +evidence demonstration. Projects gains an on-demand picker, explicit retrieval +state, inspect actions, and compact feedback controls; it does not become a new +dashboard. + +## Risks / Trade-offs + +- **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. +- **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. +- **FTS OR queries become noisy** → Keep deterministic structured evidence as + the final ranker, cap each lane, and add fixed recommendation-quality fixtures. +- **Binary analytics cannot identify a specific bad repository** → Accept the + privacy trade-off for this pass; use aggregate evidence to decide where an + opt-in labeled evaluation system is warranted later. + +## Migration Plan + +1. Ship service and API behavior with tests before exposing the preview form. +2. Ship preview, picker, analytics, and preserved-lane UI together so every CTA + lands on a working path. +3. Run targeted tests, typecheck, lint, docs/OpenSpec checks, production build, + 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. diff --git a/openspec/changes/prove-project-recommendation-value/proposal.md b/openspec/changes/prove-project-recommendation-value/proposal.md new file mode 100644 index 0000000..56b6238 --- /dev/null +++ b/openspec/changes/prove-project-recommendation-value/proposal.md @@ -0,0 +1,56 @@ +## Why + +Starboard's focused product loop is now coherent, but its first-value path and +recommendation retrieval still undercut the promise: guests cannot test a +project before signing in, signed-in users must paste repository URLs, and +connected-project matches are reranked from only the 500 most-starred catalog +entries. The next pass should prove the core value before asking for commitment +and make the grounding set representative of the full eligible catalog. + +## What Changes + +- Add a public, read-only project preview that accepts a public GitHub URL or + `owner/repository`, resolves visible metadata, and returns a bounded sample of + explained similar projects before sign-in without persisting a user project. +- Replace the fixed top-500 popularity pool with bounded hybrid retrieval across + the eligible catalog: Vectorize and full-text candidates when available, + deterministic evidence reranking, and an honest broad fallback. +- Let authenticated users choose from public repositories available through + their existing GitHub token or continue pasting a repository URL; do not add + OAuth scope. +- Make the landing and Projects activation flow project-first and remove the + remaining star-library, Hot/Watch/Stale, and digest-era framing. +- Add privacy-safe product events and lightweight useful/not-useful controls for + recommendation-set views, repository inspections, tool inspections, and + 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. +- Update active product/status documentation and add a deterministic retrieval + evaluation fixture for the core recommendation path. + +## Capabilities + +### New Capabilities + +- `public-project-preview`: Read-only project-context preview before sign-in, + including bounded GitHub resolution and non-persistent recommendation output. + +### Modified Capabilities + +- `github-project-connections`: Add an authenticated public-repository picker + and privacy-safe measurement to the existing URL-based connection flow. +- `public-discovery`: Make project preview the primary public landing handoff + while preserving direct catalog browsing. +- `repo-tool-intelligence`: Retrieve grounded peers from the eligible catalog, + expose retrieval limitations, and collect privacy-safe recommendation + usefulness signals. + +## Impact + +- Affects the project recommendation service and API, GitHub project helpers, + Projects workspace, landing page, analytics module, focused tests, and active + product/architecture/status documentation. +- 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. diff --git a/openspec/changes/prove-project-recommendation-value/specs/github-project-connections/spec.md b/openspec/changes/prove-project-recommendation-value/specs/github-project-connections/spec.md new file mode 100644 index 0000000..205f54a --- /dev/null +++ b/openspec/changes/prove-project-recommendation-value/specs/github-project-connections/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### 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/changes/prove-project-recommendation-value/specs/public-discovery/spec.md b/openspec/changes/prove-project-recommendation-value/specs/public-discovery/spec.md new file mode 100644 index 0000000..902d85a --- /dev/null +++ b/openspec/changes/prove-project-recommendation-value/specs/public-discovery/spec.md @@ -0,0 +1,19 @@ +## 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/changes/prove-project-recommendation-value/specs/public-project-preview/spec.md b/openspec/changes/prove-project-recommendation-value/specs/public-project-preview/spec.md new file mode 100644 index 0000000..b93829d --- /dev/null +++ b/openspec/changes/prove-project-recommendation-value/specs/public-project-preview/spec.md @@ -0,0 +1,57 @@ +## Purpose + +Let a visitor test Starboard's project-aware discovery with public repository +context before creating an account or persisting a project connection. + +## ADDED 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 resolves its public GitHub metadata 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/changes/prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md b/openspec/changes/prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md new file mode 100644 index 0000000..3f84dbc --- /dev/null +++ b/openspec/changes/prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md @@ -0,0 +1,68 @@ +## MODIFIED Requirements + +### Requirement: Similar repositories ground tool recommendations + +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 + +- **WHEN** grounded similar repositories contain detected tools not already + detected in the connected project +- **THEN** Starboard recommends those tools with the exact peer repositories + and detection confidence that support the recommendation + +#### Scenario: Tool is already used by the connected project + +- **WHEN** a peer repository contains a tool already detected in the connected + project +- **THEN** Starboard omits that tool from the additions it recommends + +#### Scenario: Only broad fallback repositories are available + +- **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 + +## ADDED Requirements + +### 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 diff --git a/openspec/changes/prove-project-recommendation-value/tasks.md b/openspec/changes/prove-project-recommendation-value/tasks.md new file mode 100644 index 0000000..ea51f25 --- /dev/null +++ b/openspec/changes/prove-project-recommendation-value/tasks.md @@ -0,0 +1,30 @@ +## 1. Recommendation retrieval + +- [x] 1.1 Extract candidate hydration and project recommendation retrieval into a shared server-only service. +- [x] 1.2 Add bounded Vectorize, FTS, and same-language candidate lanes across the eligible catalog with deterministic fusion and honest fallback metadata. +- [x] 1.3 Replace the connected-project route's top-500 popularity pool with the shared service and cover hybrid, degraded, and fallback behavior with focused tests. +- [x] 1.4 Add a fixed evaluation fixture that catches popularity-prefix regressions and verifies relevant lower-star peers can reach the grounding set. + +## 2. Public project preview + +- [x] 2.1 Add catalog-first public project resolution and a read-only preview API that performs no user-data writes. +- [x] 2.2 Add the public `/project-preview` surface with loading, invalid, unavailable, degraded, recommendation, and sign-in handoff states. +- [x] 2.3 Add preview API and flow tests, including catalog hits, GitHub misses, and non-persistence assertions. + +## 3. GitHub project activation + +- [x] 3.1 Add an authenticated, bounded public-repository picker endpoint using the existing GitHub token and no broader scope. +- [x] 3.2 Add the on-demand picker and callback-prefilled manual connection path to Projects while preserving the URL fallback. +- [x] 3.3 Add focused picker API and Projects interaction tests. + +## 4. Product evidence and framing + +- [x] 4.1 Replace digest-era analytics with privacy-safe project connection, recommendation view, inspection, and binary feedback events. +- [x] 4.2 Add repository/tool inspection tracking, compact useful/not-useful controls, retrieval-state copy, support strength, and Tool Intelligence links. +- [x] 4.3 Reframe the static landing hero and product demonstration around public project preview and similar-project-grounded tool evidence. + +## 5. Documentation and verification + +- [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. diff --git a/openspec/specs/github-project-connections/spec.md b/openspec/specs/github-project-connections/spec.md new file mode 100644 index 0000000..d1e510b --- /dev/null +++ b/openspec/specs/github-project-connections/spec.md @@ -0,0 +1,90 @@ +# GitHub Project Connections + +## Purpose + +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 +repository by its GitHub URL or `owner/repository` name without requesting a +broader OAuth scope. + +#### Scenario: Valid public repository + +- **WHEN** an authenticated user submits a valid public GitHub repository +- **THEN** Starboard resolves the repository, stores it as that user's connected + project, and returns the connected project + +#### Scenario: Repository is already connected + +- **WHEN** a user submits a public repository they already connected +- **THEN** Starboard returns the existing connection without creating a + duplicate + +#### Scenario: Repository cannot be resolved publicly + +- **WHEN** the submitted repository is missing, private, or inaccessible with + the current permission +- **THEN** Starboard rejects the connection with a recovery message that states + only public repositories are supported + +### Requirement: User-owned project connections + +Starboard MUST isolate project connections by authenticated user. + +#### Scenario: List connected projects + +- **WHEN** an authenticated user opens Projects +- **THEN** Starboard returns only that user's connected projects + +#### Scenario: Disconnect a project + +- **WHEN** an authenticated user disconnects one of their projects +- **THEN** Starboard removes only that user's connection and retains the shared + repository catalog record + +#### Scenario: Guest accesses Projects + +- **WHEN** an unauthenticated visitor requests a project connection or project + recommendations +- **THEN** Starboard requires GitHub sign-in and exposes no user project data + +### Requirement: Project-aware recommendations + +Starboard SHALL recommend public repositories for a connected project using +available project metadata and tool evidence, and SHALL explain the evidence +that contributed to each result. + +#### Scenario: Recommendations have evidence + +- **WHEN** a connected project has language, topic, description, or detected + tool context +- **THEN** each recommendation identifies the matching signals and links to the + repository detail surface + +#### Scenario: Sparse project context + +- **WHEN** a connected project has little or no enriched context +- **THEN** Starboard labels the result as a broad discovery fallback instead of + implying a precise project match + +#### Scenario: Project is not owned by the requester + +- **WHEN** a user requests recommendations for another user's connected project +- **THEN** Starboard responds as though the connection does not exist + +### Requirement: Free connected-project workflow + +Starboard SHALL provide project connection, similar-project discovery, and +grounded tool recommendations without billing, subscription, or paid-feature +entitlement checks. + +#### Scenario: Signed-in user uses project intelligence + +- **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 diff --git a/openspec/specs/public-discovery/spec.md b/openspec/specs/public-discovery/spec.md index 154a01c..65771e0 100644 --- a/openspec/specs/public-discovery/spec.md +++ b/openspec/specs/public-discovery/spec.md @@ -61,3 +61,20 @@ Discover surface as the alternative to connecting GitHub. - **WHEN** a visitor selects the landing-page public browsing CTA - **THEN** the visitor reaches `/discover` and can use the shared corpus + +### Requirement: Connected project discovery handoff + +Starboard SHALL let an authenticated user move from a connected project to the +public repository corpus with the project context and recommendation evidence +visible. + +#### Scenario: User opens a connected project + +- **WHEN** an authenticated user selects one of their connected projects +- **THEN** Starboard shows project-aware repository recommendations drawn from + the public Discover corpus + +#### Scenario: User opens a recommended repository + +- **WHEN** a user selects a project recommendation +- **THEN** Starboard opens the existing public repository detail surface diff --git a/openspec/specs/repo-tool-intelligence/spec.md b/openspec/specs/repo-tool-intelligence/spec.md index 78a73f4..6bb45ae 100644 --- a/openspec/specs/repo-tool-intelligence/spec.md +++ b/openspec/specs/repo-tool-intelligence/spec.md @@ -61,3 +61,46 @@ The system SHALL collect tool intelligence through scheduled or manual enrichmen #### Scenario: Tool enrichment runs - **WHEN** the enrichment job runs against the seeded corpus - **THEN** it processes a bounded number of missing or stale repositories and records progress without delaying user-facing requests + +### Requirement: Connected project tool context + +The system SHALL use available detected-tool records for a connected project as +recommendation context while preserving confidence and evidence limitations. + +#### Scenario: Project has detected tools + +- **WHEN** a connected project has normalized tool records +- **THEN** project recommendations may use matching tool categories or tools and + state those matches in their explanations + +#### Scenario: Project has no detected tools + +- **WHEN** a connected project has not been enriched or has no reliable tool + records +- **THEN** project recommendations continue with other repository evidence and + do not claim a tool match + +### 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. + +#### Scenario: Peer repositories contain additional tools + +- **WHEN** grounded similar repositories contain detected tools not already + detected in the connected project +- **THEN** Starboard recommends those tools with the exact peer repositories + and detection confidence that support the recommendation + +#### Scenario: Tool is already used by the connected project + +- **WHEN** a peer repository contains a tool already detected in the connected + project +- **THEN** Starboard omits that tool from the additions it recommends + +#### Scenario: Only broad fallback repositories are available + +- **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 diff --git a/openspec/specs/star-momentum-insights/spec.md b/openspec/specs/star-momentum-insights/spec.md index e0147ec..626e7c8 100644 --- a/openspec/specs/star-momentum-insights/spec.md +++ b/openspec/specs/star-momentum-insights/spec.md @@ -28,14 +28,16 @@ The system SHALL rank repositories by star growth over a selected window using c ### Requirement: Momentum surfaces The system SHALL display star momentum in existing Starboard surfaces without blocking page loads on external network calls. -#### Scenario: Radar momentum summary -- **WHEN** the Radar page loads -- **THEN** it shows fastest-growing repositories and 30-day growth signals from stored snapshots - #### Scenario: Discover growth sort - **WHEN** a user sorts Discover by growth - **THEN** the results use stored snapshot growth metrics and remain paginated +#### Scenario: Repository detail history + +- **WHEN** a repository has stored star history +- **THEN** its repository detail surface may show that history without making a + synchronous GitHub request + ### Requirement: 5k corpus support The system SHALL support a seeded corpus of at least 5,000 repositories without synchronous snapshot or GitHub fetch work during UI requests. diff --git a/public/api-ai.json b/public/api-ai.json index 567567a..048b72b 100644 --- a/public/api-ai.json +++ b/public/api-ai.json @@ -25,6 +25,13 @@ "kind": "static", "description": "Browse and search the seeded public repository corpus" }, + { + "id": "project-preview", + "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" + }, { "id": "tools", "url": "https://starboard.codevetter.com/tools", diff --git a/public/catalog-updates.md b/public/catalog-updates.md index 7e4490a..2fea068 100644 --- a/public/catalog-updates.md +++ b/public/catalog-updates.md @@ -11,7 +11,7 @@ Recently cataloged popular repositories in the shared Discover corpus (typically ## Refresh cadence -Catalogue growth comes from the **manual** `seed-popular` GitHub Action (automatic daily scheduling is paused). Personal stars update when a user syncs; they are separate from this public popular pool. +Catalogue growth comes from the bounded daily `seed-popular` GitHub Action; manual dispatch remains available for operator checks. Personal stars update when a user syncs and remain separate from this public popular pool. ## Related diff --git a/public/index.md b/public/index.md index 51533a7..3bb605a 100644 --- a/public/index.md +++ b/public/index.md @@ -1,9 +1,10 @@ # Starboard -GitHub stars organizer, repository discovery surface, and semantic search product. +Project-aware GitHub repository discovery and tool intelligence. ## Public without sign-in +- Preview a 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 @@ -11,7 +12,7 @@ GitHub stars organizer, repository discovery surface, and semantic search produc ## With GitHub sign-in - Sync and organize personal stars with tags and collections -- Connect public GitHub projects for explained repository recommendations +- Connect public GitHub projects by URL or the on-demand public repository picker - Search and organize a personal starred-repository library ## Agent entrypoints diff --git a/public/llms-full.txt b/public/llms-full.txt index 37e740e..b011225 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -6,10 +6,11 @@ Project-aware GitHub repository discovery and tool intelligence. # Starboard -GitHub stars organizer, repository discovery surface, and semantic search product. +Project-aware GitHub repository discovery and tool intelligence. ## Public without sign-in +- Preview a 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 @@ -17,7 +18,7 @@ GitHub stars organizer, repository discovery surface, and semantic search produc ## With GitHub sign-in - Sync and organize personal stars with tags and collections -- Connect public GitHub projects for explained repository recommendations +- Connect public GitHub projects by URL or the on-demand public repository picker - Search and organize a personal starred-repository library ## Agent entrypoints @@ -29,6 +30,7 @@ GitHub stars organizer, repository discovery surface, and semantic search produc ## Product links - Home: https://starboard.codevetter.com/ — Product +- Project Preview: https://starboard.codevetter.com/project-preview — Try project-aware recommendations before 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 f168bc1..e2200ed 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -1,10 +1,11 @@ # Starboard -> GitHub stars organizer with semantic search — sub-product of CodeVetter for repo intelligence. +> Project-aware GitHub repository discovery and evidence-backed tool intelligence. ## Product - [Home](https://starboard.codevetter.com/): Product +- [Project Preview](https://starboard.codevetter.com/project-preview): Try project-aware recommendations before 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 new file mode 100644 index 0000000..979f782 --- /dev/null +++ b/public/project-preview.md @@ -0,0 +1,13 @@ +# 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. + +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. + +Recommendations combine available semantic, catalog text, and language evidence +before deterministic reranking. Degraded and broad-fallback states are labeled. diff --git a/src/__tests__/analytics.test.ts b/src/__tests__/analytics.test.ts index 71a27b8..dc46321 100644 --- a/src/__tests__/analytics.test.ts +++ b/src/__tests__/analytics.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { trackSearchOutcome } from '@/lib/analytics'; +import { + trackRecommendationFeedback, + trackRecommendationSetViewed, + trackSearchOutcome, +} from '@/lib/analytics'; describe('trackSearchOutcome', () => { const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => @@ -35,4 +39,42 @@ describe('trackSearchOutcome', () => { }); expect(body.properties).not.toHaveProperty('result_count_exact_capped'); }); + + it('emits categorical recommendation evidence without repository identity', () => { + trackRecommendationFeedback({ + kind: 'tool', + sentiment: 'useful', + rank: 2, + retrievalMode: 'hybrid', + supportCount: 3, + confidence: 94, + }); + + const request = fetchMock.mock.calls[0]?.[1] as RequestInit; + const body = JSON.parse(request.body as string); + expect(body.event).toBe('recommendation_feedback'); + expect(body.properties).toEqual({ + project_id: 'starboard', + kind: 'tool', + sentiment: 'useful', + rank_bucket: '1-3', + retrieval_mode: 'hybrid', + support_bucket: 'three_plus', + confidence_bucket: 'high', + }); + expect(JSON.stringify(body.properties)).not.toContain('repo'); + }); + + it('buckets recommendation-set size without an exact count', () => { + trackRecommendationSetViewed('lexical-structured', 12, false); + + const request = fetchMock.mock.calls[0]?.[1] as RequestInit; + const body = JSON.parse(request.body as string); + expect(body.properties).toEqual({ + project_id: 'starboard', + retrieval_mode: 'lexical-structured', + result_count_bucket: '6-20', + fallback: false, + }); + }); }); diff --git a/src/__tests__/github-projects-api.test.ts b/src/__tests__/github-projects-api.test.ts new file mode 100644 index 0000000..c618527 --- /dev/null +++ b/src/__tests__/github-projects-api.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + fetchRepositories: vi.fn(), +})); + +vi.mock('@/lib/auth', () => ({ auth: mocks.auth })); +vi.mock('@/lib/github-projects', async (importOriginal) => ({ + ...(await importOriginal()), + fetchPublicGitHubRepositories: mocks.fetchRepositories, +})); + +import { GET } from '@/app/api/github/projects/route'; + +describe('GitHub project picker API', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.auth.mockResolvedValue({ accessToken: 'token', user: { githubId: '1' } }); + mocks.fetchRepositories.mockResolvedValue([{ id: 1, fullName: 'acme/app' }]); + }); + + it('requires an authenticated GitHub token', async () => { + mocks.auth.mockResolvedValue(null); + + const response = await GET(); + + expect(response.status).toBe(401); + expect(mocks.fetchRepositories).not.toHaveBeenCalled(); + }); + + it('returns a bounded public repository choice list', async () => { + const response = await GET(); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + repositories: [{ id: 1, fullName: 'acme/app' }], + }); + expect(mocks.fetchRepositories).toHaveBeenCalledWith('token'); + }); +}); diff --git a/src/__tests__/github-projects.test.ts b/src/__tests__/github-projects.test.ts index 0837b3e..4d2b70b 100644 --- a/src/__tests__/github-projects.test.ts +++ b/src/__tests__/github-projects.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { fetchPublicGitHubProject, parseGitHubProjectInput } from '@/lib/github-projects'; +import { + fetchPublicGitHubProject, + fetchPublicGitHubRepositories, + parseGitHubProjectInput, +} from '@/lib/github-projects'; const slug = { owner: 'openai', repo: 'openai-node', fullName: 'openai/openai-node' }; @@ -114,4 +118,38 @@ describe('parseGitHubProjectInput', () => { await expect(fetchPublicGitHubProject(slug)).rejects.toThrow('GitHub API error: 503'); }); + + it('loads only public repository choices with the existing user token', async () => { + const publicRepo = { + id: 1, + name: 'openai-node', + full_name: 'openai/openai-node', + private: false, + visibility: 'public', + owner: { login: 'openai', avatar_url: '' }, + html_url: 'https://github.com/openai/openai-node', + description: null, + language: 'TypeScript', + stargazers_count: 10, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }; + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify([publicRepo, { ...publicRepo, id: 2, private: true }])) + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(fetchPublicGitHubRepositories('token')).resolves.toEqual([ + expect.objectContaining({ id: 1, fullName: 'openai/openai-node' }), + ]); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/user/repos?'), + expect.objectContaining({ + cache: 'no-store', + headers: expect.objectContaining({ Authorization: 'Bearer token' }), + }) + ); + }); }); diff --git a/src/__tests__/project-activation-ui.test.ts b/src/__tests__/project-activation-ui.test.ts new file mode 100644 index 0000000..475e140 --- /dev/null +++ b/src/__tests__/project-activation-ui.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +function source(path: string): string { + return readFileSync(join(process.cwd(), path), 'utf8'); +} + +describe('project-first activation wiring', () => { + it('lets the static landing submit a public repository directly to preview', () => { + const landing = source('landing-astro/src/pages/index.astro'); + + expect(landing).toContain('action="/project-preview"'); + expect(landing).toContain('name="repository"'); + expect(landing).toContain('No sign-in to preview'); + expect(landing).not.toContain('Library · this week'); + expect(landing).not.toContain('>Hot<'); + expect(landing).not.toContain('>Watch<'); + expect(landing).not.toContain('>Stale<'); + }); + + it('loads GitHub choices only from the explicit picker and preserves manual entry', () => { + const workspace = source('src/components/projects-workspace.tsx'); + + expect(workspace).toContain("fetch('/api/github/projects')"); + expect(workspace).toContain("setSource('picker')"); + expect(workspace).toContain("setSource('manual')"); + expect(workspace).toContain('initialRepository={initialRepository}'); + expect(workspace).toContain('Paste a URL instead'); + }); + + it('carries a successful preview through sign-in without auto-connecting it', () => { + const preview = source('src/components/project-preview-workspace.tsx'); + const login = source('src/app/login/page.tsx'); + const projectsPage = source('src/app/projects/page.tsx'); + + expect(preview).toContain('/login?callbackUrl='); + expect(preview).toContain('/projects?repository='); + expect(preview).toContain('Nothing is saved until'); + expect(preview).toContain('you explicitly connect the project'); + expect(login).toContain("value.startsWith('//')"); + expect(login).toContain("value.includes('\\\\')"); + expect(projectsPage).toContain('initialRepository={repository ??'); + }); +}); diff --git a/src/__tests__/project-intelligence.test.ts b/src/__tests__/project-intelligence.test.ts new file mode 100644 index 0000000..0d4a575 --- /dev/null +++ b/src/__tests__/project-intelligence.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { DbResult } from '@/db/client'; +import { + createProjectIntelligence, + type ProjectIntelligenceDependencies, +} from '@/lib/project-intelligence'; +import type { ProjectRecommendationRepo } from '@/lib/project-recommendations'; + +function result(rows: Record[]): DbResult { + return { rows, columns: [], rowsAffected: 0, lastInsertRowid: null }; +} + +function project(overrides: Partial = {}): ProjectRecommendationRepo { + return { + id: 1, + name: 'checkout', + fullName: 'acme/checkout', + htmlUrl: 'https://github.com/acme/checkout', + description: 'Payments orchestration for TypeScript services', + language: 'TypeScript', + stargazersCount: 20, + archived: false, + topics: ['payments'], + aiSummary: null, + aiCategory: null, + aiKeywords: [], + tools: [], + ...overrides, + }; +} + +function row( + id: number, + fullName: string, + overrides: Record = {} +): Record { + return { + id, + name: fullName.split('/')[1], + full_name: fullName, + html_url: `https://github.com/${fullName}`, + description: null, + language: 'TypeScript', + stargazers_count: 10_000, + archived: 0, + topics: '[]', + ai_summary: null, + ai_category: null, + ai_keywords: '[]', + tools: '[]', + ...overrides, + }; +} + +function dependencies( + execute: ProjectIntelligenceDependencies['database']['execute'], + vectorOverrides: Partial> = {} +): ProjectIntelligenceDependencies { + return { + database: { execute }, + vectorStore: () => ({ + query: vi.fn().mockResolvedValue([]), + queryByRepoId: vi.fn().mockResolvedValue([]), + ...vectorOverrides, + }), + embed: vi.fn().mockResolvedValue([[0.1, 0.2]]), + }; +} + +describe('project intelligence retrieval', () => { + it('retrieves beyond a popularity prefix and reranks the evidence-rich peer first', async () => { + const execute = vi.fn(async (statement: string | { sql: string; args?: unknown[] }) => { + const sql = typeof statement === 'string' ? statement : statement.sql; + if (sql.includes('repos_fts MATCH')) return result([{ id: 900 }, { id: 2 }]); + if (sql.includes('r.language = ? COLLATE NOCASE')) return result([{ id: 2 }, { id: 900 }]); + if (sql.includes('json_each(?)')) { + return result([ + row(2, 'oss/popular', { stargazers_count: 500_000 }), + row(900, 'oss/payments-kit', { + stargazers_count: 5_100, + description: 'Payments orchestration toolkit', + topics: '["payments"]', + }), + ]); + } + throw new Error(`Unexpected query: ${sql}`); + }); + const retrieve = createProjectIntelligence( + dependencies(execute, { + queryByRepoId: vi.fn().mockResolvedValue([{ repoId: 900, distance: 0.2 }]), + }) + ); + + const response = await retrieve(project(), 10); + + expect(response.retrieval).toMatchObject({ mode: 'hybrid', candidateCount: 2 }); + expect(response.similarProjects[0].fullName).toBe('oss/payments-kit'); + expect(response.similarProjects[0].stargazersCount).toBe(5_100); + const executedSql = execute.mock.calls + .map(([statement]) => (typeof statement === 'string' ? statement : statement.sql)) + .join('\n'); + expect(executedSql).not.toContain('LIMIT 500'); + }); + + it('degrades to lexical and structured catalog candidates when Vectorize is unavailable', async () => { + const execute = vi.fn(async (statement: string | { sql: string }) => { + const sql = typeof statement === 'string' ? statement : statement.sql; + if (sql.includes('repos_fts MATCH')) return result([{ id: 7 }]); + if (sql.includes('r.language = ? COLLATE NOCASE')) return result([]); + if (sql.includes('json_each(?)')) { + return result([row(7, 'oss/checkout-kit', { topics: '["payments"]' })]); + } + throw new Error(`Unexpected query: ${sql}`); + }); + const retrieve = createProjectIntelligence( + dependencies(execute, { + queryByRepoId: vi.fn().mockRejectedValue(new Error('Vectorize unavailable')), + }) + ); + + const response = await retrieve(project()); + + expect(response.retrieval.mode).toBe('lexical-structured'); + expect(response.retrieval.semanticCandidates).toBe(0); + expect(response.similarProjects[0].fullName).toBe('oss/checkout-kit'); + }); + + it('labels a bounded catalog fallback when no project-specific lane returns candidates', async () => { + const execute = vi.fn(async (statement: string | { sql: string }) => { + const sql = typeof statement === 'string' ? statement : statement.sql; + if (sql.includes('repos_fts MATCH')) return result([]); + if (sql.includes('ORDER BY r.stargazers_count DESC')) { + return result([row(3, 'oss/broad', { language: null })]); + } + throw new Error(`Unexpected query: ${sql}`); + }); + const retrieve = createProjectIntelligence(dependencies(execute)); + + const response = await retrieve( + project({ description: null, language: null, topics: [], fullName: 'acme/x', name: 'x' }) + ); + + expect(response.retrieval).toMatchObject({ mode: 'fallback', candidateCount: 1 }); + expect(response.fallback).toBe(true); + expect(response.recommendedTools).toEqual([]); + }); +}); diff --git a/src/__tests__/project-preview-api.test.ts b/src/__tests__/project-preview-api.test.ts new file mode 100644 index 0000000..e040c33 --- /dev/null +++ b/src/__tests__/project-preview-api.test.ts @@ -0,0 +1,57 @@ +import { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + resolve: vi.fn(), + retrieve: vi.fn(), +})); + +vi.mock('@/lib/project-preview', () => ({ resolveProjectPreview: mocks.resolve })); +vi.mock('@/lib/project-intelligence', () => ({ retrieveProjectIntelligence: mocks.retrieve })); + +import { GET } from '@/app/api/project-preview/route'; + +describe('public project preview API', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.retrieve.mockResolvedValue({ + similarProjects: [], + recommendedTools: [], + fallback: false, + context: { language: 'TypeScript', topics: [], tools: [] }, + retrieval: { + mode: 'hybrid', + candidateCount: 10, + semanticCandidates: 5, + lexicalCandidates: 5, + structuredCandidates: 5, + }, + }); + }); + + it('returns a format error without recommendation work', async () => { + mocks.resolve.mockResolvedValue({ status: 'invalid' }); + + const response = await GET( + new NextRequest('http://localhost/api/project-preview?repository=invalid') + ); + + expect(response.status).toBe(400); + expect(mocks.retrieve).not.toHaveBeenCalled(); + }); + + it('returns a non-persistent recommendation preview with public caching', async () => { + const project = { id: 1, fullName: 'acme/app' }; + mocks.resolve.mockResolvedValue({ status: 'resolved', source: 'catalog', project }); + + const response = await GET( + new NextRequest('http://localhost/api/project-preview?repository=acme%2Fapp') + ); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toContain('s-maxage=1800'); + expect(payload).toMatchObject({ project, source: 'catalog', retrieval: { mode: 'hybrid' } }); + expect(mocks.retrieve).toHaveBeenCalledWith(project, 12); + }); +}); diff --git a/src/__tests__/project-preview.test.ts b/src/__tests__/project-preview.test.ts new file mode 100644 index 0000000..65e35e3 --- /dev/null +++ b/src/__tests__/project-preview.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { DbResult } from '@/db/client'; +import { createProjectPreviewResolver } from '@/lib/project-preview'; + +function result(rows: Record[]): DbResult { + return { rows, columns: [], rowsAffected: 0, lastInsertRowid: null }; +} + +describe('public project preview resolution', () => { + it('rejects invalid input before reading the catalog or GitHub', async () => { + const execute = vi.fn(); + const fetchProject = vi.fn(); + const resolve = createProjectPreviewResolver({ database: { execute }, fetchProject }); + + await expect(resolve('not-a-repository')).resolves.toEqual({ status: 'invalid' }); + expect(execute).not.toHaveBeenCalled(); + expect(fetchProject).not.toHaveBeenCalled(); + }); + + it('uses enriched catalog context without an external GitHub request', async () => { + const execute = vi.fn().mockResolvedValue( + result([ + { + id: 1, + name: 'openai-node', + full_name: 'openai/openai-node', + html_url: 'https://github.com/openai/openai-node', + description: 'Official SDK', + language: 'TypeScript', + stargazers_count: 10_000, + archived: 0, + topics: '["openai"]', + ai_summary: 'Typed SDK', + ai_category: 'sdk', + ai_keywords: '["api"]', + tools: '[{"key":"vitest","name":"Vitest","category":"testing","confidence":95}]', + }, + ]) + ); + const fetchProject = vi.fn(); + const resolve = createProjectPreviewResolver({ database: { execute }, fetchProject }); + + await expect(resolve('openai/openai-node')).resolves.toMatchObject({ + status: 'resolved', + source: 'catalog', + project: { fullName: 'openai/openai-node', tools: [{ key: 'vitest' }] }, + }); + expect(fetchProject).not.toHaveBeenCalled(); + }); + + it('resolves one public GitHub repository on a catalog miss without persisting it', async () => { + const execute = vi.fn().mockResolvedValue(result([])); + const fetchProject = vi.fn().mockResolvedValue({ + id: 2, + name: 'sdk', + fullName: 'acme/sdk', + ownerLogin: 'acme', + ownerAvatar: '', + htmlUrl: 'https://github.com/acme/sdk', + description: 'SDK', + language: 'Go', + stargazersCount: 50, + archived: false, + topics: ['sdk'], + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }); + const resolve = createProjectPreviewResolver({ database: { execute }, fetchProject }); + + await expect(resolve('acme/sdk')).resolves.toMatchObject({ + status: 'resolved', + source: 'github', + project: { id: 2, fullName: 'acme/sdk', tools: [] }, + }); + expect(execute).toHaveBeenCalledTimes(1); + expect(fetchProject).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/projects-api.test.ts b/src/__tests__/projects-api.test.ts index 233882b..72e7178 100644 --- a/src/__tests__/projects-api.test.ts +++ b/src/__tests__/projects-api.test.ts @@ -6,6 +6,8 @@ const mocks = vi.hoisted(() => ({ execute: vi.fn(), batch: vi.fn(), fetchProject: vi.fn(), + retrieveProjectIntelligence: vi.fn(), + trackProjectConnected: vi.fn(), })); vi.mock('@/lib/auth', () => ({ auth: mocks.auth })); @@ -14,6 +16,12 @@ vi.mock('@/lib/github-projects', async (importOriginal) => ({ ...(await importOriginal()), fetchPublicGitHubProject: mocks.fetchProject, })); +vi.mock('@/lib/project-intelligence', () => ({ + retrieveProjectIntelligence: mocks.retrieveProjectIntelligence, +})); +vi.mock('@/lib/analytics', () => ({ + trackProjectConnected: mocks.trackProjectConnected, +})); import { GET as getRecommendations } from '@/app/api/projects/[slug]/recommendations/route'; import { DELETE } from '@/app/api/projects/[slug]/route'; @@ -47,6 +55,19 @@ describe('connected project APIs', () => { }); mocks.execute.mockResolvedValue({ rows: [], rowsAffected: 0 }); mocks.batch.mockResolvedValue([]); + mocks.retrieveProjectIntelligence.mockResolvedValue({ + similarProjects: [], + recommendedTools: [], + fallback: true, + context: { language: null, topics: [], tools: [] }, + retrieval: { + mode: 'fallback', + candidateCount: 0, + semanticCandidates: 0, + lexicalCandidates: 0, + structuredCandidates: 0, + }, + }); }); it('requires authentication before listing projects', async () => { @@ -79,7 +100,7 @@ describe('connected project APIs', () => { const response = await POST( new NextRequest('http://localhost/api/projects', { method: 'POST', - body: JSON.stringify({ repository: 'https://github.com/acme/app' }), + body: JSON.stringify({ repository: 'https://github.com/acme/app', source: 'picker' }), headers: { 'Content-Type': 'application/json' }, }) ); @@ -91,6 +112,7 @@ describe('connected project APIs', () => { }>; expect(statements[1].sql).toContain('INSERT INTO user_projects'); expect(statements[1].args).toEqual(['user-1', 42]); + expect(mocks.trackProjectConnected).toHaveBeenCalledWith('picker'); }); it("does not reveal another user's connected project", async () => { @@ -109,36 +131,34 @@ describe('connected project APIs', () => { }); it('returns similar repositories and tools grounded in those peers', async () => { - mocks.execute - .mockResolvedValueOnce({ - rows: [ - { - ...connectedRow, - tools: '[{"key":"nextjs","name":"Next.js","category":"framework","confidence":98}]', - }, - ], - rowsAffected: 0, - }) - .mockResolvedValueOnce({ - rows: [ - { - id: 84, - name: 'peer', - full_name: 'oss/peer', - html_url: 'https://github.com/oss/peer', - description: 'A TypeScript application peer', - language: 'TypeScript', - stargazers_count: 10_000, - archived: 0, - topics: '["nextjs"]', - ai_summary: null, - ai_category: null, - ai_keywords: '[]', - tools: '[{"key":"vitest","name":"Vitest","category":"testing","confidence":93}]', - }, - ], - rowsAffected: 0, - }); + mocks.execute.mockResolvedValueOnce({ + rows: [ + { + ...connectedRow, + tools: '[{"key":"nextjs","name":"Next.js","category":"framework","confidence":98}]', + }, + ], + rowsAffected: 0, + }); + mocks.retrieveProjectIntelligence.mockResolvedValueOnce({ + similarProjects: [{ id: 84, fullName: 'oss/peer' }], + recommendedTools: [ + { + key: 'vitest', + supportCount: 1, + sources: [{ repoId: 84, fullName: 'oss/peer', confidence: 93 }], + }, + ], + fallback: false, + context: { language: 'TypeScript', topics: ['nextjs'], tools: [] }, + retrieval: { + mode: 'hybrid', + candidateCount: 1, + semanticCandidates: 1, + lexicalCandidates: 1, + structuredCandidates: 1, + }, + }); const response = await getRecommendations( new NextRequest('http://localhost/api/projects/42/recommendations?limit=10'), @@ -160,6 +180,10 @@ describe('connected project APIs', () => { supportCount: 1, sources: [expect.objectContaining({ repoId: 84, fullName: 'oss/peer', confidence: 93 })], }); + expect(mocks.retrieveProjectIntelligence).toHaveBeenCalledWith( + expect.objectContaining({ id: 42, fullName: 'acme/app' }), + 10 + ); }); it('disconnects only the signed-in user relation', async () => { diff --git a/src/__tests__/sitemap.test.ts b/src/__tests__/sitemap.test.ts index c602624..c2aa9b4 100644 --- a/src/__tests__/sitemap.test.ts +++ b/src/__tests__/sitemap.test.ts @@ -9,6 +9,7 @@ describe('sitemap', () => { expect(sitemap().map((entry) => entry.url)).toEqual([ siteUrl, `${siteUrl}/discover`, + `${siteUrl}/project-preview`, `${siteUrl}/tools`, `${siteUrl}/catalog-updates`, `${siteUrl}/changelog`, diff --git a/src/app/api/github/projects/route.ts b/src/app/api/github/projects/route.ts new file mode 100644 index 0000000..ba1ac1a --- /dev/null +++ b/src/app/api/github/projects/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server'; + +import { auth } from '@/lib/auth'; +import { fetchPublicGitHubRepositories, GitHubProjectApiError } from '@/lib/github-projects'; + +export async function GET() { + const session = await auth(); + if (!session?.accessToken || !session.user?.githubId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const repositories = await fetchPublicGitHubRepositories(session.accessToken); + return NextResponse.json({ repositories }); + } catch (error) { + if (error instanceof GitHubProjectApiError && [403, 429].includes(error.status)) { + return NextResponse.json( + { error: 'GitHub is temporarily limiting repository choices. Paste a public URL instead.' }, + { status: 429, headers: { 'Retry-After': '60' } } + ); + } + console.error('Failed to load public GitHub repositories:', error); + return NextResponse.json( + { error: 'GitHub repositories could not load. Paste a public URL instead.' }, + { status: 502 } + ); + } +} diff --git a/src/app/api/project-preview/route.ts b/src/app/api/project-preview/route.ts new file mode 100644 index 0000000..f8b6507 --- /dev/null +++ b/src/app/api/project-preview/route.ts @@ -0,0 +1,50 @@ +import { type NextRequest, NextResponse } from 'next/server'; + +import { GitHubProjectApiError } from '@/lib/github-projects'; +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); + if (resolution.status === 'invalid') { + return NextResponse.json( + { error: 'Enter a public GitHub URL or owner/repository.' }, + { status: 400 } + ); + } + if (resolution.status === 'unavailable') { + return NextResponse.json( + { error: 'That repository is unavailable. Starboard previews public projects only.' }, + { status: 404 } + ); + } + + const recommendations = await retrieveProjectIntelligence(resolution.project, 12); + return NextResponse.json( + { + project: resolution.project, + source: resolution.source, + ...recommendations, + }, + { + headers: { + 'Cache-Control': 'public, max-age=300, s-maxage=1800, stale-while-revalidate=3600', + }, + } + ); + } catch (error) { + if (error instanceof GitHubProjectApiError && [403, 429].includes(error.status)) { + return NextResponse.json( + { error: 'GitHub is temporarily limiting public lookups. Try this preview again shortly.' }, + { status: 429, headers: { 'Retry-After': '60' } } + ); + } + console.error('Failed to preview public project:', error); + return NextResponse.json( + { error: 'Project preview is temporarily unavailable. Try again shortly.' }, + { status: 502 } + ); + } +} diff --git a/src/app/api/projects/[slug]/recommendations/route.ts b/src/app/api/projects/[slug]/recommendations/route.ts index 26fd512..569b799 100644 --- a/src/app/api/projects/[slug]/recommendations/route.ts +++ b/src/app/api/projects/[slug]/recommendations/route.ts @@ -2,16 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server'; import { db } from '@/db'; import { auth } from '@/lib/auth'; -import { - PROJECT_SELECT, - parseProjectTools, - parseStringArray, - projectFromRow, -} from '@/lib/connected-projects'; -import { - type ProjectRecommendationRepo, - rankProjectRecommendations, -} from '@/lib/project-recommendations'; +import { PROJECT_SELECT, projectFromRow } from '@/lib/connected-projects'; +import { retrieveProjectIntelligence } from '@/lib/project-intelligence'; export async function GET(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) { const session = await auth(); @@ -34,63 +26,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Project not found' }, { status: 404 }); } - const candidateResult = await db.execute({ - sql: `SELECT r.id, - r.name, - r.full_name, - r.html_url, - r.description, - r.language, - r.stargazers_count, - r.archived, - r.topics, - aim.summary AS ai_summary, - aim.category AS ai_category, - aim.keywords AS ai_keywords, - COALESCE(( - SELECT json_group_array(json_object( - 'key', rt.tool_key, - 'name', rt.tool_name, - 'category', rt.category, - 'confidence', rt.confidence - )) - FROM repo_tools rt - WHERE rt.repo_id = r.id - ), '[]') AS tools - FROM repos r - LEFT JOIN repo_ai_metadata aim ON aim.repo_id = r.id - WHERE r.id != ? - AND r.archived = 0 - AND r.stargazers_count >= 5000 - ORDER BY r.stargazers_count DESC - LIMIT 500`, - args: [repoId], - }); - - const candidates: ProjectRecommendationRepo[] = candidateResult.rows.map((row) => ({ - id: Number(row.id), - name: String(row.name), - fullName: String(row.full_name), - htmlUrl: String(row.html_url), - description: typeof row.description === 'string' ? row.description : null, - language: typeof row.language === 'string' ? row.language : null, - stargazersCount: Number(row.stargazers_count ?? 0), - archived: Boolean(row.archived), - topics: parseStringArray(row.topics), - aiSummary: typeof row.ai_summary === 'string' ? row.ai_summary : null, - aiCategory: typeof row.ai_category === 'string' ? row.ai_category : null, - aiKeywords: parseStringArray(row.ai_keywords), - tools: parseProjectTools(row.tools), - })); - const limit = Math.min( Math.max(Number(request.nextUrl.searchParams.get('limit') ?? 24) || 24, 1), 50 ); const project = projectFromRow(projectResult.rows[0]); + const recommendations = await retrieveProjectIntelligence(project, limit); return NextResponse.json({ project, - ...rankProjectRecommendations(project, candidates, limit), + ...recommendations, }); } diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 8fdaf81..0d33b60 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -3,7 +3,12 @@ import { type NextRequest, NextResponse } from 'next/server'; import { db } from '@/db'; import { auth } from '@/lib/auth'; import { PROJECT_SELECT, projectFromRow } from '@/lib/connected-projects'; -import { fetchPublicGitHubProject, parseGitHubProjectInput } from '@/lib/github-projects'; +import { trackProjectConnected, type ProjectConnectionSource } from '@/lib/analytics'; +import { + fetchPublicGitHubProject, + GitHubProjectApiError, + parseGitHubProjectInput, +} from '@/lib/github-projects'; export async function GET() { const session = await auth(); @@ -28,7 +33,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const body = (await request.json().catch(() => null)) as { repository?: unknown } | null; + const body = (await request.json().catch(() => null)) as { + repository?: unknown; + source?: unknown; + } | null; const slug = parseGitHubProjectInput(typeof body?.repository === 'string' ? body.repository : ''); if (!slug) { return NextResponse.json( @@ -41,6 +49,12 @@ export async function POST(request: NextRequest) { try { project = await fetchPublicGitHubProject(slug, session.accessToken); } catch (error) { + if (error instanceof GitHubProjectApiError && [403, 429].includes(error.status)) { + return NextResponse.json( + { error: 'GitHub is temporarily limiting repository lookups. Try again shortly.' }, + { status: 429, headers: { 'Retry-After': '60' } } + ); + } console.error('Failed to resolve connected project:', error); return NextResponse.json( { error: 'GitHub could not be reached. Try connecting the project again.' }, @@ -104,5 +118,8 @@ export async function POST(request: NextRequest) { args: [session.user.githubId, project.id], }); + const source: ProjectConnectionSource = body?.source === 'picker' ? 'picker' : 'manual'; + trackProjectConnected(source); + return NextResponse.json({ project: projectFromRow(connected.rows[0]) }, { status: 201 }); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index a7424a9..6bfa19a 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -45,18 +45,27 @@ function resolveError(error: string | undefined) { return ERROR_COPY[key] ?? ERROR_COPY.Default; } +function internalCallbackUrl(value: string | undefined): string { + if (!value?.startsWith('/') || value.startsWith('//') || value.includes('\\')) { + return '/projects'; + } + return value; +} + export default async function LoginPage({ searchParams, }: { searchParams: Promise<{ error?: string; callbackUrl?: string }>; }) { + const params = await searchParams; + const callbackUrl = internalCallbackUrl(params.callbackUrl); + const callback = new URL(callbackUrl, 'https://starboard.codevetter.com'); + const continuedRepository = + callback.pathname === '/projects' ? callback.searchParams.get('repository') : null; const session = await auth(); if (session?.user?.githubId) { - redirect('/stars'); + redirect(callbackUrl); } - - const params = await searchParams; - const callbackUrl = params.callbackUrl?.startsWith('/') ? params.callbackUrl : '/stars'; const errorInfo = resolveError(params.error); return ( @@ -110,7 +119,7 @@ export default async function LoginPage({

- Your tool library + Project-aware tool intelligence

Stars that stay useful after you close the tab. @@ -140,7 +149,7 @@ export default async function LoginPage({

-

+

Live at starboard.codevetter.com

@@ -156,9 +165,14 @@ export default async function LoginPage({ Starboard · tool intelligence -

Welcome back

+

+ {continuedRepository ? `Connect ${continuedRepository}` : 'Continue to Starboard'} +

- Sign in with GitHub to open your library. We only request{' '} + {continuedRepository + ? `Your preview stays attached to ${continuedRepository}. Sign in to confirm the connection and keep its recommendations. ` + : 'Sign in with GitHub to connect public projects and keep your recommendations. '} + We only request{' '} read:user {' '} @@ -185,6 +199,16 @@ export default async function LoginPage({

)} + {continuedRepository && !errorInfo && ( +
+

Continuing your preview

+

{continuedRepository}

+

+ Nothing is connected until you confirm it on Projects. +

+
+ )} +
@@ -198,21 +222,21 @@ export default async function LoginPage({
  • - Sync stars & GitHub lists into one library + Choose a public repository from GitHub or paste its URL
  • - Semantic discovery across your library and the public catalog + Compare it with similar projects across the public catalog
  • - Similar-project grounding with repository-sourced tool recommendations + Inspect repository-sourced tool evidence and save the project
-
+

Prefer to look first?{' '} ; +}) { + const params = await searchParams; + const repository = Array.isArray(params.repository) ? params.repository[0] : params.repository; + return ; +} diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 6810f13..fd034ed 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -1,5 +1,11 @@ import { ProjectsWorkspace } from '@/components/projects-workspace'; -export default function ProjectsPage() { - return ; +export default async function ProjectsPage({ + searchParams, +}: { + searchParams: Promise<{ repository?: string | string[] }>; +}) { + const params = await searchParams; + const repository = Array.isArray(params.repository) ? params.repository[0] : params.repository; + return ; } diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 5bf88ee..77fc26a 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -15,6 +15,12 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: 'daily', priority: 0.95, }, + { + url: `${siteUrl}/project-preview`, + lastModified: now, + changeFrequency: 'weekly', + priority: 0.98, + }, { url: `${siteUrl}/tools`, lastModified: now, diff --git a/src/components/project-preview-workspace.tsx b/src/components/project-preview-workspace.tsx new file mode 100644 index 0000000..30c8444 --- /dev/null +++ b/src/components/project-preview-workspace.tsx @@ -0,0 +1,269 @@ +'use client'; + +import { ArrowLeft, FolderGit2, Loader2, Search, Wrench } from 'lucide-react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { type FormEvent, useEffect, useState } from 'react'; +import useSWR from 'swr'; + +import { + GroundedToolRecommendationCard, + ProjectRecommendationCard, +} from '@/components/project-recommendation-cards'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { trackRecommendationSetViewed, type RecommendationRetrievalMode } from '@/lib/analytics'; +import type { ProjectIntelligenceResult } from '@/lib/project-intelligence'; +import type { ProjectRecommendationRepo } from '@/lib/project-recommendations'; +import { jsonFetcher } from '@/lib/swr-fetcher'; + +interface PreviewResponse extends ProjectIntelligenceResult { + project: ProjectRecommendationRepo; + source: 'catalog' | 'github'; +} + +function retrievalCopy(mode: RecommendationRetrievalMode): string { + if (mode === 'hybrid') return 'Broad evidence match'; + if (mode === 'semantic') return 'Meaning-based match'; + if (mode === 'lexical-structured') return 'Catalog-context match'; + if (mode === 'structured') return 'Language match'; + return 'Broad public-catalog fallback'; +} + +export function ProjectPreviewWorkspace({ initialRepository }: { initialRepository: string }) { + const router = useRouter(); + const [repository, setRepository] = useState(initialRepository); + const [showAllPeers, setShowAllPeers] = useState(false); + const previewUrl = initialRepository + ? `/api/project-preview?repository=${encodeURIComponent(initialRepository)}` + : null; + const { data, error, isLoading } = useSWR(previewUrl, jsonFetcher, { + revalidateOnFocus: false, + }); + + useEffect(() => { + if (!data) return; + trackRecommendationSetViewed(data.retrieval.mode, data.similarProjects.length, data.fallback); + }, [data]); + + useEffect(() => { + setShowAllPeers(false); + }, [initialRepository]); + + function submit(event: FormEvent) { + event.preventDefault(); + const value = repository.trim(); + if (!value) return; + router.push(`/project-preview?repository=${encodeURIComponent(value)}`); + } + + const connectCallback = data + ? `/projects?repository=${encodeURIComponent(data.project.fullName)}` + : '/projects'; + const loginHref = `/login?callbackUrl=${encodeURIComponent(connectCallback)}`; + + return ( +

+
+
+ + + Starboard + + +
+
+ +
+
+ + + Back to Starboard + +
+

+ Preview tool intelligence for a public project +

+

+ See similar open-source projects and the tools their peers use. Nothing is saved until + you explicitly connect the project after GitHub sign-in. +

+
+
+ setRepository(event.target.value)} + placeholder="github.com/owner/repository" + aria-label="Public GitHub repository" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + className="h-11" + /> + +
+

+ Public repositories only. Preview is read-only and creates no project, list, or note. +

+
+ + {!initialRepository && ( +
+
+ +
+

Start with a repository you know

+

+ Paste a public project to compare it with Starboard's catalog before signing in. +

+
+ )} + + {initialRepository && isLoading && !data && ( +
+ + + Loading project recommendations + +
+ )} + + {error && ( +
+

This project could not be previewed

+

+ {error instanceof Error ? error.message : 'Try another public GitHub repository.'} +

+
+ )} + + {data && ( +
+
+
+
+

{data.project.fullName}

+ {data.project.language && ( + {data.project.language} + )} + {retrievalCopy(data.retrieval.mode)} +
+

+ {data.project.description ?? 'No repository description is available.'} +

+

+ Context source:{' '} + {data.source === 'catalog' + ? 'Starboard catalog evidence' + : 'public GitHub metadata'} +

+
+ +
+ + {data.fallback && ( +

+ This project has limited matching context. These are broad catalog picks, and no + tool recommendation is presented as grounded. +

+ )} + +
+
+

Similar projects

+

+ A focused preview from the public catalog, reranked by visible project evidence. +

+
+ {data.similarProjects.length === 0 ? ( +
+ No grounded catalog matches are available yet. Try another public project or + browse Discover. +
+ ) : ( +
+ {(showAllPeers ? data.similarProjects : data.similarProjects.slice(0, 3)).map( + (recommendation, index) => ( + + ) + )} +
+ )} + {data.similarProjects.length > 3 && ( + + )} +
+ + {!data.fallback && ( +
+
+

Tools to evaluate

+

+ Each suggestion comes only from detected tools in the similar projects above. +

+
+ {data.recommendedTools.length === 0 ? ( +
+ No additional tools are grounded strongly enough yet. +
+ ) : ( +
+ {data.recommendedTools.map((recommendation, index) => ( + + ))} +
+ )} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/project-recommendation-cards.tsx b/src/components/project-recommendation-cards.tsx new file mode 100644 index 0000000..26366f9 --- /dev/null +++ b/src/components/project-recommendation-cards.tsx @@ -0,0 +1,240 @@ +'use client'; + +import { ArrowUpRight, ThumbsDown, ThumbsUp } from 'lucide-react'; +import Link from 'next/link'; +import { useState } from 'react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + trackRecommendationFeedback, + trackRecommendationInspected, + type RecommendationKind, + type RecommendationRetrievalMode, + type RecommendationSentiment, +} from '@/lib/analytics'; +import type { + GroundedToolRecommendation, + ProjectRecommendation, +} from '@/lib/project-recommendations'; + +function formatNumber(value: number): string { + return new Intl.NumberFormat(undefined, { notation: 'compact' }).format(value); +} + +function RecommendationFeedback({ + kind, + rank, + retrievalMode, + supportCount, + confidence, +}: { + kind: RecommendationKind; + rank: number; + retrievalMode: RecommendationRetrievalMode; + supportCount?: number; + confidence?: number; +}) { + const [sentiment, setSentiment] = useState(null); + + function rate(next: RecommendationSentiment) { + if (sentiment === next) return; + setSentiment(next); + trackRecommendationFeedback({ + kind, + sentiment: next, + rank, + retrievalMode, + supportCount, + confidence, + }); + } + + return ( +
+ + {sentiment ? 'Feedback saved — you can change it' : 'Useful recommendation?'} + +
+ + +
+
+ ); +} + +export function ProjectRecommendationCard({ + recommendation, + rank, + retrievalMode, +}: { + recommendation: ProjectRecommendation; + rank: number; + retrievalMode: RecommendationRetrievalMode; +}) { + function inspect() { + trackRecommendationInspected('repository', rank, retrievalMode); + } + + return ( + + +
+
+ + + {recommendation.fullName} + + +
+ #{rank} + {recommendation.language && ( + {recommendation.language} + )} + {formatNumber(recommendation.stargazersCount)} stars +
+
+ +
+
+ +

+ {recommendation.description ?? 'No repository description is available.'} +

+
    + {recommendation.evidence.map((item) => ( +
  • + + {item} +
  • + ))} +
+ +
+
+ ); +} + +function toolSignal(recommendation: GroundedToolRecommendation): { + label: string; + averageConfidence: number; +} { + const averageConfidence = + recommendation.sources.reduce((sum, source) => sum + source.confidence, 0) / + Math.max(recommendation.sources.length, 1); + const label = + recommendation.supportCount >= 3 && averageConfidence >= 90 + ? 'Strong peer signal' + : recommendation.supportCount >= 2 + ? 'Repeated peer signal' + : 'Early peer signal'; + return { label, averageConfidence }; +} + +export function GroundedToolRecommendationCard({ + recommendation, + rank, + retrievalMode, +}: { + recommendation: GroundedToolRecommendation; + rank: number; + retrievalMode: RecommendationRetrievalMode; +}) { + const signal = toolSignal(recommendation); + + return ( + + +
+
+ + trackRecommendationInspected('tool', rank, retrievalMode)} + > + {recommendation.name} + + +

{recommendation.category}

+
+ {signal.label} +
+
+ +

+ Used by {recommendation.supportCount} grounded{' '} + {recommendation.supportCount === 1 ? 'peer' : 'peers'} with an average detection + confidence of {Math.round(signal.averageConfidence)}%. +

+
+

Repository evidence

+
    + {recommendation.sources.slice(0, 4).map((source) => ( +
  • + trackRecommendationInspected('tool', rank, retrievalMode)} + > + {source.fullName} + + + {Math.round(source.confidence)}% + +
  • + ))} +
+ {recommendation.sources.length > 4 && ( +

+ And {recommendation.sources.length - 4} more{' '} + {recommendation.sources.length - 4 === 1 ? 'repository' : 'repositories'}. +

+ )} +
+ +
+
+ ); +} diff --git a/src/components/projects-workspace.tsx b/src/components/projects-workspace.tsx index d4d3618..b3580b5 100644 --- a/src/components/projects-workspace.tsx +++ b/src/components/projects-workspace.tsx @@ -3,6 +3,7 @@ import { ArrowUpRight, BookOpen, + ChevronsUpDown, FolderGit2, Loader2, Plus, @@ -13,38 +14,44 @@ import { import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useSession } from 'next-auth/react'; -import { type FormEvent, useEffect, useMemo, useState } from 'react'; +import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'; import useSWR from 'swr'; +import { + GroundedToolRecommendationCard, + ProjectRecommendationCard, +} from '@/components/project-recommendation-cards'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; +import { + trackRecommendationSetViewed, + type ProjectConnectionSource, + type RecommendationRetrievalMode, +} from '@/lib/analytics'; import type { ConnectedProject } from '@/lib/connected-projects'; -import type { - GroundedToolRecommendation, - ProjectRecommendation, -} from '@/lib/project-recommendations'; +import type { PublicGitHubProject } from '@/lib/github-projects'; +import type { ProjectIntelligenceResult } from '@/lib/project-intelligence'; import { jsonFetcher } from '@/lib/swr-fetcher'; interface ProjectsResponse { projects: ConnectedProject[]; } -interface RecommendationsResponse { +interface RecommendationsResponse extends ProjectIntelligenceResult { project: ConnectedProject; - similarProjects: ProjectRecommendation[]; - recommendedTools: GroundedToolRecommendation[]; - fallback: boolean; - context: { - language: string | null; - topics: string[]; - tools: ConnectedProject['tools']; - }; } -function formatNumber(value: number): string { - return new Intl.NumberFormat(undefined, { notation: 'compact' }).format(value); +interface GitHubProjectsResponse { + repositories: PublicGitHubProject[]; +} + +function retrievalLabel(mode: RecommendationRetrievalMode): string { + if (mode === 'hybrid') return 'Broad evidence match'; + if (mode === 'semantic') return 'Meaning-based match'; + if (mode === 'lexical-structured') return 'Catalog-context match'; + if (mode === 'structured') return 'Language match'; + return 'Broad catalog fallback'; } function LoadingScreen() { @@ -84,10 +91,75 @@ function ProjectNav() { ); } -function ConnectProjectForm({ onConnected }: { onConnected: (project: ConnectedProject) => void }) { - const [repository, setRepository] = useState(''); +function ConnectProjectForm({ + initialRepository, + onConnected, +}: { + initialRepository: string; + onConnected: (project: ConnectedProject) => void; +}) { + const [repository, setRepository] = useState(initialRepository); + const [source, setSource] = useState('manual'); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); + const [pickerBusy, setPickerBusy] = useState(false); + const [pickerError, setPickerError] = useState(null); + const [pickerRepositories, setPickerRepositories] = useState(null); + const [pickerQuery, setPickerQuery] = useState(''); + const [activePickerIndex, setActivePickerIndex] = useState(0); + const pickerButtonRef = useRef(null); + const pickerOptionRefs = useRef>([]); + const filteredRepositories = useMemo(() => { + const query = pickerQuery.trim().toLowerCase(); + if (!query) return pickerRepositories ?? []; + return (pickerRepositories ?? []).filter( + (project) => + project.fullName.toLowerCase().includes(query) || + project.description?.toLowerCase().includes(query) || + project.language?.toLowerCase().includes(query) + ); + }, [pickerQuery, pickerRepositories]); + + function movePickerFocus(offset: number) { + if (filteredRepositories.length === 0) return; + const next = + (activePickerIndex + offset + filteredRepositories.length) % filteredRepositories.length; + setActivePickerIndex(next); + pickerOptionRefs.current[next]?.focus(); + } + + function closePicker() { + setPickerOpen(false); + queueMicrotask(() => pickerButtonRef.current?.focus()); + } + + async function togglePicker() { + const nextOpen = !pickerOpen; + setPickerOpen(nextOpen); + if (nextOpen) { + setPickerQuery(''); + setActivePickerIndex(0); + } + if (!nextOpen || pickerRepositories || pickerBusy) return; + + setPickerBusy(true); + setPickerError(null); + try { + const response = await fetch('/api/github/projects'); + const payload = (await response.json()) as GitHubProjectsResponse & { error?: string }; + if (!response.ok) throw new Error(payload.error || 'GitHub repositories could not load.'); + setPickerRepositories(payload.repositories); + } catch (reason) { + setPickerError( + reason instanceof Error + ? reason.message + : 'GitHub repositories could not load. Paste a public URL instead.' + ); + } finally { + setPickerBusy(false); + } + } async function submit(event: FormEvent) { event.preventDefault(); @@ -98,13 +170,14 @@ function ConnectProjectForm({ onConnected }: { onConnected: (project: ConnectedP const response = await fetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ repository }), + body: JSON.stringify({ repository, source }), }); const payload = (await response.json()) as { project?: ConnectedProject; error?: string }; if (!response.ok || !payload.project) { throw new Error(payload.error || 'Project could not be connected.'); } setRepository(''); + setSource('manual'); onConnected(payload.project); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Project could not be connected.'); @@ -115,21 +188,141 @@ function ConnectProjectForm({ onConnected }: { onConnected: (project: ConnectedP return (
-
- setRepository(event.target.value)} - placeholder="github.com/owner/repository" - aria-label="Public GitHub repository" - autoCapitalize="none" - autoCorrect="off" - spellCheck={false} - /> - +
+ {pickerOpen && ( +
{ + if (event.key === 'Escape') { + event.preventDefault(); + closePicker(); + } + }} + > + {pickerBusy && ( +

Loading public repositories…

+ )} + {pickerError && ( +

+ {pickerError} +

+ )} + {pickerRepositories && !pickerError && ( +
+ { + setPickerQuery(event.target.value); + setActivePickerIndex(0); + }} + onKeyDown={(event) => { + if (event.key === 'ArrowDown' && filteredRepositories.length > 0) { + event.preventDefault(); + pickerOptionRefs.current[0]?.focus(); + } + }} + placeholder="Search public repositories" + aria-label="Search public GitHub repositories" + aria-controls="github-project-results" + /> +

+ {filteredRepositories.length}{' '} + {filteredRepositories.length === 1 ? 'repository' : 'repositories'} +

+
+ )} + {pickerRepositories?.length === 0 && ( +

+ No public repositories were returned. Paste a URL instead. +

+ )} + {pickerRepositories && + pickerRepositories.length > 0 && + filteredRepositories.length === 0 && ( +

+ No repositories match that search. +

+ )} +
{ + if (event.key === 'ArrowDown') { + event.preventDefault(); + movePickerFocus(1); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + movePickerFocus(-1); + } + }} + > + {filteredRepositories.map((project, index) => ( + + ))} +
+
+ )}

Free. Public repositories only, with the current minimal GitHub permission.

@@ -142,93 +335,16 @@ function ConnectProjectForm({ onConnected }: { onConnected: (project: ConnectedP ); } -function RecommendationCard({ recommendation }: { recommendation: ProjectRecommendation }) { - return ( - - -
-
- - - {recommendation.fullName} - - -
- {recommendation.language && ( - {recommendation.language} - )} - {formatNumber(recommendation.stargazersCount)} stars -
-
- -
-
- -

- {recommendation.description ?? 'No repository description is available.'} -

-
    - {recommendation.evidence.map((item) => ( -
  • - - {item} -
  • - ))} -
-
-
- ); -} - -function GroundedToolCard({ recommendation }: { recommendation: GroundedToolRecommendation }) { - return ( - - -
-
- {recommendation.name} -

{recommendation.category}

-
- - {recommendation.supportCount} similar{' '} - {recommendation.supportCount === 1 ? 'repo' : 'repos'} - -
-
- -

Grounded in repository evidence

-
    - {recommendation.sources.slice(0, 4).map((source) => ( -
  • - - {source.fullName} - - - {Math.round(source.confidence)}% detection - -
  • - ))} -
-
-
- ); -} - -export function ProjectsWorkspace({ selectedSlug }: { selectedSlug?: string }) { +export function ProjectsWorkspace({ + selectedSlug, + initialRepository = '', +}: { + selectedSlug?: string; + initialRepository?: string; +}) { const { status } = useSession(); const router = useRouter(); + const trackedSet = useRef(null); const { data, error, isLoading, mutate } = useSWR( status === 'authenticated' ? '/api/projects' : null, jsonFetcher, @@ -236,11 +352,16 @@ export function ProjectsWorkspace({ selectedSlug }: { selectedSlug?: string }) { ); const projects = data?.projects ?? []; const selectedProject = useMemo( - () => projects.find((project) => String(project.id) === selectedSlug) ?? projects[0] ?? null, - [projects, selectedSlug] + () => + initialRepository && !selectedSlug + ? null + : (projects.find((project) => String(project.id) === selectedSlug) ?? projects[0] ?? null), + [initialRepository, projects, selectedSlug] ); const [disconnectId, setDisconnectId] = useState(null); const [disconnecting, setDisconnecting] = useState(false); + const [disconnectError, setDisconnectError] = useState(null); + const [showAllPeers, setShowAllPeers] = useState(false); const recommendationUrl = selectedProject ? `/api/projects/${selectedProject.id}/recommendations?limit=24` : null; @@ -254,15 +375,34 @@ export function ProjectsWorkspace({ selectedSlug }: { selectedSlug?: string }) { useEffect(() => { if (status === 'unauthenticated') { - router.replace('/login?callbackUrl=%2Fprojects'); + const callback = initialRepository + ? `/projects?repository=${encodeURIComponent(initialRepository)}` + : '/projects'; + router.replace(`/login?callbackUrl=${encodeURIComponent(callback)}`); } - }, [router, status]); + }, [initialRepository, router, status]); useEffect(() => { - if (selectedProject && selectedSlug !== String(selectedProject.id)) { + if (selectedProject && !initialRepository && selectedSlug !== String(selectedProject.id)) { router.replace(`/projects/${selectedProject.id}`); } - }, [router, selectedProject, selectedSlug]); + }, [initialRepository, router, selectedProject, selectedSlug]); + + useEffect(() => { + if (!recommendations || !selectedProject) return; + const key = `${selectedProject.id}:${recommendations.retrieval.mode}:${recommendations.similarProjects.length}`; + if (trackedSet.current === key) return; + trackedSet.current = key; + trackRecommendationSetViewed( + recommendations.retrieval.mode, + recommendations.similarProjects.length, + recommendations.fallback + ); + }, [recommendations, selectedProject]); + + useEffect(() => { + setShowAllPeers(false); + }, [selectedProject?.id]); async function disconnect(project: ConnectedProject) { if (disconnectId !== project.id) { @@ -270,6 +410,7 @@ export function ProjectsWorkspace({ selectedSlug }: { selectedSlug?: string }) { return; } setDisconnecting(true); + setDisconnectError(null); try { const response = await fetch(`/api/projects/${project.id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Project could not be disconnected.'); @@ -279,6 +420,10 @@ export function ProjectsWorkspace({ selectedSlug }: { selectedSlug?: string }) { { revalidate: false } ); router.replace('/projects'); + } catch (reason) { + setDisconnectError( + reason instanceof Error ? reason.message : 'Project could not be disconnected.' + ); } finally { setDisconnecting(false); } @@ -313,7 +458,7 @@ export function ProjectsWorkspace({ selectedSlug }: { selectedSlug?: string }) {
) : ( -
+
@@ -421,12 +569,20 @@ export function ProjectsWorkspace({ selectedSlug }: { selectedSlug?: string }) {
-
+
-

Similar projects

+
+

Similar projects

+ {recommendations && ( + + {retrievalLabel(recommendations.retrieval.mode)} + + )} +

- The grounding set for recommendations, ranked by visible repository evidence. + The grounding set for recommendations, retrieved from the public catalog and + reranked by visible evidence.

+ {recommendations && recommendations.similarProjects.length > 3 && ( + + )} +
+ + {disconnectError && ( +

+ {disconnectError} +

+ )} {recommendations && !recommendations.fallback && ( -
+
-

Tools used by similar projects

+

Tools to evaluate

Free recommendations derived only from detected tools in the grounded peers - above. + above, with support and source confidence visible.

{recommendations.recommendedTools.length === 0 ? ( @@ -481,15 +669,17 @@ export function ProjectsWorkspace({ selectedSlug }: { selectedSlug?: string }) {
) : (
- {recommendations.recommendedTools.map((recommendation) => ( - ( + ))}
)} -
+ )}
)} diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index f6b17dd..49e5a46 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -20,11 +20,20 @@ const POSTHOG_HOST = process.env.NEXT_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posth /** * The product-specific action behind a `core_action` event. - * Starboard's core verbs: syncing your GitHub stars in, and organizing - * them into collections. */ -export type CoreAction = 'repos_synced' | 'list_created'; -export type DigestItemAction = 'reviewed' | 'dismissed'; +export type CoreAction = 'repos_synced' | 'list_created' | 'project_connected'; +export type ProjectConnectionSource = 'manual' | 'picker'; +export type RecommendationKind = 'repository' | 'tool'; +export type RecommendationSentiment = 'useful' | 'not_useful'; +export type RecommendationRankBucket = '1-3' | '4-12' | '13+'; +export type RecommendationSupportBucket = 'one' | 'two' | 'three_plus' | 'none'; +export type RecommendationConfidenceBucket = 'high' | 'medium' | 'inferred' | 'none'; +export type RecommendationRetrievalMode = + | 'hybrid' + | 'semantic' + | 'lexical-structured' + | 'structured' + | 'fallback'; /** * The surface a search ran through. `lexical` and `semantic` are both served * by `/api/stars`; `semantic` covers the knowledgebase RAG path (which falls @@ -42,15 +51,34 @@ interface AnalyticsEventMap { core_action: { project_id: typeof PROJECT; action: CoreAction }; /** A return session by a user with prior activity. */ returned: { project_id: typeof PROJECT }; - /** The maintainer digest was rendered for the user. */ - digest_opened: { project_id: typeof PROJECT; digest_id: string; item_count: number }; - /** A digest item was reviewed or dismissed. */ - digest_item_actioned: { + /** A project was durably connected without including its identity. */ + project_connected: { project_id: typeof PROJECT; - digest_id: string; - item_id: string; - group: string; - action: DigestItemAction; + source: ProjectConnectionSource; + }; + /** A recommendation set reached the user. */ + recommendation_set_viewed: { + project_id: typeof PROJECT; + retrieval_mode: RecommendationRetrievalMode; + result_count_bucket: SearchResultBucket; + fallback: boolean; + }; + /** A repository or tool recommendation was inspected. */ + recommendation_inspected: { + project_id: typeof PROJECT; + kind: RecommendationKind; + rank_bucket: RecommendationRankBucket; + retrieval_mode: RecommendationRetrievalMode; + }; + /** Binary recommendation quality evidence with no repository identity. */ + recommendation_feedback: { + project_id: typeof PROJECT; + kind: RecommendationKind; + sentiment: RecommendationSentiment; + rank_bucket: RecommendationRankBucket; + retrieval_mode: RecommendationRetrievalMode; + support_bucket: RecommendationSupportBucket; + confidence_bucket: RecommendationConfidenceBucket; }; /** * Privacy-safe search activation evidence. One event per search request. @@ -136,23 +164,68 @@ export function trackReturned(): void { emit('returned', {}); } -/** Fire when the weekly maintainer digest is opened. */ -export function trackDigestOpened(digestId: string, itemCount: number): void { - emit('digest_opened', { digest_id: digestId, item_count: itemCount }); +export function trackProjectConnected(source: ProjectConnectionSource, distinctId?: string): void { + emit('project_connected', { source }, distinctId); + trackCoreAction('project_connected', distinctId); +} + +function resultCountBucket(resultCount: number): SearchResultBucket { + return resultCount === 0 ? 'zero' : resultCount <= 5 ? '1-5' : resultCount <= 20 ? '6-20' : '21+'; } -/** Fire when a digest item is marked reviewed or dismissed. */ -export function trackDigestItemActioned( - digestId: string, - itemId: string, - group: string, - action: DigestItemAction +export function recommendationRankBucket(rank: number): RecommendationRankBucket { + return rank <= 3 ? '1-3' : rank <= 12 ? '4-12' : '13+'; +} + +export function trackRecommendationSetViewed( + retrievalMode: RecommendationRetrievalMode, + resultCount: number, + fallback: boolean ): void { - emit('digest_item_actioned', { - digest_id: digestId, - item_id: itemId, - group, - action, + emit('recommendation_set_viewed', { + retrieval_mode: retrievalMode, + result_count_bucket: resultCountBucket(resultCount), + fallback, + }); +} + +export function trackRecommendationInspected( + kind: RecommendationKind, + rank: number, + retrievalMode: RecommendationRetrievalMode +): void { + emit('recommendation_inspected', { + kind, + rank_bucket: recommendationRankBucket(rank), + retrieval_mode: retrievalMode, + }); +} + +export function trackRecommendationFeedback(input: { + kind: RecommendationKind; + sentiment: RecommendationSentiment; + rank: number; + retrievalMode: RecommendationRetrievalMode; + supportCount?: number; + confidence?: number; +}): void { + const support = input.supportCount ?? 0; + const confidence = input.confidence ?? 0; + emit('recommendation_feedback', { + kind: input.kind, + sentiment: input.sentiment, + rank_bucket: recommendationRankBucket(input.rank), + retrieval_mode: input.retrievalMode, + support_bucket: + support <= 0 ? 'none' : support === 1 ? 'one' : support === 2 ? 'two' : 'three_plus', + confidence_bucket: + confidence <= 0 + ? 'none' + : confidence >= 90 + ? 'high' + : confidence >= 65 + ? 'medium' + : 'inferred', }); } @@ -162,11 +235,9 @@ export function trackDigestItemActioned( * surface and the result-count bucket. Exact counts are never emitted. */ export function trackSearchOutcome(surface: SearchSurface, resultCount: number): void { - const bucket: SearchResultBucket = - resultCount === 0 ? 'zero' : resultCount <= 5 ? '1-5' : resultCount <= 20 ? '6-20' : '21+'; emit('search_outcome', { surface, - result_count_bucket: bucket, + result_count_bucket: resultCountBucket(resultCount), }); } diff --git a/src/lib/catalog-updates.ts b/src/lib/catalog-updates.ts index d8f580f..7b3eedb 100644 --- a/src/lib/catalog-updates.ts +++ b/src/lib/catalog-updates.ts @@ -131,7 +131,7 @@ export async function loadCatalogUpdates( newestCatalogedAt: (newestResult.rows[0]?.newest as string | null) ?? null, changesReturned: changes.length, refreshCadence: - 'Manual seed-popular GitHub Action (auto daily schedule paused); user star sync is on-demand.', + 'Daily bounded seed-popular GitHub Action with manual dispatch; user star sync is on-demand.', }, }; } diff --git a/src/lib/connected-projects.ts b/src/lib/connected-projects.ts index 0be526e..23f8f15 100644 --- a/src/lib/connected-projects.ts +++ b/src/lib/connected-projects.ts @@ -45,12 +45,21 @@ export function parseProjectTools(value: unknown): ProjectToolSignal[] { } export function projectFromRow(row: Record): ConnectedProject { + return { + ...projectRecommendationFromRow(row), + ownerLogin: String(row.owner_login), + ownerAvatar: String(row.owner_avatar), + connectedAt: String(row.connected_at ?? ''), + }; +} + +export function projectRecommendationFromRow( + row: Record +): ProjectRecommendationRepo { return { id: Number(row.id), name: String(row.name), fullName: String(row.full_name), - ownerLogin: String(row.owner_login), - ownerAvatar: String(row.owner_avatar), htmlUrl: String(row.html_url), description: typeof row.description === 'string' ? row.description : null, language: typeof row.language === 'string' ? row.language : null, @@ -61,7 +70,6 @@ export function projectFromRow(row: Record): ConnectedProject { aiCategory: typeof row.ai_category === 'string' ? row.ai_category : null, aiKeywords: parseStringArray(row.ai_keywords), tools: parseProjectTools(row.tools), - connectedAt: String(row.connected_at ?? ''), }; } diff --git a/src/lib/github-projects.ts b/src/lib/github-projects.ts index 4115899..14931f4 100644 --- a/src/lib/github-projects.ts +++ b/src/lib/github-projects.ts @@ -20,6 +20,30 @@ export interface PublicGitHubProject { updatedAt: string; } +interface GitHubRepositoryResponse { + id: number; + name: string; + full_name: string; + private: boolean; + visibility?: string; + owner: { login: string; avatar_url: string }; + html_url: string; + description: string | null; + language: string | null; + stargazers_count: number; + archived?: boolean; + topics?: string[]; + created_at: string; + updated_at: string; +} + +export class GitHubProjectApiError extends Error { + constructor(public readonly status: number) { + super(`GitHub API error: ${status}`); + this.name = 'GitHubProjectApiError'; + } +} + const OWNER_PATTERN = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i; const REPO_PATTERN = /^[a-z\d_.-]{1,100}$/i; @@ -66,33 +90,23 @@ export async function fetchPublicGitHubProject( 'User-Agent': 'starboard', ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, + ...(accessToken ? { cache: 'no-store' as const } : { next: { revalidate: 1800 } }), } ); if (response.status === 404) return null; if (!response.ok) { - throw new Error(`GitHub API error: ${response.status}`); + throw new GitHubProjectApiError(response.status); } - const repo = (await response.json()) as { - id: number; - name: string; - full_name: string; - private: boolean; - visibility?: string; - owner: { login: string; avatar_url: string }; - html_url: string; - description: string | null; - language: string | null; - stargazers_count: number; - archived?: boolean; - topics?: string[]; - created_at: string; - updated_at: string; - }; + const repo = (await response.json()) as GitHubRepositoryResponse; if (repo.private || (repo.visibility && repo.visibility !== 'public')) return null; + return mapPublicProject(repo); +} + +function mapPublicProject(repo: GitHubRepositoryResponse): PublicGitHubProject { return { id: repo.id, name: repo.name, @@ -109,3 +123,26 @@ export async function fetchPublicGitHubProject( updatedAt: repo.updated_at, }; } + +export async function fetchPublicGitHubRepositories( + accessToken: string +): Promise { + const response = await fetch( + 'https://api.github.com/user/repos?affiliation=owner,collaborator,organization_member&visibility=public&sort=pushed&direction=desc&per_page=100', + { + cache: 'no-store', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${accessToken}`, + 'User-Agent': 'starboard', + }, + } + ); + + if (!response.ok) throw new GitHubProjectApiError(response.status); + const repositories = (await response.json()) as GitHubRepositoryResponse[]; + + return repositories + .filter((repo) => !repo.private && (!repo.visibility || repo.visibility === 'public')) + .map(mapPublicProject); +} diff --git a/src/lib/project-intelligence.ts b/src/lib/project-intelligence.ts new file mode 100644 index 0000000..951bb45 --- /dev/null +++ b/src/lib/project-intelligence.ts @@ -0,0 +1,289 @@ +import { db } from '@/db'; +import type { DbClient } from '@/db/client'; +import { projectRecommendationFromRow } from '@/lib/connected-projects'; +import { buildRepoEmbeddingText, generateEmbeddings } from '@/lib/embeddings'; +import { + type ProjectRecommendationRepo, + type ProjectRecommendationResult, + rankProjectRecommendations, +} from '@/lib/project-recommendations'; +import { repoVectors, type RepoVectorMatch } from '@/lib/repo-vectors'; +import { ftsSearchQuery, rrfFuse } from '@/lib/search'; + +const MIN_STARS_FLOOR = 5000; +const VECTOR_TOP_K = 100; +const VECTOR_DISTANCE_MAX = 0.62; +const LEXICAL_LIMIT = 250; +const STRUCTURED_LIMIT = 150; +const HYDRATION_LIMIT = 300; +const FALLBACK_LIMIT = 100; + +const ELIGIBLE_REPO_SQL = + 'r.id IN (SELECT r2.id FROM repos r2 WHERE r2.stargazers_count >= ? UNION SELECT community_ur.repo_id FROM user_repos community_ur WHERE community_ur.is_starred = 1)'; + +export type ProjectRetrievalMode = + | 'hybrid' + | 'semantic' + | 'lexical-structured' + | 'structured' + | 'fallback'; + +export interface ProjectRetrievalSummary { + mode: ProjectRetrievalMode; + candidateCount: number; + semanticCandidates: number; + lexicalCandidates: number; + structuredCandidates: number; +} + +export interface ProjectIntelligenceResult extends ProjectRecommendationResult { + retrieval: ProjectRetrievalSummary; +} + +interface VectorStore { + query(vector: number[], topK: number): Promise; + queryByRepoId(repoId: number, topK: number): Promise; +} + +export interface ProjectIntelligenceDependencies { + database: Pick; + vectorStore: () => VectorStore; + embed: (texts: string[]) => Promise; +} + +const defaultDependencies: ProjectIntelligenceDependencies = { + database: db, + vectorStore: repoVectors, + embed: generateEmbeddings, +}; + +function projectText(project: ProjectRecommendationRepo): string { + return buildRepoEmbeddingText({ + full_name: project.fullName, + description: project.description, + language: project.language, + topics: project.topics, + ai: { + summary: project.aiSummary, + category: project.aiCategory, + keywords: project.aiKeywords, + }, + }); +} + +async function semanticCandidates( + project: ProjectRecommendationRepo, + text: string, + dependencies: ProjectIntelligenceDependencies +): Promise { + try { + const store = dependencies.vectorStore(); + let matches = await store.queryByRepoId(project.id, VECTOR_TOP_K); + if (matches.length === 0) { + const [embedding] = await dependencies.embed([text]); + if (!embedding) return []; + matches = await store.query(embedding, VECTOR_TOP_K); + } + return matches + .filter((match) => match.repoId !== project.id && match.distance <= VECTOR_DISTANCE_MAX) + .map((match) => match.repoId); + } catch (error) { + console.warn('Project semantic retrieval unavailable; using catalog evidence only', error); + return []; + } +} + +async function lexicalCandidates( + project: ProjectRecommendationRepo, + text: string, + dependencies: ProjectIntelligenceDependencies +): Promise { + const query = ftsSearchQuery(text); + if (!query) return []; + const result = await dependencies.database.execute({ + sql: `SELECT r.id, MIN(matches.rank) AS best_rank + FROM ( + SELECT repos_fts.rowid AS id, + bm25(repos_fts, 10.0, 14.0, 3.0, 1.5, 2.5) AS rank + FROM repos_fts + WHERE repos_fts MATCH ? + UNION ALL + SELECT repo_ai_metadata_fts.rowid AS id, + bm25(repo_ai_metadata_fts, 4.0, 3.0, 2.0, 2.0, 2.5) AS rank + FROM repo_ai_metadata_fts + WHERE repo_ai_metadata_fts MATCH ? + ) matches + JOIN repos r ON r.id = matches.id + WHERE r.id != ? AND ${ELIGIBLE_REPO_SQL} + GROUP BY r.id + ORDER BY best_rank ASC, r.stargazers_count DESC + LIMIT ?`, + args: [query, query, project.id, MIN_STARS_FLOOR, LEXICAL_LIMIT], + }); + return result.rows.map((row) => Number(row.id)).filter(Number.isSafeInteger); +} + +async function structuredCandidates( + project: ProjectRecommendationRepo, + dependencies: ProjectIntelligenceDependencies +): Promise { + if (!project.language) return []; + const result = await dependencies.database.execute({ + sql: `SELECT r.id + FROM repos r + WHERE r.id != ? + AND r.archived = 0 + AND r.language = ? COLLATE NOCASE + AND ${ELIGIBLE_REPO_SQL} + ORDER BY r.stargazers_count DESC, r.full_name ASC + LIMIT ?`, + args: [project.id, project.language, MIN_STARS_FLOOR, STRUCTURED_LIMIT], + }); + return result.rows.map((row) => Number(row.id)).filter(Number.isSafeInteger); +} + +async function hydrateCandidates( + ids: number[], + projectId: number, + dependencies: ProjectIntelligenceDependencies +): Promise { + const result = await dependencies.database.execute({ + sql: `SELECT r.id, + r.name, + r.full_name, + r.html_url, + r.description, + r.language, + r.stargazers_count, + r.archived, + r.topics, + aim.summary AS ai_summary, + aim.category AS ai_category, + aim.keywords AS ai_keywords, + COALESCE(( + SELECT json_group_array(json_object( + 'key', rt.tool_key, + 'name', rt.tool_name, + 'category', rt.category, + 'confidence', rt.confidence + )) + FROM repo_tools rt + WHERE rt.repo_id = r.id + ), '[]') AS tools + FROM repos r + LEFT JOIN repo_ai_metadata aim ON aim.repo_id = r.id + WHERE r.id != ? + AND r.archived = 0 + AND ${ELIGIBLE_REPO_SQL} + AND r.id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`, + args: [projectId, MIN_STARS_FLOOR, JSON.stringify(ids)], + }); + const byId = new Map( + result.rows.map((row) => { + const candidate = projectRecommendationFromRow(row); + return [candidate.id, candidate] as const; + }) + ); + return ids.flatMap((id) => { + const candidate = byId.get(id); + return candidate ? [candidate] : []; + }); +} + +async function fallbackCandidates( + projectId: number, + dependencies: ProjectIntelligenceDependencies +): Promise { + const result = await dependencies.database.execute({ + sql: `SELECT r.id, + r.name, + r.full_name, + r.html_url, + r.description, + r.language, + r.stargazers_count, + r.archived, + r.topics, + aim.summary AS ai_summary, + aim.category AS ai_category, + aim.keywords AS ai_keywords, + COALESCE(( + SELECT json_group_array(json_object( + 'key', rt.tool_key, + 'name', rt.tool_name, + 'category', rt.category, + 'confidence', rt.confidence + )) + FROM repo_tools rt + WHERE rt.repo_id = r.id + ), '[]') AS tools + FROM repos r + LEFT JOIN repo_ai_metadata aim ON aim.repo_id = r.id + WHERE r.id != ? + AND r.archived = 0 + AND ${ELIGIBLE_REPO_SQL} + ORDER BY r.stargazers_count DESC, r.full_name ASC + LIMIT ?`, + args: [projectId, MIN_STARS_FLOOR, FALLBACK_LIMIT], + }); + return result.rows.map(projectRecommendationFromRow); +} + +function retrievalMode( + semanticCount: number, + lexicalCount: number, + structuredCount: number, + fallback: boolean +): ProjectRetrievalMode { + if (fallback) return 'fallback'; + if (semanticCount > 0 && (lexicalCount > 0 || structuredCount > 0)) return 'hybrid'; + if (semanticCount > 0) return 'semantic'; + if (lexicalCount > 0) return 'lexical-structured'; + return 'structured'; +} + +export function createProjectIntelligence( + dependencies: ProjectIntelligenceDependencies = defaultDependencies +) { + return async function retrieveProjectIntelligence( + project: ProjectRecommendationRepo, + limit = 24 + ): Promise { + const text = projectText(project); + const [semanticResult, lexicalResult, structuredResult] = await Promise.allSettled([ + semanticCandidates(project, text, dependencies), + lexicalCandidates(project, text, dependencies), + structuredCandidates(project, dependencies), + ]); + const semanticIds = semanticResult.status === 'fulfilled' ? semanticResult.value : []; + const lexicalIds = lexicalResult.status === 'fulfilled' ? lexicalResult.value : []; + const structuredIds = structuredResult.status === 'fulfilled' ? structuredResult.value : []; + const candidateIds = rrfFuse([semanticIds, lexicalIds, structuredIds]).slice( + 0, + HYDRATION_LIMIT + ); + const usingFallback = candidateIds.length === 0; + const candidates = usingFallback + ? await fallbackCandidates(project.id, dependencies) + : await hydrateCandidates(candidateIds, project.id, dependencies); + const ranked = rankProjectRecommendations(project, candidates, limit); + + return { + ...ranked, + retrieval: { + mode: retrievalMode( + semanticIds.length, + lexicalIds.length, + structuredIds.length, + usingFallback + ), + candidateCount: candidates.length, + semanticCandidates: semanticIds.length, + lexicalCandidates: lexicalIds.length, + structuredCandidates: structuredIds.length, + }, + }; + }; +} + +export const retrieveProjectIntelligence = createProjectIntelligence(); diff --git a/src/lib/project-preview.ts b/src/lib/project-preview.ts new file mode 100644 index 0000000..2635a75 --- /dev/null +++ b/src/lib/project-preview.ts @@ -0,0 +1,95 @@ +import { db } from '@/db'; +import type { DbClient } from '@/db/client'; +import { projectRecommendationFromRow } from '@/lib/connected-projects'; +import { + fetchPublicGitHubProject, + parseGitHubProjectInput, + type PublicGitHubProject, +} from '@/lib/github-projects'; +import type { ProjectRecommendationRepo } from '@/lib/project-recommendations'; + +export type ProjectPreviewResolution = + | { status: 'invalid' } + | { status: 'unavailable' } + | { status: 'resolved'; source: 'catalog' | 'github'; project: ProjectRecommendationRepo }; + +export interface ProjectPreviewDependencies { + database: Pick; + fetchProject: typeof fetchPublicGitHubProject; +} + +const defaultDependencies: ProjectPreviewDependencies = { + database: db, + fetchProject: fetchPublicGitHubProject, +}; + +function externalProject(project: PublicGitHubProject): ProjectRecommendationRepo { + return { + id: project.id, + name: project.name, + fullName: project.fullName, + htmlUrl: project.htmlUrl, + description: project.description, + language: project.language, + stargazersCount: project.stargazersCount, + archived: project.archived, + topics: project.topics, + aiSummary: null, + aiCategory: null, + aiKeywords: [], + tools: [], + }; +} + +export function createProjectPreviewResolver( + dependencies: ProjectPreviewDependencies = defaultDependencies +) { + return async function resolveProjectPreview(input: string): Promise { + const slug = parseGitHubProjectInput(input); + if (!slug) return { status: 'invalid' }; + + const catalog = await dependencies.database.execute({ + sql: `SELECT r.id, + r.name, + r.full_name, + r.html_url, + r.description, + r.language, + r.stargazers_count, + r.archived, + r.topics, + aim.summary AS ai_summary, + aim.category AS ai_category, + aim.keywords AS ai_keywords, + COALESCE(( + SELECT json_group_array(json_object( + 'key', rt.tool_key, + 'name', rt.tool_name, + 'category', rt.category, + 'confidence', rt.confidence + )) + FROM repo_tools rt + WHERE rt.repo_id = r.id + ), '[]') AS tools + FROM repos r + LEFT JOIN repo_ai_metadata aim ON aim.repo_id = r.id + WHERE r.full_name = ? COLLATE NOCASE + LIMIT 1`, + args: [slug.fullName], + }); + if (catalog.rows.length > 0) { + return { + status: 'resolved', + source: 'catalog', + project: projectRecommendationFromRow(catalog.rows[0]), + }; + } + + const project = await dependencies.fetchProject(slug); + return project + ? { status: 'resolved', source: 'github', project: externalProject(project) } + : { status: 'unavailable' }; + }; +} + +export const resolveProjectPreview = createProjectPreviewResolver(); From fef9c204a2b5a286aef6d0349f3d24fd64ebf4c3 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sun, 9 Aug 2026 01:09:46 +0530 Subject: [PATCH 2/2] cover GitHub picker failures --- src/__tests__/github-projects.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/__tests__/github-projects.test.ts b/src/__tests__/github-projects.test.ts index 4d2b70b..ebe858d 100644 --- a/src/__tests__/github-projects.test.ts +++ b/src/__tests__/github-projects.test.ts @@ -152,4 +152,10 @@ describe('parseGitHubProjectInput', () => { }) ); }); + + it('surfaces GitHub failures while loading repository choices', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 503 }))); + + await expect(fetchPublicGitHubRepositories('token')).rejects.toThrow('GitHub API error: 503'); + }); });