Skip to content

Commit 699ae20

Browse files
authored
feat(ci): Sentry → GitHub Issues automation (#438)
1 parent 08836bd commit 699ae20

2 files changed

Lines changed: 296 additions & 0 deletions

File tree

.github/scripts/sentry_issues.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Sentry Issues -> GitHub Issues sync script.
4+
5+
Polls the Sentry API for newly created issues within the polling window
6+
and creates corresponding GitHub issues, skipping duplicates.
7+
"""
8+
9+
import os
10+
import sys
11+
import time
12+
from datetime import datetime, timezone, timedelta
13+
from typing import Any
14+
15+
import requests
16+
17+
# ---------------------------------------------------------------------------
18+
# Configuration from environment
19+
# ---------------------------------------------------------------------------
20+
SENTRY_AUTH_TOKEN = os.environ["SENTRY_AUTH_TOKEN"]
21+
SENTRY_ORG = os.environ["SENTRY_ORG"]
22+
SENTRY_PROJECT = os.environ["SENTRY_PROJECT"]
23+
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
24+
GITHUB_REPO = os.environ["GITHUB_REPO"]
25+
POLLING_MINUTES = int(os.environ.get("POLLING_MINUTES", "31"))
26+
27+
SENTRY_API = "https://sentry.io/api/0"
28+
GITHUB_API = "https://api.github.com"
29+
30+
SENTRY_HEADERS = {"Authorization": f"Bearer {SENTRY_AUTH_TOKEN}"}
31+
GITHUB_HEADERS = {
32+
"Authorization": f"Bearer {GITHUB_TOKEN}",
33+
"Accept": "application/vnd.github+json",
34+
"X-GitHub-Api-Version": "2022-11-28",
35+
}
36+
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),
45+
}
46+
47+
# Transient HTTP status codes that are safe to retry
48+
RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
49+
50+
LABELS_TO_BOOTSTRAP = [
51+
{"name": "sentry", "color": "6f42c1", "description": "Automatically created from Sentry error monitoring"},
52+
{"name": "severity: critical", "color": "b60205", "description": "Fatal errors — immediate attention required"},
53+
{"name": "severity: high", "color": "e11d48", "description": "Errors affecting users"},
54+
{"name": "severity: medium", "color": "f59e0b", "description": "Warnings with user impact"},
55+
{"name": "severity: low", "color": "6b7280", "description": "Informational or debug-level issues"},
56+
]
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# Helpers
61+
# ---------------------------------------------------------------------------
62+
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+
83+
def ensure_label(name: str, color: str, description: str) -> None:
84+
"""Create a GitHub label if it does not already exist."""
85+
url = f"{GITHUB_API}/repos/{GITHUB_REPO}/labels"
86+
resp = request_with_retry(
87+
"POST", url,
88+
headers=GITHUB_HEADERS,
89+
json={"name": name, "color": color, "description": description},
90+
)
91+
if resp.status_code == 201:
92+
print(f" Created label: {name}")
93+
elif resp.status_code == 422:
94+
data = resp.json()
95+
errors = data.get("errors", [])
96+
# Only swallow "already_exists" — surface other validation errors
97+
if not all(e.get("code") == "already_exists" for e in errors):
98+
print(f" Warning: label creation validation error for '{name}': {resp.text}")
99+
else:
100+
print(f" Warning: unexpected status {resp.status_code} creating label '{name}': {resp.text}")
101+
102+
103+
def bootstrap_labels() -> None:
104+
for label in LABELS_TO_BOOTSTRAP:
105+
ensure_label(**label)
106+
107+
108+
def fetch_sentry_issues(cutoff: datetime) -> list[dict]:
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.
113+
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
145+
146+
147+
def github_issue_exists(sentry_id: str) -> bool:
148+
"""Return True if a GitHub issue with this Sentry ID already exists.
149+
150+
Raises SystemExit on API errors to prevent silently creating duplicates.
151+
"""
152+
url = f"{GITHUB_API}/search/issues"
153+
query = f'repo:{GITHUB_REPO} label:sentry in:body "SENTRY_ID:{sentry_id}"'
154+
resp = request_with_retry("GET", url, headers=GITHUB_HEADERS, params={"q": query, "per_page": 1})
155+
if resp.status_code != 200:
156+
print(f"ERROR: GitHub search failed ({resp.status_code}): {resp.text}", file=sys.stderr)
157+
sys.exit(1)
158+
return resp.json().get("total_count", 0) > 0
159+
160+
161+
def build_issue_body(issue: dict) -> str:
162+
sentry_id = issue["id"]
163+
level = issue.get("level", "error")
164+
count = issue.get("count", "?")
165+
user_count = issue.get("userCount", "?")
166+
first_seen = issue.get("firstSeen", "?")
167+
last_seen = issue.get("lastSeen", "?")
168+
culprit = issue.get("culprit", "?")
169+
permalink = issue.get("permalink", f"https://{SENTRY_ORG}.sentry.io/issues/{sentry_id}/")
170+
171+
return f"""\
172+
## Sentry Issue: {issue['title']}
173+
174+
**Sentry ID:** `{sentry_id}`
175+
**Culprit:** `{culprit}`
176+
**Level:** {level}
177+
178+
---
179+
180+
| Field | Value |
181+
|-------|-------|
182+
| First Seen | {first_seen} |
183+
| Last Seen | {last_seen} |
184+
| Occurrences | {count} |
185+
| Affected Users | {user_count} |
186+
187+
**Sentry URL:** {permalink}
188+
189+
---
190+
191+
> This issue was automatically created by the Sentry monitoring workflow.
192+
> To resolve, fix the underlying error and mark the Sentry issue as resolved.
193+
194+
<!-- SENTRY_ID:{sentry_id} -->
195+
"""
196+
197+
198+
def create_github_issue(issue: dict) -> None:
199+
level = issue.get("level", "error")
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 [])
203+
204+
title = f"[Sentry] {issue['title']}"
205+
body = build_issue_body(issue)
206+
207+
url = f"{GITHUB_API}/repos/{GITHUB_REPO}/issues"
208+
resp = request_with_retry(
209+
"POST", url,
210+
headers=GITHUB_HEADERS,
211+
json={"title": title, "body": body, "labels": labels},
212+
)
213+
if resp.status_code == 201:
214+
data = resp.json()
215+
print(f" Created GitHub issue #{data['number']}: {data['html_url']}")
216+
else:
217+
print(f"ERROR: GitHub issue creation failed ({resp.status_code}): {resp.text}", file=sys.stderr)
218+
sys.exit(1)
219+
220+
221+
# ---------------------------------------------------------------------------
222+
# Main
223+
# ---------------------------------------------------------------------------
224+
225+
def main() -> None:
226+
cutoff = datetime.now(timezone.utc) - timedelta(minutes=POLLING_MINUTES)
227+
print(f"Polling for Sentry issues first seen after {cutoff.isoformat()}")
228+
229+
print("Bootstrapping GitHub labels...")
230+
bootstrap_labels()
231+
232+
print(f"Fetching unresolved Sentry issues for {SENTRY_ORG}/{SENTRY_PROJECT}...")
233+
new_issues = fetch_sentry_issues(cutoff)
234+
print(f"Found {len(new_issues)} new issue(s) in the last {POLLING_MINUTES} minutes.")
235+
236+
if not new_issues:
237+
print("Nothing to do.")
238+
return
239+
240+
for issue in new_issues:
241+
sentry_id = issue["id"]
242+
title = issue["title"]
243+
print(f"\nProcessing Sentry issue {sentry_id}: {title[:80]}")
244+
245+
# Rate-limit guard for GitHub search API (30 req/min authenticated)
246+
time.sleep(2)
247+
248+
if github_issue_exists(sentry_id):
249+
print(f" Skipping — GitHub issue already exists for SENTRY_ID:{sentry_id}")
250+
continue
251+
252+
create_github_issue(issue)
253+
254+
print("\nDone.")
255+
256+
257+
if __name__ == "__main__":
258+
main()
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Sentry Issues → GitHub Issues
2+
3+
on:
4+
schedule:
5+
- cron: '*/30 * * * *' # Every 30 minutes
6+
workflow_dispatch: # Manual trigger for testing
7+
8+
permissions:
9+
issues: write
10+
contents: read
11+
12+
jobs:
13+
sync:
14+
name: Sync Sentry Issues to GitHub
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Checkout
18+
uses: actions/checkout@v4
19+
20+
- name: Set up Python
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: '3.12'
24+
25+
- name: Install dependencies
26+
run: pip install requests
27+
28+
- name: Sync Sentry issues to GitHub
29+
env:
30+
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
31+
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
32+
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
33+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34+
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).
37+
POLLING_MINUTES: '31'
38+
run: python .github/scripts/sentry_issues.py

0 commit comments

Comments
 (0)