diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c423e1f..8b8267e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Install run: | python -m pip install --upgrade pip - pip install -e ".[dev]" + pip install -e ".[dev,web]" - name: Ruff run: ruff check . diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a02af9a..9114eaf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -51,6 +51,7 @@ report (table | summary | markdown | json | issues) | `repopulse/settings.py` | `.repopulse.yml` and named profiles. | | `repopulse/report.py` | All human/machine renderers. | | `repopulse/checks/` | One independent check per file. | +| `webapp/` | Optional FastAPI + WebMCP layer (extra `web`); thin adapter over the core, not packaged. | ## Check Design diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d3586b..8cdd363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,21 @@ ### Added +- **Optional web app + WebMCP** (`webapp/`, extra `web`): one-page dashboard over the existing scan/compare engine, plus four read-only tools (`scan_repository`, `get_attention_items`, `get_check_details`, `compare_refs`) so a human and an agent share the same page state. Public github.com repositories only; `GITHUB_TOKEN` is server-side. Not packaged in `repopulse-cli`. Docs: [docs/webmcp-challenge.md](docs/webmcp-challenge.md). - **GitHub Action** (`action.yml` at the repository root): run a health check in CI with `uses: 3ssiri/RepoPulse@v1`. Writes the Markdown report to the workflow run summary, exposes `score` / `max-score` / `percentage` / `grade` / `truncated` / report paths as outputs, and optionally fails the build via `fail-under`. Inputs are passed through environment variables (no shell interpolation), and the installed package version is pinned. Docs: [docs/github-action.md](docs/github-action.md). - CI dogfoods the action on every push. +- Vercel deployment config for the optional web layer (`vercel.json`, root `requirements.txt`, `[tool.vercel] entrypoint`). Deployment-only: scoring, checks, report schemas, CLI contracts, the GitHub Action and the published `repopulse-cli` package are unchanged. + +### Fixed + +- Web dashboard keeps the last successful repository selected when a later scan fails, so compare and the on-screen report stay aligned. +- Compare form error tells humans to scan a repository first (no tool-name jargon). Whitespace-only compare refs are rejected. +- Compare-before-scan now shows the error in the page instead of failing silently. +- In-flight scan/compare results are discarded when a newer request has already changed the selected repository. +- URL-derived refs use the same 256-character limit as body refs. +- Partial WebMCP tool registration is aborted if any `registerTool` call fails. +- The web adapter reuses the privacy-check repository payload so scan/compare do not call `get_repo` twice. +- Private repositories now return the same `404 repository_not_found` as inaccessible ones instead of a distinct `403 private_repository_not_supported`. The old response let an anonymous caller probe which private repository names a deployment's `GITHUB_TOKEN` could read. ## 0.3.6 - 2026-08-07 diff --git a/README.ar.md b/README.ar.md index 949d788..6c2a5fe 100644 --- a/README.ar.md +++ b/README.ar.md @@ -129,6 +129,7 @@ repopulse compare - **منع التكرار افتراضيًا** (يتخطى العناوين المطابقة لـ Issues مفتوحة) - `--no-dedupe` لإجبار الإنشاء - **إجراء GitHub جاهز** — فحص صحة في CI مع التقرير في ملخص التشغيل (انظر أدناه) +- **تطبيق ويب اختياري** — لوحة واحدة وأربع أدوات للقراءة فقط يشترك فيها الإنسان والوكيل في الحالة نفسها. التفاصيل: [docs/webmcp-challenge.md](docs/webmcp-challenge.md) - إعدادات اختيارية `.repopulse.yml` وملفات تعريف: ```text diff --git a/README.es-ES.md b/README.es-ES.md index 1204490..2a1b2e7 100644 --- a/README.es-ES.md +++ b/README.es-ES.md @@ -77,6 +77,7 @@ Todas las opciones: [USAGE.md](USAGE.md). - Config `.repopulse.yml` y perfiles: `strict`, `library`, `docs`, `release` - Detección de nombres de archivos sensibles **sin** imprimir secretos - Ejemplo de GitHub Actions: [examples/github-action-repopulse.yml](examples/github-action-repopulse.yml) +- Capa web opcional + WebMCP: [docs/webmcp-challenge.md](docs/webmcp-challenge.md) ## Enlaces diff --git a/README.md b/README.md index c886f1b..8dfc24f 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ Private repos: pass `--token` or set `GITHUB_TOKEN`. Details: [USAGE.md](USAGE.m - `--format issues` — paste-ready Markdown for fail/warn checks. - `repopulse create-issues` — open real GitHub issues (`--dry-run` or `--yes`). - **GitHub Action** — health check in CI with the report in the run summary (see below). +- **Web app + WebMCP** — optional FastAPI layer (from a source checkout: `pip install -e ".[web]"`, then `uvicorn webapp.app:app`) serving a one-page dashboard and four read-only WebMCP tools (`scan_repository`, `get_attention_items`, `get_check_details`, `compare_refs`) so humans and agents share the same state. Details: [docs/webmcp-challenge.md](docs/webmcp-challenge.md). - Optional config `.repopulse.yml` with profiles: `strict`, `library`, `docs`, `release`. ## GitHub Action diff --git a/docs/webmcp-challenge.md b/docs/webmcp-challenge.md new file mode 100644 index 0000000..c070ba9 --- /dev/null +++ b/docs/webmcp-challenge.md @@ -0,0 +1,168 @@ +# RepoPulse Web + WebMCP (OpenAI WebMCP Challenge) + +A thin web layer over the existing RepoPulse engine. The same `HealthReport` +and `ComparisonReport` the CLI produces are served over HTTP, and the same +functions that drive the visible dashboard are registered as WebMCP tools — +so a human and an agent always share the same application state. + +## Architecture + +```text +Browser (index.html + app.js, vanilla JS) + | fetch() | document.modelContext.registerTool() + v v +FastAPI app (webapp/app.py) <----+ + | parse_github_url → GitHubClient → build_health_report / build_comparison + v +repopulse engine (unchanged core) +``` + +- `webapp/` is a **top-level adapter**, outside the `repopulse` package. The + PyPI distribution (`repopulse-cli`) does not include it and does not depend + on FastAPI. +- The web layer contains no business logic; it parses, validates, delegates + to the core, and maps errors. +- Frontend state (`state.currentReport`, `state.currentComparison`) is the + single source of truth for both the UI and the WebMCP tools. The Scan + button and `scan_repository` call the same `scanRepository()`; the Compare + button and `compare_refs` call the same `compareRefs()`. + +## How to run + +```bash +pip install -e ".[dev,web]" +uvicorn webapp.app:app --host 127.0.0.1 --port 8000 --reload +``` + +Open http://127.0.0.1:8000. Optional: set `GITHUB_TOKEN` in the server +environment to raise GitHub rate limits. The token is server-side only — +it never appears in HTML, JS, responses, or error messages. + +## Deployment (Vercel) + +The same FastAPI app is deployed unchanged; nothing is rebuilt for hosting. + +| File | Why | +|---|---| +| `pyproject.toml` → `[tool.vercel] entrypoint` | `webapp/app.py` is outside Vercel's auto-detected entrypoint locations. setuptools ignores `[tool.vercel]`, so the sdist and wheel are unaffected. | +| `vercel.json` → `fluid: true` | Fluid compute, to keep demo latency down between requests. | +| `vercel.json` → `functions."webapp/app.py".maxDuration: 60` | A scan makes several sequential GitHub API calls (repo, tree, then README / .gitignore / package.json / pyproject.toml / each workflow), so it can outlast a short default timeout. | +| `vercel.json` → `buildCommand` | Installs `requirements.txt` into the function environment. | +| `requirements.txt` | The web layer's runtime imports only. Keep in sync with the `web` extra. | + +Why the web dependencies are not in `pyproject.toml`: Vercel's Python runtime +installs **only** `[project].dependencies`. `[project.optional-dependencies]`, +PEP 735 `[dependency-groups]` and a bare `requirements.txt` are all ignored — +verified with a probe deployment. Putting FastAPI in `[project].dependencies` +would make every `pip install repopulse-cli` pull FastAPI, so the web-only +requirements are installed by `buildCommand` instead, which runs after the +framework install and before the function bundle is assembled. + +`GITHUB_TOKEN` is not set on the deployment: the demo runs against GitHub's +unauthenticated rate limit. If that becomes the bottleneck, add it as a Vercel +environment variable — never in `vercel.json`, the repository, or the frontend. + +The Vercel project's production branch is `main`, so a push to a feature +branch produces a preview deployment; production follows once the branch is +merged. + +## API + +| Endpoint | Body | Response | +|---|---|---| +| `GET /api/health` | — | `{"status","service","version"}` (no GitHub request) | +| `POST /api/scan` | `{"repository_url", "ref"?}` | `HealthReport.model_dump()` | +| `POST /api/compare` | `{"repository_url", "baseline_ref", "target_ref"}` | `ComparisonReport.model_dump()` | + +Validation and precedence: + +- Only `github.com` URLs are accepted, exclusively via + `repopulse.url_parser.parse_github_url` (SSRF boundary). +- `repository_url` max 512 chars; refs max 256 chars. +- If the URL embeds a ref (`/tree/`) and the body also has `ref`, the + body `ref` wins. + +Error contract — `{"detail": {"code", "message"}}`, no tracebacks, no raw +GitHub payloads, no tokens: + +| Code | HTTP | +|---|---| +| `invalid_repository_url` | 400 | +| `invalid_ref` | 400 | +| `repository_not_found` / `ref_not_found` | 404 | +| `github_rate_limited` | 429 | +| `github_unavailable` | 502 / 503 | +| `internal_error` | 500 | + +Note: the core client collapses "repo missing" and "private repo without a +token" into one 404; they are indistinguishable without credentials. + +## WebMCP tools + +Registered via `document.modelContext.registerTool()` (imperative API, +current W3C WebMCP draft) with `annotations: {readOnlyHint: true, +untrustedContentHint: true}`. WebMCP is progressive enhancement: without +`document.modelContext` the page works normally and shows +"WebMCP Unavailable". + +| Tool | Input | Behavior | +|---|---|---| +| `scan_repository` | `repository_url`, `ref?` | Runs the shared scan path, updates the dashboard, returns the report summary. | +| `get_attention_items` | `{}` | FAIL then WARN checks from the **current** report; no new GitHub request. | +| `get_check_details` | `check_key` | One check from the current report; returns available keys on a miss. | +| `compare_refs` | `baseline_ref`, `target_ref` | Runs the shared compare path for the currently scanned repository, updates the dashboard. | + +All four are read-only against GitHub. Tool execution receives an +`AbortSignal` (per the spec's `ToolExecuteCallbackOptions`) which is forwarded +to `fetch`, so agent-cancelled calls abort the HTTP request and reset the +loading state. + +## Security boundaries + +- **Public repositories only.** If a server-side token can see a private + repo, the repo is rejected *before* any tree/file reads. The rejection is + the same `404 repository_not_found` an inaccessible repository gets, so an + anonymous caller cannot use the API to discover which private repository + names the deployment's token can read. +- **No tokens from the client.** No PAT input, no token in request bodies; + `GITHUB_TOKEN` is read server-side only. +- **XSS:** all GitHub-derived data renders via `textContent`/`createElement`; + no `innerHTML`, no Markdown rendering, no raw README display. +- **Prompt injection:** WebMCP results contain RepoPulse analysis results + only — never raw repository content; tools are static and never derived + from repository data. +- **SSRF:** repository URLs pass only through `parse_github_url` + (`github.com` only); the client talks only to `api.github.com`. +- **Headers:** `X-Content-Type-Options: nosniff`, `Referrer-Policy: + no-referrer`. Same-origin frontend/API; no CORS, no framing rules added + (WebMCP compatibility untested for those). + +## Testing + +`tests/test_webapp.py` covers: health, index, error mapping +(invalid URL/host, 404 repo vs ref, rate limit, network failure, 502), +ref precedence, `scan_truncated` passthrough, private-repo rejection, +token/traceback leak checks, the compare contract, whitespace/blank compare +refs, overlong URL-derived refs, reused GitHub metadata, stale +scan/compare results, Compare-before-Scan UI errors, and partial WebMCP +registration abort. All network and core calls are mocked — tests never +touch real GitHub. + +## Demo flow + +Using `https://github.com/3ssiri/RepoPulse` (tags `v0.3.5`/`v0.3.6` exist): + +1. "Scan this repository and tell me the three things that deserve the most + attention before a release." → `scan_repository` → `get_attention_items` +2. "Explain the most important warning and what I should verify manually." + → `get_check_details` +3. "Compare v0.3.5 with v0.3.6 and tell me whether repository health + regressed." → `compare_refs` + +## Known limitations / future work + +- Public github.com repositories only; no OAuth, accounts, or private repos. +- No caching, history, or persistence; each scan hits the GitHub API live. +- Repo-404 vs private-repo is indistinguishable without a token. +- Deferred: OAuth/private repos, saved history, issue creation via WebMCP, + GitLab/Bitbucket, background jobs, caching. diff --git a/pyproject.toml b/pyproject.toml index 8886ee5..043641a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,12 @@ dev = [ "mypy>=2.3.0", "types-PyYAML>=6.0.12.20260724", "types-requests>=2.32.0", - "build>=1.2.0" + "build>=1.2.0", + "httpx>=0.28.0" +] +web = [ + "fastapi>=0.135.0", + "uvicorn>=0.35.0" ] [project.scripts] @@ -64,5 +69,12 @@ include = ["repopulse*"] # FURB162: fromisoformat still needs Z->offset on Python 3.11. ignore = ["B008", "FURB162"] +# Vercel deploys webapp/app.py (the optional web layer). This table is read +# only by Vercel's Python runtime; setuptools and the PyPI distribution +# ignore it. +[tool.vercel] +entrypoint = "webapp.app:app" + [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["."] diff --git a/repopulse/analyzer.py b/repopulse/analyzer.py index e279f64..2430357 100644 --- a/repopulse/analyzer.py +++ b/repopulse/analyzer.py @@ -135,10 +135,12 @@ def build_health_report( repo: str, config: RepoPulseConfig | None = None, ref: str | None = None, + *, + repo_data: dict | None = None, ) -> HealthReport: config = config or RepoPulseConfig() - repo_data = client.get_repo(owner, repo) - repository = repo_info_from_api(owner, repo, repo_data) + data = repo_data if repo_data is not None else client.get_repo(owner, repo) + repository = repo_info_from_api(owner, repo, data) # Tree + content loads use the explicit ref when given, otherwise the API default branch. tree_ref = ref or repository.default_branch # When an explicit ref is scanned, surface it as default_branch so reports/labels show which ref was used. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5e78e90 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +# Runtime dependencies of the optional web layer (webapp/), installed by the +# Vercel buildCommand in vercel.json. Vercel's Python runtime installs only +# [project].dependencies from pyproject.toml, and FastAPI must not become a +# dependency of the published `repopulse-cli` package -- so the web-only +# requirements live here. Keep in sync with the `web` extra in pyproject.toml. +fastapi>=0.135.0 +pydantic>=2.13.4 +requests>=2.34.2 +PyYAML>=6.0.3 diff --git a/tests/test_cli.py b/tests/test_cli.py index 441aa0a..a3cdc60 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -217,6 +217,30 @@ def test_build_health_report_without_ref_uses_default_branch(): assert report.repository.default_branch == "develop" +def test_build_health_report_reuses_provided_repo_data(): + client = MagicMock() + repo_data = { + "name": "repo", + "full_name": "owner/repo", + "html_url": "https://github.com/owner/repo", + "default_branch": "main", + "private": False, + "stargazers_count": 1, + "forks_count": 0, + "open_issues_count": 0, + "pushed_at": "2026-06-01T00:00:00Z", + "description": None, + } + client.get_tree.return_value = ([], False) + client.get_file_content.return_value = None + + report = build_health_report(client, "owner", "repo", repo_data=repo_data) + + client.get_repo.assert_not_called() + assert report.repository.full_name == "owner/repo" + assert report.repository.stars == 1 + + def _report_with_fail() -> HealthReport: return HealthReport( repository=RepositoryInfo( diff --git a/tests/test_webapp.py b/tests/test_webapp.py new file mode 100644 index 0000000..3f74b2b --- /dev/null +++ b/tests/test_webapp.py @@ -0,0 +1,650 @@ +"""Tests for the FastAPI web layer (webapp.app). + +All GitHub access is mocked at the webapp boundary: GitHubClient and +build_health_report are monkeypatched, following the existing test style. +""" + +import pytest + +pytest.importorskip("fastapi", reason="web extra not installed") +pytest.importorskip("httpx", reason="web extra not installed") + +from fastapi.testclient import TestClient + +import webapp.app as webapp +from repopulse import __version__ +from repopulse.github_client import GitHubAPIError +from repopulse.models import CheckResult, HealthReport, RepositoryInfo + +VALID_URL = "https://github.com/octo/hello" + + +def sample_report(scan_truncated=False) -> HealthReport: + return HealthReport( + repository=RepositoryInfo( + owner="octo", + name="hello", + full_name="octo/hello", + description="demo", + url="https://github.com/octo/hello", + default_branch="main", + private=False, + stars=10, + forks=2, + open_issues=1, + ), + checks=[ + CheckResult( + key="tests", + title="Tests", + status="warn", + score=5, + max_score=10, + message="No CI found.", + recommendations=["Add a CI workflow."], + ) + ], + total_score=80, + grade="B", + recommendations=["Add a CI workflow."], + scan_truncated=scan_truncated, + ) + + +class FakeClient: + """Fake GitHubClient; ``private`` controls the pre-scan privacy check.""" + + def __init__(self, token=None, private=False): + self.token = token + self.private = private + + def get_repo(self, owner, repo): + return {"private": self.private} + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setattr(webapp, "GitHubClient", FakeClient) + monkeypatch.setattr(webapp, "build_health_report", lambda *a, **k: sample_report()) + return TestClient(webapp.app, raise_server_exceptions=False) + + +def failing_client(monkeypatch, error): + def fake_build(*args, **kwargs): + raise error + + monkeypatch.setattr(webapp, "GitHubClient", FakeClient) + monkeypatch.setattr(webapp, "build_health_report", fake_build) + return TestClient(webapp.app, raise_server_exceptions=False) + + +def test_health(client): + response = client.get("/api/health") + assert response.status_code == 200 + assert response.json() == { + "status": "ok", + "service": "repopulse-web", + "version": __version__, + } + + +def test_index_served(client): + response = client.get("/") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "RepoPulse" in response.text + + +def test_security_headers(client): + response = client.get("/api/health") + assert response.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["Referrer-Policy"] == "no-referrer" + + +def test_scan_valid(client): + response = client.post("/api/scan", json={"repository_url": VALID_URL}) + assert response.status_code == 200 + body = response.json() + assert body["schema_version"] == "1.1" + assert body["repository"]["full_name"] == "octo/hello" + assert body["total_score"] == 80 + assert body["grade"] == "B" + assert body["checks"][0]["key"] == "tests" + assert body["recommendations"] == ["Add a CI workflow."] + assert body["scan_truncated"] is False + + +def test_scan_preserves_scan_truncated(monkeypatch): + monkeypatch.setattr(webapp, "GitHubClient", FakeClient) + monkeypatch.setattr( + webapp, "build_health_report", lambda *a, **k: sample_report(scan_truncated=True) + ) + client = TestClient(webapp.app) + response = client.post("/api/scan", json={"repository_url": VALID_URL}) + assert response.status_code == 200 + assert response.json()["scan_truncated"] is True + + +def test_scan_rejects_non_github_url(client): + response = client.post("/api/scan", json={"repository_url": "https://example.com/foo"}) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "invalid_repository_url" + + +def test_scan_rejects_gitlab_host(client): + response = client.post( + "/api/scan", json={"repository_url": "https://gitlab.com/octo/hello"} + ) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "invalid_repository_url" + + +def test_scan_rejects_overlong_url(client): + response = client.post("/api/scan", json={"repository_url": "https://github.com/" + "a" * 600}) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "invalid_repository_url" + + +def test_scan_rejects_overlong_ref(client): + response = client.post( + "/api/scan", json={"repository_url": VALID_URL, "ref": "a" * 300} + ) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "invalid_ref" + + +def test_scan_body_ref_takes_precedence_over_url_ref(monkeypatch): + captured = {} + + def fake_build(client_obj, owner, repo, config=None, ref=None, **kwargs): + captured["ref"] = ref + return sample_report() + + monkeypatch.setattr(webapp, "GitHubClient", FakeClient) + monkeypatch.setattr(webapp, "build_health_report", fake_build) + client = TestClient(webapp.app) + response = client.post( + "/api/scan", + json={"repository_url": VALID_URL + "/tree/url-ref", "ref": "body-ref"}, + ) + assert response.status_code == 200 + assert captured["ref"] == "body-ref" + + +def test_scan_url_ref_used_when_body_ref_missing(monkeypatch): + captured = {} + + def fake_build(client_obj, owner, repo, config=None, ref=None, **kwargs): + captured["ref"] = ref + return sample_report() + + monkeypatch.setattr(webapp, "GitHubClient", FakeClient) + monkeypatch.setattr(webapp, "build_health_report", fake_build) + client = TestClient(webapp.app) + response = client.post("/api/scan", json={"repository_url": VALID_URL + "/tree/dev"}) + assert response.status_code == 200 + assert captured["ref"] == "dev" + + +def test_scan_empty_body_ref_falls_back_to_url_ref(monkeypatch): + captured = {} + + def fake_build(client_obj, owner, repo, config=None, ref=None, **kwargs): + captured["ref"] = ref + return sample_report() + + monkeypatch.setattr(webapp, "GitHubClient", FakeClient) + monkeypatch.setattr(webapp, "build_health_report", fake_build) + client = TestClient(webapp.app) + response = client.post( + "/api/scan", json={"repository_url": VALID_URL + "/tree/dev", "ref": " "} + ) + assert response.status_code == 200 + assert captured["ref"] == "dev" + + +def test_scan_repository_not_found(monkeypatch): + client = failing_client( + monkeypatch, + GitHubAPIError("Repository or file was not found. Check the URL and token permissions."), + ) + response = client.post("/api/scan", json={"repository_url": VALID_URL}) + assert response.status_code == 404 + assert response.json()["detail"]["code"] == "repository_not_found" + + +def test_scan_ref_not_found(monkeypatch): + client = failing_client( + monkeypatch, + GitHubAPIError( + "Could not load git tree for octo/hello at ref 'nope'. " + "Repository or file was not found. Check the URL and token permissions." + ), + ) + response = client.post("/api/scan", json={"repository_url": VALID_URL}) + assert response.status_code == 404 + assert response.json()["detail"]["code"] == "ref_not_found" + + +def test_scan_rate_limited(monkeypatch): + client = failing_client( + monkeypatch, + GitHubAPIError("GitHub API rate limit exceeded. Provide --token or set GITHUB_TOKEN."), + ) + response = client.post("/api/scan", json={"repository_url": VALID_URL}) + assert response.status_code == 429 + detail = response.json()["detail"] + assert detail["code"] == "github_rate_limited" + assert "GITHUB_TOKEN" not in detail["message"] + + +def test_scan_network_failure(monkeypatch): + client = failing_client( + monkeypatch, GitHubAPIError("Could not connect to GitHub API: boom") + ) + response = client.post("/api/scan", json={"repository_url": VALID_URL}) + assert response.status_code == 503 + assert response.json()["detail"]["code"] == "github_unavailable" + + +def test_scan_other_github_failure(monkeypatch): + client = failing_client( + monkeypatch, GitHubAPIError("GitHub API request failed: 500 raw-body-here") + ) + response = client.post("/api/scan", json={"repository_url": VALID_URL}) + assert response.status_code == 502 + detail = response.json()["detail"] + assert detail["code"] == "github_unavailable" + assert "raw-body-here" not in detail["message"] + + +def test_scan_private_repo_rejected(monkeypatch): + called = {"build": False} + + def fake_build(*args, **kwargs): + called["build"] = True + return sample_report() + + monkeypatch.setattr( + webapp, "GitHubClient", lambda token=None: FakeClient(token, private=True) + ) + monkeypatch.setattr(webapp, "build_health_report", fake_build) + client = TestClient(webapp.app) + response = client.post("/api/scan", json={"repository_url": VALID_URL}) + assert response.status_code == 404 + assert response.json()["detail"]["code"] == "repository_not_found" + assert called["build"] is False # no tree/file reads happen for private repos + + +def test_private_repo_is_indistinguishable_from_missing_one(monkeypatch): + """A private repo the token can read must not be told apart from a missing one. + + Otherwise a deployment with a broadly scoped GITHUB_TOKEN lets anonymous + callers probe which private repository names that token can see. + """ + monkeypatch.setattr( + webapp, "GitHubClient", lambda token=None: FakeClient(token, private=True) + ) + monkeypatch.setattr(webapp, "build_health_report", lambda *a, **k: sample_report()) + private = TestClient(webapp.app).post("/api/scan", json={"repository_url": VALID_URL}) + + missing = failing_client( + monkeypatch, + GitHubAPIError("Repository or file was not found. Check the URL and token permissions."), + ).post("/api/scan", json={"repository_url": VALID_URL}) + + assert private.status_code == missing.status_code + assert private.json() == missing.json() + + +def test_scan_uses_server_side_token(monkeypatch): + captured = {} + + class TokenCapturingClient(FakeClient): + def __init__(self, token=None): + super().__init__(token) + captured["token"] = token + + monkeypatch.setattr(webapp, "GitHubClient", TokenCapturingClient) + monkeypatch.setattr(webapp, "build_health_report", lambda *a, **k: sample_report()) + monkeypatch.setenv("GITHUB_TOKEN", "secret-token-123") + client = TestClient(webapp.app) + response = client.post("/api/scan", json={"repository_url": VALID_URL}) + assert response.status_code == 200 + assert captured["token"] == "secret-token-123" + assert "secret-token-123" not in response.text + + +def test_errors_do_not_leak_token_or_traceback(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "secret-token-123") + + def fake_build(*args, **kwargs): + raise RuntimeError("internal boom with secret-token-123 inside") + + monkeypatch.setattr( + webapp, "GitHubClient", lambda token=None: FakeClient(token) + ) + monkeypatch.setattr(webapp, "build_health_report", fake_build) + client = TestClient(webapp.app, raise_server_exceptions=False) + response = client.post("/api/scan", json={"repository_url": VALID_URL}) + assert response.status_code == 500 + detail = response.json()["detail"] + assert detail["code"] == "internal_error" + assert "secret-token-123" not in response.text + assert "Traceback" not in response.text + assert "internal boom" not in response.text + + +def test_compare_valid(monkeypatch): + def fake_build(client_obj, owner, repo, config=None, ref=None, **kwargs): + return sample_report() + + monkeypatch.setattr(webapp, "GitHubClient", FakeClient) + monkeypatch.setattr(webapp, "build_health_report", fake_build) + client = TestClient(webapp.app) + response = client.post( + "/api/compare", + json={ + "repository_url": VALID_URL, + "baseline_ref": "v0.3.5", + "target_ref": "v0.3.6", + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["schema_version"] == "1.0" + assert body["kind"] == "comparison" + assert body["baseline_label"] == "v0.3.5" + assert body["target_label"] == "v0.3.6" + assert body["baseline_score"] == 80 + assert body["target_score"] == 80 + assert body["score_delta"] == 0 + assert "checks" in body + assert "improved" in body + assert "regressed" in body + assert "unchanged" in body + + +def test_compare_requires_baseline_ref(client): + response = client.post( + "/api/compare", json={"repository_url": VALID_URL, "target_ref": "main"} + ) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "invalid_ref" + + +def test_compare_requires_target_ref(client): + response = client.post( + "/api/compare", json={"repository_url": VALID_URL, "baseline_ref": "main"} + ) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "invalid_ref" + + +def test_compare_rejects_overlong_ref(client): + response = client.post( + "/api/compare", + json={ + "repository_url": VALID_URL, + "baseline_ref": "a" * 300, + "target_ref": "main", + }, + ) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "invalid_ref" + + +def test_compare_rejects_non_github_url(client): + response = client.post( + "/api/compare", + json={ + "repository_url": "https://example.com/foo", + "baseline_ref": "a", + "target_ref": "b", + }, + ) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "invalid_repository_url" + + +def test_compare_propagates_github_errors(monkeypatch): + client = failing_client( + monkeypatch, + GitHubAPIError("GitHub API rate limit exceeded. Provide --token or set GITHUB_TOKEN."), + ) + response = client.post( + "/api/compare", + json={"repository_url": VALID_URL, "baseline_ref": "a", "target_ref": "b"}, + ) + assert response.status_code == 429 + assert response.json()["detail"]["code"] == "github_rate_limited" + + +def test_compare_rejects_private_repo(monkeypatch): + monkeypatch.setattr( + webapp, "GitHubClient", lambda token=None: FakeClient(token, private=True) + ) + monkeypatch.setattr(webapp, "build_health_report", lambda *a, **k: sample_report()) + client = TestClient(webapp.app) + response = client.post( + "/api/compare", + json={"repository_url": VALID_URL, "baseline_ref": "a", "target_ref": "b"}, + ) + assert response.status_code == 404 + assert response.json()["detail"]["code"] == "repository_not_found" + + +def test_compare_strips_whitespace_refs(monkeypatch): + captured = [] + + def fake_build(client_obj, owner, repo, config=None, ref=None, **kwargs): + captured.append(ref) + return sample_report() + + monkeypatch.setattr(webapp, "GitHubClient", FakeClient) + monkeypatch.setattr(webapp, "build_health_report", fake_build) + client = TestClient(webapp.app) + response = client.post( + "/api/compare", + json={ + "repository_url": VALID_URL, + "baseline_ref": " v0.3.5 ", + "target_ref": "\tv0.3.6\n", + }, + ) + assert response.status_code == 200 + assert captured == ["v0.3.5", "v0.3.6"] + assert response.json()["baseline_label"] == "v0.3.5" + assert response.json()["target_label"] == "v0.3.6" + + +def test_compare_rejects_blank_refs(client): + response = client.post( + "/api/compare", + json={"repository_url": VALID_URL, "baseline_ref": " ", "target_ref": "main"}, + ) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "invalid_ref" + + +def test_frontend_commits_repository_only_after_scan_success(): + """Failed scans must not overwrite the selected repository (dashboard/agent share state).""" + from pathlib import Path + + source = (Path(__file__).resolve().parents[1] / "webapp" / "static" / "app.js").read_text( + encoding="utf-8" + ) + function = source.split("async function scanRepository", 1)[1].split( + "async function compareRefs", 1 + )[0] + before_try, after_try = function.split("try {", 1) + assert "state.repositoryUrl = repositoryUrl" not in before_try + assert "state.repositoryUrl = repositoryUrl" in after_try.split("} catch", 1)[0] + + +def test_frontend_compare_error_is_human_readable(): + from pathlib import Path + + source = (Path(__file__).resolve().parents[1] / "webapp" / "static" / "app.js").read_text( + encoding="utf-8" + ) + function = source.split("async function compareRefs", 1)[1].split( + "/* All GitHub-derived data", 1 + )[0] + assert "scan_repository" not in function + assert "Scan a repository first" in function + + +FRONTEND_JS = ( + __import__("pathlib").Path(__file__).resolve().parents[1] / "webapp" / "static" / "app.js" +) + + +def _frontend_source() -> str: + return FRONTEND_JS.read_text(encoding="utf-8") + + +def test_frontend_compare_without_scan_goes_through_seterror(): + """Compare-before-scan must hit the catch that calls setError, not throw first.""" + function = _frontend_source().split("async function compareRefs", 1)[1].split( + "/* All GitHub-derived data", 1 + )[0] + before_try, rest = function.split("try {", 1) + assert "No repository is selected" not in before_try + try_body, catch_body = rest.split("} catch", 1) + assert "No repository is selected" in try_body + assert "setError" in catch_body + + +def test_scan_rejects_overlong_url_derived_ref(client): + long_ref = "a" * 300 + url = f"{VALID_URL}/tree/{long_ref}" + assert len(url) <= 512 + response = client.post("/api/scan", json={"repository_url": url}) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "invalid_ref" + + +def test_scan_url_ref_at_max_length_is_accepted(monkeypatch): + captured = {} + + def fake_build(client_obj, owner, repo, config=None, ref=None, **kwargs): + captured["ref"] = ref + return sample_report() + + monkeypatch.setattr(webapp, "GitHubClient", FakeClient) + monkeypatch.setattr(webapp, "build_health_report", fake_build) + client = TestClient(webapp.app) + ref = "b" * 256 + response = client.post("/api/scan", json={"repository_url": f"{VALID_URL}/tree/{ref}"}) + assert response.status_code == 200 + assert captured["ref"] == ref + + +def test_scan_passes_privacy_metadata_into_build(monkeypatch): + captured = {} + + class CountingClient(FakeClient): + def __init__(self, token=None): + super().__init__(token) + self.calls = 0 + + def get_repo(self, owner, repo): + self.calls += 1 + captured["client"] = self + return {"private": False, "full_name": "octo/hello"} + + def fake_build(client_obj, owner, repo, config=None, ref=None, repo_data=None, **kwargs): + captured["repo_data"] = repo_data + return sample_report() + + monkeypatch.setattr(webapp, "GitHubClient", CountingClient) + monkeypatch.setattr(webapp, "build_health_report", fake_build) + client = TestClient(webapp.app) + response = client.post("/api/scan", json={"repository_url": VALID_URL}) + assert response.status_code == 200 + assert captured["client"].calls == 1 + assert captured["repo_data"] == {"private": False, "full_name": "octo/hello"} + + +def test_compare_reuses_privacy_metadata_for_both_scans(monkeypatch): + captured = {"repo_data": [], "get_repo": 0} + + class CountingClient(FakeClient): + def get_repo(self, owner, repo): + captured["get_repo"] += 1 + return {"private": False} + + def fake_build(client_obj, owner, repo, config=None, ref=None, repo_data=None, **kwargs): + captured["repo_data"].append(repo_data) + return sample_report() + + monkeypatch.setattr(webapp, "GitHubClient", CountingClient) + monkeypatch.setattr(webapp, "build_health_report", fake_build) + client = TestClient(webapp.app) + response = client.post( + "/api/compare", + json={"repository_url": VALID_URL, "baseline_ref": "a", "target_ref": "b"}, + ) + assert response.status_code == 200 + assert captured["get_repo"] == 1 + assert captured["repo_data"] == [{"private": False}, {"private": False}] + + +def test_frontend_aborts_partial_webmcp_registration(): + source = _frontend_source() + block = source.split("Promise.all", 1)[1] + catch = block.split(".catch", 1)[1] + abort_at = catch.find("registration.abort()") + unavailable_at = catch.find("webmcpAvailable = false") + assert abort_at != -1 + assert unavailable_at != -1 + assert abort_at < unavailable_at + + +def test_stale_compare_is_discarded_after_newer_scan(): + """Compare for repo A must not commit after a newer scan of repo B.""" + import json + import re + import shutil + import subprocess + + if shutil.which("node") is None: + pytest.skip("node is required to execute the stale-result predicate") + + source = _frontend_source() + match = re.search( + r"function isStaleResult\([^)]*\) \{\s*return [^;]+;\s*\}", + source, + ) + assert match is not None, "isStaleResult helper is missing" + script = ( + match.group(0) + + """ + const stale = isStaleResult(1, 2, 'https://github.com/a/a', 'https://github.com/b/b'); + const same = isStaleResult(2, 2, 'https://github.com/b/b', 'https://github.com/b/b'); + const olderScan = isStaleResult(1, 2, 'https://github.com/b/b', 'https://github.com/b/b'); + console.log(JSON.stringify({stale, same, olderScan})); + """ + ) + result = subprocess.run(["node", "-e", script], capture_output=True, text=True, check=True) + payload = json.loads(result.stdout) + assert payload["stale"] is True + assert payload["same"] is False + assert payload["olderScan"] is True + + +def test_frontend_scan_and_compare_ignore_stale_results(): + source = _frontend_source() + scan = source.split("async function scanRepository", 1)[1].split( + "async function compareRefs", 1 + )[0] + compare = source.split("async function compareRefs", 1)[1].split( + "/* All GitHub-derived data", 1 + )[0] + for body in (scan, compare): + assert "isStaleResult(" in body + commit_at = body.find("state.current") + stale_at = body.find("isStaleResult(") + assert stale_at != -1 + assert stale_at < commit_at or "if (isStaleResult" in body diff --git a/tests/test_webapp_state_regression.py b/tests/test_webapp_state_regression.py new file mode 100644 index 0000000..4809029 --- /dev/null +++ b/tests/test_webapp_state_regression.py @@ -0,0 +1,91 @@ +"""Regression coverage for RepoPulse Web shared-state request ordering. + +These tests intentionally keep the frontend dependency-free: they verify the +small staleness and registration contracts in app.js without introducing a +JavaScript test stack. +""" + +from pathlib import Path + +APP_JS = Path(__file__).resolve().parents[1] / "webapp" / "static" / "app.js" + + +def _source() -> str: + return APP_JS.read_text(encoding="utf-8") + + +def _async_function(name: str, next_marker: str) -> str: + source = _source() + return source.split(f"async function {name}", 1)[1].split(next_marker, 1)[0] + + +def test_fresh_scan_can_switch_from_repository_a_to_b(): + """A fresh scan must ignore the previously selected repository identity.""" + scan = _async_function("scanRepository", "async function compareRefs") + + stale_check = "isStaleResult(started.generation, requestGeneration, null, null)" + assert stale_check in scan + assert scan.find(stale_check) < scan.find("state.repositoryUrl = repositoryUrl") + + +def test_late_compare_cannot_commit_after_repository_switch(): + """A comparison remains pinned to the repository selected when it started.""" + compare = _async_function("compareRefs", "/* All GitHub-derived data") + + assert "startedUrl, state.repositoryUrl" in compare + assert "isStaleResult(" in compare + assert compare.find("isStaleResult(") < compare.find("state.currentComparison = comparison") + + +def test_failed_scan_puts_the_retained_selection_back_in_the_form(): + """A failed scan keeps the previous selection; the form must show it again. + + Otherwise the form displays the repository the user typed while Compare + still acts on the retained one, and the two silently disagree. + """ + scan = _async_function("scanRepository", "async function compareRefs") + catch = scan.split("} catch (error) {", 1)[1] + + assert "syncScanForm(state.repositoryUrl, state.ref)" in catch + # Only when a previous scan actually succeeded - a first failed scan must + # not wipe what the user typed. + assert catch.find("state.currentReport") < catch.find("syncScanForm(") + + +def test_stale_helper_separates_generation_from_optional_repository_identity(): + """Generation always matters; repository identity is checked only when supplied.""" + source = _source() + helper = source.split("function isStaleResult", 1)[1].split("function beginRequest", 1)[0] + + assert "startedGeneration !== currentGeneration" in helper + assert "startedUrl !== null && currentUrl !== null" in helper + assert "startedUrl !== currentUrl" in helper + + +def test_newer_request_still_supersedes_older_scan_and_compare(): + """Generation ordering remains the common stale-result boundary.""" + scan = _async_function("scanRepository", "async function compareRefs") + compare = _async_function("compareRefs", "/* All GitHub-derived data") + + assert "started.generation !== requestGeneration" in scan + assert "started.generation !== requestGeneration" in compare + + +def test_webmcp_sync_registration_failure_is_handled_before_promise_all(): + """A synchronous registerTool throw must abort partial registrations cleanly.""" + source = _source() + registration = source.split("function registerWebMCPTools", 1)[1].split( + "function main", 1 + )[0] + before_promise = registration.split("Promise.all(registrations)", 1)[0] + + assert "let registrations;" in before_promise + assert "try {" in before_promise + assert "registrations = tools.map" in before_promise + assert "Promise.resolve(" in before_promise + + sync_catch = before_promise.split("} catch", 1)[1] + assert "registration.abort()" in sync_catch + assert "state.webmcpAvailable = false" in sync_catch + assert "renderWebMCPStatus()" in sync_catch + assert "return;" in sync_catch diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..8a38123 --- /dev/null +++ b/vercel.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "fluid": true, + "buildCommand": "uv pip install --python .vercel/python/.venv/bin/python -r requirements.txt", + "functions": { + "webapp/app.py": { + "maxDuration": 60 + } + } +} diff --git a/webapp/__init__.py b/webapp/__init__.py new file mode 100644 index 0000000..f8057b4 --- /dev/null +++ b/webapp/__init__.py @@ -0,0 +1 @@ +"""RepoPulse web layer: a thin FastAPI adapter over the core engine.""" diff --git a/webapp/app.py b/webapp/app.py new file mode 100644 index 0000000..f29ba96 --- /dev/null +++ b/webapp/app.py @@ -0,0 +1,250 @@ +"""RepoPulse web layer (OpenAI WebMCP challenge). + +A thin FastAPI adapter over the existing RepoPulse engine. It holds no +business logic of its own: scans go through ``repopulse.analyzer`` and +comparisons through ``repopulse.compare``, exactly like the CLI. + +Boundaries: +- Public github.com repositories only (enforced before any tree/file reads). +- Optional ``GITHUB_TOKEN`` is read server-side only; it never reaches + responses, HTML, or JS. +- Errors are mapped to a stable ``{"detail": {"code", "message"}}`` contract + without tracebacks, tokens, or raw GitHub payloads. +""" + +import os +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from repopulse import __version__ +from repopulse.analyzer import build_health_report +from repopulse.compare import build_comparison +from repopulse.github_client import GitHubAPIError, GitHubClient +from repopulse.url_parser import parse_github_url + +STATIC_DIR = Path(__file__).parent / "static" + +MAX_URL_LENGTH = 512 +MAX_REF_LENGTH = 256 + +FIELD_ERROR_CODES = { + "repository_url": "invalid_repository_url", + "ref": "invalid_ref", + "baseline_ref": "invalid_ref", + "target_ref": "invalid_ref", +} + + +class ApiError(Exception): + """Error carrying the public error contract.""" + + def __init__(self, status_code: int, code: str, message: str): + super().__init__(message) + self.status_code = status_code + self.code = code + self.message = message + + +class ScanRequest(BaseModel): + repository_url: str = Field(max_length=MAX_URL_LENGTH) + ref: str | None = Field(default=None, max_length=MAX_REF_LENGTH) + + +class CompareRequest(BaseModel): + repository_url: str = Field(max_length=MAX_URL_LENGTH) + baseline_ref: str = Field(max_length=MAX_REF_LENGTH) + target_ref: str = Field(max_length=MAX_REF_LENGTH) + + +def _error_response(status_code: int, code: str, message: str) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content={"detail": {"code": code, "message": message}}, + ) + + +def _make_client() -> GitHubClient: + # Server-side token only; never exposed to the frontend. + return GitHubClient(os.getenv("GITHUB_TOKEN")) + + +def _normalize_ref(ref: str | None) -> str | None: + if ref is None: + return None + ref = ref.strip() + return ref or None + + +def _checked_ref(ref: str | None, *, required: bool = False) -> str | None: + normalized = _normalize_ref(ref) + if normalized is None: + if required: + raise ApiError(400, "invalid_ref", "Invalid request parameters.") + return None + if len(normalized) > MAX_REF_LENGTH: + raise ApiError(400, "invalid_ref", "Invalid request parameters.") + return normalized + + +def _require_ref(ref: str) -> str: + checked = _checked_ref(ref, required=True) + assert checked is not None + return checked + + +def _parse_repository(repository_url: str) -> tuple[str, str, str | None]: + try: + return parse_github_url(repository_url) + except ValueError as error: + raise ApiError(400, "invalid_repository_url", str(error)) from error + + +def _reject_private(client: GitHubClient, owner: str, repo: str) -> dict: + """Refuse private repositories before any tree/file content is read. + + A private repository answers exactly like an inaccessible one. If it did + not, a deployment whose ``GITHUB_TOKEN`` can read private repositories + would let anonymous callers probe which private names that token sees. + """ + try: + data = client.get_repo(owner, repo) + except GitHubAPIError as error: + raise _map_github_error(error) from error + if data.get("private"): + raise _repository_not_found() + return data + + +def _repository_not_found() -> ApiError: + return ApiError( + 404, + "repository_not_found", + "Repository was not found. Only public github.com repositories are supported.", + ) + + +def _map_github_error(error: GitHubAPIError) -> ApiError: + """Translate core GitHubAPIError messages into the public error contract. + + The core client does not expose status codes, so mapping is done on its + stable message shapes. Raw GitHub response bodies are never forwarded. + """ + text = str(error) + if "rate limit exceeded" in text: + return ApiError( + 429, + "github_rate_limited", + "GitHub API rate limit exceeded. Try again later.", + ) + if "Could not connect" in text: + return ApiError( + 503, + "github_unavailable", + "Could not reach the GitHub API. Try again later.", + ) + if "was not found" in text: + if "at ref" in text: + return ApiError( + 404, + "ref_not_found", + "The requested ref was not found in this repository.", + ) + return _repository_not_found() + return ApiError( + 502, + "github_unavailable", + "GitHub API request failed. Try again later.", + ) + + +def create_app() -> FastAPI: + app = FastAPI(title="RepoPulse Web", version=__version__) + + @app.middleware("http") + async def security_headers(request: Request, call_next): # type: ignore[no-untyped-def] + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "no-referrer" + return response + + @app.exception_handler(ApiError) + async def api_error_handler(request: Request, error: ApiError) -> JSONResponse: + return _error_response(error.status_code, error.code, error.message) + + @app.exception_handler(RequestValidationError) + async def validation_error_handler( + request: Request, error: RequestValidationError + ) -> JSONResponse: + fields = [ + str(loc) + for err in error.errors() + for loc in err.get("loc", ()) + if loc != "body" + ] + code = next( + (FIELD_ERROR_CODES[field] for field in fields if field in FIELD_ERROR_CODES), + "invalid_request", + ) + return _error_response(400, code, "Invalid request parameters.") + + @app.exception_handler(Exception) + async def unhandled_error_handler(request: Request, error: Exception) -> JSONResponse: + return _error_response(500, "internal_error", "Unexpected server error.") + + @app.get("/api/health") + def health() -> dict: + return {"status": "ok", "service": "repopulse-web", "version": __version__} + + @app.post("/api/scan") + def scan(payload: ScanRequest) -> dict: + owner, repo, url_ref = _parse_repository(payload.repository_url) + body_ref = _checked_ref(payload.ref) + effective_ref = body_ref if body_ref is not None else _checked_ref(url_ref) + client = _make_client() + repo_data = _reject_private(client, owner, repo) + try: + report = build_health_report( + client, owner, repo, ref=effective_ref, repo_data=repo_data + ) + except GitHubAPIError as error: + raise _map_github_error(error) from error + return report.model_dump() + + @app.post("/api/compare") + def compare(payload: CompareRequest) -> dict: + owner, repo, _ = _parse_repository(payload.repository_url) + baseline_ref = _require_ref(payload.baseline_ref) + target_ref = _require_ref(payload.target_ref) + client = _make_client() + repo_data = _reject_private(client, owner, repo) + try: + baseline = build_health_report( + client, owner, repo, ref=baseline_ref, repo_data=repo_data + ) + target = build_health_report( + client, owner, repo, ref=target_ref, repo_data=repo_data + ) + except GitHubAPIError as error: + raise _map_github_error(error) from error + comparison = build_comparison( + baseline, + target, + baseline_label=baseline_ref, + target_label=target_ref, + ) + return comparison.model_dump() + + @app.get("/", include_in_schema=False) + def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + return app + + +app = create_app() diff --git a/webapp/static/app.js b/webapp/static/app.js new file mode 100644 index 0000000..e5b6983 --- /dev/null +++ b/webapp/static/app.js @@ -0,0 +1,503 @@ +/* RepoPulse web frontend — vanilla JS, no build step. + * + * Human and agent share the same state: the WebMCP tools call the exact + * same scanRepository()/compareRefs() functions as the on-page buttons, + * and both paths update `state` and re-render the visible UI. + */ +"use strict"; + +const state = { + repositoryUrl: "", + ref: "", + currentReport: null, + currentComparison: null, + status: "idle", + error: null, + webmcpAvailable: false, +}; + +let requestGeneration = 0; +let activeController = null; + +function isStaleResult(startedGeneration, currentGeneration, startedUrl, currentUrl) { + return startedGeneration !== currentGeneration || + (startedUrl !== null && currentUrl !== null && currentUrl !== "" && startedUrl !== currentUrl); +} + +function beginRequest() { + requestGeneration += 1; + if (activeController) { + activeController.abort(); + } + activeController = new AbortController(); + return { generation: requestGeneration, controller: activeController }; +} + +function bindExternalSignal(controller, external) { + if (!external) return; + if (external.aborted) { + controller.abort(); + return; + } + external.addEventListener("abort", function onAbort() { + controller.abort(); + }, { once: true }); +} + +const els = {}; + +function collectElements() { + for (const id of [ + "webmcp-badge", "scan-form", "repository-url", "ref", "scan-button", + "status", "error", "report-section", "report-repo", "report-score", + "report-grade", "report-truncated", "checks-list", "recommendations-list", + "attention-section", "attention-list", "compare-form", "baseline-ref", + "target-ref", "compare-button", "comparison-result", "comparison-delta", + "improved-list", "regressed-list", "unchanged-list", + ]) { + els[id] = document.getElementById(id); + } +} + +function setStatus(status, message) { + state.status = status; + els["status"].textContent = message || ""; + const busy = status === "scanning" || status === "comparing"; + els["scan-button"].disabled = busy; + els["compare-button"].disabled = busy; +} + +function setError(message) { + state.error = message || null; + if (message) { + els["error"].textContent = message; + els["error"].hidden = false; + } else { + els["error"].textContent = ""; + els["error"].hidden = true; + } +} + +async function parseApiResponse(response) { + const body = await response.json().catch(() => null); + if (!response.ok) { + const detail = body && body.detail; + const message = detail && detail.message ? detail.message : "Request failed."; + const error = new Error(message); + error.code = detail && detail.code ? detail.code : "unknown_error"; + throw error; + } + return body; +} + +function syncScanForm(repositoryUrl, ref) { + if (els["repository-url"]) els["repository-url"].value = repositoryUrl; + if (els["ref"]) els["ref"].value = ref || ""; +} + +/* Single scan path used by BOTH the Scan button and the WebMCP + * scan_repository tool. Returns the structured HealthReport. */ +async function scanRepository(repositoryUrl, ref, signal) { + const started = beginRequest(); + bindExternalSignal(started.controller, signal); + setError(null); + setStatus("scanning", "Scanning " + repositoryUrl + " ..."); + try { + const response = await fetch("/api/scan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ repository_url: repositoryUrl, ref: ref || null }), + signal: started.controller.signal, + }); + const report = await parseApiResponse(response); + // A fresh scan is allowed to select a different repository; only a newer + // request generation makes this scan stale. + if (isStaleResult(started.generation, requestGeneration, null, null)) { + const error = new Error("Request superseded."); + error.name = "AbortError"; + throw error; + } + state.repositoryUrl = repositoryUrl; + state.ref = ref || ""; + state.currentReport = report; + state.currentComparison = null; + syncScanForm(repositoryUrl, ref); + renderReport(report); + renderComparison(null); + setStatus("idle", "Scan complete: " + report.repository.full_name + + " — " + report.total_score + "/" + report.max_score + " (" + report.grade + ")"); + return report; + } catch (error) { + if (started.generation !== requestGeneration) { + throw error; + } + setStatus("idle", ""); + if (error && error.name === "AbortError") { + setError("Scan was cancelled."); + } else { + setError(error.message || "Scan failed."); + } + // The failed scan did not change the selection, so put the retained one + // back in the form. Without this the form shows the repository the user + // typed while Compare still acts on the previous one. Guarded on + // state.currentReport so a first, failed scan does not wipe the input. + if (state.currentReport) { + syncScanForm(state.repositoryUrl, state.ref); + } + throw error; + } +} + +/* Single compare path used by BOTH the Compare button and the WebMCP + * compare_refs tool. Returns the structured ComparisonReport. */ +async function compareRefs(baselineRef, targetRef, signal) { + const started = beginRequest(); + bindExternalSignal(started.controller, signal); + const startedUrl = state.repositoryUrl; + try { + if (!startedUrl) { + throw new Error("No repository is selected. Scan a repository first."); + } + setError(null); + setStatus("comparing", "Comparing " + baselineRef + " with " + targetRef + " ..."); + const response = await fetch("/api/compare", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + repository_url: startedUrl, + baseline_ref: baselineRef, + target_ref: targetRef, + }), + signal: started.controller.signal, + }); + const comparison = await parseApiResponse(response); + // Comparisons are pinned to the repository selected when they started. + if (isStaleResult( + started.generation, requestGeneration, startedUrl, state.repositoryUrl + )) { + const error = new Error("Request superseded."); + error.name = "AbortError"; + throw error; + } + state.currentComparison = comparison; + renderComparison(comparison); + setStatus("idle", "Comparison complete: delta " + comparison.score_delta + + " (" + comparison.baseline_score + " → " + comparison.target_score + ")"); + return comparison; + } catch (error) { + if (started.generation !== requestGeneration) { + throw error; + } + setStatus("idle", ""); + if (error && error.name === "AbortError") { + setError("Comparison was cancelled."); + } else { + setError(error.message || "Comparison failed."); + } + throw error; + } +} + +/* All GitHub-derived data is untrusted: render with textContent and + * createElement only, never innerHTML. */ +function appendTextItem(list, text, className) { + const li = document.createElement("li"); + if (className) li.className = className; + li.textContent = text; + list.appendChild(li); +} + +function renderCheck(list, check) { + const li = document.createElement("li"); + li.className = "check check-" + check.status; + + const head = document.createElement("div"); + head.className = "check-head"; + const badge = document.createElement("span"); + badge.className = "badge badge-" + check.status; + badge.textContent = check.status.toUpperCase(); + const title = document.createElement("strong"); + title.textContent = check.title; + const score = document.createElement("span"); + score.className = "muted"; + score.textContent = " " + check.score + "/" + check.max_score; + head.appendChild(badge); + head.appendChild(title); + head.appendChild(score); + li.appendChild(head); + + const message = document.createElement("p"); + message.textContent = check.message; + li.appendChild(message); + + if (check.recommendations && check.recommendations.length > 0) { + const recs = document.createElement("ul"); + for (const rec of check.recommendations) { + appendTextItem(recs, rec); + } + li.appendChild(recs); + } + list.appendChild(li); +} + +function attentionItems(report) { + const failures = report.checks.filter((c) => c.status === "fail"); + const warnings = report.checks.filter((c) => c.status === "warn"); + return failures.concat(warnings); +} + +function renderReport(report) { + els["report-repo"].textContent = report.repository.full_name; + els["report-score"].textContent = + "Score " + report.total_score + "/" + report.max_score; + const grade = els["report-grade"]; + grade.textContent = "Grade " + report.grade; + grade.className = "badge badge-grade"; + els["report-truncated"].hidden = !report.scan_truncated; + + els["checks-list"].replaceChildren(); + for (const check of report.checks) { + renderCheck(els["checks-list"], check); + } + + els["recommendations-list"].replaceChildren(); + if (report.recommendations.length === 0) { + appendTextItem(els["recommendations-list"], "No recommendations."); + } else { + for (const rec of report.recommendations) { + appendTextItem(els["recommendations-list"], rec); + } + } + els["report-section"].hidden = false; + + els["attention-list"].replaceChildren(); + const items = attentionItems(report); + if (items.length === 0) { + appendTextItem(els["attention-list"], "Nothing needs attention. All checks pass."); + } else { + for (const check of items) { + renderCheck(els["attention-list"], check); + } + } + els["attention-section"].hidden = false; +} + +function renderComparison(comparison) { + if (!comparison) { + els["comparison-result"].hidden = true; + return; + } + els["comparison-delta"].textContent = + comparison.baseline_label + ": " + comparison.baseline_score + "/" + comparison.baseline_max_score + + " → " + comparison.target_label + ": " + comparison.target_score + "/" + comparison.target_max_score + + " (delta " + (comparison.score_delta >= 0 ? "+" : "") + comparison.score_delta + ")"; + const lists = { + improved: els["improved-list"], + regressed: els["regressed-list"], + unchanged: els["unchanged-list"], + }; + for (const key of Object.keys(lists)) { + lists[key].replaceChildren(); + const values = comparison[key]; + if (values.length === 0) { + appendTextItem(lists[key], "None."); + } else { + for (const value of values) { + appendTextItem(lists[key], value); + } + } + } + els["comparison-result"].hidden = false; +} + +function renderWebMCPStatus() { + const badge = els["webmcp-badge"]; + if (state.webmcpAvailable) { + badge.textContent = "WebMCP Available"; + badge.className = "badge badge-on"; + } else { + badge.textContent = "WebMCP Unavailable"; + badge.className = "badge badge-off"; + } +} + +function reportSummary(report) { + return { + repository: report.repository.full_name, + score: report.total_score, + max_score: report.max_score, + grade: report.grade, + scan_truncated: report.scan_truncated, + checks: report.checks.map((c) => ({ + key: c.key, title: c.title, status: c.status, + score: c.score, max_score: c.max_score, message: c.message, + })), + recommendations: report.recommendations, + }; +} + +function registerWebMCPTools() { + // Feature detection: WebMCP is progressive enhancement only. + if (!("modelContext" in document) || !document.modelContext || + typeof document.modelContext.registerTool !== "function") { + state.webmcpAvailable = false; + renderWebMCPStatus(); + return; + } + + const registration = new AbortController(); + const readOnly = { readOnlyHint: true, untrustedContentHint: true }; + + const tools = [ + { + name: "scan_repository", + title: "Scan repository", + description: "Scan a public GitHub repository with RepoPulse, update the visible dashboard, and return its health report. Use this before asking for check details or attention items.", + inputSchema: { + type: "object", + properties: { + repository_url: { type: "string", description: "Public github.com repository URL." }, + ref: { type: "string", description: "Optional branch, tag, or commit ref." }, + }, + required: ["repository_url"], + }, + annotations: readOnly, + execute: async (input, options) => { + const report = await scanRepository( + input.repository_url, input.ref || null, options && options.signal); + return reportSummary(report); + }, + }, + { + name: "get_attention_items", + title: "Get attention items", + description: "Return the failing and warning checks from the current RepoPulse report, ordered FAIL first then WARN. Reads the current page state only; run scan_repository first.", + inputSchema: { type: "object", properties: {} }, + annotations: readOnly, + execute: async () => { + const report = state.currentReport; + if (!report) { + return { error: "No repository report is loaded. Run scan_repository first." }; + } + return { + repository: report.repository.full_name, + score: report.total_score, + max_score: report.max_score, + grade: report.grade, + attention_items: attentionItems(report).map((c) => ({ + key: c.key, title: c.title, status: c.status, + message: c.message, recommendations: c.recommendations, + })), + }; + }, + }, + { + name: "get_check_details", + title: "Get check details", + description: "Return full details for one RepoPulse check from the current report, including its recommendations. Reads the current page state only; run scan_repository first.", + inputSchema: { + type: "object", + properties: { + check_key: { type: "string", description: "RepoPulse check key from the current report." }, + }, + required: ["check_key"], + }, + annotations: readOnly, + execute: async (input) => { + const report = state.currentReport; + if (!report) { + return { error: "No repository report is loaded. Run scan_repository first." }; + } + const check = report.checks.find((c) => c.key === input.check_key); + if (!check) { + return { + error: "Unknown check key: " + input.check_key, + available_keys: report.checks.map((c) => c.key), + }; + } + return { + repository: report.repository.full_name, + score: report.total_score, + grade: report.grade, + check: check, + }; + }, + }, + { + name: "compare_refs", + title: "Compare refs", + description: "Compare repository health between two refs of the currently scanned repository, update the visible dashboard, and return the comparison report. Run scan_repository first to select the repository.", + inputSchema: { + type: "object", + properties: { + baseline_ref: { type: "string", description: "Baseline branch, tag, or commit ref." }, + target_ref: { type: "string", description: "Target branch, tag, or commit ref." }, + }, + required: ["baseline_ref", "target_ref"], + }, + annotations: readOnly, + execute: async (input, options) => { + if (!state.repositoryUrl) { + return { error: "No repository is selected. Run scan_repository first." }; + } + const comparison = await compareRefs( + input.baseline_ref, input.target_ref, options && options.signal); + return { + repository: comparison.target_repository, + baseline_label: comparison.baseline_label, + target_label: comparison.target_label, + baseline_score: comparison.baseline_score, + target_score: comparison.target_score, + score_delta: comparison.score_delta, + improved: comparison.improved, + regressed: comparison.regressed, + unchanged: comparison.unchanged, + checks: comparison.checks, + }; + }, + }, + ]; + + let registrations; + try { + registrations = tools.map((tool) => + Promise.resolve( + document.modelContext.registerTool(tool, { signal: registration.signal }) + )); + } catch (_error) { + registration.abort(); + state.webmcpAvailable = false; + renderWebMCPStatus(); + return; + } + + Promise.all(registrations).then(() => { + state.webmcpAvailable = true; + renderWebMCPStatus(); + }).catch(() => { + registration.abort(); + state.webmcpAvailable = false; + renderWebMCPStatus(); + }); +} + +function main() { + collectElements(); + renderWebMCPStatus(); + + els["scan-form"].addEventListener("submit", (event) => { + event.preventDefault(); + scanRepository(els["repository-url"].value.trim(), els["ref"].value.trim()) + .catch(() => {}); + }); + + els["compare-form"].addEventListener("submit", (event) => { + event.preventDefault(); + compareRefs(els["baseline-ref"].value.trim(), els["target-ref"].value.trim()) + .catch(() => {}); + }); + + registerWebMCPTools(); +} + +document.addEventListener("DOMContentLoaded", main); diff --git a/webapp/static/index.html b/webapp/static/index.html new file mode 100644 index 0000000..9b7c361 --- /dev/null +++ b/webapp/static/index.html @@ -0,0 +1,91 @@ + + + + + + RepoPulse — Repository health for humans and agents + + + + + +
+
+

Scan a repository

+
+ + + + + +
+
+ +

+ + + + + + +
+

Compare refs

+
+ + + + + +
+ +
+
+ + + + + + diff --git a/webapp/static/styles.css b/webapp/static/styles.css new file mode 100644 index 0000000..38fd83b --- /dev/null +++ b/webapp/static/styles.css @@ -0,0 +1,102 @@ +:root { + --bg: #0f1420; + --card: #1a2233; + --text: #e6ebf4; + --muted: #93a0b5; + --accent: #4f8cff; + --pass: #3fb96c; + --warn: #d9a23b; + --fail: #e05c5c; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +.site-header, main, .site-footer { + max-width: 52rem; + margin: 0 auto; + padding: 1rem; +} + +.site-header h1 { margin-bottom: 0.2rem; } +.subtitle { color: var(--muted); margin-top: 0; } + +.badge { + display: inline-block; + padding: 0.15rem 0.6rem; + border-radius: 1rem; + font-size: 0.8rem; + border: 1px solid var(--muted); + color: var(--muted); +} +.badge-on { border-color: var(--pass); color: var(--pass); } +.badge-off { border-color: var(--muted); color: var(--muted); } +.badge-grade { border-color: var(--accent); color: var(--accent); } +.badge-pass { border-color: var(--pass); color: var(--pass); } +.badge-warn { border-color: var(--warn); color: var(--warn); } +.badge-fail { border-color: var(--fail); color: var(--fail); } + +.card { + background: var(--card); + border-radius: 0.6rem; + padding: 1rem 1.25rem; + margin: 1rem 0; +} + +form { display: grid; gap: 0.5rem; } +label { font-size: 0.9rem; color: var(--muted); } +input { + padding: 0.5rem 0.7rem; + border-radius: 0.4rem; + border: 1px solid var(--muted); + background: var(--bg); + color: var(--text); + font-size: 1rem; +} +button { + margin-top: 0.4rem; + padding: 0.55rem 1rem; + border: none; + border-radius: 0.4rem; + background: var(--accent); + color: #fff; + font-size: 1rem; + cursor: pointer; +} +button:disabled { opacity: 0.5; cursor: wait; } + +.status { color: var(--muted); min-height: 1.2em; } +.error { color: var(--fail); } +.warning { color: var(--warn); } +.muted { color: var(--muted); font-weight: normal; font-size: 0.85rem; } + +.checks { list-style: none; padding: 0; display: grid; gap: 0.6rem; } +.check { + border: 1px solid #2a3550; + border-radius: 0.5rem; + padding: 0.6rem 0.8rem; +} +.check-head { display: flex; gap: 0.6rem; align-items: center; } +.check p { margin: 0.4rem 0 0; color: var(--muted); } +.check ul { margin: 0.4rem 0 0; color: var(--muted); } +.check-fail { border-left: 4px solid var(--fail); } +.check-warn { border-left: 4px solid var(--warn); } +.check-pass { border-left: 4px solid var(--pass); } + +.report-summary { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; } + +.comparison-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 1rem; +} + +.site-footer { color: var(--muted); display: flex; gap: 0.6rem; } +.site-footer a { color: var(--accent); }