Skip to content

Commit aa5da0b

Browse files
Ajit Pratap Singhclaude
authored andcommitted
feat(ci): add Sentry → GitHub Issues automation
Adds a GitHub Actions workflow that polls Sentry every 30 minutes for new unresolved issues and automatically creates GitHub issues with full error context, severity labels, and deduplication. - .github/workflows/sentry-to-github.yml: cron workflow (every 30min) - .github/scripts/sentry_issues.py: Sentry API poller with dedup logic GitHub issues get: [sentry] + [bug] + [severity: {level}] labels. Deduplication via hidden <!-- SENTRY_ID:{id} --> marker in body. Backfilled 4 existing Sentry issues as GitHub issues #434-437. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 08836bd commit aa5da0b

2 files changed

Lines changed: 241 additions & 0 deletions

File tree

.github/scripts/sentry_issues.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
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+
import json
13+
from datetime import datetime, timezone, timedelta
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 -> GitHub severity label
38+
LEVEL_TO_SEVERITY = {
39+
"fatal": "severity: critical",
40+
"error": "severity: high",
41+
"warning": "severity: medium",
42+
"info": "severity: low",
43+
"debug": "severity: low",
44+
}
45+
46+
LABELS_TO_BOOTSTRAP = [
47+
{"name": "sentry", "color": "6f42c1", "description": "Automatically created from Sentry error monitoring"},
48+
{"name": "severity: critical", "color": "b60205", "description": "Fatal errors — immediate attention required"},
49+
{"name": "severity: high", "color": "e11d48", "description": "Errors affecting users"},
50+
{"name": "severity: medium", "color": "f59e0b", "description": "Warnings with user impact"},
51+
{"name": "severity: low", "color": "6b7280", "description": "Informational or debug-level issues"},
52+
]
53+
54+
55+
# ---------------------------------------------------------------------------
56+
# Helpers
57+
# ---------------------------------------------------------------------------
58+
59+
def ensure_label(name: str, color: str, description: str) -> None:
60+
"""Create a GitHub label if it does not already exist."""
61+
url = f"{GITHUB_API}/repos/{GITHUB_REPO}/labels"
62+
resp = requests.post(
63+
url,
64+
headers=GITHUB_HEADERS,
65+
json={"name": name, "color": color, "description": description},
66+
)
67+
if resp.status_code == 201:
68+
print(f" Created label: {name}")
69+
elif resp.status_code == 422:
70+
pass # Already exists
71+
else:
72+
print(f" Warning: unexpected status {resp.status_code} creating label '{name}': {resp.text}")
73+
74+
75+
def bootstrap_labels() -> None:
76+
for label in LABELS_TO_BOOTSTRAP:
77+
ensure_label(**label)
78+
79+
80+
def fetch_sentry_issues(cutoff: datetime) -> list[dict]:
81+
"""Return all unresolved Sentry issues first seen after cutoff."""
82+
url = f"{SENTRY_API}/projects/{SENTRY_ORG}/{SENTRY_PROJECT}/issues/"
83+
params = {"query": "is:unresolved", "limit": 100, "sort": "date"}
84+
resp = requests.get(url, headers=SENTRY_HEADERS, params=params)
85+
if resp.status_code != 200:
86+
print(f"ERROR: Sentry API returned {resp.status_code}: {resp.text}", file=sys.stderr)
87+
sys.exit(1)
88+
89+
issues = resp.json()
90+
new_issues = []
91+
for issue in issues:
92+
first_seen = datetime.fromisoformat(issue["firstSeen"].replace("Z", "+00:00"))
93+
if first_seen >= cutoff:
94+
new_issues.append(issue)
95+
return new_issues
96+
97+
98+
def github_issue_exists(sentry_id: str) -> bool:
99+
"""Return True if a GitHub issue with this Sentry ID already exists."""
100+
url = f"{GITHUB_API}/search/issues"
101+
query = f'repo:{GITHUB_REPO} label:sentry in:body "SENTRY_ID:{sentry_id}"'
102+
resp = requests.get(url, headers=GITHUB_HEADERS, params={"q": query, "per_page": 1})
103+
if resp.status_code != 200:
104+
print(f" Warning: GitHub search returned {resp.status_code}: {resp.text}")
105+
return False
106+
return resp.json().get("total_count", 0) > 0
107+
108+
109+
def build_issue_body(issue: dict) -> str:
110+
sentry_id = issue["id"]
111+
level = issue.get("level", "error")
112+
count = issue.get("count", "?")
113+
user_count = issue.get("userCount", "?")
114+
first_seen = issue.get("firstSeen", "?")
115+
last_seen = issue.get("lastSeen", "?")
116+
culprit = issue.get("culprit", "?")
117+
permalink = issue.get("permalink", f"https://{SENTRY_ORG}.sentry.io/issues/{sentry_id}/")
118+
119+
return f"""\
120+
## Sentry Issue: {issue['title']}
121+
122+
**Sentry ID:** `{sentry_id}`
123+
**Culprit:** `{culprit}`
124+
**Level:** {level}
125+
126+
---
127+
128+
| Field | Value |
129+
|-------|-------|
130+
| First Seen | {first_seen} |
131+
| Last Seen | {last_seen} |
132+
| Occurrences | {count} |
133+
| Affected Users | {user_count} |
134+
135+
**Sentry URL:** {permalink}
136+
137+
---
138+
139+
> This issue was automatically created by the Sentry monitoring workflow.
140+
> To resolve, fix the underlying error and mark the Sentry issue as resolved.
141+
142+
<!-- SENTRY_ID:{sentry_id} -->
143+
"""
144+
145+
146+
def create_github_issue(issue: dict) -> None:
147+
level = issue.get("level", "error")
148+
severity_label = LEVEL_TO_SEVERITY.get(level, "severity: high")
149+
labels = ["sentry", "bug", severity_label]
150+
151+
title = f"[Sentry] {issue['title']}"
152+
body = build_issue_body(issue)
153+
154+
url = f"{GITHUB_API}/repos/{GITHUB_REPO}/issues"
155+
resp = requests.post(
156+
url,
157+
headers=GITHUB_HEADERS,
158+
json={"title": title, "body": body, "labels": labels},
159+
)
160+
if resp.status_code == 201:
161+
data = resp.json()
162+
print(f" Created GitHub issue #{data['number']}: {data['html_url']}")
163+
else:
164+
print(f"ERROR: GitHub issue creation failed ({resp.status_code}): {resp.text}", file=sys.stderr)
165+
sys.exit(1)
166+
167+
168+
# ---------------------------------------------------------------------------
169+
# Main
170+
# ---------------------------------------------------------------------------
171+
172+
def main() -> None:
173+
cutoff = datetime.now(timezone.utc) - timedelta(minutes=POLLING_MINUTES)
174+
print(f"Polling for Sentry issues first seen after {cutoff.isoformat()}")
175+
176+
print("Bootstrapping GitHub labels...")
177+
bootstrap_labels()
178+
179+
print(f"Fetching unresolved Sentry issues for {SENTRY_ORG}/{SENTRY_PROJECT}...")
180+
new_issues = fetch_sentry_issues(cutoff)
181+
print(f"Found {len(new_issues)} new issue(s) in the last {POLLING_MINUTES} minutes.")
182+
183+
if not new_issues:
184+
print("Nothing to do.")
185+
return
186+
187+
for issue in new_issues:
188+
sentry_id = issue["id"]
189+
title = issue["title"]
190+
print(f"\nProcessing Sentry issue {sentry_id}: {title[:80]}")
191+
192+
# Rate-limit guard for GitHub search API (30 req/min authenticated)
193+
time.sleep(2)
194+
195+
if github_issue_exists(sentry_id):
196+
print(f" Skipping — GitHub issue already exists for SENTRY_ID:{sentry_id}")
197+
continue
198+
199+
create_github_issue(issue)
200+
201+
print("\nDone.")
202+
203+
204+
if __name__ == "__main__":
205+
main()
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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: gosqlx-website
33+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34+
GITHUB_REPO: ${{ github.repository }}
35+
POLLING_MINUTES: '31'
36+
run: python .github/scripts/sentry_issues.py

0 commit comments

Comments
 (0)