diff --git a/python/robomp/src/github_backend.py b/python/robomp/src/github_backend.py index b8ced989f..8f9604077 100644 --- a/python/robomp/src/github_backend.py +++ b/python/robomp/src/github_backend.py @@ -79,6 +79,7 @@ class GitHubBackend(Protocol): ) -> None: ... async def add_issue_labels(self, repo: str, number: int, labels: list[str]) -> tuple[str, ...]: ... + async def remove_issue_label(self, repo: str, number: int, label: str) -> None: ... async def submit_pr_review( self, diff --git a/python/robomp/src/github_client.py b/python/robomp/src/github_client.py index 48e3002bb..9bfcfa978 100644 --- a/python/robomp/src/github_client.py +++ b/python/robomp/src/github_client.py @@ -7,6 +7,7 @@ import time from collections.abc import Mapping from dataclasses import dataclass from typing import Any +from urllib.parse import quote import httpx @@ -446,6 +447,16 @@ class GitHubClient: ) return tuple(str(lbl["name"]) if isinstance(lbl, dict) else str(lbl) for lbl in (data or [])) + async def remove_issue_label(self, repo: str, number: int, label: str) -> None: + """Remove one label from an issue (or PR).""" + if not label: + return + encoded = quote(label, safe="") + await self.request( + "DELETE", + f"/repos/{repo}/issues/{number}/labels/{encoded}", + ) + async def submit_pr_review( self, *, diff --git a/python/robomp/src/proxy/server.py b/python/robomp/src/proxy/server.py index 6246382ae..96e7629db 100644 --- a/python/robomp/src/proxy/server.py +++ b/python/robomp/src/proxy/server.py @@ -509,6 +509,19 @@ def create_proxy_app(settings: Settings) -> FastAPI: return _gh_error_response(exc) return JSONResponse({"labels": list(applied)}) + @app.post("/gh/v1/remove_issue_label") + async def remove_issue_label(request: Request) -> JSONResponse: + data = await _json_body(request) + repo = _require_str(data.get("repo"), "repo") + number = _require_int(data.get("number"), "number") + label = _require_str(data.get("label"), "label") + github: GitHubClient = request.app.state.github + try: + await github.remove_issue_label(repo, number, label) + except GitHubError as exc: + return _gh_error_response(exc) + return JSONResponse({"ok": True}) + @app.post("/gh/v1/submit_pr_review") async def submit_pr_review(request: Request) -> JSONResponse: data = await _json_body(request) diff --git a/python/robomp/src/proxy_client.py b/python/robomp/src/proxy_client.py index ca539b5e9..406cc839f 100644 --- a/python/robomp/src/proxy_client.py +++ b/python/robomp/src/proxy_client.py @@ -282,6 +282,15 @@ class GitHubProxyClient: ) return tuple(str(lbl) for lbl in (data.get("labels") if isinstance(data, dict) else None) or []) + async def remove_issue_label(self, repo: str, number: int, label: str) -> None: + if not label: + return + await self._request( + "POST", + "/gh/v1/remove_issue_label", + json_body={"repo": repo, "number": number, "label": label}, + ) + async def submit_pr_review( self, *, diff --git a/python/robomp/src/tasks.py b/python/robomp/src/tasks.py index d78ff4978..41c1c1b62 100644 --- a/python/robomp/src/tasks.py +++ b/python/robomp/src/tasks.py @@ -22,6 +22,7 @@ from robomp.sandbox import GitTransport, SandboxManager from robomp.worker import DirectiveInfo, TaskInputs, ThreadMessage, run_task log = logging.getLogger(__name__) +_NEEDS_INFO_LABEL = "needs-info" def _comment_from_payload(payload: Mapping[str, Any]) -> CommentInfo: @@ -498,6 +499,13 @@ async def handle_comment( author_email=settings.git_author_email, slot_uid=slot_uid, ) + if existing.state == "needs_info": + try: + await github.remove_issue_label(repo.full_name, issue.number, _NEEDS_INFO_LABEL) + except GitHubError as exc: + if exc.status != 404: + log.warning("needs-info label cleanup failed", extra={"key": key, "err": str(exc)}) + db.set_issue_state(key, "reproducing") inputs = TaskInputs( settings=settings, db=db, diff --git a/python/robomp/tests/test_proxy_client.py b/python/robomp/tests/test_proxy_client.py index 691ba5519..ed10b1d48 100644 --- a/python/robomp/tests/test_proxy_client.py +++ b/python/robomp/tests/test_proxy_client.py @@ -338,6 +338,8 @@ def round_trip_app(proxy_settings: Settings): 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/labels/needs-info" and req.method == "DELETE": + return httpx.Response(200, json={}) if path == "/repos/octo/widget/issues/1/assignees": return httpx.Response(201, json={}) return httpx.Response(404, json={"message": f"unrouted {req.method} {path}"}) @@ -405,6 +407,7 @@ async def test_round_trip_all_endpoints(round_trip_app) -> None: 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 await client.remove_issue_label("octo/widget", 1, "needs-info") is None assert labels == ("triage",) assert await client.add_assignees("octo/widget", 1, ["alice"]) is None diff --git a/python/robomp/tests/test_proxy_server.py b/python/robomp/tests/test_proxy_server.py index 094193dbb..0b8fb2878 100644 --- a/python/robomp/tests/test_proxy_server.py +++ b/python/robomp/tests/test_proxy_server.py @@ -505,6 +505,27 @@ async def test_add_issue_labels(proxy_settings: Settings) -> None: assert json.loads(captured["req"].content) == {"labels": ["triage", "bug"]} +async def test_remove_issue_label(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":1,"label":"needs-info"}' + async with await _async_client(app) as client: + resp = await client.post( + "/gh/v1/remove_issue_label", + content=body, + headers={**_signed("POST", "/gh/v1/remove_issue_label", body), "Content-Type": "application/json"}, + ) + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + assert captured["req"].method == "DELETE" + assert captured["req"].url.path == "/repos/octo/widget/issues/1/labels/needs-info" + + async def test_add_assignees(proxy_settings: Settings) -> None: captured: dict[str, httpx.Request] = {} diff --git a/python/robomp/tests/test_server.py b/python/robomp/tests/test_server.py index f572e268a..a6af6f02a 100644 --- a/python/robomp/tests/test_server.py +++ b/python/robomp/tests/test_server.py @@ -2248,12 +2248,17 @@ async def test_handle_comment_resumes_needs_info_reply( monkeypatch.setattr(tasks, "_resolve_repo_and_issue", _resolve) post_comment_calls: list = [] + removed_labels: list[tuple[str, int, str]] = [] async def _capture_post(self, *args, **kwargs): post_comment_calls.append((args, kwargs)) return None + async def _capture_remove_label(self, repo: str, number: int, label: str) -> None: + removed_labels.append((repo, number, label)) + monkeypatch.setattr(GitHubClient, "post_comment", _capture_post) + monkeypatch.setattr(GitHubClient, "remove_issue_label", _capture_remove_label) payload = { "action": "created", @@ -2280,9 +2285,10 @@ async def test_handle_comment_resumes_needs_info_reply( assert call["task_kind"] == "handle_comment" assert call["comment"].body == "I am on Bun 1.3.14 and here is the trace" assert sandbox.ensure_calls[0]["existing_branch"] == "farm/old/branch" + assert removed_labels == [("octo/widget", 88, "needs-info")] assert post_comment_calls == [], "needs-info replies must not get the finalized-issue notice" row = db.get_issue("octo/widget#88") - assert row is not None and row.state == "needs_info" + assert row is not None and row.state == "reproducing" close_database()