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:
@@ -130,7 +130,9 @@ class SentryBridgeTestCase(unittest.TestCase):
|
||||
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
|
||||
self.db = ControlPlaneDB(self.db_path)
|
||||
self.created: list[dict] = []
|
||||
self.comments: list[dict] = []
|
||||
self._next_issue_number = 900
|
||||
self._next_comment_id = 5000
|
||||
|
||||
def _create_issue_fn(self):
|
||||
def create_fn(title, body, labels, g_org, g_repo):
|
||||
@@ -149,6 +151,22 @@ class SentryBridgeTestCase(unittest.TestCase):
|
||||
|
||||
return create_fn
|
||||
|
||||
def _comment_issue_fn(self):
|
||||
def comment_fn(issue_number, body, g_org, g_repo):
|
||||
self._next_comment_id += 1
|
||||
self.comments.append(
|
||||
{
|
||||
"issue_number": issue_number,
|
||||
"body": body,
|
||||
"org": g_org,
|
||||
"repo": g_repo,
|
||||
"comment_id": self._next_comment_id,
|
||||
}
|
||||
)
|
||||
return {"success": True, "comment_id": self._next_comment_id}
|
||||
|
||||
return comment_fn
|
||||
|
||||
def _link(self, issue_id: str = "4001"):
|
||||
return self.db.get_incident_link_by_provider(
|
||||
provider="sentry",
|
||||
@@ -167,6 +185,9 @@ class SentryBridgeTestCase(unittest.TestCase):
|
||||
mappings=[_mapping()],
|
||||
http_fn=http,
|
||||
create_issue_fn=self._create_issue_fn(),
|
||||
# Always supplied, including dry runs: the bridge itself must
|
||||
# withhold the comment when apply=False (AC4 + AC8).
|
||||
comment_issue_fn=self._comment_issue_fn(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -379,6 +400,101 @@ class TestCreateUpdateDedupe(SentryBridgeTestCase):
|
||||
self.assertEqual(result["results"][0]["outcome"], OUTCOME_UPDATED)
|
||||
self.assertEqual(self._link()["event_count"], 42)
|
||||
|
||||
def test_recurrence_posts_a_comment_on_the_second_scan(self):
|
||||
"""AC4: continued Sentry events comment on the linked Gitea issue."""
|
||||
first = self._watchdog(FakeHttp())
|
||||
self.assertEqual(first["results"][0]["outcome"], OUTCOME_CREATED)
|
||||
self.assertEqual(self.comments, [], "creation must not post a recurrence comment")
|
||||
|
||||
recurring = FakeHttp(routes=[(200, [_raw_issue("4001", count="42")], {})])
|
||||
second = self._watchdog(recurring)
|
||||
|
||||
self.assertEqual(second["results"][0]["outcome"], OUTCOME_UPDATED)
|
||||
self.assertEqual(len(self.comments), 1, "recurrence must post exactly one comment")
|
||||
comment = self.comments[0]
|
||||
linked_number = int(self._link()["gitea_issue_number"])
|
||||
self.assertEqual(comment["issue_number"], linked_number)
|
||||
self.assertEqual(comment["org"], GITEA_ORG)
|
||||
self.assertEqual(comment["repo"], GITEA_REPO)
|
||||
# AC5 fields carried on the recurrence record.
|
||||
self.assertIn("4001", comment["body"])
|
||||
self.assertIn("42", comment["body"])
|
||||
self.assertIn("recurrence_basis", comment["body"])
|
||||
reported = second["results"][0]["recurrence_comment"]
|
||||
self.assertTrue(reported["posted"])
|
||||
self.assertEqual(reported["comment_id"], comment["comment_id"])
|
||||
self.assertEqual(len(self.created), 1, "recurrence must not create a duplicate issue")
|
||||
|
||||
def test_dry_run_scan_posts_no_recurrence_comment(self):
|
||||
"""AC4 + AC8: dry run never comments, even on a linked recurrence."""
|
||||
self._watchdog(FakeHttp())
|
||||
recurring = FakeHttp(routes=[(200, [_raw_issue("4001", count="42")], {})])
|
||||
result = self._watchdog(recurring, apply=False)
|
||||
|
||||
self.assertEqual(result["results"][0]["outcome"], OUTCOME_PREVIEW)
|
||||
self.assertEqual(self.comments, [], "dry-run must not post recurrence comments")
|
||||
|
||||
def test_repeat_scan_without_new_events_posts_no_comment(self):
|
||||
"""A scan that observes no new events must stay silent."""
|
||||
self._watchdog(FakeHttp())
|
||||
result = self._watchdog(FakeHttp())
|
||||
|
||||
self.assertEqual(result["results"][0]["outcome"], OUTCOME_UPDATED)
|
||||
self.assertEqual(self.comments, [], "unchanged event state must not comment")
|
||||
self.assertFalse(result["results"][0]["recurrence_comment"]["posted"])
|
||||
|
||||
def test_recurrence_comment_failure_keeps_the_link_durable(self):
|
||||
"""A failed comment must not roll back or block the incident_links row."""
|
||||
self._watchdog(FakeHttp())
|
||||
|
||||
def failing_comment(issue_number, body, g_org, g_repo):
|
||||
raise RuntimeError("gitea comment route unavailable")
|
||||
|
||||
recurring = FakeHttp(routes=[(200, [_raw_issue("4001", count="42")], {})])
|
||||
result = bridge.watchdog(
|
||||
self.db,
|
||||
_config(),
|
||||
token=TOKEN,
|
||||
apply=True,
|
||||
mappings=[_mapping()],
|
||||
http_fn=recurring,
|
||||
create_issue_fn=self._create_issue_fn(),
|
||||
comment_issue_fn=failing_comment,
|
||||
)
|
||||
|
||||
entry = result["results"][0]
|
||||
self.assertEqual(entry["outcome"], OUTCOME_UPDATED)
|
||||
self.assertFalse(entry["recurrence_comment"]["posted"])
|
||||
self.assertEqual(self._link()["event_count"], 42, "link must still be updated")
|
||||
|
||||
def test_recurrence_comment_is_redacted(self):
|
||||
"""AC2: recurrence comments pass through the same redaction path."""
|
||||
self._watchdog(FakeHttp())
|
||||
recurring = FakeHttp(
|
||||
routes=[
|
||||
(
|
||||
200,
|
||||
[
|
||||
_raw_issue(
|
||||
"4001",
|
||||
count="42",
|
||||
metadata={
|
||||
"type": "RuntimeError",
|
||||
"value": "token=abc123supersecret",
|
||||
},
|
||||
)
|
||||
],
|
||||
{},
|
||||
)
|
||||
]
|
||||
)
|
||||
self._watchdog(recurring)
|
||||
|
||||
self.assertEqual(len(self.comments), 1)
|
||||
body = self.comments[0]["body"]
|
||||
self.assertNotIn("abc123supersecret", body)
|
||||
self.assertNotIn(TOKEN, body)
|
||||
|
||||
def test_link_survives_a_new_db_handle(self):
|
||||
"""AC6: bridge mapping survives process restarts."""
|
||||
self._watchdog(FakeHttp())
|
||||
|
||||
Reference in New Issue
Block a user