From ab4a6adbaf8e642fd7c1cce67d97af5236246f23 Mon Sep 17 00:00:00 2001 From: 3ssiri <88985279+3ssiri@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:50:03 +0300 Subject: [PATCH 01/16] feat: add WebMCP challenge web app Thin FastAPI adapter (webapp/) over the unchanged repopulse core: GET /api/health, POST /api/scan, POST /api/compare with a stable error contract, public-repos-only enforcement before tree reads, and a vanilla-JS one-page dashboard. Four read-only WebMCP tools (scan_repository, get_attention_items, get_check_details, compare_refs) share the exact same state and code paths as the UI buttons. Core engine, CLI, schemas, scoring, and action behavior untouched. New optional 'web' extra (fastapi, uvicorn); base install unchanged. --- .github/workflows/ci.yml | 2 +- ARCHITECTURE.md | 1 + README.md | 1 + docs/webmcp-challenge.md | 135 +++++++++++++ pyproject.toml | 8 +- tests/test_webapp.py | 413 +++++++++++++++++++++++++++++++++++++ webapp/__init__.py | 1 + webapp/app.py | 219 ++++++++++++++++++++ webapp/static/app.js | 426 +++++++++++++++++++++++++++++++++++++++ webapp/static/index.html | 91 +++++++++ webapp/static/styles.css | 102 ++++++++++ 11 files changed, 1397 insertions(+), 2 deletions(-) create mode 100644 docs/webmcp-challenge.md create mode 100644 tests/test_webapp.py create mode 100644 webapp/__init__.py create mode 100644 webapp/app.py create mode 100644 webapp/static/app.js create mode 100644 webapp/static/index.html create mode 100644 webapp/static/styles.css 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/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..356ca43 --- /dev/null +++ b/docs/webmcp-challenge.md @@ -0,0 +1,135 @@ +# 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. + +## 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 | +| `private_repository_not_supported` | 403 | +| `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 with 403 *before* any tree/file reads. +- **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` (27 tests) 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, and the compare contract. 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..31c89e3 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] @@ -66,3 +71,4 @@ ignore = ["B008", "FURB162"] [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["."] diff --git a/tests/test_webapp.py b/tests/test_webapp.py new file mode 100644 index 0000000..1f162dc --- /dev/null +++ b/tests/test_webapp.py @@ -0,0 +1,413 @@ +"""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): + 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): + 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): + 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 == 403 + assert response.json()["detail"]["code"] == "private_repository_not_supported" + assert called["build"] is False # no tree/file reads happen for private repos + + +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): + 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 == 403 + assert response.json()["detail"]["code"] == "private_repository_not_supported" 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..f7d72af --- /dev/null +++ b/webapp/app.py @@ -0,0 +1,219 @@ +"""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 _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) -> None: + """Refuse private repositories before any tree/file content is read.""" + try: + data = client.get_repo(owner, repo) + except GitHubAPIError as error: + raise _map_github_error(error) from error + if data.get("private"): + raise ApiError( + 403, + "private_repository_not_supported", + "Private repositories are not supported by the web app.", + ) + + +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 ApiError( + 404, + "repository_not_found", + "Repository was not found. Only public github.com repositories are supported.", + ) + 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 = _normalize_ref(payload.ref) + effective_ref = body_ref if body_ref is not None else url_ref + client = _make_client() + _reject_private(client, owner, repo) + try: + report = build_health_report(client, owner, repo, ref=effective_ref) + 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) + client = _make_client() + _reject_private(client, owner, repo) + try: + baseline = build_health_report(client, owner, repo, ref=payload.baseline_ref) + target = build_health_report(client, owner, repo, ref=payload.target_ref) + except GitHubAPIError as error: + raise _map_github_error(error) from error + comparison = build_comparison( + baseline, + target, + baseline_label=payload.baseline_ref, + target_label=payload.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..73b0a63 --- /dev/null +++ b/webapp/static/app.js @@ -0,0 +1,426 @@ +/* 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, +}; + +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; +} + +/* 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) { + state.repositoryUrl = repositoryUrl; + state.ref = ref || ""; + 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: signal || undefined, + }); + const report = await parseApiResponse(response); + state.currentReport = report; + state.currentComparison = null; + renderReport(report); + renderComparison(null); + setStatus("idle", "Scan complete: " + report.repository.full_name + + " — " + report.total_score + "/" + report.max_score + " (" + report.grade + ")"); + return report; + } catch (error) { + state.currentReport = state.currentReport; // keep previous report visible + setStatus("idle", ""); + if (error && error.name === "AbortError") { + setError("Scan was cancelled."); + } else { + setError(error.message || "Scan failed."); + } + 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) { + if (!state.repositoryUrl) { + throw new Error("No repository is selected. Run scan_repository first."); + } + setError(null); + setStatus("comparing", "Comparing " + baselineRef + " with " + targetRef + " ..."); + try { + const response = await fetch("/api/compare", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + repository_url: state.repositoryUrl, + baseline_ref: baselineRef, + target_ref: targetRef, + }), + signal: signal || undefined, + }); + const comparison = await parseApiResponse(response); + state.currentComparison = comparison; + renderComparison(comparison); + setStatus("idle", "Comparison complete: delta " + comparison.score_delta + + " (" + comparison.baseline_score + " → " + comparison.target_score + ")"); + return comparison; + } catch (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, + }; + }, + }, + ]; + + Promise.all( + tools.map((tool) => + document.modelContext.registerTool(tool, { signal: registration.signal })) + ).then(() => { + state.webmcpAvailable = true; + renderWebMCPStatus(); + }).catch(() => { + 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); } From 2f42c1fad8916b8fcace6685e2143632ffb02415 Mon Sep 17 00:00:00 2001 From: 3ssiri <88985279+3ssiri@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:07:18 +0300 Subject: [PATCH 02/16] fix: keep web scan selection aligned after failures Do not overwrite the selected repository until a scan succeeds, so compare and the visible report stay on the same repo. Use a human-facing compare error, strip/reject blank compare refs, and record the web layer in the changelog and translations. --- CHANGELOG.md | 6 ++++ README.ar.md | 1 + README.es-ES.md | 1 + docs/webmcp-challenge.md | 5 ++-- tests/test_webapp.py | 61 ++++++++++++++++++++++++++++++++++++++++ webapp/app.py | 17 ++++++++--- webapp/static/app.js | 13 ++++++--- 7 files changed, 94 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d3586b..0c51d62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,15 @@ ### 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. +### 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. + ## 0.3.6 - 2026-08-07 Security/trust release: all five findings from an external security review are addressed. 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/docs/webmcp-challenge.md b/docs/webmcp-challenge.md index 356ca43..fa12418 100644 --- a/docs/webmcp-challenge.md +++ b/docs/webmcp-challenge.md @@ -109,10 +109,11 @@ loading state. ## Testing -`tests/test_webapp.py` (27 tests) covers: health, index, error mapping +`tests/test_webapp.py` (31 tests) 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, and the compare contract. All network and core +token/traceback leak checks, the compare contract, whitespace/blank compare +refs, and frontend state-commit contracts. All network and core calls are mocked — tests never touch real GitHub. ## Demo flow diff --git a/tests/test_webapp.py b/tests/test_webapp.py index 1f162dc..3766cc2 100644 --- a/tests/test_webapp.py +++ b/tests/test_webapp.py @@ -411,3 +411,64 @@ def test_compare_rejects_private_repo(monkeypatch): ) assert response.status_code == 403 assert response.json()["detail"]["code"] == "private_repository_not_supported" + + +def test_compare_strips_whitespace_refs(monkeypatch): + captured = [] + + def fake_build(client_obj, owner, repo, config=None, ref=None): + 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 diff --git a/webapp/app.py b/webapp/app.py index f7d72af..e8f6a8a 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -80,6 +80,13 @@ def _normalize_ref(ref: str | None) -> str | None: return ref or None +def _require_ref(ref: str) -> str: + normalized = _normalize_ref(ref) + if normalized is None: + raise ApiError(400, "invalid_ref", "Invalid request parameters.") + return normalized + + def _parse_repository(repository_url: str) -> tuple[str, str, str | None]: try: return parse_github_url(repository_url) @@ -193,18 +200,20 @@ def scan(payload: ScanRequest) -> dict: @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() _reject_private(client, owner, repo) try: - baseline = build_health_report(client, owner, repo, ref=payload.baseline_ref) - target = build_health_report(client, owner, repo, ref=payload.target_ref) + baseline = build_health_report(client, owner, repo, ref=baseline_ref) + target = build_health_report(client, owner, repo, ref=target_ref) except GitHubAPIError as error: raise _map_github_error(error) from error comparison = build_comparison( baseline, target, - baseline_label=payload.baseline_ref, - target_label=payload.target_ref, + baseline_label=baseline_ref, + target_label=target_ref, ) return comparison.model_dump() diff --git a/webapp/static/app.js b/webapp/static/app.js index 73b0a63..46e7a02 100644 --- a/webapp/static/app.js +++ b/webapp/static/app.js @@ -62,11 +62,14 @@ async function parseApiResponse(response) { 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) { - state.repositoryUrl = repositoryUrl; - state.ref = ref || ""; setError(null); setStatus("scanning", "Scanning " + repositoryUrl + " ..."); try { @@ -77,15 +80,17 @@ async function scanRepository(repositoryUrl, ref, signal) { signal: signal || undefined, }); const report = await parseApiResponse(response); + 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) { - state.currentReport = state.currentReport; // keep previous report visible setStatus("idle", ""); if (error && error.name === "AbortError") { setError("Scan was cancelled."); @@ -100,7 +105,7 @@ async function scanRepository(repositoryUrl, ref, signal) { * compare_refs tool. Returns the structured ComparisonReport. */ async function compareRefs(baselineRef, targetRef, signal) { if (!state.repositoryUrl) { - throw new Error("No repository is selected. Run scan_repository first."); + throw new Error("No repository is selected. Scan a repository first."); } setError(null); setStatus("comparing", "Comparing " + baselineRef + " with " + targetRef + " ..."); From bbb2a431b3aaaa4821d51d8aab0367ceb3bea07c Mon Sep 17 00:00:00 2001 From: 3ssiri <88985279+3ssiri@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:31:25 +0300 Subject: [PATCH 03/16] fix: address WebMCP review races and validation Show Compare-before-Scan errors in the UI, discard stale scan/compare results, enforce the 256-char ref limit on URL-derived refs, abort partial WebMCP registration, and reuse privacy-check repo metadata so scan/compare do not call get_repo twice. --- CHANGELOG.md | 5 ++ docs/webmcp-challenge.md | 8 +- repopulse/analyzer.py | 6 +- tests/test_cli.py | 24 ++++++ tests/test_webapp.py | 165 +++++++++++++++++++++++++++++++++++++-- webapp/app.py | 35 ++++++--- webapp/static/app.js | 65 +++++++++++++-- 7 files changed, 281 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c51d62..a6bc4a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ - 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. ## 0.3.6 - 2026-08-07 diff --git a/docs/webmcp-challenge.md b/docs/webmcp-challenge.md index fa12418..0d3453b 100644 --- a/docs/webmcp-challenge.md +++ b/docs/webmcp-challenge.md @@ -109,12 +109,14 @@ loading state. ## Testing -`tests/test_webapp.py` (31 tests) covers: health, index, error mapping +`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, and frontend state-commit contracts. All network and core -calls are mocked — tests never touch real GitHub. +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 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/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 index 3766cc2..87ba1cc 100644 --- a/tests/test_webapp.py +++ b/tests/test_webapp.py @@ -156,7 +156,7 @@ def test_scan_rejects_overlong_ref(client): def test_scan_body_ref_takes_precedence_over_url_ref(monkeypatch): captured = {} - def fake_build(client_obj, owner, repo, config=None, ref=None): + def fake_build(client_obj, owner, repo, config=None, ref=None, **kwargs): captured["ref"] = ref return sample_report() @@ -174,7 +174,7 @@ def fake_build(client_obj, owner, repo, config=None, ref=None): def test_scan_url_ref_used_when_body_ref_missing(monkeypatch): captured = {} - def fake_build(client_obj, owner, repo, config=None, ref=None): + def fake_build(client_obj, owner, repo, config=None, ref=None, **kwargs): captured["ref"] = ref return sample_report() @@ -189,7 +189,7 @@ def fake_build(client_obj, owner, repo, config=None, ref=None): def test_scan_empty_body_ref_falls_back_to_url_ref(monkeypatch): captured = {} - def fake_build(client_obj, owner, repo, config=None, ref=None): + def fake_build(client_obj, owner, repo, config=None, ref=None, **kwargs): captured["ref"] = ref return sample_report() @@ -315,7 +315,7 @@ def fake_build(*args, **kwargs): def test_compare_valid(monkeypatch): - def fake_build(client_obj, owner, repo, config=None, ref=None): + def fake_build(client_obj, owner, repo, config=None, ref=None, **kwargs): return sample_report() monkeypatch.setattr(webapp, "GitHubClient", FakeClient) @@ -416,7 +416,7 @@ def test_compare_rejects_private_repo(monkeypatch): def test_compare_strips_whitespace_refs(monkeypatch): captured = [] - def fake_build(client_obj, owner, repo, config=None, ref=None): + def fake_build(client_obj, owner, repo, config=None, ref=None, **kwargs): captured.append(ref) return sample_report() @@ -472,3 +472,158 @@ def test_frontend_compare_error_is_human_readable(): )[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/webapp/app.py b/webapp/app.py index e8f6a8a..319894b 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -80,13 +80,23 @@ def _normalize_ref(ref: str | None) -> str | None: return ref or None -def _require_ref(ref: str) -> str: +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) @@ -94,7 +104,7 @@ def _parse_repository(repository_url: str) -> tuple[str, str, str | None]: raise ApiError(400, "invalid_repository_url", str(error)) from error -def _reject_private(client: GitHubClient, owner: str, repo: str) -> None: +def _reject_private(client: GitHubClient, owner: str, repo: str) -> dict: """Refuse private repositories before any tree/file content is read.""" try: data = client.get_repo(owner, repo) @@ -106,6 +116,7 @@ def _reject_private(client: GitHubClient, owner: str, repo: str) -> None: "private_repository_not_supported", "Private repositories are not supported by the web app.", ) + return data def _map_github_error(error: GitHubAPIError) -> ApiError: @@ -187,12 +198,14 @@ def health() -> dict: @app.post("/api/scan") def scan(payload: ScanRequest) -> dict: owner, repo, url_ref = _parse_repository(payload.repository_url) - body_ref = _normalize_ref(payload.ref) - effective_ref = body_ref if body_ref is not None else url_ref + body_ref = _checked_ref(payload.ref) + effective_ref = body_ref if body_ref is not None else _checked_ref(url_ref) client = _make_client() - _reject_private(client, owner, repo) + repo_data = _reject_private(client, owner, repo) try: - report = build_health_report(client, owner, repo, ref=effective_ref) + 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() @@ -203,10 +216,14 @@ def compare(payload: CompareRequest) -> dict: baseline_ref = _require_ref(payload.baseline_ref) target_ref = _require_ref(payload.target_ref) client = _make_client() - _reject_private(client, owner, repo) + repo_data = _reject_private(client, owner, repo) try: - baseline = build_health_report(client, owner, repo, ref=baseline_ref) - target = build_health_report(client, owner, repo, ref=target_ref) + 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( diff --git a/webapp/static/app.js b/webapp/static/app.js index 46e7a02..45ff70e 100644 --- a/webapp/static/app.js +++ b/webapp/static/app.js @@ -16,6 +16,33 @@ const state = { webmcpAvailable: false, }; +let requestGeneration = 0; +let activeController = null; + +function isStaleResult(startedGeneration, currentGeneration, startedUrl, currentUrl) { + return startedGeneration !== currentGeneration || (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() { @@ -70,6 +97,8 @@ function syncScanForm(repositoryUrl, 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 { @@ -77,9 +106,14 @@ async function scanRepository(repositoryUrl, ref, signal) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ repository_url: repositoryUrl, ref: ref || null }), - signal: signal || undefined, + signal: started.controller.signal, }); const report = await parseApiResponse(response); + if (isStaleResult(started.generation, requestGeneration, repositoryUrl, state.repositoryUrl)) { + const error = new Error("Request superseded."); + error.name = "AbortError"; + throw error; + } state.repositoryUrl = repositoryUrl; state.ref = ref || ""; state.currentReport = report; @@ -91,6 +125,9 @@ async function scanRepository(repositoryUrl, ref, signal) { " — " + 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."); @@ -104,29 +141,40 @@ async function scanRepository(repositoryUrl, ref, signal) { /* 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) { - if (!state.repositoryUrl) { - throw new Error("No repository is selected. Scan a repository first."); - } - setError(null); - setStatus("comparing", "Comparing " + baselineRef + " with " + targetRef + " ..."); + 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: state.repositoryUrl, + repository_url: startedUrl, baseline_ref: baselineRef, target_ref: targetRef, }), - signal: signal || undefined, + signal: started.controller.signal, }); const comparison = await parseApiResponse(response); + 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."); @@ -404,6 +452,7 @@ function registerWebMCPTools() { state.webmcpAvailable = true; renderWebMCPStatus(); }).catch(() => { + registration.abort(); state.webmcpAvailable = false; renderWebMCPStatus(); }); From d768ce72d56fad312e986542027d6f0406d22905 Mon Sep 17 00:00:00 2001 From: ALI ASIRI <88985279+3ssiri@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:42:31 +0300 Subject: [PATCH 04/16] fix: allow fresh scans to change repositories --- webapp/static/app.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/webapp/static/app.js b/webapp/static/app.js index 45ff70e..8f9c6da 100644 --- a/webapp/static/app.js +++ b/webapp/static/app.js @@ -19,8 +19,12 @@ const state = { let requestGeneration = 0; let activeController = null; -function isStaleResult(startedGeneration, currentGeneration, startedUrl, currentUrl) { - return startedGeneration !== currentGeneration || (currentUrl !== "" && startedUrl !== currentUrl); +function isStaleRequest(startedGeneration) { + return startedGeneration !== requestGeneration; +} + +function isStaleComparison(startedGeneration, startedUrl) { + return isStaleRequest(startedGeneration) || startedUrl !== state.repositoryUrl; } function beginRequest() { @@ -109,7 +113,7 @@ async function scanRepository(repositoryUrl, ref, signal) { signal: started.controller.signal, }); const report = await parseApiResponse(response); - if (isStaleResult(started.generation, requestGeneration, repositoryUrl, state.repositoryUrl)) { + if (isStaleRequest(started.generation)) { const error = new Error("Request superseded."); error.name = "AbortError"; throw error; @@ -161,7 +165,7 @@ async function compareRefs(baselineRef, targetRef, signal) { signal: started.controller.signal, }); const comparison = await parseApiResponse(response); - if (isStaleResult(started.generation, requestGeneration, startedUrl, state.repositoryUrl)) { + if (isStaleComparison(started.generation, startedUrl)) { const error = new Error("Request superseded."); error.name = "AbortError"; throw error; @@ -477,4 +481,4 @@ function main() { registerWebMCPTools(); } -document.addEventListener("DOMContentLoaded", main); +document.addEventListener("DOMContentLoaded", main); \ No newline at end of file From 2029b78136aa08208c582a2eef81a6c8beb4ac01 Mon Sep 17 00:00:00 2001 From: ALI ASIRI <88985279+3ssiri@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:42:49 +0300 Subject: [PATCH 05/16] test: cover repository switch state regression --- tests/test_webapp_state_regression.py | 60 +++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/test_webapp_state_regression.py diff --git a/tests/test_webapp_state_regression.py b/tests/test_webapp_state_regression.py new file mode 100644 index 0000000..99b4b48 --- /dev/null +++ b/tests/test_webapp_state_regression.py @@ -0,0 +1,60 @@ +"""Regression coverage for RepoPulse Web shared-state request ordering. + +These tests intentionally keep the frontend dependency-free: they verify the +small staleness contract 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 _function(name: str, next_name: str) -> str: + source = _source() + return source.split(f"function {name}", 1)[1].split(f"function {next_name}", 1)[0] + + +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 depend only on request generation, not the old selected URL.""" + helper = _function("isStaleRequest", "isStaleComparison") + scan = _async_function("scanRepository", "async function compareRefs") + + assert "startedGeneration !== requestGeneration" in helper + assert "repositoryUrl" not in helper + assert "state.repositoryUrl" not in helper + + stale_check = "if (isStaleRequest(started.generation))" + assert stale_check in scan + assert scan.find(stale_check) < scan.find("state.repositoryUrl = repositoryUrl") + assert "isStaleComparison(" not in scan + + +def test_late_compare_cannot_commit_after_repository_switch(): + """A comparison is valid only while its starting repository remains selected.""" + helper = _function("isStaleComparison", "beginRequest") + compare = _async_function("compareRefs", "/* All GitHub-derived data") + + assert "isStaleRequest(startedGeneration)" in helper + assert "startedUrl !== state.repositoryUrl" in helper + + stale_check = "if (isStaleComparison(started.generation, startedUrl))" + assert stale_check in compare + assert compare.find(stale_check) < compare.find("state.currentComparison = comparison") + + +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 From 205aa8c861400332c658ecb1ca17558e0bbe163b Mon Sep 17 00:00:00 2001 From: ALI ASIRI <88985279+3ssiri@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:43:46 +0300 Subject: [PATCH 06/16] fix: preserve fresh repository switches --- webapp/static/app.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/webapp/static/app.js b/webapp/static/app.js index 8f9c6da..437e4cc 100644 --- a/webapp/static/app.js +++ b/webapp/static/app.js @@ -19,12 +19,10 @@ const state = { let requestGeneration = 0; let activeController = null; -function isStaleRequest(startedGeneration) { - return startedGeneration !== requestGeneration; -} - -function isStaleComparison(startedGeneration, startedUrl) { - return isStaleRequest(startedGeneration) || startedUrl !== state.repositoryUrl; +function isStaleResult(startedGeneration, currentGeneration, startedUrl, currentUrl) { + if (startedGeneration !== currentGeneration) return true; + if (startedUrl === null || currentUrl === null) return false; + return currentUrl !== "" && startedUrl !== currentUrl; } function beginRequest() { @@ -113,7 +111,9 @@ async function scanRepository(repositoryUrl, ref, signal) { signal: started.controller.signal, }); const report = await parseApiResponse(response); - if (isStaleRequest(started.generation)) { + // 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; @@ -165,7 +165,10 @@ async function compareRefs(baselineRef, targetRef, signal) { signal: started.controller.signal, }); const comparison = await parseApiResponse(response); - if (isStaleComparison(started.generation, startedUrl)) { + // 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; From a0e7e9b12938094b5fbb081074a774dac17bd3be Mon Sep 17 00:00:00 2001 From: ALI ASIRI <88985279+3ssiri@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:43:56 +0300 Subject: [PATCH 07/16] test: align state regression coverage with shared helper --- tests/test_webapp_state_regression.py | 35 ++++++++++++--------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/tests/test_webapp_state_regression.py b/tests/test_webapp_state_regression.py index 99b4b48..c5d0d4c 100644 --- a/tests/test_webapp_state_regression.py +++ b/tests/test_webapp_state_regression.py @@ -13,42 +13,37 @@ def _source() -> str: return APP_JS.read_text(encoding="utf-8") -def _function(name: str, next_name: str) -> str: - source = _source() - return source.split(f"function {name}", 1)[1].split(f"function {next_name}", 1)[0] - - 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 depend only on request generation, not the old selected URL.""" - helper = _function("isStaleRequest", "isStaleComparison") + """A fresh scan must ignore the previously selected repository identity.""" scan = _async_function("scanRepository", "async function compareRefs") - assert "startedGeneration !== requestGeneration" in helper - assert "repositoryUrl" not in helper - assert "state.repositoryUrl" not in helper - - stale_check = "if (isStaleRequest(started.generation))" + stale_check = "isStaleResult(started.generation, requestGeneration, null, null)" assert stale_check in scan assert scan.find(stale_check) < scan.find("state.repositoryUrl = repositoryUrl") - assert "isStaleComparison(" not in scan def test_late_compare_cannot_commit_after_repository_switch(): - """A comparison is valid only while its starting repository remains selected.""" - helper = _function("isStaleComparison", "beginRequest") + """A comparison remains pinned to the repository selected when it started.""" compare = _async_function("compareRefs", "/* All GitHub-derived data") - assert "isStaleRequest(startedGeneration)" in helper - assert "startedUrl !== state.repositoryUrl" in helper + assert "startedUrl, state.repositoryUrl" in compare + assert "isStaleResult(" in compare + assert compare.find("isStaleResult(") < compare.find("state.currentComparison = comparison") + + +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] - stale_check = "if (isStaleComparison(started.generation, startedUrl))" - assert stale_check in compare - assert compare.find(stale_check) < compare.find("state.currentComparison = comparison") + 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(): From 24e76c555ea8420a03265d74f350f58108b5533f Mon Sep 17 00:00:00 2001 From: ALI ASIRI <88985279+3ssiri@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:44:40 +0300 Subject: [PATCH 08/16] fix: keep stale helper compatible with regression tests --- webapp/static/app.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/webapp/static/app.js b/webapp/static/app.js index 437e4cc..4501f91 100644 --- a/webapp/static/app.js +++ b/webapp/static/app.js @@ -20,9 +20,8 @@ let requestGeneration = 0; let activeController = null; function isStaleResult(startedGeneration, currentGeneration, startedUrl, currentUrl) { - if (startedGeneration !== currentGeneration) return true; - if (startedUrl === null || currentUrl === null) return false; - return currentUrl !== "" && startedUrl !== currentUrl; + return startedGeneration !== currentGeneration || + (startedUrl !== null && currentUrl !== null && currentUrl !== "" && startedUrl !== currentUrl); } function beginRequest() { From f0186d72c7fa631b7da27bacf18626e6d48c342d Mon Sep 17 00:00:00 2001 From: ALI ASIRI <88985279+3ssiri@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:44:50 +0300 Subject: [PATCH 09/16] test: cover optional repository identity in stale helper --- tests/test_webapp_state_regression.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_webapp_state_regression.py b/tests/test_webapp_state_regression.py index c5d0d4c..09f458b 100644 --- a/tests/test_webapp_state_regression.py +++ b/tests/test_webapp_state_regression.py @@ -42,7 +42,7 @@ def test_stale_helper_separates_generation_from_optional_repository_identity(): 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 !== null && currentUrl !== null" in helper assert "startedUrl !== currentUrl" in helper From 1e7697fe6a9abbd02d97fac83591f37bce431785 Mon Sep 17 00:00:00 2001 From: ALI ASIRI <88985279+3ssiri@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:54:22 +0300 Subject: [PATCH 10/16] fix: handle synchronous WebMCP registration failures --- webapp/static/app.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/webapp/static/app.js b/webapp/static/app.js index 4501f91..8e77f3a 100644 --- a/webapp/static/app.js +++ b/webapp/static/app.js @@ -451,10 +451,20 @@ function registerWebMCPTools() { }, ]; - Promise.all( - tools.map((tool) => - document.modelContext.registerTool(tool, { signal: registration.signal })) - ).then(() => { + 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(() => { @@ -483,4 +493,4 @@ function main() { registerWebMCPTools(); } -document.addEventListener("DOMContentLoaded", main); \ No newline at end of file +document.addEventListener("DOMContentLoaded", main); From bd5cbc392b49c904cf3e4eaff193b02bd487a228 Mon Sep 17 00:00:00 2001 From: ALI ASIRI <88985279+3ssiri@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:54:37 +0300 Subject: [PATCH 11/16] test: cover synchronous WebMCP registration failure --- tests/test_webapp_state_regression.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/test_webapp_state_regression.py b/tests/test_webapp_state_regression.py index 09f458b..6a0a616 100644 --- a/tests/test_webapp_state_regression.py +++ b/tests/test_webapp_state_regression.py @@ -1,7 +1,8 @@ """Regression coverage for RepoPulse Web shared-state request ordering. These tests intentionally keep the frontend dependency-free: they verify the -small staleness contract in app.js without introducing a JavaScript test stack. +small staleness and registration contracts in app.js without introducing a +JavaScript test stack. """ from pathlib import Path @@ -53,3 +54,23 @@ def test_newer_request_still_supersedes_older_scan_and_compare(): 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 From 116e68364bf006169607aa86c267351eeec7c49e Mon Sep 17 00:00:00 2001 From: Ali Assiri Date: Tue, 1 Sep 2026 03:52:32 +0000 Subject: [PATCH 12/16] chore: add Vercel deployment config for the web layer Deploys the existing FastAPI app (webapp/app.py) as-is: - pyproject.toml [tool.vercel] entrypoint points at webapp.app:app, since webapp/app.py is outside Vercel's auto-detected entrypoint locations. setuptools ignores the table; sdist and wheel contents are unchanged. - vercel.json enables fluid compute and raises maxDuration to 60s, because a scan makes several sequential GitHub API calls. - requirements.txt lists only the runtime imports the web layer needs, so the function does not pull in the CLI-only dependencies. No change to scoring, checks, CLI contracts, or the published package. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0158GxB1urMdo6XYFAkHfSFT --- pyproject.toml | 6 ++++++ requirements.txt | 8 ++++++++ vercel.json | 9 +++++++++ 3 files changed, 23 insertions(+) create mode 100644 requirements.txt create mode 100644 vercel.json diff --git a/pyproject.toml b/pyproject.toml index 31c89e3..043641a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..67f4beb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +# Runtime dependencies for the optional web layer (webapp/) only. +# The PyPI distribution `repopulse-cli` is defined by pyproject.toml and is +# unaffected by this file; Vercel's Python runtime reads it to build the +# FastAPI function. 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/vercel.json b/vercel.json new file mode 100644 index 0000000..87eaacc --- /dev/null +++ b/vercel.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "fluid": true, + "functions": { + "webapp/app.py": { + "maxDuration": 60 + } + } +} From 4a2c058a25bb10bf923990c1695a62035dd30b26 Mon Sep 17 00:00:00 2001 From: Ali Assiri Date: Tue, 1 Sep 2026 04:01:49 +0000 Subject: [PATCH 13/16] fix: install web-layer dependencies on Vercel The first deployment returned 500 with ModuleNotFoundError: No module named 'fastapi'. A probe deployment confirmed Vercel's Python runtime installs only [project].dependencies from pyproject.toml: [project.optional-dependencies], PEP 735 [dependency-groups], and requirements.txt are all ignored. FastAPI must not become a dependency of the published `repopulse-cli` package, so the web-only requirements are installed from requirements.txt by a buildCommand that runs after the framework install and before the function bundle is assembled. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0158GxB1urMdo6XYFAkHfSFT --- requirements.txt | 9 +++++---- vercel.json | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 67f4beb..5e78e90 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ -# Runtime dependencies for the optional web layer (webapp/) only. -# The PyPI distribution `repopulse-cli` is defined by pyproject.toml and is -# unaffected by this file; Vercel's Python runtime reads it to build the -# FastAPI function. Keep in sync with the `web` extra in pyproject.toml. +# 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 diff --git a/vercel.json b/vercel.json index 87eaacc..8a38123 100644 --- a/vercel.json +++ b/vercel.json @@ -1,6 +1,7 @@ { "$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 From 4895d7e13cd27786c1ea690c148d39c6b6a9efe2 Mon Sep 17 00:00:00 2001 From: Ali Assiri Date: Tue, 1 Sep 2026 05:07:39 +0000 Subject: [PATCH 14/16] docs: document the Vercel deployment of the web layer Records what each deployment file is for and, in particular, why the web dependencies cannot live in pyproject.toml: Vercel's Python runtime installs only [project].dependencies, so FastAPI would otherwise have to become a dependency of the published repopulse-cli package. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0158GxB1urMdo6XYFAkHfSFT --- CHANGELOG.md | 1 + docs/webmcp-challenge.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6bc4a9..a8cd0a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - **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 diff --git a/docs/webmcp-challenge.md b/docs/webmcp-challenge.md index 0d3453b..7ea664b 100644 --- a/docs/webmcp-challenge.md +++ b/docs/webmcp-challenge.md @@ -38,6 +38,34 @@ 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 | From f8f36bcaeafb41a536d8500b826d13550075c294 Mon Sep 17 00:00:00 2001 From: 3ssiri <88985279+3ssiri@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:23:57 +0300 Subject: [PATCH 15/16] fix: stop private repositories from leaking their existence A private repository the deployment's GITHUB_TOKEN could read returned 403 private_repository_not_supported, while a missing or inaccessible one returned 404 repository_not_found. An anonymous caller could use that difference to probe which private repository names the token can see. Both cases now return the identical 404 response. The privacy check still runs before any tree or file content is read. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + docs/webmcp-challenge.md | 6 ++++-- tests/test_webapp.py | 29 +++++++++++++++++++++++++---- webapp/app.py | 27 ++++++++++++++++----------- 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8cd0a4..8cdd363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - 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/docs/webmcp-challenge.md b/docs/webmcp-challenge.md index 7ea664b..c070ba9 100644 --- a/docs/webmcp-challenge.md +++ b/docs/webmcp-challenge.md @@ -89,7 +89,6 @@ GitHub payloads, no tokens: |---|---| | `invalid_repository_url` | 400 | | `invalid_ref` | 400 | -| `private_repository_not_supported` | 403 | | `repository_not_found` / `ref_not_found` | 404 | | `github_rate_limited` | 429 | | `github_unavailable` | 502 / 503 | @@ -121,7 +120,10 @@ loading state. ## Security boundaries - **Public repositories only.** If a server-side token can see a private - repo, the repo is rejected with 403 *before* any tree/file reads. + 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`; diff --git a/tests/test_webapp.py b/tests/test_webapp.py index 87ba1cc..3f74b2b 100644 --- a/tests/test_webapp.py +++ b/tests/test_webapp.py @@ -271,11 +271,32 @@ def fake_build(*args, **kwargs): 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 == 403 - assert response.json()["detail"]["code"] == "private_repository_not_supported" + 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 = {} @@ -409,8 +430,8 @@ def test_compare_rejects_private_repo(monkeypatch): "/api/compare", json={"repository_url": VALID_URL, "baseline_ref": "a", "target_ref": "b"}, ) - assert response.status_code == 403 - assert response.json()["detail"]["code"] == "private_repository_not_supported" + assert response.status_code == 404 + assert response.json()["detail"]["code"] == "repository_not_found" def test_compare_strips_whitespace_refs(monkeypatch): diff --git a/webapp/app.py b/webapp/app.py index 319894b..f29ba96 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -105,20 +105,29 @@ def _parse_repository(repository_url: str) -> tuple[str, str, str | None]: def _reject_private(client: GitHubClient, owner: str, repo: str) -> dict: - """Refuse private repositories before any tree/file content is read.""" + """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 ApiError( - 403, - "private_repository_not_supported", - "Private repositories are not supported by the web app.", - ) + 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. @@ -145,11 +154,7 @@ def _map_github_error(error: GitHubAPIError) -> ApiError: "ref_not_found", "The requested ref was not found in this repository.", ) - return ApiError( - 404, - "repository_not_found", - "Repository was not found. Only public github.com repositories are supported.", - ) + return _repository_not_found() return ApiError( 502, "github_unavailable", From ace7473605bd75d8cb23fb92d4151d6b84e2242d Mon Sep 17 00:00:00 2001 From: 3ssiri <88985279+3ssiri@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:01:22 +0300 Subject: [PATCH 16/16] fix: put the retained repository back in the scan form after a failure After repository A scanned successfully and a scan of B failed, the form kept showing B while state.repositoryUrl still held A. Compare then acted on A without saying so, contradicting the repository the form displayed. The failure path now resyncs the form to the retained selection, guarded on a previous successful report so a first failed scan does not wipe the input. Verified in Chrome against a real backend: after a failed scan of a non-existent repository, the form shows the previously scanned repository. Co-Authored-By: Claude Opus 5 --- tests/test_webapp_state_regression.py | 15 +++++++++++++++ webapp/static/app.js | 7 +++++++ 2 files changed, 22 insertions(+) diff --git a/tests/test_webapp_state_regression.py b/tests/test_webapp_state_regression.py index 6a0a616..4809029 100644 --- a/tests/test_webapp_state_regression.py +++ b/tests/test_webapp_state_regression.py @@ -37,6 +37,21 @@ def test_late_compare_cannot_commit_after_repository_switch(): 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() diff --git a/webapp/static/app.js b/webapp/static/app.js index 8e77f3a..e5b6983 100644 --- a/webapp/static/app.js +++ b/webapp/static/app.js @@ -137,6 +137,13 @@ async function scanRepository(repositoryUrl, ref, signal) { } 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; } }