Sentry → GitHub Issue Sync #621
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Sentry → GitHub Issue Sync | |
| # Runs every 6 hours and on manual trigger. | |
| # Creates a GitHub issue for each unresolved Sentry issue (labeled `sentry-crash`). | |
| # Auto-closes linked GitHub issues when the corresponding Sentry issue is resolved. | |
| # | |
| # Required GitHub secret: SENTRY_AUTH_TOKEN | |
| # Generate at: https://sentry.io/settings/account/api/auth-tokens/ | |
| # Scopes needed: project:read, event:read, issue:read | |
| on: | |
| schedule: | |
| - cron: '0 */6 * * *' | |
| workflow_dispatch: | |
| workflow_call: | |
| jobs: | |
| sync: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| issues: write | |
| steps: | |
| - name: Sync Sentry issues to GitHub | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 | |
| env: | |
| SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | |
| SENTRY_ORG: ${{ secrets.SENTRY_ORG || 'af-developments' }} | |
| SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT || 'autocat' }} | |
| with: | |
| script: | | |
| if (!process.env.SENTRY_AUTH_TOKEN) { | |
| core.info('SENTRY_AUTH_TOKEN not found - skipping sync.'); | |
| return; | |
| } | |
| const SENTRY_ORG = process.env.SENTRY_ORG; | |
| const SENTRY_PROJECT = process.env.SENTRY_PROJECT; | |
| const SENTRY_API = 'https://sentry.io/api/0'; | |
| const SENTRY_HEADERS = { | |
| 'Authorization': `Bearer ${process.env.SENTRY_AUTH_TOKEN}`, | |
| 'Content-Type': 'application/json', | |
| }; | |
| const { owner, repo } = context.repo; | |
| core.info(`Syncing Sentry issues for ${SENTRY_ORG}/${SENTRY_PROJECT}...`); | |
| // ── Ensure the sentry-crash label exists ───────────────────────── | |
| try { | |
| await github.rest.issues.getLabel({ owner, repo, name: 'sentry-crash' }); | |
| } catch { | |
| await github.rest.issues.createLabel({ | |
| owner, repo, | |
| name: 'sentry-crash', | |
| color: 'd93f0b', | |
| description: 'Crash automatically detected and synced from Sentry', | |
| }); | |
| core.info('Created label: sentry-crash'); | |
| } | |
| // ── Fetch unresolved Sentry issues ──────────────────────────────── | |
| const issuesUrl = `${SENTRY_API}/projects/${SENTRY_ORG}/${SENTRY_PROJECT}/issues/?query=is:unresolved&limit=25&sort=date`; | |
| const sentryRes = await fetch(issuesUrl, { headers: SENTRY_HEADERS }); | |
| if (!sentryRes.ok) { | |
| const errorText = await sentryRes.text(); | |
| if (sentryRes.status === 403) { | |
| core.setFailed(`Sentry API error (403 Forbidden): The SENTRY_AUTH_TOKEN lacks required permissions. Ensure it has "project:read" and "issue:read" scopes for the "${SENTRY_ORG}" organization.`); | |
| } else { | |
| core.setFailed(`Sentry API error (${sentryRes.status}): ${errorText}`); | |
| } | |
| return; | |
| } | |
| const sentryIssues = await sentryRes.json(); | |
| if (!Array.isArray(sentryIssues)) { | |
| core.setFailed(`Unexpected Sentry response (expected array): ${JSON.stringify(sentryIssues)}`); | |
| return; | |
| } | |
| core.info(`Fetched ${sentryIssues.length} unresolved Sentry issues`); | |
| // ── Create or Update GitHub issues ─────────────────────────────── | |
| for (const issue of sentryIssues) { | |
| const searchQuery = `"[Sentry #${issue.id}]" repo:${owner}/${repo} is:issue`; | |
| let searchRes; | |
| try { | |
| searchRes = await github.rest.search.issuesAndPullRequests({ q: searchQuery }); | |
| } catch (e) { | |
| core.warning(`GitHub search failed for Sentry #${issue.id} (${e.status ?? e.message}) — skipping`); | |
| continue; | |
| } | |
| // Fetch the latest event ID for this issue | |
| let lastEventId = ''; | |
| try { | |
| const eventsRes = await fetch(`${SENTRY_API}/issues/${issue.id}/events/?limit=1`, { headers: SENTRY_HEADERS }); | |
| if (eventsRes.ok) { | |
| const events = await eventsRes.json(); | |
| if (events.length > 0) lastEventId = events[0].id; | |
| } | |
| } catch (e) { | |
| core.warning(`Could not fetch events for Sentry #${issue.id}: ${e.message}`); | |
| } | |
| if (searchRes.data.total_count > 0) { | |
| const ghIssue = searchRes.data.items[0]; | |
| if (ghIssue.state === 'closed') { | |
| await github.rest.issues.update({ | |
| owner, repo, | |
| issue_number: ghIssue.number, | |
| state: 'open', | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner, repo, | |
| issue_number: ghIssue.number, | |
| body: `🔄 This crash has reappeared in Sentry (status: **${issue.status}**). Re-opening issue for investigation.`, | |
| }); | |
| core.info(`Re-opened GitHub #${ghIssue.number} for Sentry #${issue.id}`); | |
| } | |
| continue; | |
| } | |
| const firstSeen = new Date(issue.firstSeen).toISOString().split('T')[0]; | |
| const body = [ | |
| `## Automated Crash Report`, | |
| ``, | |
| `| Field | Value |`, | |
| `|---|---|`, | |
| `| **Sentry Issue** | [${issue.shortId}](${issue.permalink}) |`, | |
| `| **First Seen** | ${firstSeen} |`, | |
| `| **Event Count** | ${issue.count} |`, | |
| `| **Affected Users** | ${issue.userCount} |`, | |
| `| **Culprit** | \`${issue.culprit || 'Unknown'}\` |`, | |
| ``, | |
| `### Error Details`, | |
| `\`\`\``, | |
| issue.title, | |
| `\`\`\``, | |
| ``, | |
| `---`, | |
| `> 👆 **Subscribe to this issue** to receive notifications when this crash is fixed.`, | |
| `> Your crash report helps improve AutoCat for everyone!`, | |
| ``, | |
| `<!-- sentry-issue-id: ${issue.id} -->`, | |
| `<!-- sentry-short-id: ${issue.shortId} -->`, | |
| lastEventId ? `<!-- last-event-id: ${lastEventId} -->` : '', | |
| lastEventId ? `<!-- Sentry #${lastEventId} -->` : '', | |
| ].filter(l => l).join('\n'); | |
| await github.rest.issues.create({ | |
| owner, repo, | |
| title: `[Crash] ${issue.title} [${issue.shortId}] [Sentry #${issue.id}]`, | |
| body, | |
| labels: ['bug', 'sentry-crash', 'automated'], | |
| }); | |
| core.info(`Created GitHub issue for Sentry ${issue.shortId} (${issue.id})`); | |
| } | |
| // ── Close GitHub issues whose Sentry issue is now resolved ──────── | |
| const openIssues = await github.paginate(github.rest.issues.listForRepo, { | |
| owner, repo, | |
| labels: 'sentry-crash', | |
| state: 'open', | |
| }); | |
| core.info(`Checking ${openIssues.length} open sentry-crash GitHub issues for resolution status`); | |
| for (const ghIssue of openIssues) { | |
| const match = ghIssue.title.match(/\[Sentry #(\d+)\]/); | |
| if (!match) continue; | |
| const sentryIssueId = match[1]; | |
| try { | |
| const issueRes = await fetch( | |
| `${SENTRY_API}/issues/${sentryIssueId}/`, | |
| { headers: SENTRY_HEADERS }, | |
| ); | |
| if (!issueRes.ok) { | |
| core.warning(`Could not fetch Sentry issue ${sentryIssueId}: ${issueRes.status}`); | |
| continue; | |
| } | |
| const sentryIssue = await issueRes.json(); | |
| if (sentryIssue.status === 'resolved') { | |
| await github.rest.issues.createComment({ | |
| owner, repo, | |
| issue_number: ghIssue.number, | |
| body: `✅ This crash has been marked as **resolved** in Sentry. A fix should be available in a recent or upcoming release.`, | |
| }); | |
| await github.rest.issues.update({ | |
| owner, repo, | |
| issue_number: ghIssue.number, | |
| state: 'closed', | |
| state_reason: 'completed', | |
| }); | |
| core.info(`Closed GitHub #${ghIssue.number} (Sentry #${sentryIssueId} resolved)`); | |
| } | |
| } catch (err) { | |
| core.error(`Error processing Sentry issue ${sentryIssueId}: ${err.message}`); | |
| } | |
| } |