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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user