From d234e36e3f327ce30db1b3012d15a6b8a5bce704 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 11 Nov 2026 01:52:53 -0500 Subject: [PATCH] Tickets: don't adopt a Closed ticket Tinker couldn't reopen Tinker's create response says what it did (action). For recurrence_noted it matched a Closed ticket it could not reopen unattended: that ticket isn't tracking the alert (this is what hid micro1 enp3s0 behind an old reboot ticket), so Gandalf now logs it and keeps no ticket for the alert. Each such create adds a "recurred" comment to the closed ticket, so the same title is retried once a day rather than every poll. Other actions are logged. See tinker_tickets#116 and #115. Co-Authored-By: Claude Opus 5.5 --- monitor.py | 26 +++++++++++++++++++++++++- tests/test_monitor.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/monitor.py b/monitor.py index 6894ece..4d75dec 100644 --- a/monitor.py +++ b/monitor.py @@ -393,6 +393,8 @@ class TicketClient: self.comment_url = cfg.get('comment_url') or ( urljoin(self.url, 'api/ticket_comment_api.php') if self.url else '') self.comments_denied = False + # Titles whose create matched a Closed ticket Tinker couldn't reopen: {title: (ticket_id, when)} + self._closed_matches: Dict[str, tuple] = {} def comment(self, ticket_id: str, text: str) -> bool: """Add a Markdown comment to a ticket. Never raises; False means try again later.""" @@ -425,10 +427,23 @@ class TicketClient: logger.error(f'Ticket comment failed for #{ticket_id}: {e}') return False + # How long to wait before asking again after a create matched an unreopenable Closed ticket. + # Each ask adds a "recurred" comment to that ticket, so once a poll would pile them up. + CLOSED_MATCH_RETRY_S = 24 * 3600 + def create(self, title: str, description: str, priority: str = '2') -> Optional[str]: + """Open a ticket. Returns its ID, or None if there is no ticket to track. + + Tinker deduplicates: the response's `action` says what happened (new, deduplicated, + updated, reopened, recurrence_noted). recurrence_noted means it matched a Closed ticket + it could not reopen unattended; that ticket isn't tracking this alert, so it isn't used. + """ if not self.api_key or not self.url: logger.warning('Ticket API not configured: skipping ticket creation') return None + seen = self._closed_matches.get(title) + if seen and time.time() - seen[1] < self.CLOSED_MATCH_RETRY_S: + return None try: resp = requests.post( self.url, @@ -450,7 +465,16 @@ class TicketClient: if not tid: logger.warning(f'Ticket API success but no ticket_id in response: {data}') return None - logger.info(f'Created ticket #{tid}: {title}') + action = data.get('action') or 'created' + if action == 'recurrence_noted': + self._closed_matches[title] = (tid, time.time()) + logger.warning( + f'Ticket API matched Closed ticket #{tid}, which it could not reopen; not using it ' + f'for this alert. Reopen #{tid} in Tinker to track it there. Retrying in 24 h: {title}' + ) + return None + self._closed_matches.pop(title, None) + logger.info(f'Ticket #{tid} ({action}): {title}') return tid if data.get('existing_ticket_id'): logger.info(f'Duplicate suppressed by API: existing #{data["existing_ticket_id"]}') diff --git a/tests/test_monitor.py b/tests/test_monitor.py index ad418c4..6c694f2 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -748,3 +748,38 @@ class TestTicketClientComment: with patch.object(monitor.requests, 'post', side_effect=OSError('down')): assert c.comment('42', 'hi') is False assert not c.comments_denied + + +class TestTicketClientCreate: + def _client(self): + return monitor.TicketClient({'url': 'http://t.test/create_ticket_api.php', 'api_key': 'k'}) + + def _post(self, body): + resp = MagicMock(status_code=200) + resp.json.return_value = body + return patch.object(monitor.requests, 'post', return_value=resp) + + def test_new_and_deduplicated_tickets_are_used(self): + c = self._client() + with self._post({'success': True, 'ticket_id': '5', 'action': 'deduplicated'}): + assert c.create('t', 'd') == '5' + with self._post({'success': True, 'ticket_id': '6'}): + assert c.create('u', 'd') == '6' + + def test_recurrence_on_a_closed_ticket_is_not_used_and_not_retried_each_poll(self): + c = self._client() + with self._post({'success': True, 'ticket_id': '993', 'action': 'recurrence_noted'}) as post: + assert c.create('t', 'd') is None + assert c.create('t', 'd') is None + assert post.call_count == 1 # the second poll doesn't add another comment to the closed ticket + with self._post({'success': True, 'ticket_id': '7', 'action': 'none'}) as post: + assert c.create('other', 'd') == '7' # other alerts are unaffected + + def test_retry_after_a_day(self): + c = self._client() + with self._post({'success': True, 'ticket_id': '993', 'action': 'recurrence_noted'}): + c.create('t', 'd') + c._closed_matches['t'] = ('993', time.time() - c.CLOSED_MATCH_RETRY_S - 1) + with self._post({'success': True, 'ticket_id': '8', 'action': 'none'}): + assert c.create('t', 'd') == '8' + assert 't' not in c._closed_matches -- 2.47.3