diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 4ce1657..1702865 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -214,8 +214,9 @@ 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 draft includes a concise summary and validation evidence, and whether a -similar open PR already references the same bounty. When live GitHub or +the bounty has recent maintainer activity, whether the draft includes a concise +summary and validation evidence, 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. @@ -224,7 +225,8 @@ Results: - `PASS`: the draft has the expected reference, summary, evidence, and no obvious duplicate from the available GitHub data. - `WARN`: the draft may still be valid, but agents should fix missing evidence, - add a clearer summary, or inspect similar open PRs before submitting. + add a clearer summary, inspect similar open PRs, or confirm a stale bounty + round still has maintainer activity before submitting. - `FAIL`: do not submit until the missing bounty reference or closed/exhausted bounty reference is fixed. diff --git a/scripts/submission_quality_gate.py b/scripts/submission_quality_gate.py index a4aaa8e..22e67fe 100644 --- a/scripts/submission_quality_gate.py +++ b/scripts/submission_quality_gate.py @@ -5,6 +5,7 @@ import re import subprocess import sys +from datetime import UTC, datetime, timedelta from difflib import SequenceMatcher from typing import Any from urllib.error import URLError @@ -18,6 +19,8 @@ SUMMARY_RE = re.compile(r"\b(summary|what changed|changes?)\b", re.IGNORECASE) GH_TIMEOUT_SECONDS = 30 DEFAULT_API_HOST = "https://api.mrwk.ltclab.site" +DEFAULT_MAX_MAINTAINER_AGE_DAYS = 14 +MAINTAINER_ASSOCIATIONS = {"OWNER", "MEMBER", "COLLABORATOR"} def _check(name: str, status: str, message: str) -> dict[str, str]: @@ -44,6 +47,60 @@ def _bounty_payability_verified(raw: dict[str, Any]) -> bool: return raw.get("payability_verified", True) is not False +def _parse_datetime(value: Any) -> datetime | None: + if not isinstance(value, str) or not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def _isoformat_utc(value: datetime) -> str: + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def _current_time(data: dict[str, Any]) -> datetime: + return _parse_datetime(data.get("now")) or datetime.now(UTC) + + +def _maintainer_activity_check( + bounty_ref: int, bounty: dict[str, Any], now: datetime +) -> dict[str, str] | None: + if "last_maintainer_activity_at" not in bounty and "maintainer_activity_verified" not in bounty: + return None + if bounty.get("maintainer_activity_verified") is False: + return _check( + "maintainer_activity", + "warn", + f"recent maintainer activity for bounty #{bounty_ref} could not be verified", + ) + last_activity = _parse_datetime(bounty.get("last_maintainer_activity_at")) + if last_activity is None: + return _check( + "maintainer_activity", + "warn", + f"recent maintainer activity for bounty #{bounty_ref} could not be verified", + ) + max_age_days = int(bounty.get("max_maintainer_age_days", DEFAULT_MAX_MAINTAINER_AGE_DAYS)) + delta = now - last_activity + age_days = max(0, int(delta.total_seconds() // 86400)) + if delta > timedelta(days=max_age_days): + return _check( + "maintainer_activity", + "warn", + f"last maintainer activity for bounty #{bounty_ref} was {age_days} days ago", + ) + return _check( + "maintainer_activity", + "pass", + f"maintainer activity for bounty #{bounty_ref} was seen {age_days} days ago", + ) + + def _title_from_submission(text: str) -> str: for line in text.splitlines(): clean = line.strip(" -:\t") @@ -104,6 +161,7 @@ def _similar_open_prs( def evaluate_submission(data: dict[str, Any]) -> dict[str, Any]: text = str(data.get("submission_text") or "") + now = _current_time(data) bounties = { int(item["number"]): item for item in data.get("bounties", []) @@ -152,6 +210,10 @@ def evaluate_submission(data: dict[str, Any]) -> dict[str, Any]: checks.append( _check("bounty_payable", "pass", f"referenced bounty #{bounty_ref} is open") ) + if bounty is not None: + activity_check = _maintainer_activity_check(bounty_ref, bounty, now) + if activity_check is not None: + checks.append(activity_check) if SUMMARY_RE.search(text): checks.append(_check("summary_present", "pass", "summary text found")) @@ -219,6 +281,39 @@ def _run_gh_json(args: list[str]) -> Any: return json.loads(completed.stdout) +def _load_issue_maintainer_activity(repo: str, issue_number: int) -> dict[str, Any]: + issue = _run_gh_json( + [ + "gh", + "issue", + "view", + str(issue_number), + "--repo", + repo, + "--json", + "author,comments,createdAt", + ] + ) + activity_times = [] + repo_owner = repo.split("/", 1)[0].lower() + issue_author = str((issue.get("author") or {}).get("login") or "").lower() + created_at = _parse_datetime(issue.get("createdAt")) + if issue_author == repo_owner and created_at is not None: + activity_times.append(created_at) + for comment in issue.get("comments") or []: + if str(comment.get("authorAssociation") or "").upper() not in MAINTAINER_ASSOCIATIONS: + continue + created_at = _parse_datetime(comment.get("createdAt")) + if created_at is not None: + activity_times.append(created_at) + if not activity_times: + return {"maintainer_activity_verified": False} + return { + "maintainer_activity_verified": True, + "last_maintainer_activity_at": _isoformat_utc(max(activity_times)), + } + + def _load_api_bounties(repo: str, api_host: str) -> dict[int, dict[str, Any]]: url = f"{api_host.rstrip('/')}/api/v1/bounties?status=open" try: @@ -243,7 +338,12 @@ def _load_api_bounties(repo: str, api_host: str) -> dict[int, dict[str, Any]]: return bounties -def _load_live_context(repo: str, submission_text: str, api_host: str) -> dict[str, Any]: +def _load_live_context( + repo: str, + submission_text: str, + api_host: str, + max_maintainer_age_days: int = DEFAULT_MAX_MAINTAINER_AGE_DAYS, +) -> dict[str, Any]: load_warnings: list[str] = [] try: prs = _run_gh_json( @@ -288,6 +388,7 @@ def _load_live_context(repo: str, submission_text: str, api_host: str) -> dict[s except RuntimeError as exc: api_bounties = {} load_warnings.append(str(exc)) + referenced_bounties = set(_bounty_refs(submission_text)) bounties = [] for issue in issues: if "bounty" not in str(issue.get("title", "")).lower(): @@ -304,6 +405,15 @@ def _load_live_context(repo: str, submission_text: str, api_host: str) -> dict[s and awards_remaining is not None, } ) + if issue["number"] in referenced_bounties: + try: + bounties[-1].update(_load_issue_maintainer_activity(repo, issue["number"])) + bounties[-1]["max_maintainer_age_days"] = max_maintainer_age_days + except (RuntimeError, FileNotFoundError, json.JSONDecodeError) as exc: + bounties[-1]["maintainer_activity_verified"] = False + load_warnings.append( + f"maintainer activity unavailable for bounty #{issue['number']}: {exc}" + ) data = {"submission_text": submission_text, "bounties": bounties, "pull_requests": prs} if load_warnings: data["load_warning"] = "; ".join(load_warnings) @@ -340,6 +450,12 @@ def main(argv: list[str] | None = None) -> int: source.add_argument("--text-file", help="Read submission text and live context with gh.") parser.add_argument("--repo", default="ramimbo/mergework") parser.add_argument("--api-host", default=DEFAULT_API_HOST) + parser.add_argument( + "--max-maintainer-age-days", + type=int, + default=DEFAULT_MAX_MAINTAINER_AGE_DAYS, + help="Warn when the referenced bounty has no maintainer activity within this many days.", + ) parser.add_argument("--format", choices=["json", "text"], default="text") args = parser.parse_args(argv) @@ -347,7 +463,12 @@ def main(argv: list[str] | None = None) -> int: data = _load_input(args.input) else: with open(args.text_file, encoding="utf-8") as handle: - data = _load_live_context(args.repo, handle.read(), args.api_host) + data = _load_live_context( + args.repo, + handle.read(), + args.api_host, + args.max_maintainer_age_days, + ) result = evaluate_submission(data) if data.get("load_warning"): result["load_warning"] = data["load_warning"] diff --git a/tests/test_submission_quality_gate.py b/tests/test_submission_quality_gate.py index 331c039..d3ab255 100644 --- a/tests/test_submission_quality_gate.py +++ b/tests/test_submission_quality_gate.py @@ -124,6 +124,87 @@ def test_submission_quality_gate_warns_for_similar_open_pr() -> None: ] +def test_submission_quality_gate_passes_recent_maintainer_activity() -> None: + result = evaluate_submission( + { + "submission_text": "Summary: add validation\n\nRefs #319\n\nValidation: pytest passed", + "now": "2026-05-26T12:00:00Z", + "bounties": [ + { + "number": 319, + "state": "OPEN", + "awards_remaining": 1, + "last_maintainer_activity_at": "2026-05-25T12:00:00Z", + "maintainer_activity_verified": True, + "max_maintainer_age_days": 14, + } + ], + "pull_requests": [], + } + ) + + assert result["status"] == "pass" + assert { + "name": "maintainer_activity", + "status": "pass", + "message": "maintainer activity for bounty #319 was seen 1 days ago", + } in result["checks"] + + +def test_submission_quality_gate_warns_for_stale_maintainer_activity() -> None: + result = evaluate_submission( + { + "submission_text": "Summary: add validation\n\nRefs #319\n\nValidation: pytest passed", + "now": "2026-05-26T12:00:00Z", + "bounties": [ + { + "number": 319, + "state": "OPEN", + "awards_remaining": 1, + "last_maintainer_activity_at": "2026-04-01T12:00:00Z", + "maintainer_activity_verified": True, + "max_maintainer_age_days": 14, + } + ], + "pull_requests": [], + } + ) + + assert result["status"] == "warn" + assert { + "name": "maintainer_activity", + "status": "warn", + "message": "last maintainer activity for bounty #319 was 55 days ago", + } in result["checks"] + + +def test_submission_quality_gate_warns_when_activity_exceeds_threshold_by_seconds() -> None: + result = evaluate_submission( + { + "submission_text": "Summary: add validation\n\nRefs #319\n\nValidation: pytest passed", + "now": "2026-05-16T12:00:01Z", + "bounties": [ + { + "number": 319, + "state": "OPEN", + "awards_remaining": 1, + "last_maintainer_activity_at": "2026-05-02T12:00:00Z", + "maintainer_activity_verified": True, + "max_maintainer_age_days": 14, + } + ], + "pull_requests": [], + } + ) + + assert result["status"] == "warn" + assert { + "name": "maintainer_activity", + "status": "warn", + "message": "last maintainer activity for bounty #319 was 14 days ago", + } in result["checks"] + + def test_submission_quality_gate_cli_returns_failure_exit(capsys, tmp_path) -> None: fixture = { "submission_text": "Summary: missing reference\n\nValidation: pytest passed", @@ -169,6 +250,13 @@ def fake_run(args, **kwargs): 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) @@ -199,6 +287,128 @@ def fake_run(args, **kwargs): } 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"]: + 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( + { + "author": {"login": "ramimbo"}, + "createdAt": "2026-05-20T00:00:00Z", + "comments": [ + { + "authorAssociation": "OWNER", + "createdAt": "2026-05-25T12:00:00Z", + }, + { + "authorAssociation": "CONTRIBUTOR", + "createdAt": "2026-05-25T13:00:00Z", + }, + ], + } + ), + 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", + 14, + ) + data["now"] = "2026-05-26T12:00:00Z" + + assert data["bounties"][0]["maintainer_activity_verified"] is True + assert data["bounties"][0]["last_maintainer_activity_at"] == "2026-05-25T12:00:00Z" + + result = evaluate_submission(data) + assert result["status"] == "pass" + assert { + "name": "maintainer_activity", + "status": "pass", + "message": "maintainer activity for bounty #319 was seen 1 days ago", + } in result["checks"] + + +def test_submission_quality_gate_live_context_accepts_member_comments( + 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( + { + "author": {"login": "someone_else"}, + "createdAt": "2026-05-20T00:00:00Z", + "comments": [ + { + "authorAssociation": "MEMBER", + "createdAt": "2026-05-25T12:00:00Z", + } + ], + } + ), + 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", + 14, + ) + data["now"] = "2026-05-26T12:00:00Z" + + assert data["bounties"][0]["maintainer_activity_verified"] is True + assert data["bounties"][0]["last_maintainer_activity_at"] == "2026-05-25T12:00:00Z" + + result = evaluate_submission(data) + assert result["status"] == "pass" + assert { + "name": "maintainer_activity", + "status": "pass", + "message": "maintainer activity for bounty #319 was seen 1 days ago", + } in result["checks"] + + def test_submission_quality_gate_warns_when_live_payability_is_unverified( monkeypatch, capsys, tmp_path ) -> None: @@ -212,6 +422,13 @@ def fake_run(args, **kwargs): 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) def fake_load_api_bounties(repo, api_host): @@ -235,6 +452,11 @@ def fake_load_api_bounties(repo, api_host): "status": "warn", "message": "referenced bounty #319 payability could not be verified", } in output["checks"] + assert { + "name": "maintainer_activity", + "status": "warn", + "message": "recent maintainer activity for bounty #319 could not be verified", + } in output["checks"] assert main(["--text-file", str(text_path), "--format", "text"]) == 0 text_output = capsys.readouterr().out @@ -277,6 +499,13 @@ def fake_run(args, **kwargs): 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)