diff --git a/docs/node-maintenance.md b/docs/node-maintenance.md index b14e682..a845bc9 100644 --- a/docs/node-maintenance.md +++ b/docs/node-maintenance.md @@ -338,7 +338,9 @@ fixes available** issue is created, updated, or reopened for the repository. Availability does not prove compatibility or release-cooldown eligibility. Review the upstream fix and everything your patch changes before upgrading, removing the patch and obsolete assertion, and rerunning. **Patches are never removed automatically.** -A complete check with no remaining notifications closes the issue; unavailable +A complete check with no remaining notifications adds a resolution comment with +the verification run and closes the issue, preserving its last findings and any +maintainer notes. Later healthy runs do not add more comments. Unavailable evidence does not close it. The reporter uses the existing `issues: write` permission without checking out or executing project code. diff --git a/node/maintenance.md b/node/maintenance.md index 1cafcbb..c80b170 100644 --- a/node/maintenance.md +++ b/node/maintenance.md @@ -96,6 +96,9 @@ optional maintainer comment, and verified official-fix availability in the **Node local patches: official fixes available** issue when a newer stable fixed release is verified. It never removes a patch or claims the release is compatible with project constraints. Missing evidence cannot close that notification. +When a complete check clears the notification, the reporter preserves the issue +body, adds a resolution comment linking to the verification run, and closes it. +Further healthy runs leave the closed issue unchanged. | Rerun result | Publisher behavior | | --- | --- | diff --git a/node/runtime/configuration_issue.py b/node/runtime/configuration_issue.py index e1c339e..8eb392a 100644 --- a/node/runtime/configuration_issue.py +++ b/node/runtime/configuration_issue.py @@ -1,6 +1,7 @@ """Maintain one bot-owned configuration issue using a data-only audit report.""" import argparse +import hashlib import html import json import os @@ -172,10 +173,68 @@ def update_issue(repository, report, run_url, request=github_request): ) -def maintain_issue(repository, *, marker, title, body, failing, request=github_request): +def ensure_resolution_comment(repository, issue, body, request): + """Reuse the bot's resolution for the same preserved finding body on retry.""" + digest = hashlib.sha256(issue["body"].encode()).hexdigest() + marker = f"" + comment_body = marker + "\n\n" + body + if len(comment_body.encode()) > MAX_ISSUE_BYTES: + raise ValueError("issue resolution comment exceeds its size limit") + for page in range(1, 101): + comments = request( + "GET", + f"repos/{repository}/issues/{issue['number']}/comments?per_page=100&page={page}", + ) + if not isinstance(comments, list) or any( + not isinstance(comment, dict) for comment in comments + ): + raise ValueError("GitHub did not return valid issue comments") + for comment in comments: + author = comment.get("user", {}) + if ( + author.get("login") == BOT + and author.get("type") == "Bot" + and isinstance(comment.get("body"), str) + and comment["body"].startswith(marker + "\n") + ): + return + if len(comments) < 100: + result = request( + "POST", + f"repos/{repository}/issues/{issue['number']}/comments", + {"body": comment_body}, + ) + if ( + not isinstance(result, dict) + or type(result.get("id")) is not int + or result["id"] <= 0 + ): + raise ValueError("GitHub did not confirm the resolution comment") + return + raise ValueError( + "Cannot prove complete resolution comment history; issue left open" + ) + + +def maintain_issue( + repository, + *, + marker, + title, + body, + failing, + request=github_request, + resolution_comment=None, +): """Reuse one bot-owned marked issue, including after closure.""" if not body.startswith(marker + "\n") or len(body.encode()) > MAX_ISSUE_BYTES: raise ValueError("invalid marked issue body") + if resolution_comment is not None and ( + not isinstance(resolution_comment, str) + or not resolution_comment.strip() + or len(resolution_comment.encode()) > MAX_ISSUE_BYTES + ): + raise ValueError("invalid issue resolution comment") issue = existing_issue(repository, request, marker) if not failing and (issue is None or issue["state"] == "closed"): return {"operation": "unchanged", "number": issue["number"] if issue else None} @@ -188,10 +247,12 @@ def maintain_issue(repository, *, marker, title, body, failing, request=github_r state = "open" if failing else "closed" if issue["state"] == state and issue["body"] == body: return {"operation": "unchanged", "number": issue["number"]} + payload = {"body": body, "state": state} + if not failing and resolution_comment is not None: + ensure_resolution_comment(repository, issue, resolution_comment, request) + payload = {"state": state} result = request( - "PATCH", - f"repos/{repository}/issues/{issue['number']}", - {"body": body, "state": state}, + "PATCH", f"repos/{repository}/issues/{issue['number']}", payload ) operation = ( "closed" diff --git a/node/runtime/local_patch_issue.py b/node/runtime/local_patch_issue.py index 600cb03..d39aa26 100644 --- a/node/runtime/local_patch_issue.py +++ b/node/runtime/local_patch_issue.py @@ -55,6 +55,17 @@ def update_issue(repository, report, run_url, request=ISSUES["github_request"]): body=body, failing=available, request=request, + resolution_comment=( + "Local-patch notification resolved.\n\n" + + ( + "No local patch declarations remain in the project configuration.\n\n" + if not report["patches"] + else "The latest complete audit has no declared local patch with a verified newer official fix requiring notification.\n\n" + + preview + ) + + "The original findings above are preserved as history. This does not certify local patches or resolve unrelated security findings.\n\n" + + f"[Verification run]({run_url})" + ), ) diff --git a/node/tests/test_configuration_issue.py b/node/tests/test_configuration_issue.py index 58ebf32..8aad4f3 100644 --- a/node/tests/test_configuration_issue.py +++ b/node/tests/test_configuration_issue.py @@ -34,6 +34,7 @@ class GitHub: def __init__(self, issues=None): self.issues = copy.deepcopy(issues or []) self.calls = [] + self.comments = [] def request(self, method, path, payload=None): self.calls.append((method, path, copy.deepcopy(payload))) @@ -41,9 +42,15 @@ def request(self, method, path, payload=None): if method == "GET": query = urllib.parse.parse_qs(urllib.parse.urlsplit(path).query) page = int(query["page"][0]) + if "/comments?" in path: + return copy.deepcopy(self.comments[(page - 1) * 100 : page * 100]) assert query["state"] == ["all"] and query["creator"] == [BOT["login"]] return copy.deepcopy(self.issues[(page - 1) * 100 : page * 100]) if method == "POST": + if path.endswith("/comments"): + comment = {"id": len(self.comments) + 1, "user": BOT, **payload} + self.comments.append(comment) + return copy.deepcopy(comment) issue = {"number": 77, "user": BOT, "state": "open", **payload} self.issues.append(issue) return copy.deepcopy(issue) diff --git a/node/tests/test_local_patches.py b/node/tests/test_local_patches.py index 7440595..235be29 100644 --- a/node/tests/test_local_patches.py +++ b/node/tests/test_local_patches.py @@ -251,13 +251,156 @@ def update(value, run=1): ) assert update(value)["operation"] == "reopened" assert len(github.issues) == 1 - assert sum(method == "POST" for method, _, _ in github.calls) == 1 + assert ( + sum( + method == "POST" and not path.endswith("/comments") + for method, path, _ in github.calls + ) + == 1 + ) assert ( "|" in github.issues[0]["body"] and "