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
+143
View File
@@ -484,6 +484,78 @@ def build_gitea_issue_body(inc: NormalizedIncident) -> str:
return "\n".join(lines)
def incident_recurred(
existing: dict[str, Any], inc: NormalizedIncident
) -> tuple[bool, str]:
"""Did new provider events arrive since the existing link was last synced?
AC4 asks for a recurrence comment when events *continue*, so a scan that
observes no new events must stay silent instead of re-posting the same
state on every pass.
"""
old_count = existing.get("event_count")
new_count = inc.event_count
if (
isinstance(old_count, int)
and isinstance(new_count, int)
and new_count > old_count
):
return True, f"event_count advanced {old_count} -> {new_count}"
old_seen = str(existing.get("last_seen") or "").strip()
new_seen = str(inc.last_seen or "").strip()
if new_seen and new_seen != old_seen:
return True, f"last_seen advanced '{old_seen}' -> '{new_seen}'"
return False, "no new provider events since the last sync"
def build_recurrence_comment_body(
inc: NormalizedIncident, existing: dict[str, Any], *, reason: str = ""
) -> str:
"""Sanitized recurrence comment for an already-linked Gitea issue (AC4).
Uses the same redaction path as :func:`build_gitea_issue_body`; never
carries tokens, raw paths, or session state.
"""
lines = [
"## Observability incident recurrence (bridge #612)",
"",
"<!-- mcp-incident-bridge:recurrence:v1 -->",
f"<!-- provider={inc.provider} issue_id={inc.provider_issue_id} -->",
"",
f"Continued `{inc.provider}` events for this linked incident.",
"",
f"- **provider_issue_id:** `{inc.provider_issue_id}`",
]
if inc.provider_short_id:
lines.append(f"- **provider_short_id:** `{inc.provider_short_id}`")
if inc.provider_permalink:
lines.append(f"- **provider_url:** {inc.provider_permalink}")
lines.extend(
[
f"- **event_count:** `{existing.get('event_count')}` -> "
f"`{inc.event_count if inc.event_count is not None else ''}`",
f"- **first_seen:** `{inc.first_seen or ''}`",
f"- **last_seen:** `{inc.last_seen or ''}`",
f"- **environment:** `{inc.environment or ''}`",
f"- **severity:** `{inc.severity or ''}`",
f"- **culprit:** `{inc.culprit or ''}`",
f"- **status:** `{inc.status}`",
f"- **recurrence_basis:** `{reason}`",
"",
"### Latest summary",
"",
redact_text(inc.summary) or "(no summary)",
"",
"### Canonical next action",
"",
"Author: this incident is still firing — investigate under the "
"normal Gitea workflow. This comment records observability "
"recurrence only and changes no workflow state.",
]
)
return "\n".join(lines)
def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | None:
"""Fail closed if existing link targets a different Gitea issue/repo."""
eg_org = str(existing.get("gitea_org") or "")
@@ -514,6 +586,9 @@ def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | N
CreateIssueFn = Callable[[str, str, list[str], str, str], dict[str, Any]]
# create_issue_fn(title, body, labels, gitea_org, gitea_repo) -> {"number": int, ...}
CommentIssueFn = Callable[[int, str, str, str], dict[str, Any]]
# comment_issue_fn(gitea_issue_number, body, gitea_org, gitea_repo) -> {"success": bool, ...}
def reconcile_incident(
db: ControlPlaneDB | None,
@@ -523,6 +598,7 @@ def reconcile_incident(
mapping: ProjectMapping | None = None,
apply: bool = False,
create_issue_fn: CreateIssueFn | None = None,
comment_issue_fn: CommentIssueFn | None = None,
force_gitea_issue_number: int | None = None,
) -> dict[str, Any]:
"""Reconcile one observation into incident_links + optional Gitea issue.
@@ -531,6 +607,11 @@ def reconcile_incident(
*apply=True*: upsert link; create Gitea issue when none linked (requires
``create_issue_fn``) or use ``force_gitea_issue_number`` for explicit link.
When an existing link is reused and the provider reports *new* events,
``comment_issue_fn`` posts a sanitized recurrence comment on the linked
Gitea issue (AC4). Dry runs never comment, and a missing
``comment_issue_fn`` withholds the comment without failing the link.
Never creates control-plane ``work_items`` for raw incidents.
"""
base: dict[str, Any] = {
@@ -549,6 +630,7 @@ def reconcile_incident(
"gitea_issue": None,
"action": None,
"mapping": None,
"recurrence_comment": None,
"substrate": "control_plane_db.incident_links",
"durable_work_system": "gitea_issues",
}
@@ -652,10 +734,14 @@ def reconcile_incident(
# --- apply path ---
issue_number: int | None = None
created = False
recurrence: tuple[bool, str] | None = None
if existing:
issue_number = int(existing["gitea_issue_number"])
action = "updated_existing_link"
outcome = OUTCOME_UPDATED
# Compare against the pre-upsert link row: the upsert below overwrites
# event_count/last_seen, which would erase the recurrence signal.
recurrence = incident_recurred(existing, inc)
elif force_gitea_issue_number is not None:
issue_number = int(force_gitea_issue_number)
action = "link_explicit_issue"
@@ -729,6 +815,63 @@ def reconcile_incident(
}
return base
# AC4: continued provider events post a recurrence comment on the linked
# Gitea issue. The durable incident_links row is already written above, so
# a comment failure never rolls back or blocks the mapping — the next scan
# retries while the link stays authoritative.
if outcome == OUTCOME_UPDATED and recurrence is not None:
recurred, why = recurrence
if not recurred:
base["recurrence_comment"] = {"posted": False, "reason": why}
elif comment_issue_fn is None:
base["recurrence_comment"] = {
"posted": False,
"reason": (
"no comment_issue_fn supplied; recurrence comment withheld "
"(link remains durable)"
),
"recurrence_basis": why,
}
else:
try:
comment_res = comment_issue_fn(
issue_number,
build_recurrence_comment_body(inc, existing, reason=why),
inc.gitea_org,
inc.gitea_repo,
)
except Exception as exc: # noqa: BLE001 - never break the link write
base["recurrence_comment"] = {
"posted": False,
"reason": (
f"recurrence comment failed: {redact_text(exc)} "
"(link remains durable)"
),
"recurrence_basis": why,
}
else:
posted = (
bool(comment_res.get("success"))
if isinstance(comment_res, dict)
else bool(comment_res)
)
base["recurrence_comment"] = {
"posted": posted,
"recurrence_basis": why,
"gitea_issue_number": issue_number,
"comment_id": (
comment_res.get("comment_id")
if isinstance(comment_res, dict)
else None
),
}
if posted:
base["gitea_mutated"] = True
elif isinstance(comment_res, dict):
base["recurrence_comment"]["reasons"] = [
redact_text(r) for r in (comment_res.get("reasons") or [])
]
base["success"] = True
base["performed"] = True
base["db_mutated"] = True