Tickets: don't adopt a Closed ticket Tinker couldn't reopen
Lint / JS (eslint) (push) Successful in 14s
Lint / Python (flake8) (push) Successful in 57s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 55s
Lint / Python (flake8) (pull_request) Successful in 1m31s
Test / Python Tests (pytest) (push) Successful in 1m51s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m22s
Test / Python Tests (pytest) (pull_request) Successful in 1m46s
Lint / JS (eslint) (push) Successful in 14s
Lint / Python (flake8) (push) Successful in 57s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 55s
Lint / Python (flake8) (pull_request) Successful in 1m31s
Test / Python Tests (pytest) (push) Successful in 1m51s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m22s
Test / Python Tests (pytest) (pull_request) Successful in 1m46s
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 <noreply@anthropic.com>
This commit is contained in:
+25
-1
@@ -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"]}')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user