Skip to content

Commit 01393d0

Browse files
Ajit Pratap Singhclaude
authored andcommitted
fix(ci): second round of review fixes for Sentry→GitHub workflow
- Add request_with_retry() with exponential backoff for transient errors (429, 500, 502, 503, 504) — max 3 attempts, 2/4/8s waits - Add pagination support: follows Sentry Link header to fetch all issues beyond the 100-item first page; stops early once past the cutoff date - Fix label assignment: warning/info/debug no longer get "bug" label — only fatal/error are genuine bugs - Replace remaining raw requests.* calls with request_with_retry() - Document the 31-minute polling window in the workflow comment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 64ae763 commit 01393d0

2 files changed

Lines changed: 77 additions & 28 deletions

File tree

.github/scripts/sentry_issues.py

Lines changed: 75 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import sys
1111
import time
1212
from datetime import datetime, timezone, timedelta
13+
from typing import Any
1314

1415
import requests
1516

@@ -33,15 +34,19 @@
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+
4550
LABELS_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"},
@@ -55,14 +60,33 @@
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+
5883
def 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

84108
def 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

100147
def 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

151198
def 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()

.github/workflows/sentry-to-github.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,7 @@ jobs:
3232
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
3333
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3434
GITHUB_REPO: ${{ github.repository }}
35+
# 31 min (not 30) to overlap slightly with the previous run window,
36+
# absorbing GitHub Actions scheduling jitter (~1-2 min on busy runners).
3537
POLLING_MINUTES: '31'
3638
run: python .github/scripts/sentry_issues.py

0 commit comments

Comments
 (0)