feat: auto-release status:in-progress on close and merge (#58)
When Gitea-Tools closes an issue, merges a PR, or closes a PR without merging, remove status:in-progress from the affected issue(s) so closed work doesn't retain stale in-progress state (as happened with #46/#48). Helpers (deterministic, unit-testable) in mcp_server.py: - extract_linked_issue_numbers(text, branch_name): pure; matches Closes/Fixes/Resolves/Refs #N and issue-<N> in a branch name (not review/pr-N). - release_in_progress_label(issue_numbers, repo_context): removes ONLY status:in-progress; absent label / undefined repo label = no-op success; per-issue failures recorded in `failed` (never falsely reported clean); emits an audit record per removal when auditing is enabled. - cleanup_in_progress_for_pr(pr_payload, repo_context): links from PR title/body + head branch, then releases; empty when nothing confidently linked (no guessing, no mutation). Wired into: - gitea_close_issue → releases from that issue. - gitea_merge_pr → releases from PR-linked issues (reuses the read-back PR). - gitea_edit_pr (state=closed) → releases from PR-linked issues WITHOUT closing them. Each returns the cleanup summary under `in_progress_cleanup`. Only necessary label GET/DELETE calls added; no auth/profile/retry/config/worktree/release-tag changes; backwards compatible. Tests: tests/test_in_progress_cleanup.py (18 cases) — extraction, present/absent label, no-issues/no-link no-op, multiple issues, failure reported, label not defined, only-status-removed, PR keyword/branch links, close-without-merge does not close the issue, and audit records the removal. Updated TestCloseIssue for the added release calls. Full suite 322 passed / 0 failures; py_compile clean; git diff --check clean; no secrets. Closes #58. Refs #46, #48. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+126
-2
@@ -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 #<number>. 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-<N> 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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user