Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,21 @@

### Added

- **Optional web app + WebMCP** (`webapp/`, extra `web`): one-page dashboard over the existing scan/compare engine, plus four read-only tools (`scan_repository`, `get_attention_items`, `get_check_details`, `compare_refs`) so a human and an agent share the same page state. Public github.com repositories only; `GITHUB_TOKEN` is server-side. Not packaged in `repopulse-cli`. Docs: [docs/webmcp-challenge.md](docs/webmcp-challenge.md).
- **GitHub Action** (`action.yml` at the repository root): run a health check in CI with `uses: 3ssiri/RepoPulse@v1`. Writes the Markdown report to the workflow run summary, exposes `score` / `max-score` / `percentage` / `grade` / `truncated` / report paths as outputs, and optionally fails the build via `fail-under`. Inputs are passed through environment variables (no shell interpolation), and the installed package version is pinned. Docs: [docs/github-action.md](docs/github-action.md).
- CI dogfoods the action on every push.
- Vercel deployment config for the optional web layer (`vercel.json`, root `requirements.txt`, `[tool.vercel] entrypoint`). Deployment-only: scoring, checks, report schemas, CLI contracts, the GitHub Action and the published `repopulse-cli` package are unchanged.

### Fixed

- Web dashboard keeps the last successful repository selected when a later scan fails, so compare and the on-screen report stay aligned.
- Compare form error tells humans to scan a repository first (no tool-name jargon). Whitespace-only compare refs are rejected.
- Compare-before-scan now shows the error in the page instead of failing silently.
- In-flight scan/compare results are discarded when a newer request has already changed the selected repository.
- URL-derived refs use the same 256-character limit as body refs.
- Partial WebMCP tool registration is aborted if any `registerTool` call fails.
- The web adapter reuses the privacy-check repository payload so scan/compare do not call `get_repo` twice.
- Private repositories now return the same `404 repository_not_found` as inaccessible ones instead of a distinct `403 private_repository_not_supported`. The old response let an anonymous caller probe which private repository names a deployment's `GITHUB_TOKEN` could read.

## 0.3.6 - 2026-08-07

Expand Down
1 change: 1 addition & 0 deletions README.ar.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ repopulse compare <baseline> <target>
- **منع التكرار افتراضيًا** (يتخطى العناوين المطابقة لـ Issues مفتوحة)
- `--no-dedupe` لإجبار الإنشاء
- **إجراء GitHub جاهز** — فحص صحة في CI مع التقرير في ملخص التشغيل (انظر أدناه)
- **تطبيق ويب اختياري** — لوحة واحدة وأربع أدوات للقراءة فقط يشترك فيها الإنسان والوكيل في الحالة نفسها. التفاصيل: [docs/webmcp-challenge.md](docs/webmcp-challenge.md)
- إعدادات اختيارية `.repopulse.yml` وملفات تعريف:

```text
Expand Down
1 change: 1 addition & 0 deletions README.es-ES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
168 changes: 168 additions & 0 deletions docs/webmcp-challenge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# RepoPulse Web + WebMCP (OpenAI WebMCP Challenge)

A thin web layer over the existing RepoPulse engine. The same `HealthReport`
and `ComparisonReport` the CLI produces are served over HTTP, and the same
functions that drive the visible dashboard are registered as WebMCP tools —
so a human and an agent always share the same application state.

## Architecture

```text
Browser (index.html + app.js, vanilla JS)
| fetch() | document.modelContext.registerTool()
v v
FastAPI app (webapp/app.py) <----+
| parse_github_url → GitHubClient → build_health_report / build_comparison
v
repopulse engine (unchanged core)
```

- `webapp/` is a **top-level adapter**, outside the `repopulse` package. The
PyPI distribution (`repopulse-cli`) does not include it and does not depend
on FastAPI.
- The web layer contains no business logic; it parses, validates, delegates
to the core, and maps errors.
- Frontend state (`state.currentReport`, `state.currentComparison`) is the
single source of truth for both the UI and the WebMCP tools. The Scan
button and `scan_repository` call the same `scanRepository()`; the Compare
button and `compare_refs` call the same `compareRefs()`.

## How to run

```bash
pip install -e ".[dev,web]"
uvicorn webapp.app:app --host 127.0.0.1 --port 8000 --reload
```

Open http://127.0.0.1:8000. Optional: set `GITHUB_TOKEN` in the server
environment to raise GitHub rate limits. The token is server-side only —
it never appears in HTML, JS, responses, or error messages.

## Deployment (Vercel)

The same FastAPI app is deployed unchanged; nothing is rebuilt for hosting.

| File | Why |
|---|---|
| `pyproject.toml` → `[tool.vercel] entrypoint` | `webapp/app.py` is outside Vercel's auto-detected entrypoint locations. setuptools ignores `[tool.vercel]`, so the sdist and wheel are unaffected. |
| `vercel.json` → `fluid: true` | Fluid compute, to keep demo latency down between requests. |
| `vercel.json` → `functions."webapp/app.py".maxDuration: 60` | A scan makes several sequential GitHub API calls (repo, tree, then README / .gitignore / package.json / pyproject.toml / each workflow), so it can outlast a short default timeout. |
| `vercel.json` → `buildCommand` | Installs `requirements.txt` into the function environment. |
| `requirements.txt` | The web layer's runtime imports only. Keep in sync with the `web` extra. |

Why the web dependencies are not in `pyproject.toml`: Vercel's Python runtime
installs **only** `[project].dependencies`. `[project.optional-dependencies]`,
PEP 735 `[dependency-groups]` and a bare `requirements.txt` are all ignored —
verified with a probe deployment. Putting FastAPI in `[project].dependencies`
would make every `pip install repopulse-cli` pull FastAPI, so the web-only
requirements are installed by `buildCommand` instead, which runs after the
framework install and before the function bundle is assembled.

`GITHUB_TOKEN` is not set on the deployment: the demo runs against GitHub's
unauthenticated rate limit. If that becomes the bottleneck, add it as a Vercel
environment variable — never in `vercel.json`, the repository, or the frontend.

The Vercel project's production branch is `main`, so a push to a feature
branch produces a preview deployment; production follows once the branch is
merged.

## API

| Endpoint | Body | Response |
|---|---|---|
| `GET /api/health` | — | `{"status","service","version"}` (no GitHub request) |
| `POST /api/scan` | `{"repository_url", "ref"?}` | `HealthReport.model_dump()` |
| `POST /api/compare` | `{"repository_url", "baseline_ref", "target_ref"}` | `ComparisonReport.model_dump()` |

Validation and precedence:

- Only `github.com` URLs are accepted, exclusively via
`repopulse.url_parser.parse_github_url` (SSRF boundary).
- `repository_url` max 512 chars; refs max 256 chars.
- If the URL embeds a ref (`/tree/<ref>`) and the body also has `ref`, the
body `ref` wins.

Error contract — `{"detail": {"code", "message"}}`, no tracebacks, no raw
GitHub payloads, no tokens:

| Code | HTTP |
|---|---|
| `invalid_repository_url` | 400 |
| `invalid_ref` | 400 |
| `repository_not_found` / `ref_not_found` | 404 |
| `github_rate_limited` | 429 |
| `github_unavailable` | 502 / 503 |
| `internal_error` | 500 |

Note: the core client collapses "repo missing" and "private repo without a
token" into one 404; they are indistinguishable without credentials.

## WebMCP tools

Registered via `document.modelContext.registerTool()` (imperative API,
current W3C WebMCP draft) with `annotations: {readOnlyHint: true,
untrustedContentHint: true}`. WebMCP is progressive enhancement: without
`document.modelContext` the page works normally and shows
"WebMCP Unavailable".

| Tool | Input | Behavior |
|---|---|---|
| `scan_repository` | `repository_url`, `ref?` | Runs the shared scan path, updates the dashboard, returns the report summary. |
| `get_attention_items` | `{}` | FAIL then WARN checks from the **current** report; no new GitHub request. |
| `get_check_details` | `check_key` | One check from the current report; returns available keys on a miss. |
| `compare_refs` | `baseline_ref`, `target_ref` | Runs the shared compare path for the currently scanned repository, updates the dashboard. |

All four are read-only against GitHub. Tool execution receives an
`AbortSignal` (per the spec's `ToolExecuteCallbackOptions`) which is forwarded
to `fetch`, so agent-cancelled calls abort the HTTP request and reset the
loading state.

## Security boundaries

- **Public repositories only.** If a server-side token can see a private
repo, the repo is rejected *before* any tree/file reads. The rejection is
the same `404 repository_not_found` an inaccessible repository gets, so an
anonymous caller cannot use the API to discover which private repository
names the deployment's token can read.
- **No tokens from the client.** No PAT input, no token in request bodies;
`GITHUB_TOKEN` is read server-side only.
- **XSS:** all GitHub-derived data renders via `textContent`/`createElement`;
no `innerHTML`, no Markdown rendering, no raw README display.
- **Prompt injection:** WebMCP results contain RepoPulse analysis results
only — never raw repository content; tools are static and never derived
from repository data.
- **SSRF:** repository URLs pass only through `parse_github_url`
(`github.com` only); the client talks only to `api.github.com`.
- **Headers:** `X-Content-Type-Options: nosniff`, `Referrer-Policy:
no-referrer`. Same-origin frontend/API; no CORS, no framing rules added
(WebMCP compatibility untested for those).

## Testing

`tests/test_webapp.py` covers: health, index, error mapping
(invalid URL/host, 404 repo vs ref, rate limit, network failure, 502),
ref precedence, `scan_truncated` passthrough, private-repo rejection,
token/traceback leak checks, the compare contract, whitespace/blank compare
refs, overlong URL-derived refs, reused GitHub metadata, stale
scan/compare results, Compare-before-Scan UI errors, and partial WebMCP
registration abort. All network and core calls are mocked — tests never
touch real GitHub.

## Demo flow

Using `https://github.com/3ssiri/RepoPulse` (tags `v0.3.5`/`v0.3.6` exist):

1. "Scan this repository and tell me the three things that deserve the most
attention before a release." → `scan_repository` → `get_attention_items`
2. "Explain the most important warning and what I should verify manually."
→ `get_check_details`
3. "Compare v0.3.5 with v0.3.6 and tell me whether repository health
regressed." → `compare_refs`

## Known limitations / future work

- Public github.com repositories only; no OAuth, accounts, or private repos.
- No caching, history, or persistence; each scan hits the GitHub API live.
- Repo-404 vs private-repo is indistinguishable without a token.
- Deferred: OAuth/private repos, saved history, issue creation via WebMCP,
GitLab/Bitbucket, background jobs, caching.
14 changes: 13 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -64,5 +69,12 @@ include = ["repopulse*"]
# FURB162: fromisoformat still needs Z->offset on Python 3.11.
ignore = ["B008", "FURB162"]

# Vercel deploys webapp/app.py (the optional web layer). This table is read
# only by Vercel's Python runtime; setuptools and the PyPI distribution
# ignore it.
[tool.vercel]
entrypoint = "webapp.app:app"

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
6 changes: 4 additions & 2 deletions repopulse/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Runtime dependencies of the optional web layer (webapp/), installed by the
# Vercel buildCommand in vercel.json. Vercel's Python runtime installs only
# [project].dependencies from pyproject.toml, and FastAPI must not become a
# dependency of the published `repopulse-cli` package -- so the web-only
# requirements live here. Keep in sync with the `web` extra in pyproject.toml.
fastapi>=0.135.0
pydantic>=2.13.4
requests>=2.34.2
PyYAML>=6.0.3
24 changes: 24 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading