diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 406720f..eb0c082 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -225,12 +225,14 @@ python scripts/submission_quality_gate.py --text-file pr-body.md --repo ramimbo/ The gate is advisory. It does not reserve work, claim acceptance, make payments, or block maintainer decisions. It checks for a `Bounty #` or `Refs #` reference, whether the referenced bounty appears open, whether -the bounty has recent maintainer activity, whether the draft includes a concise +the bounty has recent maintainer activity, whether active attempt reservations +already exist for the referenced bounty, whether the draft includes a concise summary and validation evidence, whether multiple bounty references are mixed into one draft, and whether a similar open PR already references the same -bounty. When live GitHub or -MergeWork API data is unavailable, the gate degrades to advisory warnings -instead of blocking submission. +bounty. The active-attempt lookup is read-only and uses the internal bounty id +from `/api/v1/bounties`; if the attempts API is unavailable, the gate keeps +other checks and reports an advisory warning instead of crashing or hiding +payability results. Results: diff --git a/scripts/submission_quality_gate.py b/scripts/submission_quality_gate.py index bd98413..6b80048 100644 --- a/scripts/submission_quality_gate.py +++ b/scripts/submission_quality_gate.py @@ -8,7 +8,7 @@ from datetime import UTC, datetime, timedelta from difflib import SequenceMatcher from typing import Any -from urllib.error import URLError +from urllib.error import HTTPError, URLError from urllib.request import urlopen BOUNTY_REF_RE = re.compile(r"(?:bounty|refs?|fixes|closes)\s+#(\d+)", re.IGNORECASE) @@ -55,6 +55,42 @@ def _bounty_payability_verified(raw: dict[str, Any]) -> bool: return raw.get("payability_verified", True) is not False +def _active_attempts_verified(raw: dict[str, Any]) -> bool: + return raw.get("active_attempts_verified", True) is not False + + +def _safe_attempts(raw: dict[str, Any]) -> list[dict[str, Any]]: + attempts = raw.get("active_attempts", []) + if not isinstance(attempts, list): + return [] + return [attempt for attempt in attempts if isinstance(attempt, dict)] + + +def _attempt_field(attempt: dict[str, Any], *names: str) -> Any: + for name in names: + value = attempt.get(name) + if value not in (None, ""): + return value + return None + + +def _format_attempt_summary(attempt: dict[str, Any]) -> str: + parts: list[str] = [] + submitter = _attempt_field(attempt, "submitter", "submitter_account", "account", "github_login") + if submitter: + parts.append(f"submitter={submitter}") + source_url = _attempt_field(attempt, "source_url", "public_source_url", "url") + if source_url: + parts.append(f"source={source_url}") + status = _attempt_field(attempt, "status") + if status: + parts.append(f"status={status}") + expires_at = _attempt_field(attempt, "expires_at", "expiresAt", "expiry_time") + if expires_at: + parts.append(f"expires={expires_at}") + return ", ".join(parts) or "active attempt" + + def _parse_datetime(value: Any) -> datetime | None: if not isinstance(value, str) or not value: return None @@ -232,6 +268,33 @@ def evaluate_submission(data: dict[str, Any]) -> dict[str, Any]: activity_check = _maintainer_activity_check(bounty_ref, bounty, now) if activity_check is not None: checks.append(activity_check) + if "active_attempts" in bounty or "active_attempts_verified" in bounty: + active_attempts = _safe_attempts(bounty) + if active_attempts: + checks.append( + _check( + "active_attempts", + "warn", + f"{len(active_attempts)} active attempt(s) already exist " + f"for bounty #{bounty_ref}", + ) + ) + elif not _active_attempts_verified(bounty): + checks.append( + _check( + "active_attempts", + "warn", + f"active attempts for bounty #{bounty_ref} could not be verified", + ) + ) + else: + checks.append( + _check( + "active_attempts", + "pass", + f"no active attempts found for bounty #{bounty_ref}", + ) + ) if SUMMARY_RE.search(text): checks.append(_check("summary_present", "pass", "summary text found")) @@ -272,6 +335,7 @@ def evaluate_submission(data: dict[str, Any]) -> dict[str, Any]: "bounty_reference": bounty_ref, "checks": checks, "similar_open_prs": similar, + "active_attempts": _safe_attempts(bounties.get(bounty_ref, {})) if bounty_ref else [], } @@ -349,6 +413,7 @@ def _load_api_bounties(repo: str, api_host: str) -> dict[int, dict[str, Any]]: if not isinstance(issue_number, int): continue bounties[issue_number] = { + "id": item.get("id"), "number": issue_number, "state": item.get("status", "open"), "awards_remaining": item.get("awards_remaining"), @@ -356,6 +421,32 @@ def _load_api_bounties(repo: str, api_host: str) -> dict[int, dict[str, Any]]: return bounties +def _normalize_attempt(raw: dict[str, Any]) -> dict[str, Any]: + return { + "submitter": _attempt_field( + raw, "submitter", "submitter_account", "account", "github_login" + ), + "source_url": _attempt_field(raw, "source_url", "public_source_url", "url"), + "status": _attempt_field(raw, "status"), + "expires_at": _attempt_field(raw, "expires_at", "expiresAt", "expiry_time"), + } + + +def _load_api_attempts(api_host: str, bounty_id: Any) -> list[dict[str, Any]]: + if not isinstance(bounty_id, int): + raise RuntimeError("MergeWork API bounty id unavailable for attempts lookup") + url = f"{api_host.rstrip('/')}/api/v1/bounties/{bounty_id}/attempts" + try: + with urlopen(url, timeout=GH_TIMEOUT_SECONDS) as response: + payload = json.loads(response.read().decode("utf-8")) + except (HTTPError, OSError, URLError, json.JSONDecodeError) as exc: + raise RuntimeError(f"MergeWork API attempts data unavailable: {exc}") from exc + attempts = payload.get("attempts") if isinstance(payload, dict) else payload + if not isinstance(attempts, list): + raise RuntimeError("MergeWork API attempts data must be a list") + return [_normalize_attempt(attempt) for attempt in attempts if isinstance(attempt, dict)] + + def _load_live_context( repo: str, submission_text: str, @@ -415,6 +506,7 @@ def _load_live_context( awards_remaining = api_bounty.get("awards_remaining") bounties.append( { + "id": api_bounty.get("id"), "number": issue["number"], "title": issue.get("title"), "state": issue.get("state"), @@ -432,6 +524,24 @@ def _load_live_context( load_warnings.append( f"maintainer activity unavailable for bounty #{issue['number']}: {exc}" ) + bounty_id = api_bounty.get("id") + if isinstance(bounty_id, int): + try: + bounties[-1]["active_attempts"] = _load_api_attempts(api_host, bounty_id) + bounties[-1]["active_attempts_verified"] = True + except RuntimeError as exc: + bounties[-1]["active_attempts"] = [] + bounties[-1]["active_attempts_verified"] = False + load_warnings.append( + f"active attempts unavailable for bounty #{issue['number']}: {exc}" + ) + else: + bounties[-1]["active_attempts"] = [] + bounties[-1]["active_attempts_verified"] = False + load_warnings.append( + f"active attempts unavailable for bounty #{issue['number']}: " + "MergeWork API bounty id unavailable for attempts lookup" + ) data = {"submission_text": submission_text, "bounties": bounties, "pull_requests": prs} if load_warnings: data["load_warning"] = "; ".join(load_warnings) @@ -458,6 +568,10 @@ def format_text(result: dict[str, Any]) -> str: lines.append("Similar open PRs:") for pr in result["similar_open_prs"]: lines.append(f"- #{pr['number']}: {pr['title']} {pr.get('url') or ''}".rstrip()) + if result.get("active_attempts"): + lines.append("Active attempts:") + for attempt in result["active_attempts"]: + lines.append(f"- {_format_attempt_summary(attempt)}") return "\n".join(lines) diff --git a/tests/test_submission_quality_gate.py b/tests/test_submission_quality_gate.py index e2af81f..581624a 100644 --- a/tests/test_submission_quality_gate.py +++ b/tests/test_submission_quality_gate.py @@ -76,6 +76,125 @@ def test_submission_quality_gate_fails_closed_or_exhausted_bounty() -> None: } in result["checks"] +def test_submission_quality_gate_passes_when_no_active_attempts() -> None: + result = evaluate_submission( + { + "submission_text": "Summary: add validation\n\nRefs #319\n\nValidation: pytest passed", + "bounties": [ + { + "number": 319, + "state": "OPEN", + "awards_remaining": 1, + "active_attempts": [], + "active_attempts_verified": True, + } + ], + "pull_requests": [], + } + ) + + assert result["status"] == "pass" + assert result["active_attempts"] == [] + assert { + "name": "active_attempts", + "status": "pass", + "message": "no active attempts found for bounty #319", + } in result["checks"] + + +def test_submission_quality_gate_warns_for_one_active_attempt() -> None: + result = evaluate_submission( + { + "submission_text": "Summary: add validation\n\nRefs #319\n\nValidation: pytest passed", + "bounties": [ + { + "number": 319, + "state": "OPEN", + "awards_remaining": 1, + "active_attempts": [ + { + "submitter": "github:agent-one", + "source_url": "https://github.com/ramimbo/mergework/pull/12", + "status": "active", + "expires_at": "2026-05-27T00:00:00Z", + } + ], + "active_attempts_verified": True, + } + ], + "pull_requests": [], + } + ) + + assert result["status"] == "warn" + assert result["active_attempts"] == [ + { + "submitter": "github:agent-one", + "source_url": "https://github.com/ramimbo/mergework/pull/12", + "status": "active", + "expires_at": "2026-05-27T00:00:00Z", + } + ] + assert { + "name": "active_attempts", + "status": "warn", + "message": "1 active attempt(s) already exist for bounty #319", + } in result["checks"] + + +def test_submission_quality_gate_warns_for_multiple_active_attempts() -> None: + result = evaluate_submission( + { + "submission_text": "Summary: add validation\n\nRefs #319\n\nValidation: pytest passed", + "bounties": [ + { + "number": 319, + "state": "OPEN", + "awards_remaining": 1, + "active_attempts": [ + {"submitter": "github:agent-one", "status": "active"}, + {"submitter": "github:agent-two", "status": "active"}, + ], + "active_attempts_verified": True, + } + ], + "pull_requests": [], + } + ) + + assert result["status"] == "warn" + assert { + "name": "active_attempts", + "status": "warn", + "message": "2 active attempt(s) already exist for bounty #319", + } in result["checks"] + + +def test_submission_quality_gate_warns_when_active_attempts_unavailable() -> None: + result = evaluate_submission( + { + "submission_text": "Summary: add validation\n\nRefs #319\n\nValidation: pytest passed", + "bounties": [ + { + "number": 319, + "state": "OPEN", + "awards_remaining": 1, + "active_attempts": [], + "active_attempts_verified": False, + } + ], + "pull_requests": [], + } + ) + + assert result["status"] == "warn" + assert { + "name": "active_attempts", + "status": "warn", + "message": "active attempts for bounty #319 could not be verified", + } in result["checks"] + + def test_submission_quality_gate_warns_for_missing_evidence() -> None: result = evaluate_submission( { @@ -330,6 +449,56 @@ def fake_run(args, **kwargs): } in result["checks"] +def test_submission_quality_gate_live_context_warns_when_attempt_id_missing( + monkeypatch, +) -> None: + def fake_run(args, **kwargs): + if args[:3] == ["gh", "pr", "list"]: + return subprocess.CompletedProcess(args=args, returncode=0, stdout="[]", stderr="") + if args[:3] == ["gh", "issue", "list"]: + return subprocess.CompletedProcess( + args=args, + returncode=0, + stdout=json.dumps([{"number": 319, "title": "MRWK bounty: gate", "state": "OPEN"}]), + stderr="", + ) + if args[:3] == ["gh", "issue", "view"]: + return subprocess.CompletedProcess( + args=args, + returncode=0, + stdout=json.dumps({"createdAt": "2026-05-20T00:00:00Z", "comments": []}), + stderr="", + ) + raise AssertionError(args) + + monkeypatch.setattr(submission_quality_gate.subprocess, "run", fake_run) + monkeypatch.setattr( + submission_quality_gate, + "_load_api_bounties", + lambda repo, api_host: {319: {"number": 319, "state": "OPEN", "awards_remaining": 1}}, + ) + + data = submission_quality_gate._load_live_context( + "ramimbo/mergework", + "Summary: work\n\nRefs #319\n\nValidation: pytest passed", + "https://api.example.test", + ) + result = evaluate_submission(data) + + assert data["bounties"][0]["active_attempts"] == [] + assert data["bounties"][0]["active_attempts_verified"] is False + assert ( + "active attempts unavailable for bounty #319: " + "MergeWork API bounty id unavailable for attempts lookup" + ) in data["load_warning"] + assert result["status"] == "warn" + assert { + "name": "active_attempts", + "status": "warn", + "message": "active attempts for bounty #319 could not be verified", + } in result["checks"] + + def test_submission_quality_gate_live_context_adds_maintainer_activity(monkeypatch) -> None: def fake_run(args, **kwargs): if args[:3] == ["gh", "pr", "list"]: @@ -369,7 +538,12 @@ def fake_run(args, **kwargs): monkeypatch.setattr( submission_quality_gate, "_load_api_bounties", - lambda repo, api_host: {319: {"number": 319, "state": "OPEN", "awards_remaining": 1}}, + lambda repo, api_host: { + 319: {"id": 11, "number": 319, "state": "OPEN", "awards_remaining": 1} + }, + ) + monkeypatch.setattr( + submission_quality_gate, "_load_api_attempts", lambda api_host, bounty_id: [] ) data = submission_quality_gate._load_live_context( @@ -429,7 +603,12 @@ def fake_run(args, **kwargs): monkeypatch.setattr( submission_quality_gate, "_load_api_bounties", - lambda repo, api_host: {319: {"number": 319, "state": "OPEN", "awards_remaining": 1}}, + lambda repo, api_host: { + 319: {"id": 11, "number": 319, "state": "OPEN", "awards_remaining": 1} + }, + ) + monkeypatch.setattr( + submission_quality_gate, "_load_api_attempts", lambda api_host, bounty_id: [] ) data = submission_quality_gate._load_live_context(