1010import sys
1111import time
1212from datetime import datetime , timezone , timedelta
13+ from typing import Any
1314
1415import requests
1516
3334 "X-GitHub-Api-Version" : "2022-11-28" ,
3435}
3536
36- # Sentry level -> GitHub severity label
37- LEVEL_TO_SEVERITY = {
38- "fatal" : "severity: critical" ,
39- "error" : "severity: high" ,
40- "warning" : "severity: medium" ,
41- "info" : "severity: low" ,
42- "debug" : "severity: low" ,
37+ # Sentry level -> (severity label, include "bug" label)
38+ # fatal/error are genuine bugs; warning/info/debug are not necessarily bugs.
39+ LEVEL_TO_SEVERITY : dict [str , tuple [str , bool ]] = {
40+ "fatal" : ("severity: critical" , True ),
41+ "error" : ("severity: high" , True ),
42+ "warning" : ("severity: medium" , False ),
43+ "info" : ("severity: low" , False ),
44+ "debug" : ("severity: low" , False ),
4345}
4446
47+ # Transient HTTP status codes that are safe to retry
48+ RETRYABLE_STATUSES = {429 , 500 , 502 , 503 , 504 }
49+
4550LABELS_TO_BOOTSTRAP = [
4651 {"name" : "sentry" , "color" : "6f42c1" , "description" : "Automatically created from Sentry error monitoring" },
4752 {"name" : "severity: critical" , "color" : "b60205" , "description" : "Fatal errors — immediate attention required" },
5560# Helpers
5661# ---------------------------------------------------------------------------
5762
63+ def request_with_retry (
64+ method : str ,
65+ url : str ,
66+ * ,
67+ max_attempts : int = 3 ,
68+ ** kwargs : Any ,
69+ ) -> requests .Response :
70+ """Perform an HTTP request, retrying on transient errors with exponential backoff."""
71+ kwargs .setdefault ("timeout" , 30 )
72+ for attempt in range (1 , max_attempts + 1 ):
73+ resp = requests .request (method , url , ** kwargs )
74+ if resp .status_code not in RETRYABLE_STATUSES :
75+ return resp
76+ wait = 2 ** attempt # 2s, 4s, 8s
77+ print (f" Transient { resp .status_code } on attempt { attempt } /{ max_attempts } , retrying in { wait } s..." )
78+ if attempt < max_attempts :
79+ time .sleep (wait )
80+ return resp # Return last response after exhausting retries
81+
82+
5883def ensure_label (name : str , color : str , description : str ) -> None :
5984 """Create a GitHub label if it does not already exist."""
6085 url = f"{ GITHUB_API } /repos/{ GITHUB_REPO } /labels"
61- resp = requests . post (
62- url ,
86+ resp = request_with_retry (
87+ "POST" , url ,
6388 headers = GITHUB_HEADERS ,
6489 json = {"name" : name , "color" : color , "description" : description },
65- timeout = 30 ,
6690 )
6791 if resp .status_code == 201 :
6892 print (f" Created label: { name } " )
@@ -82,19 +106,42 @@ def bootstrap_labels() -> None:
82106
83107
84108def fetch_sentry_issues (cutoff : datetime ) -> list [dict ]:
85- """Return all unresolved Sentry issues first seen after cutoff."""
86- url = f"{ SENTRY_API } /projects/{ SENTRY_ORG } /{ SENTRY_PROJECT } /issues/"
87- params = {"query" : "is:unresolved" , "limit" : 100 , "sort" : "date" }
88- resp = requests .get (url , headers = SENTRY_HEADERS , params = params , timeout = 30 )
89- if resp .status_code != 200 :
90- print (f"ERROR: Sentry API returned { resp .status_code } : { resp .text } " , file = sys .stderr )
91- sys .exit (1 )
109+ """Return all unresolved Sentry issues first seen after cutoff.
110+
111+ Follows Sentry's Link-header pagination so no issues are missed even when
112+ there are more than 100 unresolved issues in the project.
92113
93- # Python 3.11+ fromisoformat handles Z natively; we target 3.12 in the workflow
94- return [
95- issue for issue in resp .json ()
96- if datetime .fromisoformat (issue ["firstSeen" ]) >= cutoff
97- ]
114+ Python 3.11+ fromisoformat handles the trailing 'Z' natively (no replace needed).
115+ """
116+ url : str | None = f"{ SENTRY_API } /projects/{ SENTRY_ORG } /{ SENTRY_PROJECT } /issues/"
117+ params : dict | None = {"query" : "is:unresolved" , "limit" : 100 , "sort" : "date" }
118+ new_issues : list [dict ] = []
119+
120+ while url :
121+ resp = request_with_retry ("GET" , url , headers = SENTRY_HEADERS , params = params )
122+ if resp .status_code != 200 :
123+ print (f"ERROR: Sentry API returned { resp .status_code } : { resp .text } " , file = sys .stderr )
124+ sys .exit (1 )
125+
126+ page = resp .json ()
127+ for issue in page :
128+ first_seen = datetime .fromisoformat (issue ["firstSeen" ])
129+ if first_seen >= cutoff :
130+ new_issues .append (issue )
131+ else :
132+ # Issues are sorted by date desc; once we pass the cutoff, stop paginating.
133+ return new_issues
134+
135+ # Follow next-page link if present (format: <url>; rel="next"; results="true")
136+ link_header = resp .headers .get ("Link" , "" )
137+ url = None
138+ params = None
139+ for part in link_header .split ("," ):
140+ if 'rel="next"' in part and 'results="true"' in part :
141+ url = part .split (";" )[0 ].strip ().strip ("<>" )
142+ break
143+
144+ return new_issues
98145
99146
100147def github_issue_exists (sentry_id : str ) -> bool :
@@ -104,7 +151,7 @@ def github_issue_exists(sentry_id: str) -> bool:
104151 """
105152 url = f"{ GITHUB_API } /search/issues"
106153 query = f'repo:{ GITHUB_REPO } label:sentry in:body "SENTRY_ID:{ sentry_id } "'
107- resp = requests . get ( url , headers = GITHUB_HEADERS , params = {"q" : query , "per_page" : 1 }, timeout = 30 )
154+ resp = request_with_retry ( "GET" , url , headers = GITHUB_HEADERS , params = {"q" : query , "per_page" : 1 })
108155 if resp .status_code != 200 :
109156 print (f"ERROR: GitHub search failed ({ resp .status_code } ): { resp .text } " , file = sys .stderr )
110157 sys .exit (1 )
@@ -150,18 +197,18 @@ def build_issue_body(issue: dict) -> str:
150197
151198def create_github_issue (issue : dict ) -> None :
152199 level = issue .get ("level" , "error" )
153- severity_label = LEVEL_TO_SEVERITY .get (level , "severity: high" )
154- labels = ["sentry" , "bug" , severity_label ]
200+ severity_label , is_bug = LEVEL_TO_SEVERITY .get (level , ("severity: high" , True ))
201+ # Only add "bug" for fatal/error levels — warnings and below are not necessarily bugs
202+ labels = ["sentry" , severity_label ] + (["bug" ] if is_bug else [])
155203
156204 title = f"[Sentry] { issue ['title' ]} "
157205 body = build_issue_body (issue )
158206
159207 url = f"{ GITHUB_API } /repos/{ GITHUB_REPO } /issues"
160- resp = requests . post (
161- url ,
208+ resp = request_with_retry (
209+ "POST" , url ,
162210 headers = GITHUB_HEADERS ,
163211 json = {"title" : title , "body" : body , "labels" : labels },
164- timeout = 30 ,
165212 )
166213 if resp .status_code == 201 :
167214 data = resp .json ()
0 commit comments