fix: post AC4 recurrence comments on linked incident issues (#607)

Remediates review 479/481 findings F1 and F2 on PR #767.

F1: issue #607 AC4 requires that an existing linked Gitea issue receives a
recurrence comment when Sentry events continue. That path did not exist.
This adds:

- incident_bridge.incident_recurred() — true only when event_count or
  last_seen genuinely advanced, compared against the pre-upsert link row, so
  an unchanged rescan stays silent and the exactly-once property holds.
- incident_bridge.build_recurrence_comment_body() — reuses the same
  redact_text path as build_gitea_issue_body, so redaction is not
  re-implemented.
- A comment_issue_fn hook on reconcile_incident, invoked only on
  OUTCOME_UPDATED with genuinely new events. The durable incident_links row
  is written first, so a comment failure degrades to "link updated, comment
  withheld" without corrupting the mapping or creating a duplicate issue.
- _incident_recurrence_comment_fn() in gitea_mcp_server.py, routing through
  the sanctioned gitea_create_issue_comment path named in issue #607. It
  returns None on dry runs and when the profile lacks gitea.issue.comment, so
  the AC8 dry-run default and disabled-mode safety hold.
- comment_issue_fn threading through sentry_incident_bridge.watchdog(), with
  the per-issue recurrence_comment outcome recorded in the scan result.

F2: observation_from_issue never populates a fingerprint, so the "deduped by
Sentry issue id and fingerprint" claim was unsupported. The
gitea_sentry_reconcile_issue and gitea_sentry_watchdog docstrings now state
the real dedupe basis: provider identity (provider, base URL, org, project,
Sentry issue id).

Tests: five new AC4 cases in tests/test_sentry_incident_bridge.py covering a
comment on the second scan, dry-run silence, no-new-events silence, link
durability when the comment fails, and comment redaction. Focused suite
43 passed (was 38).

Refs #607
This commit is contained in:
2026-07-20 04:08:52 -05:00
parent 716fc21a0d
commit bc968dd2e0
4 changed files with 330 additions and 2 deletions
+65 -2
View File
@@ -18158,6 +18158,41 @@ def gitea_observability_list_projects(
}
def _incident_recurrence_comment_fn(
*,
apply: bool,
remote: str,
host: str | None,
org: str | None,
repo: str | None,
worktree_path: str | None = None,
):
"""Sanctioned issue-comment route for AC4 recurrence comments (#607).
Returns ``None`` on dry runs (never comment) and when the active profile
lacks ``gitea.issue.comment``. Withholding the callable degrades to "link
updated, no comment" rather than failing the durable mapping, and the
reconcile result records why the comment was withheld.
"""
if not apply:
return None
if _profile_operation_gate("gitea.issue.comment"):
return None
def comment_fn(issue_number, body, g_org, g_repo):
return gitea_create_issue_comment(
issue_number=int(issue_number),
body=body,
remote=remote,
host=host,
org=g_org or org,
repo=g_repo or repo,
worktree_path=worktree_path,
)
return comment_fn
@mcp.tool()
def gitea_observability_reconcile_incident(
observation_json: str,
@@ -18284,12 +18319,22 @@ def gitea_observability_reconcile_incident(
worktree_path=worktree_path,
)
comment_fn = _incident_recurrence_comment_fn(
apply=bool(apply),
remote=remote,
host=host,
org=org,
repo=repo,
worktree_path=worktree_path,
)
result = incident_bridge.reconcile_incident(
db,
observation=observation if isinstance(observation, dict) else {},
mappings=mappings,
apply=bool(apply),
create_issue_fn=create_fn,
comment_issue_fn=comment_fn,
force_gitea_issue_number=force_gitea_issue_number,
)
result["remote"] = remote
@@ -18471,6 +18516,10 @@ def gitea_sentry_reconcile_issue(
observation, then delegates dedupe/link/create to the sanctioned
``gitea_observability_reconcile_incident`` path. Dry-run by default.
Dedupe basis: provider identity provider, base URL, org, project, and
Sentry issue id. This bridge does not populate ``fingerprint``, so
fingerprint plays no part in its dedupe decisions.
Gitea remains the source of truth; the raw Sentry incident is never an
assignable control-plane work item.
"""
@@ -18626,8 +18675,14 @@ def gitea_sentry_watchdog(
Dry-run by default. ``apply=true`` additionally requires
``MCP_SENTRY_ISSUE_BRIDGE_ENABLED`` and issue-create permission. Each
incident is deduped through the #612 ``incident_links`` substrate, so a
recurring failure updates one issue instead of creating duplicates.
incident is deduped through the #612 ``incident_links`` substrate by
provider identity (provider, base URL, org, project, Sentry issue id), so
a recurring failure updates one issue instead of creating duplicates.
When a linked issue sees continued events, a sanitized recurrence comment
is posted through ``gitea_create_issue_comment`` (AC4). That requires
``gitea.issue.comment``; without it the link still updates and the result
records that the comment was withheld.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
@@ -18724,6 +18779,14 @@ def gitea_sentry_watchdog(
limit=limit,
max_pages=max_pages,
create_issue_fn=create_fn,
comment_issue_fn=_incident_recurrence_comment_fn(
apply=bool(apply),
remote=remote,
host=host,
org=org,
repo=repo,
worktree_path=worktree_path,
),
)
result["remote"] = remote
return result