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:
2026-07-02 06:00:34 -04:00
parent 6089ec724a
commit 7c2aabf6ac
3 changed files with 326 additions and 5 deletions
+126 -2
View File
@@ -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()