diff --git a/mcp_server.py b/mcp_server.py index ae7e711..d152adb 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -14,6 +14,7 @@ Configuration (mcp_config.json): } """ import os +import re import sys import functools import contextlib @@ -211,6 +212,102 @@ def _audit_pr_result(action: str): return decorate +# ── status:in-progress auto-release (#58) ────────────────────────────────────── +# When Gitea-Tools closes an issue, or merges/closes a linked PR, drop the +# status:in-progress label from the affected issue(s). Deterministic + testable; +# the only network calls are the label lookup/removal on the relevant issues. + +IN_PROGRESS_LABEL = "status:in-progress" + +# "Closes/Closed/Close", "Fixes/Fixed/Fix", "Resolves/Resolved/Resolve", +# "Refs/Ref" followed by #. Refs is treated as linked work per project +# convention (Closes closes the issue; Refs links without closing). +_LINK_RE = re.compile( + r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|ref[s]?)\s+#(\d+)", re.IGNORECASE +) +# Branch names like fix/issue-123-slug, feat/issue-123-slug (NOT review/pr-456). +_BRANCH_ISSUE_RE = re.compile(r"(?:^|[/-])issue-(\d+)") + + +def extract_linked_issue_numbers(text, branch_name=None): + """Return a sorted list of unique issue numbers linked by *text*/*branch_name*. + + Pure: no I/O. Matches Closes/Fixes/Resolves/Refs #N in text and issue- in + a branch name. Guesses nothing beyond these explicit conventions. + """ + nums = set() + if text: + nums.update(int(m.group(1)) for m in _LINK_RE.finditer(text)) + if branch_name: + nums.update(int(m.group(1)) for m in _BRANCH_ISSUE_RE.finditer(branch_name)) + return sorted(nums) + + +def release_in_progress_label(issue_numbers, repo_context, *, api_request=api_request): + """Remove IN_PROGRESS_LABEL from each issue in *issue_numbers*. + + *repo_context* provides base/auth (+ optional host/remote/org/repo/trigger for + audit). Returns a dict: + {linked_issues, released[], absent[], failed{n: reason}} + Absent label is a no-op success. Only IN_PROGRESS_LABEL is ever removed. A + failed removal is recorded in ``failed`` (never falsely reported clean). + """ + result = {"linked_issues": list(issue_numbers), "released": [], + "absent": [], "failed": {}} + if not issue_numbers: + return result + base = repo_context["base"] + auth = repo_context["auth"] + try: + labels = api_request("GET", f"{base}/labels?limit=100", auth) or [] + label_id = next( + (lb["id"] for lb in labels if lb.get("name") == IN_PROGRESS_LABEL), None) + except Exception as exc: # noqa: BLE001 + result["failed"] = {n: _redact(str(exc)) for n in issue_numbers} + return result + if label_id is None: + # Label not defined in the repo → nothing to remove. + result["absent"] = list(issue_numbers) + return result + for n in issue_numbers: + try: + current = api_request("GET", f"{base}/issues/{n}/labels", auth) or [] + if not any(lb.get("name") == IN_PROGRESS_LABEL for lb in current): + result["absent"].append(n) + continue + api_request("DELETE", f"{base}/issues/{n}/labels/{label_id}", auth) + result["released"].append(n) + _audit("unlabel_issue", host=repo_context.get("host"), + remote=repo_context.get("remote"), org=repo_context.get("org"), + repo=repo_context.get("repo"), result=gitea_audit.SUCCEEDED, + issue_number=n, + request_metadata={"op": "remove", "label": IN_PROGRESS_LABEL, + "trigger": repo_context.get("trigger")}) + except Exception as exc: # noqa: BLE001 + result["failed"][n] = _redact(str(exc)) + _audit("unlabel_issue", host=repo_context.get("host"), + remote=repo_context.get("remote"), org=repo_context.get("org"), + repo=repo_context.get("repo"), result=gitea_audit.FAILED, + issue_number=n, reason=_redact(str(exc)), + request_metadata={"op": "remove", "label": IN_PROGRESS_LABEL, + "trigger": repo_context.get("trigger")}) + return result + + +def cleanup_in_progress_for_pr(pr_payload, repo_context, *, api_request=api_request): + """Release IN_PROGRESS_LABEL from issues linked by a PR payload. + + Reads the PR title/body and head branch to find linked issues, then releases + the label. Returns the release-result dict (with empty ``linked_issues`` when + nothing could be confidently linked — no guessing, no mutation). + """ + pr_payload = pr_payload or {} + text = " ".join([pr_payload.get("title") or "", pr_payload.get("body") or ""]) + branch = (pr_payload.get("head") or {}).get("ref") + issues = extract_linked_issue_numbers(text, branch) + return release_in_progress_label(issues, repo_context, api_request=api_request) + + # ── Tools ───────────────────────────────────────────────────────────────────── @mcp.tool() @@ -743,7 +840,7 @@ def gitea_edit_pr( with _audited("edit_pr", host=h, remote=remote, org=o, repo=r, pr_number=pr_number, request_metadata={"fields": sorted(payload)}): data = api_request("PATCH", url, auth, payload) - return { + out = { "success": True, "number": data["number"], "title": data["title"], @@ -751,6 +848,14 @@ def gitea_edit_pr( "state": data["state"], "url": data["html_url"], } + # If this closed the PR (without merging), release status:in-progress from + # its linked issues — but do NOT close those issues. + if payload.get("state") == "closed": + out["in_progress_cleanup"] = cleanup_in_progress_for_pr( + data, {"base": repo_api_url(h, o, r), "auth": auth, "host": h, + "remote": remote, "org": o, "repo": r, "trigger": "close_pr"}, + api_request=api_request) + return out @mcp.tool() @@ -1030,6 +1135,19 @@ def gitea_merge_pr( result["performed"] = True result["merge_result"] = f"PR #{pr_number} merged via '{do}'." reasons.append(f"all gates passed; merged PR #{pr_number} via '{do}'") + + # Release status:in-progress from issues linked by the merged PR. Reuse the + # read-back PR dict (it is the full PR); only re-fetch if it is unavailable. + pr_full = merged if isinstance(merged, dict) else None + if pr_full is None: + try: + pr_full = api_request("GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) + except Exception: # noqa: BLE001 + pr_full = None + result["in_progress_cleanup"] = cleanup_in_progress_for_pr( + pr_full, {"base": repo_api_url(h, o, r), "auth": auth, "host": h, + "remote": remote, "org": o, "repo": r, "trigger": "merge_pr"}, + api_request=api_request) return result @@ -1157,7 +1275,13 @@ def gitea_close_issue( with _audited("close_issue", host=h, remote=remote, org=o, repo=r, issue_number=issue_number, request_metadata={"state": "closed"}): api_request("PATCH", url, auth, {"state": "closed"}) - return {"success": True, "message": f"Issue #{issue_number} closed."} + cleanup = release_in_progress_label( + [issue_number], + {"base": repo_api_url(h, o, r), "auth": auth, "host": h, "remote": remote, + "org": o, "repo": r, "trigger": "close_issue"}, + api_request=api_request) + return {"success": True, "message": f"Issue #{issue_number} closed.", + "in_progress_cleanup": cleanup} @mcp.tool() diff --git a/tests/test_in_progress_cleanup.py b/tests/test_in_progress_cleanup.py new file mode 100644 index 0000000..91987dd --- /dev/null +++ b/tests/test_in_progress_cleanup.py @@ -0,0 +1,188 @@ +"""Tests for status:in-progress auto-release on close/merge (#58). + +Covers the deterministic helpers (extract_linked_issue_numbers, +release_in_progress_label, cleanup_in_progress_for_pr) with a fake api_request — +no real network, no real labels, no secrets. +""" +import os +import sys +import json +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import mcp_server as m # noqa: E402 + +BASE = "https://gitea.example.invalid/api/v1/repos/Org/Repo" +CTX = {"base": BASE, "auth": "token x", "host": "gitea.example.invalid", + "remote": "prgs", "org": "Org", "repo": "Repo", "trigger": "test"} +LABEL = "status:in-progress" + + +class FakeApi: + """Routes label lookups/removals; records calls; can fail chosen deletes.""" + + def __init__(self, repo_has_label=True, issue_labels=None, fail_delete=()): + self.repo_labels = [{"id": 10, "name": LABEL}, + {"id": 11, "name": "important"}] if repo_has_label \ + else [{"id": 11, "name": "important"}] + self.issue_labels = issue_labels or {} # {issue_no: [label names]} + self.fail_delete = set(fail_delete) + self.calls = [] + + def _issue_no(self, url): + return int(url.split("/issues/")[1].split("/")[0]) + + def __call__(self, method, url, auth=None, payload=None): + self.calls.append((method, url)) + if "/api/v1/user" in url: + return {"login": "bot"} + if method == "GET" and url.endswith("/labels?limit=100"): + return list(self.repo_labels) + if method == "GET" and "/issues/" in url and url.endswith("/labels"): + n = self._issue_no(url) + return [{"name": x} for x in self.issue_labels.get(n, [])] + if method == "DELETE" and "/issues/" in url and "/labels/" in url: + n = self._issue_no(url) + if n in self.fail_delete: + raise RuntimeError("HTTP 500: label remove failed") + return None + return {} + + def deletes(self): + return [u for (mth, u) in self.calls if mth == "DELETE"] + + +# --------------------------------------------------------------------------- +# extract_linked_issue_numbers (pure) +# --------------------------------------------------------------------------- +class TestExtract(unittest.TestCase): + + def test_keywords(self): + self.assertEqual(m.extract_linked_issue_numbers("Closes #123"), [123]) + self.assertEqual(m.extract_linked_issue_numbers("fixes #7 and Resolves #8"), [7, 8]) + self.assertEqual(m.extract_linked_issue_numbers("Refs #42"), [42]) + + def test_branch(self): + self.assertEqual( + m.extract_linked_issue_numbers("", "fix/issue-123-slug"), [123]) + self.assertEqual( + m.extract_linked_issue_numbers("", "feat/issue-9-x"), [9]) + + def test_review_branch_not_treated_as_issue(self): + self.assertEqual(m.extract_linked_issue_numbers("", "review/pr-456-x"), []) + + def test_multiple_dedup_sorted(self): + self.assertEqual( + m.extract_linked_issue_numbers("Closes #5, refs #2", "fix/issue-2-a"), + [2, 5]) + + def test_none_when_no_links(self): + self.assertEqual(m.extract_linked_issue_numbers("just a message"), []) + + +# --------------------------------------------------------------------------- +# release_in_progress_label +# --------------------------------------------------------------------------- +class TestRelease(unittest.TestCase): + + def test_removes_present_label(self): + api = FakeApi(issue_labels={123: [LABEL, "bug"]}) + r = m.release_in_progress_label([123], CTX, api_request=api) + self.assertEqual(r["released"], [123]) + self.assertEqual(r["failed"], {}) + # Only the status:in-progress label id (10) is deleted; "bug" untouched. + self.assertEqual(api.deletes(), [f"{BASE}/issues/123/labels/10"]) + + def test_absent_label_is_noop(self): + api = FakeApi(issue_labels={123: ["bug"]}) + r = m.release_in_progress_label([123], CTX, api_request=api) + self.assertEqual(r["absent"], [123]) + self.assertEqual(r["released"], []) + self.assertEqual(api.deletes(), []) # nothing removed + + def test_no_issues_is_noop(self): + api = FakeApi() + r = m.release_in_progress_label([], CTX, api_request=api) + self.assertEqual(r["linked_issues"], []) + self.assertEqual(api.calls, []) # zero network + + def test_multiple_issues(self): + api = FakeApi(issue_labels={1: [LABEL], 2: [LABEL]}) + r = m.release_in_progress_label([1, 2], CTX, api_request=api) + self.assertEqual(sorted(r["released"]), [1, 2]) + + def test_failure_reported_not_clean(self): + api = FakeApi(issue_labels={1: [LABEL], 2: [LABEL]}, fail_delete={2}) + r = m.release_in_progress_label([1, 2], CTX, api_request=api) + self.assertEqual(r["released"], [1]) + self.assertIn(2, r["failed"]) + self.assertIn("failed", r["failed"][2].lower()) + + def test_label_not_defined_in_repo(self): + api = FakeApi(repo_has_label=False, issue_labels={1: ["important"]}) + r = m.release_in_progress_label([1], CTX, api_request=api) + self.assertEqual(r["absent"], [1]) + self.assertEqual(api.deletes(), []) + + def test_only_status_label_removed(self): + api = FakeApi(issue_labels={1: [LABEL, "important", "bug"]}) + m.release_in_progress_label([1], CTX, api_request=api) + self.assertEqual(api.deletes(), [f"{BASE}/issues/1/labels/10"]) # id 10 only + + +# --------------------------------------------------------------------------- +# cleanup_in_progress_for_pr (merge / close-without-merge share this) +# --------------------------------------------------------------------------- +class TestCleanupForPr(unittest.TestCase): + + def test_closes_keyword_releases(self): + api = FakeApi(issue_labels={123: [LABEL]}) + pr = {"title": "x", "body": "Closes #123", "head": {"ref": "wip"}} + r = m.cleanup_in_progress_for_pr(pr, CTX, api_request=api) + self.assertEqual(r["released"], [123]) + + def test_branch_releases(self): + api = FakeApi(issue_labels={123: [LABEL]}) + pr = {"title": "x", "body": "", "head": {"ref": "fix/issue-123-slug"}} + r = m.cleanup_in_progress_for_pr(pr, CTX, api_request=api) + self.assertEqual(r["released"], [123]) + + def test_close_without_merge_does_not_close_issue(self): + # cleanup only ever removes a label; it never PATCHes issue state. + api = FakeApi(issue_labels={123: [LABEL]}) + pr = {"title": "", "body": "Refs #123", "head": {"ref": "wip"}} + m.cleanup_in_progress_for_pr(pr, CTX, api_request=api) + self.assertFalse(any(mth == "PATCH" for (mth, _u) in api.calls)) + + def test_no_linked_issue_no_mutation(self): + api = FakeApi() + pr = {"title": "misc", "body": "no links", "head": {"ref": "wip"}} + r = m.cleanup_in_progress_for_pr(pr, CTX, api_request=api) + self.assertEqual(r["linked_issues"], []) + self.assertEqual(api.deletes(), []) + + +# --------------------------------------------------------------------------- +# Audit records the label mutation when enabled +# --------------------------------------------------------------------------- +class TestAudit(unittest.TestCase): + + def test_audit_records_release(self): + api = FakeApi(issue_labels={7: [LABEL]}) + with tempfile.TemporaryDirectory() as d: + log = os.path.join(d, "audit.log") + env = {"GITEA_AUDIT_LOG": log, "GITEA_PROFILE_NAME": "gitea-merger"} + # get_auth_header → None so _audit resolves username without network. + with patch.dict(os.environ, env, clear=True), \ + patch("mcp_server.get_auth_header", return_value=None): + m.release_in_progress_label([7], CTX, api_request=api) + recs = [json.loads(x) for x in open(log, encoding="utf-8") if x.strip()] + self.assertTrue(any(r["action"] == "unlabel_issue" and r["issue_number"] == 7 + and r["result"] == "succeeded" for r in recs)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 85a47d9..ccc0ebc 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -89,12 +89,21 @@ class TestCloseIssue(unittest.TestCase): @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_closes_issue(self, _auth, mock_api): - mock_api.return_value = {"state": "closed"} + # PATCH close, then status:in-progress release (labels lookup, issue + # labels, DELETE) — see #58. + mock_api.side_effect = [ + {"state": "closed"}, # PATCH close + [{"id": 10, "name": "status:in-progress"}], # repo labels + [{"name": "status:in-progress"}], # issue #42 labels + None, # DELETE label + ] result = gitea_close_issue(issue_number=42) self.assertTrue(result["success"]) self.assertIn("42", result["message"]) - payload = mock_api.call_args[0][3] - self.assertEqual(payload["state"], "closed") + first = mock_api.call_args_list[0] + self.assertEqual(first.args[0], "PATCH") + self.assertEqual(first.args[3]["state"], "closed") + self.assertIn(42, result["in_progress_cleanup"]["released"]) # ---------------------------------------------------------------------------