diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 7092c86..e1b0bd1 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,43 +1,7 @@
-## Problem
+## What
-What verified problem does this solve?
+## Why
-Reference:
-- User feedback
-- Benchmark
-- Incident
-- Security finding
+## How tested
-*(If none, close this PR.)*
-
----
-
-## Evidence Before
-
-Current numbers:
-
-TTFV:
-Memory:
-Latency:
-Error rate:
-Queue delay:
-
----
-
-## Evidence After
-
-New numbers:
-
-TTFV:
-Memory:
-Latency:
-Error rate:
-Queue delay:
-
----
-
-## Risk
-
-What could this break?
-
-Rollback plan?
+## Screenshots (if applicable)
diff --git a/.github/PULL_REQUEST_TEMPLATE/perf.md b/.github/PULL_REQUEST_TEMPLATE/perf.md
new file mode 100644
index 0000000..bf0e99a
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/perf.md
@@ -0,0 +1,21 @@
+## What and why
+
+## Evidence before
+
+TTFV:
+Memory:
+Latency:
+Error rate:
+Queue delay:
+
+## Evidence after
+
+TTFV:
+Memory:
+Latency:
+Error rate:
+Queue delay:
+
+## How tested
+
+## Risk and rollback plan
diff --git a/.github/workflows/assignment-limit.yml b/.github/workflows/assignment-limit.yml
index 8030308..b3f562f 100644
--- a/.github/workflows/assignment-limit.yml
+++ b/.github/workflows/assignment-limit.yml
@@ -25,9 +25,5 @@ jobs:
if (context.eventName === 'issues') {
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body: message });
- await github.rest.issues.removeAssignees({ ...context.repo, issue_number: context.issue.number, assignees: [username] });
- } else if (context.eventName === 'pull_request') {
- await github.rest.issues.createComment({ ...context.repo, issue_number: context.payload.pull_request.number, body: message });
- await github.rest.pulls.update({ ...context.repo, pull_number: context.payload.pull_request.number, state: 'closed' });
}
}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..a675323
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,28 @@
+name: CI
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ env:
+ DATABASE_URL: postgres://x:x@localhost:5432/x
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: pk_test_Zm9yZ2VsZW5zLmNvbQ==
+ CLERK_SECRET_KEY: sk_test_placeholder
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm lint
+ - run: pnpm typecheck
+ - run: pnpm test
+ - run: pnpm build
diff --git a/.github/workflows/db-migration-check.yml b/.github/workflows/db-migration-check.yml
index 0785a42..a08f25a 100644
--- a/.github/workflows/db-migration-check.yml
+++ b/.github/workflows/db-migration-check.yml
@@ -9,35 +9,13 @@ on:
jobs:
check-migrations:
runs-on: ubuntu-latest
- services:
- postgres:
- image: postgres:15-alpine
- env:
- POSTGRES_USER: user
- POSTGRES_PASSWORD: password
- POSTGRES_DB: forgelens
- ports:
- - 5432:5432
- options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
-
steps:
- uses: actions/checkout@v4
-
- - uses: pnpm/action-setup@v2
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
with:
- version: 8
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '20'
- cache: 'pnpm'
-
- - name: Install dependencies
- run: pnpm install --filter db...
-
- # Ponytail: Just push the schema to the ephemeral DB to see if it blows up
- - name: Dry Run Migration
- env:
- DATABASE_URL: postgresql://user:password@localhost:5432/forgelens
- run: pnpm --filter db run db:push
+ node-version: 20
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - name: Check migration consistency
+ run: pnpm --filter @forgelens/db exec drizzle-kit check
diff --git a/.github/workflows/enforce-evidence.yml b/.github/workflows/enforce-evidence.yml
index 56a0194..752b812 100644
--- a/.github/workflows/enforce-evidence.yml
+++ b/.github/workflows/enforce-evidence.yml
@@ -1,25 +1,32 @@
name: Enforce PR Evidence
on:
pull_request:
- types: [opened, edited, synchronize]
+ types: [opened, edited, synchronize, labeled, unlabeled]
jobs:
enforce-evidence:
runs-on: ubuntu-latest
steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - id: changed
+ uses: dorny/paths-filter@v3
+ with:
+ filters: |
+ relevant:
+ - 'packages/github/**'
+ - 'apps/web/src/inngest/**'
+ - 'packages/db/src/**'
- name: Check PR Body for Evidence
- uses: actions/github-script@v6
+ if: contains(github.event.pull_request.labels.*.name, 'perf') || steps.changed.outputs.relevant == 'true'
+ uses: actions/github-script@v7
with:
script: |
- const body = context.payload.pull_request.body;
- if (!body) {
- core.setFailed("PR body is empty. Please fill out the PR template.");
- return;
- }
- // Ponytail: ultra simple regex to check if they filled out the numbers
- const missingBefore = body.includes("TTFV:\nMemory:\nLatency:");
- const missingAfter = body.includes("New numbers:\n\nTTFV:\nMemory:\nLatency:");
-
+ const body = (context.payload.pull_request.body || '').replace(/\r\n/g, '\n');
+ const emptyMetrics = /TTFV:\s*Memory:\s*Latency:\s*(?:Error rate:\s*)?(?:Queue delay:\s*)?(?:$|\n)/m;
+ const missingBefore = emptyMetrics.test(body);
+ const missingAfter = body.includes('New numbers:') && emptyMetrics.test(body);
if (missingBefore || missingAfter) {
- core.setFailed("Directive 002 violation: You must provide benchmark evidence in the PR template. Do not leave the metrics blank.");
+ core.setFailed('Performance changes must include benchmark evidence in the PR body.');
}
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 212e0ea..f8aad4c 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -1,7 +1,7 @@
name: Manage Stale Issues and PRs
on:
schedule:
- - cron: '0 * * * *' # Runs every hour
+ - cron: '0 9 * * *' # Runs daily
permissions:
issues: write
@@ -13,15 +13,14 @@ jobs:
steps:
- uses: actions/stale@v8
with:
- days-before-stale: 4
- days-before-close: 2
- stale-issue-message: 'This issue has been inactive for 4 days. It has been marked as stale and the assignee will be removed shortly to allow others to work on it.'
- stale-pr-message: 'This PR has been inactive for 4 days. @maintainer @contributor please review or push updates, otherwise it will be closed in 2 days.'
- close-issue-message: 'This issue was closed due to inactivity.'
- close-pr-message: 'This PR was closed due to inactivity.'
+ days-before-issue-stale: 14
+ days-before-issue-close: -1
+ days-before-pr-stale: -1
+ days-before-pr-close: -1
+ stale-issue-message: 'This issue has been inactive for 14 days. It has been marked stale and its assignee removed so others can pick it up.'
stale-issue-label: 'stale'
- stale-pr-label: 'stale'
exempt-issue-labels: 'pinned,security'
exempt-pr-labels: 'do-not-merge'
remove-issue-stale-when-updated: true
remove-pr-stale-when-updated: true
+ remove-issue-assignees-when-stale: true
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index abfe012..e853982 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,130 +1,59 @@
-# Contributing to VoidSwift
+# Contributing to ForgeLens
-First off, thank you for your interest in contributing to **VoidSwift**! β€οΈ
+Thanks for being here! Whether you fix a typo or ship a feature, you're welcome. This guide is short on purpose.
-Whether you're fixing bugs, improving documentation, or adding new features, every contribution helps make the project better. Please read these guidelines before getting started.
+## Our promise
----
+**You will get a first maintainer response on your pull request within 72 hours.** We never auto-close PRs for inactivity. If you haven't heard from us in 72 hours, ping a maintainer in the PR or on Discord. That's on us, not you.
-# Getting Started
+## Pick an issue
-1. Fork the repository.
-2. Clone your fork locally.
-3. Create a new branch from the default branch.
-4. Make your changes.
-5. Test your changes (if applicable).
-6. Commit your changes with a clear commit message.
-7. Push your branch and open a Pull Request.
+1. Browse issues labelled `good first issue` (starter ideas live in [docs/GOOD_FIRST_ISSUES.md](docs/GOOD_FIRST_ISSUES.md)).
+2. Comment `.take` on an unassigned issue and you are assigned automatically.
+3. Please hold at most 3 assigned issues at a time. If you can't continue, just say so and we'll free it up. Assigned issues quiet for 14 days get unassigned (never closed), so someone else can pick them up.
+4. Want to do something bigger? Open an issue first so we can agree on the approach.
----
+## Set up locally (about 10 minutes)
-# Before You Start
+Follow "Quick start" in the [README](README.md). You need Node 20+, pnpm 9 and Docker.
-* Search existing issues before opening a new one.
-* If you're planning a large feature or significant change, open an issue first so we can discuss the best approach.
-* Keep each Pull Request focused on a single feature or fix.
-* Read this document completely before contributing.
+## Run the checks locally
----
+```bash
+pnpm lint
+pnpm typecheck
+pnpm test
+pnpm build
+```
-# Contribution Limits
+Run all four before pushing; they are the same steps CI runs.
-To keep contributions active and ensure everyone gets a chance to contribute:
+## What CI checks, and why
-* A contributor may have **up to 3 assigned issues** at any given time.
-* A contributor may have **up to 3 open Pull Requests** at any given time.
-* If you are unable to continue working on an assigned issue, please let us know so it can be reassigned.
-* Issues that remain **inactive for 2-3 days** without any progress or updates may be **unassigned**.
-* Pull Requests that remain **inactive for 2-3 days** without responding to review comments or showing progress may be **closed**.
+| Check | Why |
+| --- | --- |
+| `pnpm lint` | Consistent code style and common bug patterns. |
+| `pnpm typecheck` | TypeScript errors across every package, caught before runtime. |
+| `pnpm test` | The scoring engine in `packages/domain-analytics` must stay correct. |
+| `pnpm build` | The app has to compile. |
+| DB migration check | If you touch `packages/db/src/schemas`, run `pnpm db:generate` and commit the new migration. |
+| PR evidence (only for performance work) | PRs labelled `perf`, or touching `packages/github`, `apps/web/src/inngest` or `packages/db/src`, need before/after numbers. Use the template `.github/PULL_REQUEST_TEMPLATE/perf.md` (add `?template=perf.md` to the PR URL). UI and docs PRs skip this. |
-> **Waiting for Review?**
->
-> If your Pull Request is waiting for a maintainer's review and you haven't received any response within **2-3 days**, please join our **VoidSwift Discord** and politely ping a **Maintainer** or **Project Admin**. This helps ensure your PR isn't accidentally missed.
+Green means the code works; red means something real is wrong.
----
+## Pull requests
-# Pull Request Guidelines
+- One issue or feature per PR. Link it with `Closes #123`.
+- Use [Conventional Commits](https://www.conventionalcommits.org/) (`fix(web): ...`, `docs: ...`).
+- Fill in the short template: What / Why / How tested / Screenshots.
+- Never invent numbers (scores, benchmarks) in docs or UI. Scores come only from `packages/domain-analytics`, never from an LLM.
-Before opening a Pull Request, please ensure that:
+## Reporting bugs and requesting features
-* Your PR solves only **one issue** or introduces **one feature**.
-* Your branch is up to date with the default branch.
-* Your commit messages are meaningful and descriptive.
-* You link related issues using:
+Search existing issues first. For bugs include steps to reproduce, expected vs. actual behavior, and your environment. For features, describe the problem before the solution.
- * `Closes #123`
- * `Fixes #123`
-* You update documentation if your changes require it.
-* Your code follows the project's existing style.
-* Your PR is ready for review.
+## Community and conduct
----
+Chat with maintainers on the project Discord. Be respectful, constructive and welcoming. Harassment, abuse and spam are not tolerated.
-# Code Style
-
-Please follow the existing coding style of the project.
-
-* Write clean and readable code.
-* Avoid unnecessary refactoring.
-* Keep changes focused.
-* Add tests whenever possible, especially for bug fixes.
-* Remove debugging code before submitting your PR.
-
----
-
-# Good First Contributions
-
-If you're new to the project, look for issues labeled:
-
-* `good first issue`
-* `help wanted`
-
-These issues are intended for new contributors.
-
----
-
-# Reporting Bugs
-
-When opening a bug report, please include:
-
-* Clear description of the issue.
-* Steps to reproduce.
-* Expected behavior.
-* Actual behavior.
-* Screenshots or logs (if applicable).
-* Environment details (OS, version, etc.).
-
----
-
-# Feature Requests
-
-Before requesting a feature:
-
-* Check existing issues to avoid duplicates.
-* Clearly explain the problem you're trying to solve.
-* Describe your proposed solution.
-* Include any additional context if needed.
-
----
-
-# Community
-
-For discussions, support, announcements, and faster communication, please join the **VoidSwift Discord**.
-
-If you're actively contributing, we highly recommend joining the Discord server so maintainers can communicate with you easily regarding reviews, changes, or issue assignments.
-
----
-
-# Code of Conduct
-
-Please be respectful, constructive, and welcoming.
-
-Harassment, abusive language, spam, or toxic behavior will not be tolerated. We want VoidSwift to be a friendly and inclusive community for everyone.
-
----
-
-# Thank You β€οΈ
-
-Every contribution, whether it's code, documentation, bug reports, or suggestions, helps improve VoidSwift.
-
-Thank you for being part of our community. Happy coding! π
+Thank you for helping make ForgeLens better!
diff --git a/README.md b/README.md
index 8792613..1482231 100644
--- a/README.md
+++ b/README.md
@@ -1,521 +1,104 @@
-
+
ForgeLens
- The Deterministic Repository Intelligence & Analytics Engine.
- Understand the true health, resilience, and operational reality of any codebase in under 60 seconds.
+ See any repo's health, bus factor and maintainer risk in under a minute.
-
-
-
-
-
+
----
+
-## πΈ Demo
+## Try it live
-*(Note: UI assets are populated during the Beta phase)*
+**Live demo:** _add the deployed URL here after the first deploy (see [docs/DEPLOY.md](docs/DEPLOY.md))._
-**Dashboard Preview**
-A real-time overview of your imported repositories, displaying aggregated Health and Resilience scores.
+No sign-up needed: paste `owner/repo` on the landing page, or open one of the pre-analyzed showcase repos. Every public report lives at `/r//`.
-**Timeline Preview**
-The raw event stream showing GitHub commits, pull requests, and ingestion checkpoints.
+## What the scores mean
-**Architecture Preview**
-```mermaid
-graph LR
- A[GitHub Repo] -->|Stream| B(Inngest Worker)
- B -->|Normalize| C[(PostgreSQL)]
- B -->|Compute| D{Analytics Engine}
- D -->|Produce| C
- C --> E[Next.js App Router]
- E --> F((User))
-```
-
----
-
-## π Table of Contents
-
-1. [What is ForgeLens?](#what-is-forgelens)
-2. [Why ForgeLens Exists](#why-forgelens-exists)
-3. [Core Principles](#core-principles)
-4. [Features](#features)
-5. [Repository Architecture](#repository-architecture)
-6. [System Architecture](#system-architecture)
-7. [Request Lifecycle](#request-lifecycle)
-8. [Data Flow](#data-flow)
-9. [Sync Engine](#sync-engine)
-10. [Analytics Engine](#analytics-engine)
-11. [AI Architecture](#ai-architecture)
-12. [Database](#database)
-13. [Security](#security)
-14. [Performance](#performance)
-15. [Testing](#testing)
-16. [DevOps](#devops)
-17. [Project Structure](#project-structure)
-18. [Technology Stack](#technology-stack)
-19. [Engineering Decisions](#engineering-decisions)
-20. [API](#api)
-21. [GitHub Integration](#github-integration)
-22. [Benchmarks](#benchmarks)
-23. [Documentation](#documentation)
-24. [ForgeLens Labs](#forgelens-labs)
-25. [Roadmap](#roadmap)
-26. [Known Limitations](#known-limitations)
-27. [FAQ](#faq)
-28. [Contributing](#contributing)
-29. [License](#license)
-30. [Acknowledgements](#acknowledgements)
-31. [Final Engineering Statement](#final-engineering-statement)
-
----
-
-## π What is ForgeLens?
-
-### The Problem
-GitHub tells you what happened. It tells you who committed code, when a PR was merged, and how many stars a project has. It does **not** tell you if the project is healthy, if the maintainers are burning out, or if the repository is entirely dependent on a single developer who hasn't logged in for 90 days.
-
-### The Vision
-To build a deterministic, explainable analytics engine that translates raw version control events into actionable operational intelligence.
-
-### The Mission
-Give Engineering Managers, Open Source Maintainers, and Tech Leads the ability to measure the exact resilience of a codebase before adopting it into production.
-
-### Target Users
-- **Engineering Managers:** Identifying bus factors and burnout in internal teams.
-- **OSS Maintainers:** Tracking community health and contributor pipelines.
-- **Enterprise Architects:** Vetting open-source dependencies for long-term viability.
-
-### Non-Goals
-- We are not a CI/CD platform.
-- We are not a project management tool (Jira/Linear).
-- We do not use AI to "guess" health scores. Magic is prohibited in the core engine.
-
----
-
-## β Why ForgeLens Exists
-
-### The History and Pain Points
-Historically, evaluating a repository meant manually scanning contributor graphs, checking the date of the last merged PR, and hoping the primary author wouldn't abandon the project. Existing solutions either focus purely on vanity metrics (stars, forks) or employ black-box AI that hallucinates "sentiment" without providing cryptographic proof of the calculation.
-
-### Why ForgeLens is Different
-ForgeLens treats repository metadata as a continuous, append-only event stream. We don't summarize data; we compile it. Every scoreβHealth, Bus Factor, Resilienceβis backed by deterministic evidence strings that point directly to the raw data. If ForgeLens says a repository has a Bus Factor of 1, it will tell you exactly *who* that person is and *why* their absence would freeze the project.
-
----
-
-## βοΈ Core Principles
-
-ForgeLens is governed by strict engineering philosophies:
-
-1. **Evidence over Intuition:** Every metric must be mathematically provable.
-2. **LLMs Never Compute Truth:** AI is for querying and summarization (TCGE Labs), never for generating core analytics or health scores.
-3. **Constant-Memory Ingestion:** Systems must scale to `torvalds/linux` without Out-Of-Memory (OOM) kills. We stream, normalize, persist, and discard.
-4. **Idempotency is Mandatory:** If a worker dies, it resumes from a cursor. It never restarts from zero.
-5. **Back-Pressure:** The queue yields to the database. We do not DDOS our own infrastructure.
-6. **Zero-Downtime Projections (CQRS-lite):** Analytics algorithms are versioned. Migrations are non-destructive replays.
-7. **Performance Budgets:** CI fails if Node memory exceeds 500MB or queue latency exceeds 5 seconds.
-8. **Docs as Code:** Architecture is strictly tracked in ADRs. Failure is tracked in Blameless Postmortems.
-
----
-
-## β¨ Features
-
-### Current (Beta)
-- **Repository Import:** Stream-based ingestion of GitHub commits and PRs.
-- **Health Engine:** Deterministic scoring across Velocity, Activity, Responsiveness, and Maintenance.
-- **Resilience Engine:** Operational risk scoring including Bus Factor, Knowledge Distribution, Review Coverage, and Contributor Freshness.
-- **Contributor Intelligence:** Automated classification of core maintainers, flight risks, and domain experts.
-- **Historical Snapshots:** Time-series tracking of repository health.
-- **Debug Console:** Hidden admin view for tracking ingestion cursors, rate-limits, and memory pressure.
-
-### Future / Experimental (Labs)
-- **Temporal Code Graph Engine (TCGE):** A multi-dimensional AST database tracking how code relationships evolve over time.
-- **AI Query Gateway:** Natural language interfaces to interrogate the TCGE (e.g., "When did we introduce the memory leak in the billing module?").
-- **Slack / GitLab Integrations:** Multi-provider ingestion streams.
-
----
-
-## π Repository Architecture
-
-ForgeLens is a strict Monorepo powered by Turborepo and PNPM workspaces.
-
-```text
-Forge-Lens/
-βββ apps/
-β βββ web/ # Next.js 14 App Router, UI, Server Actions, Inngest Workers
-βββ packages/
-β βββ db/ # PostgreSQL schema, Drizzle ORM, Migrations
-β βββ domain-analytics/ # Pure TS algorithms (Health, Resilience, Contributors)
-β βββ github/ # Streaming Octokit client, Rate limit throttler
-β βββ ui/ # Shared React components, Tailwind config
-β βββ config/ # Shared ESLint, TSConfig settings
-βββ benchmark/ # Operational V8 traces and historical JSON performance
-βββ docs/ # ADRs, Postmortems, Release Gates, Product Truth
-βββ .github/ # Actions, Strict PR Templates
-```
-
-### Bounded Contexts
-- `apps/web` handles **transport** (HTTP, UI, Jobs).
-- `packages/db` handles **persistence**.
-- `packages/domain-analytics` handles **business logic**. It is strictly decoupled from the database and the UI. It accepts plain JSON objects and returns deterministic scores.
-
----
-
-## ποΈ System Architecture
-
-ForgeLens is built on an Event-Driven, CQRS-lite architecture.
-
-```mermaid
-graph TD
- User([User]) -->|Imports Repo| Web[Next.js API]
- Web -->|Fires Event| Redis[(Redis Queue)]
- Redis -->|Consumes| Worker[Inngest Worker]
-
- Worker -->|Fetch Stream| GitHub[GitHub API]
- GitHub -->|Yields Pages| Worker
-
- Worker -->|Batch Inserts| DB[(PostgreSQL Primary)]
-
- Worker -->|Queries Raw Events| Analytics[Domain Analytics]
- Analytics -->|Calculates v0.1| Projections[DB Snapshots]
-
- Projections --> UI[Next.js Dashboard]
- UI --> User
-```
-
-### Flow Breakdown
-1. **Frontend:** React Server Components (RSC) query read-only PostgreSQL projections.
-2. **Backend:** Next.js Server Actions validate input via Zod and dispatch events to Inngest.
-3. **Workers:** Serverless functions that orchestrate streaming ingestion, applying Octokit backoff on 429s.
-4. **Database:** Drizzle ORM on PostgreSQL, heavily indexed on `repositoryId`.
-
----
-
-## π Request Lifecycle
-
-When a user clicks "Import":
-
-```mermaid
-sequenceDiagram
- participant U as User
- participant A as Next.js API
- participant I as Inngest Queue
- participant W as Sync Worker
- participant G as GitHub API
- participant D as PostgreSQL
- participant E as Analytics Engine
-
- U->>A: POST /import { repo: "torvalds/linux" }
- A->>D: Create Repository (isSyncing: true)
- A->>I: Event: repository/sync
- A-->>U: 202 Accepted (Redirect to Dashboard)
-
- I->>W: Trigger Job (concurrency: 10)
- W->>G: getCommitsStream() (Iterative)
- loop Every 100 items
- G-->>W: Page N
- W->>D: Batch Insert (ON CONFLICT DO NOTHING)
- W->>D: Update commitsCursor
- end
-
- W->>D: Fetch normalized raw events
- W->>E: computeHealth(), computeResilience()
- E-->>W: { score: 85, version: "v0.1", evidence: [...] }
- W->>D: Write Snapshots & Update Repo
- W->>U: Webhook / UI Polling updates to "Synced"
-```
-
----
-
-## π Data Flow (CQRS-lite)
-
-We do not overwrite historical data. We project it.
-
-```mermaid
-graph LR
- subgraph Write Model
- A[Raw GitHub Commits] --> B[Insert DB]
- C[Raw GitHub PRs] --> B
- end
-
- subgraph Read Model
- B --> D[Analytics Compiler]
- D --> E[Health Projections v1]
- D --> F[Resilience Projections v1]
- end
-
- E --> G[Dashboard UI]
- F --> G
-```
-
----
-
-## π Sync Engine
-
-The ingestion pipeline is designed for **Operation Iron** (extreme survival).
-
-### Streaming & Pagination
-Instead of loading a repository into an array (which instantly OOMs on 1M+ commit repos), we use `async function* getCommitsStream`. Memory remains flat at ~120MB regardless of repository size.
-
-### Rate Limiting & Backoff
-We use `@octokit/plugin-throttling`. If GitHub returns a `429 Too Many Requests`, the worker pauses execution exponentially. It does not crash.
-
-### Checkpoints & Recovery
-Every batch insert updates a `cursor` in the database. If a worker receives a `SIGTERM` or Redis drops, the next worker resumes exactly from that cursor.
-
-### Back-Pressure
-Inngest concurrency is strictly limited to 10 parallel synchronizations. We throttle ingestion to protect PostgreSQL connection pools from exhaustion.
-
----
-
-## π§ Analytics Engine
-
-Located purely in `packages/domain-analytics`. It contains zero database or UI code.
-
-### 1. Health Engine (v0.1)
-- **Velocity (30%):** PRs merged per week, commit frequency.
-- **Activity (30%):** Total active contributors over 90 days.
-- **Responsiveness (20%):** Average time to close PRs/Issues.
-- **Maintenance (20%):** Ratio of commits to merged PRs.
-
-### 2. Resilience Engine (v0.1)
-- **Bus Factor (35%):** How many people author 50% of the commits. A Bus Factor of 1 immediately tanks the score.
-- **Knowledge Distribution (25%):** Ratio of core maintainers to drive-by contributors.
-- **Review Coverage (20%):** Enforced peer review patterns.
-- **Contributor Freshness (20%):** If the top 5 committers haven't been seen in 90 days, the repository is effectively abandoned.
-
-### Explainability
-The engine returns `evidence` strings. Example: `β Critical Risk: Single point of failure (Bus Factor 1).` The UI renders this directly.
-
----
-
-## π€ AI Architecture (Disabled in Core)
-
-*Note: AI features are part of ForgeLens Labs and strictly excluded from the deterministic Analytics Engine.*
-
-**Future Implementation (TCGE):**
-- **Temporal Code Graph:** Parses code into AST trees and tracks variable changes over Git histories.
-- **Vector Search:** Embeds code changes via OpenAI/Gemini embeddings.
-- **LLM Gateway:** Retrieves historical context to answer natural language architecture questions.
-
----
-
-## ποΈ Database
-
-**ORM:** Drizzle. **Driver:** PostgreSQL (pg).
-
-### Core Schemas
-- `repositories`: Tracks metadata, sync status, and pagination cursors.
-- `commits` / `pull_requests`: Raw append-only event logs. Indexed heavily on `repositoryId`.
-- `contributors`: Computed profiles (Name, Role, Last Active).
-- `repository_snapshots`: Time-series tracking of Health and Resilience scores for charting.
-
-### Indexing Strategy
-All foreign keys (e.g., `repositoryId`) feature explicit B-Tree indexes (`index("repo_idx").on(table.repositoryId)`).
-
----
-
-## π Security
-
-We adhere to the OWASP Top 10 and enterprise security architectures.
-
-- **Authentication:** Managed by Clerk (OIDC/SAML ready).
-- **Environment Variables:** Strictly validated at boot via Zod (`@t3-oss/env-nextjs`). Crash early if secrets are missing.
-- **Database Connections:** SSL enforced.
-- **Input Validation:** All imports run against strict regex bounds to prevent SSRF and Path Traversal.
-- **Audit Logging:** Every sync, failure, and throttle event is logged.
-
----
-
-## β‘ Performance
-
-ForgeLens enforces a strict **Performance Budget**:
-- Memory: `< 500 MB`
-- UI Paint: `< 1s`
-- DB Insertion: `> 500 rows/s`
-- Analytics Compile: `< 300 ms`
+All scores are **deterministic** (no LLM involved) and computed from recent commit and pull request history (default window: the last 12 months, capped at 5,000 items per stream). Every score comes with human-readable evidence lines explaining *why*.
-Violations fail the CI pipeline.
+| Score | What it tells you |
+| --- | --- |
+| **Health** (0-100) | Activity, velocity, maintenance and community signals. |
+| **Resilience** (0-100) | How well the project survives people leaving: knowledge spread, review coverage, freshness. |
+| **Bus factor** | How few contributors carry most of the work. Lower is riskier. |
----
+Tiers used by badges: **Excellent** 80+, **Good** 60+, **Warning** 40+, **Critical** below 40.
-## π§ͺ Testing
+### Add a badge to your README
-1. **Unit Testing:** `packages/domain-analytics` is 100% unit tested using Vitest. Mathematical edge cases (Bus Factor 1, 0 commits, negative time bounds) are explicitly verified.
-2. **E2E Testing:** Handled via Playwright to ensure the core import loop is unbreakable.
-3. **Chaos Engineering:** Tested against API 500s, DB disconnects, and SIGTERMs during benchmarks.
+Open your repo's report page and copy the Markdown from the "Add this badge" box. It looks like:
----
-
-## ποΈ DevOps
-
-- **CI/CD:** GitHub Actions test the Turborepo graph.
-- **Tracing:** OpenTelemetry (`@opentelemetry/api`) spans wrap every phase of the Inngest worker.
-- **Metrics:** Exposed via standard Prometheus scrape endpoints.
-
----
-
-## π¦ Project Structure
-
-| Package/App | Type | Responsibility |
-| :--- | :--- | :--- |
-| `apps/web` | Next.js App | UI, Routing, API, Server Actions, Inngest workers |
-| `packages/db` | Library | Drizzle Schemas, Migrations, DB Client |
-| `packages/domain-analytics` | Library | Pure math, deterministic compilers, unit tests |
-| `packages/github` | Library | Octokit client, streaming generators, rate limits |
-| `packages/ui` | Library | Tailwind configuration, Shared React components |
-| `packages/config` | Library | Global TS, ESLint rules |
-
----
-
-## π οΈ Technology Stack
-
-| Technology | Reason | Alternative Considered |
-| :--- | :--- | :--- |
-| **Next.js 14 (App Router)** | RSCs reduce client bundle size; seamless Server Actions for DB writes. | Remix (Lacks ecosystem momentum for UI libs) |
-| **PostgreSQL** | Relational integrity is mandatory for analytics. | MongoDB (Terrible for complex JOINs and aggregations) |
-| **Drizzle ORM** | SQL-like syntax, edge-compatible, high performance. | Prisma (Rust engine overhead, slow cold starts) |
-| **Inngest** | Event-driven, auto-retries, serverless-friendly queues. | Redis BullMQ (Requires maintaining heavy worker infra) |
-| **Turborepo** | Blazing fast monorepo builds with remote caching. | Nx (Overly complex for our package scale) |
-| **Clerk** | Drop-in enterprise auth (SAML/SSO ready). | NextAuth (Requires manual DB adapter maintenance) |
-
----
-
-## π Engineering Decisions
-
-Our decisions are documented in `docs/adr/`.
-- **ADR-001 (Monorepo):** To prevent version drift between analytics and the UI.
-- **ADR-002 (Inngest):** Chosen to handle long-running GitHub streams without Vercel 10-second timeout limits.
-- **ADR-003 (CQRS-lite):** To allow Analytics algorithms to upgrade (v1 -> v2) without destructive database migrations.
-
----
-
-## π API
-
-*(Internal Server Actions)*
-
-```typescript
-// Import a repository
-const result = await importRepository("torvalds/linux");
-// Dispatches Inngest event: { name: "repository/sync", data: { repositoryId: "..." } }
+```md
+[](https://YOUR-HOST/r/OWNER/REPO)
```
-Errors are mapped to strict UI states using React Error Boundaries.
-
----
-
-## π GitHub Integration
-
-We use Octokit REST (not GraphQL, to avoid complex node cursor limits on massive repos).
-- Implements `@octokit/plugin-throttling` for exponential backoff on 429 and Secondary Rate Limits.
-- Uses `async function*` generators to yield HTTP pages individually, preventing memory bloat.
-
----
+A repo that has not been analyzed shows `not analyzed`, never a made-up number.
-## π Benchmarks
+## Quick start (about 10 minutes)
-**Operation Iron** executes against:
-1. `octocat/Hello-World` (Smoke)
-2. `vercel/next.js` (Real Project)
-3. `microsoft/vscode` (Large)
-4. `kubernetes/kubernetes` (Very Large)
-5. `torvalds/linux` (Torture Test)
+Requirements: Node 20+, pnpm 9, Docker.
-Results are permanently tracked in `benchmark/history/` as JSON payloads to prove regression boundaries.
+```bash
+git clone https://github.com/voidswift/Forge-Lens.git
+cd Forge-Lens
----
+# 1. Configure environment
+cp apps/web/.env.example apps/web/.env.local
+# Fill in at least: Clerk keys (dashboard.clerk.com) and GITHUB_TOKEN.
+# Generate DB_ENCRYPTION_KEY:
+# node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"
-## π Documentation
+# 2. Install, start Postgres 15, apply migrations
+pnpm run setup
-- `docs/adr/`: Architecture Decision Records.
-- `docs/release/`: Feature Freeze gates, Beta Checklists, and Performance Budgets.
-- `docs/postmortems/`: PM-XXXX files for blameless incident analysis.
-- `docs/PRODUCT_TRUTH.md`: The only file that dictates roadmapβbased strictly on beta usage facts.
-- `docs/labs/`: Future R&D (TCGE).
+# 3. (Optional) pre-analyze the showcase repos (needs GITHUB_TOKEN)
+pnpm seed:showcase
----
-
-## π¬ ForgeLens Labs
-
-Labs is our R&D division. It houses the **Temporal Code Graph Engine (TCGE)**.
-*Note: TCGE is strictly quarantined from ForgeLens Core. Core must function as a complete, valuable analytics product even if TCGE is never released.*
-
----
-
-## πΊοΈ Roadmap
-
-1. **Current:** Operation Iron (Hardening, Backpressure, Streaming).
-2. **Beta:** 10-20 User Observation Phase. Populate `PRODUCT_TRUTH.md`.
-3. **Production:** Open to public, implement SSO.
-4. **Future:** GitLab / BitBucket support.
-5. **Long-Term:** Merge TCGE for semantic code querying.
-
----
-
-## β οΈ Known Limitations
-
-*We do not hide weaknesses.*
-
-1. **GitHub Only:** We do not currently support GitLab or Bitbucket.
-2. **Public Repos Only:** The MVP does not request private repository OAuth scopes.
-3. **Algorithm Maturity:** Health and Resilience scores are at `v0.1` and will aggressively evolve based on beta user feedback.
-4. **Analytics Database Load:** The analytics engine currently runs queries directly against the primary DB. True CQRS streaming projections are scaffolding-only.
-
----
-
-## π¬ FAQ
-
-**1. Does ForgeLens use AI to calculate the Health Score?**
-No. Health is mathematically deterministic. AI is strictly banned from generating metrics to ensure 100% explainability.
-
-**2. What happens if an import takes 3 hours?**
-The Inngest worker handles it via cursors and streaming. The UI will display a live "Importing..." timeline.
-
-**3. Will my server crash on `torvalds/linux`?**
-No. Operation Iron introduced streaming pagination. Peak RAM remains below 500MB regardless of repository size.
-
-*(Add 27 more based on Beta feedback...)*
-
----
-
-## π€ Contributing
+# 4. Run the app and the Inngest dev server (two terminals)
+pnpm dev
+npx inngest-cli dev -u http://localhost:3000/api/inngest
+```
-1. **Development Setup:**
- ```bash
- pnpm install
- pnpm run dev
- ```
-2. **Architecture Rules:** No business logic in React components. No direct DB queries in UI files.
-3. **Testing Rules:** Every Domain Analytics change requires Vitest coverage.
-4. **Commit Rules:** We strictly follow Conventional Commits.
-5. **PR Rules:** Fill out the `.github/PULL_REQUEST_TEMPLATE.md`. No evidence = No merge.
+Notes:
----
+- Use `pnpm run setup`, not `pnpm setup` (the latter is a built-in pnpm command).
+- Port 5432 busy? `POSTGRES_PORT=5433 docker compose up -d` and change the port in `DATABASE_URL`.
+- Without `OPENAI_API_KEY` the optional AI Insights page is simply hidden.
-## π License
+## Checks
-MIT License.
+```bash
+pnpm lint && pnpm typecheck && pnpm test && pnpm build
+```
----
+CI runs exactly these on every pull request.
-## π Acknowledgements
+## Project layout
-Designed with inspiration from modern enterprise observability principles (Google SRE, Stripe Infra, GitHub Platform).
+| Path | Purpose |
+| --- | --- |
+| `apps/web` | Next.js App Router app, Clerk auth, Inngest workers |
+| `packages/db` | Drizzle schema and migrations (Postgres) |
+| `packages/domain-analytics` | Pure TypeScript scoring, fully unit-tested |
+| `packages/github` | Octokit client with throttling |
+| `packages/ai` | Optional semantic review (not part of any score) |
----
+Deep dive: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Deploying: [docs/DEPLOY.md](docs/DEPLOY.md).
-## π Final Engineering Statement
+## Contributing
-ForgeLens is not a dashboard. It is a disciplined, hardened, deterministic analytics engine.
+Friendly first PRs are very welcome. Browse issues labelled `good first issue`, comment `.take` to claim one, and read [CONTRIBUTING.md](CONTRIBUTING.md). We promise a first maintainer response within 72 hours. Starter ideas: [docs/GOOD_FIRST_ISSUES.md](docs/GOOD_FIRST_ISSUES.md).
-It was built under a singular directive: **Reality Wins.**
+## License
-Every feature is backed by evidence. Every metric is explainable. Every failure is documented. We do not merge code because it looks impressive; we merge code because it survives contact with reality. This repository serves as a blueprint for building software that is correct, observable, and impossible to surprise twice.
+MIT, as declared by this project. Note: a `LICENSE` file still needs to be added to the repository root.
diff --git a/apps/web/.env.example b/apps/web/.env.example
new file mode 100644
index 0000000..bd7cbee
--- /dev/null
+++ b/apps/web/.env.example
@@ -0,0 +1,36 @@
+# Copy to apps/web/.env.local and fill in. Never commit real secrets.
+
+# --- Database (required) ---
+# Local default matches docker-compose.yml. Production: Neon/Supabase connection string.
+DATABASE_URL=postgres://forgelens:forgelens@localhost:5432/forgelens
+
+# 32-character secret used to encrypt stored GitHub tokens (AES-256-GCM). Required in production.
+# Generate: node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"
+DB_ENCRYPTION_KEY=
+
+# --- Clerk auth (required for sign-in) --- https://dashboard.clerk.com -> API Keys
+NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
+CLERK_SECRET_KEY=
+# Clerk dashboard -> Webhooks -> your endpoint -> Signing Secret (needed for /api/webhooks/clerk)
+CLERK_WEBHOOK_SECRET=
+
+# --- GitHub ---
+# Server-side token for PUBLIC repos only (showcase seed + public-repo fallback).
+# https://github.com/settings/tokens -> fine-grained, "Public repositories (read-only)".
+GITHUB_TOKEN=
+# Any random string; must match the secret configured on your GitHub webhook.
+GITHUB_WEBHOOK_SECRET=
+
+# --- Inngest --- https://app.inngest.com (not needed locally: run `npx inngest-cli dev`)
+INNGEST_EVENT_KEY=
+INNGEST_SIGNING_KEY=
+
+# --- Ingestion caps (optional; defaults shown) ---
+INGEST_MAX_MONTHS=12
+INGEST_MAX_ITEMS=5000
+
+# --- Optional ---
+# Enables the AI Insights page. Leave empty to hide it.
+OPENAI_API_KEY=
+# Public base URL, used in badge Markdown. Defaults to the request host.
+NEXT_PUBLIC_APP_URL=
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index 5ef6a52..7b8da95 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!.env.example
# vercel
.vercel
diff --git a/apps/web/package.json b/apps/web/package.json
index 72a3083..9b08b0e 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -2,11 +2,14 @@
"name": "web",
"version": "0.1.0",
"private": true,
+ "type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint"
+ "lint": "eslint",
+ "typecheck": "tsc --noEmit",
+ "seed:showcase": "tsx --env-file=.env.local scripts/seed-showcase.mts"
},
"dependencies": {
"@clerk/nextjs": "^7.5.12",
@@ -15,7 +18,7 @@
"@forgelens/domain-analytics": "workspace:^",
"@forgelens/github": "workspace:^",
"@opentelemetry/api": "^1.9.1",
- "drizzle-orm": "^0.45.2",
+ "drizzle-orm": "0.45.2",
"inngest": "^4.11.0",
"next": "16.2.10",
"react": "19.2.4",
@@ -31,6 +34,7 @@
"eslint": "^9",
"eslint-config-next": "16.2.10",
"tailwindcss": "^4",
- "typescript": "^5"
+ "typescript": "^5",
+ "tsx": "4.20.6"
}
}
diff --git a/apps/web/scripts/seed-showcase.mts b/apps/web/scripts/seed-showcase.mts
new file mode 100644
index 0000000..724b245
--- /dev/null
+++ b/apps/web/scripts/seed-showcase.mts
@@ -0,0 +1,83 @@
+/**
+ * Imports and fully computes the showcase repos using the server GITHUB_TOKEN.
+ * Usage: pnpm seed:showcase (needs DATABASE_URL and GITHUB_TOKEN; see .env.example)
+ */
+import { db, repositories } from "@forgelens/db";
+import { GitHubClient } from "@forgelens/github";
+import { eq } from "drizzle-orm";
+import {
+ computeAndPersist,
+ ingestCommitsChunk,
+ ingestLimits,
+ ingestPullRequestsChunk,
+ setStatus,
+} from "../src/lib/ingest";
+import { SHOWCASE_REPOS, SHOWCASE_USER_ID } from "../src/lib/showcase";
+
+async function main() {
+ const token = process.env.GITHUB_TOKEN;
+ if (!token) throw new Error("GITHUB_TOKEN is required to seed showcase repos.");
+ if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required.");
+
+ const client = new GitHubClient(token);
+ const limits = ingestLimits();
+ console.log(`Ingest cap: ${limits.months} months / ${limits.maxItems} items per stream`);
+
+ for (const fullName of SHOWCASE_REPOS) {
+ const [owner, name] = fullName.split("/");
+ console.log(`\n== ${fullName}`);
+ const gh = await client.getRepository(owner, name);
+ if (!gh) {
+ console.warn(" not found, skipping");
+ continue;
+ }
+ const repositoryId = gh.id.toString();
+ const meta = {
+ userId: SHOWCASE_USER_ID,
+ githubId: gh.id,
+ name: gh.name,
+ fullName: gh.full_name,
+ isPrivate: false,
+ isShowcase: true,
+ description: gh.description,
+ language: gh.language,
+ stars: gh.stargazers_count,
+ forks: gh.forks_count,
+ watchers: gh.watchers_count,
+ license: gh.license?.name,
+ syncStatus: "QUEUED",
+ };
+ await db.insert(repositories).values({ id: repositoryId, ...meta }).onConflictDoUpdate({
+ target: repositories.id,
+ set: { ...meta, updatedAt: new Date() },
+ });
+
+ const ref = { repositoryId, owner, name };
+ try {
+ await setStatus(repositoryId, "Fetching Commits");
+ for (let page: number | null = 1; page !== null; ) {
+ const r = await ingestCommitsChunk(client, ref, page, limits);
+ console.log(` commits +${r.count}`);
+ page = r.nextPage;
+ }
+ await setStatus(repositoryId, "Fetching PRs");
+ for (let page: number | null = 1; page !== null; ) {
+ const r = await ingestPullRequestsChunk(client, ref, page, limits);
+ console.log(` prs +${r.count}`);
+ page = r.nextPage;
+ }
+ await setStatus(repositoryId, "Calculating Health");
+ const res = await computeAndPersist(repositoryId);
+ console.log(` health=${res.healthScore} resilience=${res.resilienceScore} busFactor=${res.busFactor}`);
+ } catch (e) {
+ console.error(` failed: ${(e instanceof Error ? e.message : String(e)).slice(0, 300)}`);
+ await db.update(repositories).set({ syncStatus: "FAILED" }).where(eq(repositories.id, repositoryId));
+ }
+ }
+ process.exit(0);
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/apps/web/src/actions/analytics.ts b/apps/web/src/actions/analytics.ts
index 2d13e91..b2ffdff 100644
--- a/apps/web/src/actions/analytics.ts
+++ b/apps/web/src/actions/analytics.ts
@@ -1,7 +1,8 @@
"use server";
import { auth } from "@clerk/nextjs/server";
-import { getDashboardMetrics } from "@forgelens/domain-analytics";
+import { db, repositories, commits, pullRequests } from "@forgelens/db";
+import { and, count, eq, gte } from "drizzle-orm";
export async function getDashboardAnalytics() {
const { userId } = await auth();
@@ -10,6 +11,32 @@ export async function getDashboardAnalytics() {
throw new Error("Unauthorized");
}
- // The database and aggregation logic is safely abstracted to the Domain layer.
- return await getDashboardMetrics(userId);
+ const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+ const owned = eq(repositories.userId, userId);
+
+ const [[repoRow], [commitRow], [openRow], [closedRow]] = await Promise.all([
+ db.select({ n: count() }).from(repositories).where(owned),
+ db
+ .select({ n: count() })
+ .from(commits)
+ .innerJoin(repositories, eq(commits.repositoryId, repositories.id))
+ .where(and(owned, gte(commits.timestamp, since))),
+ db
+ .select({ n: count() })
+ .from(pullRequests)
+ .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
+ .where(and(owned, eq(pullRequests.state, "open"))),
+ db
+ .select({ n: count() })
+ .from(pullRequests)
+ .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
+ .where(and(owned, eq(pullRequests.state, "closed"))),
+ ]);
+
+ return {
+ totalRepos: repoRow.n,
+ totalCommits7d: commitRow.n,
+ openPrs: openRow.n,
+ closedPrs: closedRow.n,
+ };
}
diff --git a/apps/web/src/actions/import.ts b/apps/web/src/actions/import.ts
index 936d42d..dae7ae5 100644
--- a/apps/web/src/actions/import.ts
+++ b/apps/web/src/actions/import.ts
@@ -7,7 +7,7 @@ import { GitHubClient } from "@forgelens/github";
import { inngest } from "@/inngest/client";
import { revalidatePath } from "next/cache";
-export async function importRepository(prevState: any, formData: FormData) {
+export async function importRepository(_prevState: unknown, formData: FormData) {
const repoName = formData.get("repository")?.toString().trim();
if (!repoName) {
@@ -74,14 +74,14 @@ export async function importRepository(prevState: any, formData: FormData) {
data: {
repositoryId: repoId,
fullName: githubRepo.full_name,
- githubToken,
+ userId,
}
});
revalidatePath("/dashboard");
return { success: true };
- } catch (error: any) {
+ } catch (error) {
console.error("Import error:", error);
- return { error: error.message || "Failed to import repository." };
+ return { error: error instanceof Error ? error.message : "Failed to import repository." };
}
}
diff --git a/apps/web/src/actions/sync.ts b/apps/web/src/actions/sync.ts
index 722c1ff..333d2cd 100644
--- a/apps/web/src/actions/sync.ts
+++ b/apps/web/src/actions/sync.ts
@@ -28,6 +28,8 @@ export async function syncRepositories() {
// Upsert the repository record quickly
await db.insert(repositories).values({
id: repo.id.toString(),
+ githubId: repo.id,
+ name: repo.name,
userId,
fullName: repo.full_name,
isPrivate: repo.private,
@@ -39,7 +41,7 @@ export async function syncRepositories() {
data: {
repositoryId: repo.id.toString(),
fullName: repo.full_name,
- githubToken: githubToken
+ userId,
}
});
}
diff --git a/apps/web/src/app/admin/import-debug/page.tsx b/apps/web/src/app/admin/import-debug/page.tsx
index 903a435..86da166 100644
--- a/apps/web/src/app/admin/import-debug/page.tsx
+++ b/apps/web/src/app/admin/import-debug/page.tsx
@@ -1,5 +1,4 @@
-import { db } from "@forgelens/db";
-import { repositories } from "@forgelens/db/schemas/repositories";
+import { db, repositories } from "@forgelens/db";
import { desc } from "drizzle-orm";
export const dynamic = "force-dynamic";
diff --git a/apps/web/src/app/api/badge/[owner]/[repo]/route.ts b/apps/web/src/app/api/badge/[owner]/[repo]/route.ts
new file mode 100644
index 0000000..d1b3b8b
--- /dev/null
+++ b/apps/web/src/app/api/badge/[owner]/[repo]/route.ts
@@ -0,0 +1,44 @@
+import { healthTier } from "@forgelens/domain-analytics";
+import { renderBadge } from "@/lib/badge";
+import { clientIp, rateLimit } from "@/lib/rate-limit";
+import { getPublicRepository, isValidSlug } from "@/lib/reports";
+
+const SIX_HOURS = 6 * 60 * 60;
+
+export async function GET(req: Request, { params }: { params: Promise<{ owner: string; repo: string }> }) {
+ const { owner, repo } = await params;
+
+ const headers = {
+ "Content-Type": "image/svg+xml; charset=utf-8",
+ "Cache-Control": `public, max-age=${SIX_HOURS}, s-maxage=${SIX_HOURS}`,
+ };
+
+ if (!isValidSlug(owner, repo)) {
+ return new Response(renderBadge("ForgeLens", "not analyzed", "#9f9f9f"), { status: 404, headers });
+ }
+
+ const rl = rateLimit(`badge:${clientIp(req.headers)}`, 120);
+ if (!rl.ok) {
+ return new Response("Too many requests", { status: 429, headers: { "Retry-After": String(rl.retryAfter) } });
+ }
+
+ let score: number | null = null;
+ try {
+ score = (await getPublicRepository(owner, repo))?.healthScore ?? null;
+ } catch (e) {
+ console.error("badge lookup failed", e);
+ // Don't cache a transient failure for hours.
+ return new Response(renderBadge("ForgeLens", "unavailable", "#9f9f9f"), {
+ status: 503,
+ headers: { "Content-Type": headers["Content-Type"], "Cache-Control": "no-store" },
+ });
+ }
+
+ if (score === null) {
+ // Short cache so a repo that gets analyzed soon after doesn't show stale text for hours.
+ return new Response(renderBadge("ForgeLens", "not analyzed", "#9f9f9f"), {
+ headers: { ...headers, "Cache-Control": "public, max-age=300, s-maxage=300" },
+ });
+ }
+ return new Response(renderBadge("ForgeLens", `health ${score}`, healthTier(score).color), { headers });
+}
diff --git a/apps/web/src/app/api/webhooks/github/route.ts b/apps/web/src/app/api/webhooks/github/route.ts
index cd8c456..9735860 100644
--- a/apps/web/src/app/api/webhooks/github/route.ts
+++ b/apps/web/src/app/api/webhooks/github/route.ts
@@ -25,7 +25,26 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
- const payload = JSON.parse(body);
+ const payload = JSON.parse(body) as {
+ repository: { id: number };
+ commits: Array<{
+ id: string;
+ message: string;
+ author: { name: string; email: string };
+ url: string;
+ timestamp: string;
+ }>;
+ pull_request: {
+ id: number;
+ number: number;
+ title: string;
+ state: string;
+ html_url: string;
+ user: { login: string };
+ created_at: string;
+ updated_at: string;
+ };
+ };
try {
if (event === "push") {
@@ -39,7 +58,7 @@ export async function POST(req: NextRequest) {
if (!repoExists) return NextResponse.json({ status: "Ignored" }, { status: 200 });
- const commitsToInsert = payload.commits.map((commit: any) => ({
+ const commitsToInsert = payload.commits.map((commit) => ({
id: commit.id,
repositoryId: repoExists.id,
message: commit.message,
diff --git a/apps/web/src/app/dashboard/insights/page.tsx b/apps/web/src/app/dashboard/insights/page.tsx
index b378ecb..6a43a2f 100644
--- a/apps/web/src/app/dashboard/insights/page.tsx
+++ b/apps/web/src/app/dashboard/insights/page.tsx
@@ -1,9 +1,12 @@
import { generateAiInsights } from "@/actions/insights";
import ReactMarkdown from "react-markdown";
+import { notFound } from "next/navigation";
export const maxDuration = 30;
export default async function InsightsPage() {
+ // AI is optional: without a key the page (and its nav item) simply does not exist.
+ if (!process.env.OPENAI_API_KEY) notFound();
const insights = await generateAiInsights();
return (
diff --git a/apps/web/src/app/dashboard/layout.tsx b/apps/web/src/app/dashboard/layout.tsx
index 0e42402..8be8a43 100644
--- a/apps/web/src/app/dashboard/layout.tsx
+++ b/apps/web/src/app/dashboard/layout.tsx
@@ -9,9 +9,11 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx
index 2a230c5..c36d1ba 100644
--- a/apps/web/src/app/dashboard/page.tsx
+++ b/apps/web/src/app/dashboard/page.tsx
@@ -55,7 +55,7 @@ export default async function DashboardPage() {
{repos.length === 0 ? (