feat(robomp): resume needs-info issues (#2728)
This commit is contained in:
@@ -162,8 +162,8 @@ The integration test spawns a real `omp --mode rpc` against an
|
||||
- **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.
|
||||
`repro_record`; `mark_unable_to_reproduce` asks for missing details,
|
||||
marks the row `needs_info`, and resumes the same session on the next reply.
|
||||
- **Crash recovery.** On startup, `db.reset_stuck_running()` flips
|
||||
`running` rows back to `queued`. Existing `<session_dir>/*.jsonl`
|
||||
triggers `--continue`. Drain bounded by
|
||||
|
||||
@@ -23,6 +23,7 @@ IssueState = Literal[
|
||||
"opened",
|
||||
"merged",
|
||||
"closed",
|
||||
"needs_info",
|
||||
"abandoned",
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -23,7 +23,7 @@ from omp_rpc import HostTool, HostToolContext, RpcCommandError, host_tool
|
||||
|
||||
from robomp import persona
|
||||
from robomp.config import Settings
|
||||
from robomp.db import Database, issue_key
|
||||
from robomp.db import Database, IssueState, issue_key
|
||||
from robomp.git_ops import GitCommandError, HeadDriftError
|
||||
from robomp.github_backend import GitHubBackend
|
||||
from robomp.github_client import GitHubError, IssueInfo, PullRequestFileInfo, RepoInfo
|
||||
@@ -49,6 +49,7 @@ _REPO_COMMAND_SCRUBBED_ENV_KEYS: tuple[str, ...] = (
|
||||
"ROBOMP_REPLAY_TOKEN",
|
||||
"ROBOMP_GH_PROXY_HMAC_KEY",
|
||||
)
|
||||
_NEEDS_INFO_LABEL = "needs-info"
|
||||
_AGENT_HOME = Path("/srv/agent-home")
|
||||
_PRE_PR_FIX_TIMEOUT_SECONDS = 600.0
|
||||
_PRE_PR_CHECK_TIMEOUT_SECONDS = 600.0
|
||||
@@ -137,6 +138,43 @@ def _run_coro(loop: asyncio.AbstractEventLoop, coro: Any) -> Any:
|
||||
return future.result()
|
||||
|
||||
|
||||
def _issue_needs_info(bindings: ToolBindings) -> bool:
|
||||
row = bindings.db.get_issue(bindings.issue_key)
|
||||
return row is not None and row.state == "needs_info"
|
||||
|
||||
|
||||
def _optional_label_error(exc: Exception) -> str:
|
||||
return f"{type(exc).__name__}: {exc}"
|
||||
|
||||
|
||||
def _remove_needs_info_label(bindings: ToolBindings) -> bool:
|
||||
try:
|
||||
_run_coro(
|
||||
bindings.loop,
|
||||
bindings.github.remove_issue_label(bindings.repo.full_name, bindings.issue.number, _NEEDS_INFO_LABEL),
|
||||
)
|
||||
except GitHubError as exc:
|
||||
if exc.status == 404:
|
||||
return True
|
||||
log.warning("needs-info label cleanup failed", extra={"issue": bindings.issue_key, "err": str(exc)})
|
||||
return False
|
||||
except Exception as exc: # noqa: BLE001 - best-effort optional label cleanup
|
||||
log.warning(
|
||||
"needs-info label cleanup failed",
|
||||
extra={"issue": bindings.issue_key, "err": _optional_label_error(exc)},
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _advance_needs_info(bindings: ToolBindings, state: IssueState) -> bool:
|
||||
if not _issue_needs_info(bindings):
|
||||
return False
|
||||
label_cleared = _remove_needs_info_label(bindings)
|
||||
bindings.db.set_issue_state(bindings.issue_key, state)
|
||||
return label_cleared
|
||||
|
||||
|
||||
def _audit(
|
||||
bindings: ToolBindings, name: str, args: Mapping[str, Any], result: Any | None = None, error: str | None = None
|
||||
) -> None:
|
||||
@@ -405,17 +443,14 @@ def _run_pre_publish_bun_check(
|
||||
_raise_command(msg)
|
||||
|
||||
|
||||
_AUTOCLOSE_INELIGIBLE_STATES: frozenset[str] = frozenset({"closed", "merged", "abandoned"})
|
||||
_AUTOCLOSE_INELIGIBLE_STATES: frozenset[str] = frozenset({"closed", "merged", "needs_info", "abandoned"})
|
||||
|
||||
|
||||
def _should_schedule_autoclose(bindings: ToolBindings, target_number: int) -> float | None:
|
||||
"""Return the configured close window (hours) when this comment should
|
||||
schedule an auto-close; ``None`` otherwise.
|
||||
|
||||
Conditions: feature enabled in `Settings`, the comment lands on the
|
||||
originating issue (not a different number, not a PR thread), the issue is
|
||||
classified as `question`, and the issue is not already in a terminal
|
||||
state (closed/merged/abandoned).
|
||||
schedule the question auto-close job: feature enabled, same issue,
|
||||
classified as `question`, and the issue is not already in a terminal or
|
||||
waiting-for-reporter state.
|
||||
"""
|
||||
settings = bindings.settings
|
||||
if settings is None or not settings.question_autoclose_enabled:
|
||||
@@ -697,6 +732,7 @@ def _build_open_pr(bindings: ToolBindings) -> HostTool[Any, Any]:
|
||||
# Make sure the branch is pushed (idempotent) using the same preflight as gh_push_branch.
|
||||
_guarded_push_branch(bindings, args, "gh_open_pr", bindings.workspace.branch)
|
||||
base = args.get("base") or bindings.repo.default_branch
|
||||
was_needs_info = _issue_needs_info(bindings)
|
||||
try:
|
||||
pr = _run_coro(
|
||||
bindings.loop,
|
||||
@@ -714,6 +750,7 @@ def _build_open_pr(bindings: ToolBindings) -> HostTool[Any, Any]:
|
||||
_raise_command(f"GitHub rejected PR: {exc.status} {exc.message}")
|
||||
bindings.db.set_issue_pr(bindings.issue_key, pr.number)
|
||||
bindings.db.set_issue_state(bindings.issue_key, "opened")
|
||||
needs_info_label_cleared = _remove_needs_info_label(bindings) if was_needs_info else False
|
||||
artifact = bindings.workspace.artifacts_dir / "pr.json"
|
||||
artifact.write_text(
|
||||
json.dumps(
|
||||
@@ -728,7 +765,10 @@ def _build_open_pr(bindings: ToolBindings) -> HostTool[Any, Any]:
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_audit(bindings, "gh_open_pr", args, result={"pr_number": pr.number, "url": pr.html_url})
|
||||
result: dict[str, Any] = {"pr_number": pr.number, "url": pr.html_url}
|
||||
if needs_info_label_cleared:
|
||||
result["cleared_needs_info"] = True
|
||||
_audit(bindings, "gh_open_pr", args, result=result)
|
||||
return f"opened #{pr.number}: {pr.html_url}"
|
||||
|
||||
return host_tool(
|
||||
@@ -842,7 +882,10 @@ def _build_repro_record(bindings: ToolBindings) -> HostTool[Any, Any]:
|
||||
if _slot_permissions_active(bindings.slot_uid):
|
||||
assert bindings.slot_uid is not None
|
||||
os.chown(target, bindings.slot_uid, bindings.slot_uid)
|
||||
_audit(bindings, "repro_record", args, result={"path": str(target.relative_to(bindings.workspace.root))})
|
||||
result: dict[str, Any] = {"path": str(target.relative_to(bindings.workspace.root))}
|
||||
if _advance_needs_info(bindings, "reproducing"):
|
||||
result["cleared_needs_info"] = True
|
||||
_audit(bindings, "repro_record", args, result=result)
|
||||
return "recorded"
|
||||
|
||||
return host_tool(
|
||||
@@ -888,9 +931,26 @@ def _build_mark_unable(bindings: ToolBindings) -> HostTool[Any, Any]:
|
||||
except GitHubError as exc:
|
||||
_audit(bindings, "mark_unable_to_reproduce", args, error=str(exc))
|
||||
_raise_command(f"GitHub rejected comment: {exc.status} {exc.message}")
|
||||
bindings.db.set_issue_state(bindings.issue_key, "abandoned")
|
||||
_audit(bindings, "mark_unable_to_reproduce", args, result={"comment_id": comment.id})
|
||||
return f"posted abandonment comment id={comment.id}"
|
||||
result: dict[str, Any] = {"comment_id": comment.id, "state": "needs_info"}
|
||||
try:
|
||||
labels = _run_coro(
|
||||
bindings.loop,
|
||||
bindings.github.add_issue_labels(bindings.repo.full_name, bindings.issue.number, [_NEEDS_INFO_LABEL]),
|
||||
)
|
||||
result["labels"] = list(labels)
|
||||
except GitHubError as exc:
|
||||
# Some repos have not created the optional status label yet. The
|
||||
# durable behavior is the non-terminal sqlite state plus the visible
|
||||
# info-request comment, so label setup must not block resumption.
|
||||
log.warning("needs-info label failed", extra={"issue": bindings.issue_key, "err": str(exc)})
|
||||
result["label_error"] = f"{exc.status} {exc.message}"
|
||||
except Exception as exc: # noqa: BLE001 - best-effort optional label setup
|
||||
error = _optional_label_error(exc)
|
||||
log.warning("needs-info label failed", extra={"issue": bindings.issue_key, "err": error})
|
||||
result["label_error"] = error
|
||||
bindings.db.set_issue_state(bindings.issue_key, "needs_info")
|
||||
_audit(bindings, "mark_unable_to_reproduce", args, result=result)
|
||||
return f"posted needs-info comment id={comment.id}"
|
||||
|
||||
return host_tool(
|
||||
name="mark_unable_to_reproduce",
|
||||
|
||||
@@ -3,12 +3,12 @@ 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:
|
||||
You classified this issue and reproduced the bug, but did NOT reach a turn-ending action. Acceptable turn-ending 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.
|
||||
2. `mark_unable_to_reproduce` — you genuinely cannot reproduce after a real attempt and need reporter-provided reproduction details.
|
||||
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.
|
||||
You MUST end this turn by calling one of the three turn-ending tools listed above.
|
||||
|
||||
@@ -61,10 +61,10 @@ description = "Persist a reproduction transcript (command, output, exit code) fo
|
||||
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."
|
||||
description = "Ask the reporter for missing reproduction details, mark the issue `needs_info`, and keep the session resumable for the next reply."
|
||||
|
||||
[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."
|
||||
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 and unclear maintainer requests use `gh_post_comment`; missing reporter reproduction details use `mark_unable_to_reproduce`. `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."
|
||||
|
||||
@@ -28,4 +28,4 @@ the classification calls for code. Drive the todo list to completion:
|
||||
- `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.
|
||||
`mark_unable_to_reproduce` with the exact reporter details needed. You NEVER guess at fixes.
|
||||
|
||||
@@ -46,7 +46,7 @@ NEVER apply `provider` or `platform` speculatively. They REQUIRE explicit eviden
|
||||
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.
|
||||
- **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`. 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.
|
||||
|
||||
|
||||
@@ -5,3 +5,5 @@
|
||||
## Information needed
|
||||
|
||||
{{info_needed}}
|
||||
|
||||
I'll keep this issue waiting on reporter details and resume from this context when the requested information arrives.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -382,10 +382,94 @@ def test_repro_record_writes_transcript(db: Database, tmp_path: Path) -> None:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
|
||||
def test_repro_record_clears_needs_info_after_actionable_reply(db: Database, tmp_path: Path) -> None:
|
||||
removed: list[tuple[str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
removed.append((request.method, request.url.path))
|
||||
return httpx.Response(204)
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
db.set_issue_state(bindings.issue_key, "needs_info")
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "repro_record")
|
||||
result = tool.execute(
|
||||
{
|
||||
"title": "panic on empty input",
|
||||
"command": "bun test foo.test.ts",
|
||||
"output": "Error: boom",
|
||||
"exit_code": 1,
|
||||
},
|
||||
_ctx(),
|
||||
)
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert result == "recorded"
|
||||
assert removed == [("DELETE", "/repos/octo/widget/issues/42/labels/needs-info")]
|
||||
issue = db.get_issue(bindings.issue_key)
|
||||
assert issue and issue.state == "reproducing"
|
||||
|
||||
|
||||
def test_repro_record_advances_needs_info_when_label_is_missing(db: Database, tmp_path: Path) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "DELETE"
|
||||
assert request.url.path == "/repos/octo/widget/issues/42/labels/needs-info"
|
||||
return httpx.Response(404, json={"message": "Label does not exist"})
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
db.set_issue_state(bindings.issue_key, "needs_info")
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "repro_record")
|
||||
tool.execute(
|
||||
{
|
||||
"title": "panic on empty input",
|
||||
"command": "bun test foo.test.ts",
|
||||
"output": "Error: boom",
|
||||
"exit_code": 1,
|
||||
},
|
||||
_ctx(),
|
||||
)
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
issue = db.get_issue(bindings.issue_key)
|
||||
assert issue and issue.state == "reproducing"
|
||||
|
||||
|
||||
def test_repro_record_advances_needs_info_when_cleanup_transport_fails(db: Database, tmp_path: Path) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("connection dropped", request=request)
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
db.set_issue_state(bindings.issue_key, "needs_info")
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "repro_record")
|
||||
result = tool.execute(
|
||||
{
|
||||
"title": "panic on empty input",
|
||||
"command": "bun test foo.test.ts",
|
||||
"output": "Error: boom",
|
||||
"exit_code": 1,
|
||||
},
|
||||
_ctx(),
|
||||
)
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert result == "recorded"
|
||||
issue = db.get_issue(bindings.issue_key)
|
||||
assert issue and issue.state == "reproducing"
|
||||
|
||||
|
||||
def test_repro_record_chowns_to_slot_when_root(db: Database, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
chowns: list[tuple[Path, int, int]] = []
|
||||
monkeypatch.setattr(host_tools, "_slot_permissions_active", lambda slot_uid: slot_uid is not None)
|
||||
monkeypatch.setattr("robomp.host_tools.os.chown", lambda path, uid, gid: chowns.append((Path(path), uid, gid)))
|
||||
monkeypatch.setattr(
|
||||
"robomp.host_tools.os.chown",
|
||||
lambda path, uid, gid: chowns.append((Path(path), uid, gid)),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(lambda r: httpx.Response(500)), slot_uid=2001)
|
||||
try:
|
||||
@@ -419,12 +503,15 @@ def test_repro_record_rejects_bad_args(db: Database, tmp_path: Path) -> None:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
|
||||
def test_mark_unable_posts_comment_and_abandons(db: Database, tmp_path: Path) -> None:
|
||||
def test_mark_unable_posts_comment_marks_needs_info_and_labels_issue(db: Database, tmp_path: Path) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(201, json={"id": 77, "user": {"login": "robomp-bot"}, "body": "x", "created_at": "t"})
|
||||
if request.url.path.endswith("/labels"):
|
||||
captured["labels"] = json.loads(request.content)
|
||||
return httpx.Response(200, json=[{"name": "bug"}, {"name": "needs-info"}])
|
||||
captured["comment"] = json.loads(request.content)
|
||||
return httpx.Response(201, json={"id": 321, "user": {"login": "robomp-bot"}, "body": "x", "created_at": "t"})
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
try:
|
||||
@@ -432,10 +519,57 @@ def test_mark_unable_posts_comment_and_abandons(db: Database, tmp_path: Path) ->
|
||||
result = tool.execute({"diagnosis": "needed exact version", "info_needed": "post bun --version"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
assert "abandonment" in result
|
||||
assert "Could not reproduce" in captured["body"]["body"]
|
||||
|
||||
assert "needs-info comment" in result
|
||||
assert captured["labels"] == {"labels": ["needs-info"]}
|
||||
assert "resume from this context" in captured["comment"]["body"]
|
||||
issue = db.get_issue(bindings.issue_key)
|
||||
assert issue and issue.state == "abandoned"
|
||||
assert issue and issue.state == "needs_info"
|
||||
|
||||
|
||||
def test_mark_unable_keeps_needs_info_when_label_is_missing(db: Database, tmp_path: Path) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/labels"):
|
||||
return httpx.Response(422, json={"message": "Label does not exist"})
|
||||
return httpx.Response(201, json={"id": 321, "user": {"login": "robomp-bot"}, "body": "x", "created_at": "t"})
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "mark_unable_to_reproduce")
|
||||
tool.execute({"diagnosis": "needed exact version", "info_needed": "post bun --version"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
issue = db.get_issue(bindings.issue_key)
|
||||
assert issue and issue.state == "needs_info"
|
||||
|
||||
|
||||
def test_mark_unable_keeps_needs_info_when_label_transport_fails(db: Database, tmp_path: Path) -> None:
|
||||
comments = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal comments
|
||||
if request.url.path.endswith("/labels"):
|
||||
raise httpx.ConnectError("connection dropped", request=request)
|
||||
comments += 1
|
||||
return httpx.Response(201, json={"id": 321, "user": {"login": "robomp-bot"}, "body": "x", "created_at": "t"})
|
||||
|
||||
bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler))
|
||||
try:
|
||||
tool = next(x for x in build(bindings) if x.name == "mark_unable_to_reproduce")
|
||||
tool.execute({"diagnosis": "needed exact version", "info_needed": "post bun --version"}, _ctx())
|
||||
finally:
|
||||
_stop_loop(loop, t)
|
||||
|
||||
assert comments == 1
|
||||
issue = db.get_issue(bindings.issue_key)
|
||||
assert issue and issue.state == "needs_info"
|
||||
row = db._conn.execute(
|
||||
"SELECT result_json FROM tool_calls WHERE tool='mark_unable_to_reproduce' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
result = json.loads(row["result_json"])
|
||||
assert "ConnectError" in result["label_error"]
|
||||
|
||||
|
||||
def test_abort_task_signals_controller_and_abandons_without_comment(db: Database, tmp_path: Path) -> None:
|
||||
|
||||
@@ -183,6 +183,16 @@ def test_review_completion_reminder_mentions_submit_only() -> None:
|
||||
assert "gh_open_pr" not in out
|
||||
|
||||
|
||||
def test_completion_reminder_limits_mark_unable_to_reporter_details() -> None:
|
||||
out = persona.completion_reminder(
|
||||
repo=_Repo(),
|
||||
issue=_Issue(),
|
||||
workspace=_Workspace(),
|
||||
)
|
||||
assert "reporter-provided reproduction details" in out
|
||||
assert "maintainer input" not in out
|
||||
|
||||
|
||||
def test_system_append_renders_configured_bot_login() -> None:
|
||||
out = persona.system_append(
|
||||
repo=_Repo(),
|
||||
@@ -196,6 +206,17 @@ def test_system_append_renders_configured_bot_login() -> None:
|
||||
assert "**robomp**" not in out
|
||||
|
||||
|
||||
def test_system_append_routes_push_refusal_to_maintainer_comment_only() -> None:
|
||||
out = persona.system_append(
|
||||
repo=_Repo(),
|
||||
issue=_Issue(),
|
||||
workspace=_Workspace(),
|
||||
bot_login="Svitter",
|
||||
)
|
||||
assert "Push refused for reasons you cannot resolve? Ask the maintainer via `gh_post_comment`." in out
|
||||
assert "or use `mark_unable_to_reproduce`" not in out
|
||||
|
||||
|
||||
def test_system_append_pr_review_renders_configured_bot_login() -> None:
|
||||
out = persona.system_append_pr_review(
|
||||
repo=_Repo(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
|
||||
@@ -2211,6 +2211,87 @@ async def test_handle_comment_finalized_without_directive_still_replies(
|
||||
close_database()
|
||||
|
||||
|
||||
async def test_handle_comment_resumes_needs_info_without_preemptive_cleanup(
|
||||
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
|
||||
) -> None:
|
||||
"""A needs-info reply resumes first; host tools clear state only after actionable work."""
|
||||
from robomp import tasks
|
||||
from robomp.github_client import GitHubClient, IssueInfo, RepoInfo
|
||||
|
||||
sandbox = _RecordingSandbox(tmp_path)
|
||||
db = get_database(settings.sqlite_path)
|
||||
db.upsert_issue(
|
||||
key="octo/widget#88",
|
||||
repo="octo/widget",
|
||||
number=88,
|
||||
state="needs_info",
|
||||
branch="farm/old/branch",
|
||||
)
|
||||
|
||||
repo = RepoInfo(
|
||||
full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False
|
||||
)
|
||||
issue = IssueInfo(
|
||||
repo="octo/widget",
|
||||
number=88,
|
||||
title="boom",
|
||||
body="details",
|
||||
state="open",
|
||||
author="alice",
|
||||
labels=("needs-info",),
|
||||
is_pull_request=False,
|
||||
)
|
||||
|
||||
async def _resolve(_gh, _payload):
|
||||
return repo, issue
|
||||
|
||||
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",
|
||||
"issue": {"number": 88, "user": {"login": "alice"}, "title": "boom"},
|
||||
"comment": {
|
||||
"user": {"login": "alice"},
|
||||
"body": "I am on Bun 1.3.14 and here is the trace",
|
||||
"id": 4,
|
||||
"created_at": "2026-05-14T23:00:00Z",
|
||||
},
|
||||
"repository": {"full_name": "octo/widget"},
|
||||
}
|
||||
await tasks.handle_comment(
|
||||
settings=settings,
|
||||
db=db,
|
||||
github=GitHubClient("t"),
|
||||
git_transport=LocalGitTransport(token=None),
|
||||
sandbox=sandbox,
|
||||
payload=payload,
|
||||
delivery_id="test-delivery-needs-info",
|
||||
)
|
||||
assert len(stub_run_task) == 1
|
||||
call = stub_run_task[0]
|
||||
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 == []
|
||||
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"
|
||||
close_database()
|
||||
|
||||
|
||||
async def test_directive_handler_attaches_thread_from_github(
|
||||
settings: Settings, tmp_path: Path, stub_run_task, monkeypatch
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user