224 lines
7.1 KiB
Python
224 lines
7.1 KiB
Python
"""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()
|