Skip to content

Sentry → GitHub Issue Sync #621

Sentry → GitHub Issue Sync

Sentry → GitHub Issue Sync #621

Workflow file for this run

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}`);
}
}