feat(robomp): improved authorization and login normalization
- Implemented case-insensitive normalization for bot logins to handle mention handles and `[bot]` suffixes consistently. - Added support for `ROBOMP_MAINTAINER_LOGINS` to allow authorized non-owner users to execute implementations. - Refined authorization logic to distinguish between personal repository owners and organizational accounts. - Updated documentation and added comprehensive tests to verify authorization handling across tasks, workers, and directive processing.
This commit is contained in:
@@ -93,13 +93,55 @@ def test_blank_bot_login_rejected(monkeypatch: pytest.MonkeyPatch, env: dict[str
|
||||
Settings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_bot_login_strips_at_prefix(monkeypatch: pytest.MonkeyPatch, env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("ROBOMP_BOT_LOGIN", " @roboomp ")
|
||||
@pytest.mark.parametrize(
|
||||
"raw_login",
|
||||
[
|
||||
"roboomp",
|
||||
" @roboomp ",
|
||||
" @ROBOOMP ",
|
||||
"roboomp[bot]",
|
||||
"@roboomp[bot]",
|
||||
" @ROBOOMP[BOT] ",
|
||||
],
|
||||
)
|
||||
def test_bot_login_normalizes_mention_case_and_app_suffix(
|
||||
monkeypatch: pytest.MonkeyPatch, env: dict[str, str], raw_login: str
|
||||
) -> None:
|
||||
monkeypatch.setenv("ROBOMP_BOT_LOGIN", raw_login)
|
||||
reset_settings_cache()
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.bot_login == "roboomp"
|
||||
|
||||
|
||||
def test_maintainer_logins_normalize_csv_entries(
|
||||
monkeypatch: pytest.MonkeyPatch, env: dict[str, str]
|
||||
) -> None:
|
||||
monkeypatch.setenv("ROBOMP_MAINTAINER_LOGINS", " can1357, @ROBOOMP , @Alice[bot] ,, ")
|
||||
reset_settings_cache()
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.maintainer_logins == frozenset({"can1357", "roboomp", "alice[bot]"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_login", "expected"),
|
||||
[
|
||||
("roboomp", "roboomp"),
|
||||
(" @roboomp ", "roboomp"),
|
||||
(" @ROBOOMP ", "roboomp"),
|
||||
("roboomp[bot]", "roboomp[bot]"),
|
||||
("@roboomp[bot]", "roboomp[bot]"),
|
||||
(" @ROBOOMP[BOT] ", "roboomp[bot]"),
|
||||
],
|
||||
)
|
||||
def test_maintainer_logins_common_entry_forms(
|
||||
monkeypatch: pytest.MonkeyPatch, env: dict[str, str], raw_login: str, expected: str
|
||||
) -> None:
|
||||
monkeypatch.setenv("ROBOMP_MAINTAINER_LOGINS", raw_login)
|
||||
reset_settings_cache()
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.maintainer_logins == frozenset({expected})
|
||||
|
||||
|
||||
def test_model_pool_single(env: dict[str, str]) -> None:
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
assert cfg.model_pool == (cfg.model,)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import pytest
|
||||
|
||||
from robomp.github_events import (
|
||||
extract_mention,
|
||||
@@ -143,6 +144,23 @@ def test_route_pr_conversation_uses_handle_pr_conversation() -> None:
|
||||
assert decision.task == "handle_pr_conversation"
|
||||
|
||||
|
||||
def test_route_pr_conversation_normalizes_bot_author_suffix() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {"user": {"login": "alice"}, "body": "looks good"},
|
||||
"issue": {"number": 9, "user": {"login": f"{BOT}[bot]"}, "pull_request": {"url": "x"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=f"@{BOT}[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."""
|
||||
|
||||
@@ -511,6 +529,11 @@ def test_extract_mention_returns_body_minus_mention() -> None:
|
||||
assert extract_mention("@robomp-bot do X", "robomp-bot") == "do X"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured_login", ["@roboomp", "roboomp[bot]", "@roboomp[bot]"])
|
||||
def test_extract_mention_accepts_prefixed_or_app_bot_login(configured_login: str) -> None:
|
||||
assert extract_mention("@roboomp go ahead", configured_login) == "go ahead"
|
||||
|
||||
|
||||
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
|
||||
@@ -614,7 +637,7 @@ def test_route_directive_authorizes_personal_repo_owner_without_author_associati
|
||||
"body": "@robomp-bot go ahead and push",
|
||||
},
|
||||
"issue": {"number": 9},
|
||||
"repository": {"full_name": "can1357/widget"},
|
||||
"repository": {"full_name": "can1357/widget", "owner": {"login": "can1357", "type": "User"}},
|
||||
},
|
||||
allowlist=frozenset({"can1357/widget"}),
|
||||
bot_login=BOT,
|
||||
@@ -623,6 +646,26 @@ def test_route_directive_authorizes_personal_repo_owner_without_author_associati
|
||||
assert decision.directive_body == "go ahead and push"
|
||||
assert decision.directive_author == "can1357"
|
||||
assert decision.directive_authorizes_impl is True
|
||||
assert decision.association == "OWNER"
|
||||
|
||||
|
||||
def test_route_directive_does_not_authorize_org_owner_name_without_author_association() -> None:
|
||||
decision = route(
|
||||
"issue_comment",
|
||||
{
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "octo"},
|
||||
"body": "@robomp-bot go ahead and push",
|
||||
},
|
||||
"issue": {"number": 9},
|
||||
"repository": {"full_name": "octo/widget", "owner": {"login": "octo", "type": "Organization"}},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=BOT,
|
||||
)
|
||||
assert decision.directive is False
|
||||
assert decision.directive_authorizes_impl is False
|
||||
|
||||
|
||||
def test_route_directive_from_collaborator_does_not_authorize_impl() -> None:
|
||||
@@ -730,6 +773,29 @@ def test_route_directive_set_on_review_comment() -> None:
|
||||
assert decision.directive_body == "use a generator here"
|
||||
|
||||
|
||||
def test_route_review_comment_normalizes_bot_author_suffix() -> 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": f"{BOT}[bot]"}},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
},
|
||||
allowlist=ALLOWLIST,
|
||||
bot_login=f"@{BOT}[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 ----------
|
||||
|
||||
|
||||
|
||||
@@ -1234,6 +1234,32 @@ def test_impl_gate_allows_authorized_proposal_to_reach_pr_validation(db: Databas
|
||||
assert "OWNER or allowlisted maintainer" not in msg
|
||||
|
||||
|
||||
def test_impl_gate_allows_authorized_proposal_push_to_reach_repo_commands(
|
||||
db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from dataclasses import replace
|
||||
|
||||
calls: list[list[str] | tuple[str, ...]] = []
|
||||
|
||||
def record_repo_command(_bindings: ToolBindings, cmd: list[str] | tuple[str, ...], *, timeout: float | None = None):
|
||||
del timeout
|
||||
calls.append(cmd)
|
||||
raise RuntimeError("authorized proposal reached gh_push_branch repo command")
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
|
||||
db.set_issue_classification(bindings.issue_key, "proposal")
|
||||
bindings = replace(bindings, impl_authorized=True)
|
||||
monkeypatch.setattr(host_tools, "_run_repo_command", record_repo_command)
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "gh_push_branch")
|
||||
with pytest.raises(RuntimeError, match="authorized proposal reached gh_push_branch repo command"):
|
||||
tool.execute({}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert calls
|
||||
|
||||
|
||||
def test_impl_gate_allows_bug_without_directive_to_reach_pr_validation(db: Database, tmp_path: Path) -> None:
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda _r: httpx.Response(500)))
|
||||
db.set_issue_classification(bindings.issue_key, "bug")
|
||||
|
||||
@@ -1536,6 +1536,43 @@ def test_webhook_directive_on_unknown_issue_is_queued_with_metadata(env) -> None
|
||||
assert directive == {"body": "please refactor X", "author": "can1357", "pragmas": [], "authorizes_impl": True}
|
||||
|
||||
|
||||
def test_webhook_directive_authorizes_deployed_app_login_without_author_association(
|
||||
monkeypatch: pytest.MonkeyPatch, env
|
||||
) -> None:
|
||||
monkeypatch.setenv("ROBOMP_BOT_LOGIN", "@roboomp[bot]")
|
||||
monkeypatch.setenv("ROBOMP_REPO_ALLOWLIST", "can1357/widget")
|
||||
reset_settings_cache()
|
||||
cfg = Settings() # type: ignore[call-arg]
|
||||
cfg.ensure_paths()
|
||||
app = create_app(cfg)
|
||||
payload = {
|
||||
"action": "created",
|
||||
"comment": {
|
||||
"user": {"login": "can1357"},
|
||||
"body": "@roboomp go ahead",
|
||||
},
|
||||
"issue": {"number": 3196},
|
||||
"repository": {
|
||||
"full_name": "can1357/widget",
|
||||
"owner": {"login": "can1357", "type": "User"},
|
||||
},
|
||||
}
|
||||
raw = json.dumps(payload).encode()
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/webhook/github",
|
||||
content=raw,
|
||||
headers=_signed_headers("test-webhook-secret", raw, event="issue_comment", delivery="dir-app-login"),
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
assert resp.json()["state"] == "queued"
|
||||
row = get_database(cfg.sqlite_path).get_event("dir-app-login")
|
||||
close_database()
|
||||
assert row is not None
|
||||
directive = row.payload.get("_robomp_directive")
|
||||
assert directive == {"body": "go ahead", "author": "can1357", "pragmas": [], "authorizes_impl": True}
|
||||
|
||||
|
||||
def test_webhook_maintainer_bypasses_rate_limit(
|
||||
rate_limited_settings: Settings,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from robomp import tasks
|
||||
from robomp.github_client import IssueInfo, RepoInfo
|
||||
from robomp.tasks import _attach_thread, _directive_from_payload
|
||||
from robomp.worker import DirectiveInfo
|
||||
|
||||
@@ -85,3 +89,179 @@ async def test_attach_thread_preserves_authorizes_impl(monkeypatch: pytest.Monke
|
||||
assert hydrated.body == "test body"
|
||||
assert hydrated.author == "test_author"
|
||||
assert hydrated.authorizes_impl is True
|
||||
|
||||
|
||||
|
||||
def _payload_with_directive(*, issue_number: int, body: str = "@robomp-bot ship it") -> dict[str, object]:
|
||||
return {
|
||||
"repository": {
|
||||
"full_name": "octo/widget",
|
||||
"default_branch": "main",
|
||||
"clone_url": "https://x/octo/widget.git",
|
||||
"private": False,
|
||||
},
|
||||
"issue": {
|
||||
"number": issue_number,
|
||||
"title": "proposal",
|
||||
"body": "issue body",
|
||||
"state": "open",
|
||||
"user": {"login": "alice"},
|
||||
"labels": [{"name": "proposal"}],
|
||||
},
|
||||
"comment": {
|
||||
"id": 99,
|
||||
"body": body,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"user": {"login": "owner"},
|
||||
},
|
||||
"_robomp_directive": {
|
||||
"body": body,
|
||||
"author": "owner",
|
||||
"authorizes_impl": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _workspace(tmp_path):
|
||||
repo_dir = tmp_path / "repo"
|
||||
session_dir = tmp_path / "session"
|
||||
repo_dir.mkdir(exist_ok=True)
|
||||
session_dir.mkdir(exist_ok=True)
|
||||
return SimpleNamespace(root=tmp_path, repo_dir=repo_dir, session_dir=session_dir, branch="robomp/issue-42")
|
||||
|
||||
|
||||
async def test_handle_comment_preserves_authorizes_impl_to_run_task(
|
||||
db, tmp_path, settings, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = _workspace(tmp_path)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_attach_thread(
|
||||
_github: object,
|
||||
directive: DirectiveInfo | None,
|
||||
_repo: str,
|
||||
_number: int,
|
||||
*,
|
||||
is_pr: bool,
|
||||
) -> DirectiveInfo | None:
|
||||
assert directive is not None
|
||||
assert is_pr is False
|
||||
captured["attached_authorizes_impl"] = directive.authorizes_impl
|
||||
return directive
|
||||
|
||||
async def fake_run_task(
|
||||
*,
|
||||
task_kind: str,
|
||||
inputs: object,
|
||||
directive: DirectiveInfo | None = None,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
del inputs
|
||||
captured["task_kind"] = task_kind
|
||||
captured["run_task_authorizes_impl"] = directive.authorizes_impl if directive is not None else None
|
||||
|
||||
monkeypatch.setattr(tasks, "_attach_thread", fake_attach_thread)
|
||||
monkeypatch.setattr(tasks, "run_task", fake_run_task)
|
||||
|
||||
await tasks.handle_comment(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=SimpleNamespace(),
|
||||
sandbox=SimpleNamespace(natives_cache=None, ensure_workspace=lambda **_kwargs: workspace),
|
||||
git_transport=SimpleNamespace(),
|
||||
payload=_payload_with_directive(issue_number=42),
|
||||
delivery_id="d-comment",
|
||||
)
|
||||
|
||||
assert captured == {
|
||||
"attached_authorizes_impl": True,
|
||||
"task_kind": "triage_issue",
|
||||
"run_task_authorizes_impl": True,
|
||||
}
|
||||
|
||||
|
||||
async def test_handle_pr_conversation_preserves_authorizes_impl_to_run_task(
|
||||
db, tmp_path, settings, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = _workspace(tmp_path)
|
||||
db.upsert_issue(
|
||||
key="octo/widget#42",
|
||||
repo="octo/widget",
|
||||
number=42,
|
||||
state="opened",
|
||||
branch=workspace.branch,
|
||||
session_dir=str(workspace.session_dir),
|
||||
pr_number=7,
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeGitHub:
|
||||
async def get_repo(self, repo: str) -> RepoInfo:
|
||||
assert repo == "octo/widget"
|
||||
return RepoInfo(full_name=repo, default_branch="main", clone_url="https://x/octo/widget.git", private=False)
|
||||
|
||||
async def get_issue(self, repo: str, number: int) -> IssueInfo:
|
||||
assert repo == "octo/widget"
|
||||
assert number == 42
|
||||
return IssueInfo(
|
||||
repo=repo,
|
||||
number=number,
|
||||
title="proposal",
|
||||
body="issue body",
|
||||
state="open",
|
||||
author="alice",
|
||||
labels=("proposal",),
|
||||
is_pull_request=False,
|
||||
)
|
||||
|
||||
async def fake_attach_thread(
|
||||
_github: object,
|
||||
directive: DirectiveInfo | None,
|
||||
repo: str,
|
||||
number: int,
|
||||
*,
|
||||
is_pr: bool,
|
||||
) -> DirectiveInfo | None:
|
||||
assert directive is not None
|
||||
assert repo == "octo/widget"
|
||||
assert number == 7
|
||||
assert is_pr is True
|
||||
captured["attached_authorizes_impl"] = directive.authorizes_impl
|
||||
return directive
|
||||
|
||||
async def fake_run_task(
|
||||
*,
|
||||
task_kind: str,
|
||||
inputs: object,
|
||||
pr_number: int | None = None,
|
||||
directive: DirectiveInfo | None = None,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
del inputs
|
||||
captured["task_kind"] = task_kind
|
||||
captured["pr_number"] = pr_number
|
||||
captured["run_task_authorizes_impl"] = directive.authorizes_impl if directive is not None else None
|
||||
|
||||
monkeypatch.setattr(tasks, "_attach_thread", fake_attach_thread)
|
||||
monkeypatch.setattr(tasks, "run_task", fake_run_task)
|
||||
|
||||
payload = _payload_with_directive(issue_number=7)
|
||||
issue_payload = payload["issue"]
|
||||
assert isinstance(issue_payload, dict)
|
||||
issue_payload["pull_request"] = {"url": "https://api.github.com/repos/octo/widget/pulls/7"}
|
||||
await tasks.handle_pr_conversation(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=FakeGitHub(),
|
||||
sandbox=SimpleNamespace(natives_cache=None, ensure_workspace=lambda **_kwargs: workspace),
|
||||
git_transport=SimpleNamespace(),
|
||||
payload=payload,
|
||||
delivery_id="d-pr-comment",
|
||||
)
|
||||
|
||||
assert captured == {
|
||||
"attached_authorizes_impl": True,
|
||||
"task_kind": "handle_comment",
|
||||
"pr_number": 7,
|
||||
"run_task_authorizes_impl": True,
|
||||
}
|
||||
@@ -192,6 +192,32 @@ async def test_run_task_sets_impl_authorized_from_directive(
|
||||
assert captured == {"impl_authorized": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_preserves_impl_authorized_when_resuming(
|
||||
tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
inputs, _bindings = _make_inputs(tmp_path, settings, session_has_jsonl=True)
|
||||
captured: dict[str, bool] = {}
|
||||
|
||||
monkeypatch.setattr(worker, "_build_prompt", lambda *args, **kwargs: "prompt")
|
||||
|
||||
def capture_build(bindings: worker.ToolBindings) -> tuple:
|
||||
captured["impl_authorized"] = bindings.impl_authorized
|
||||
return ()
|
||||
|
||||
monkeypatch.setattr(worker.host_tools, "build", capture_build)
|
||||
|
||||
result = await worker.run_task(
|
||||
task_kind="handle_comment",
|
||||
inputs=inputs,
|
||||
directive=worker.DirectiveInfo(body="go ahead", author="can1357", authorizes_impl=True),
|
||||
)
|
||||
|
||||
assert result == "ok"
|
||||
assert captured == {"impl_authorized": True}
|
||||
assert _FakeRpcClient.instances[0].kwargs["extra_args"] == ("--continue",)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
Reference in New Issue
Block a user