Skip to content

Alert Sync To Issues #316

Alert Sync To Issues

Alert Sync To Issues #316

name: Alert Sync To Issues
on:
schedule:
- cron: '17 * * * *'
workflow_dispatch:
permissions:
actions: read
contents: read
issues: write
security-events: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Sync CI/Dependabot/CodeQL Alerts To Issues
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const AUTO_LABEL = 'auto-alert-sync';
const SOURCE_CI = 'source:ci';
const SOURCE_DEP = 'source:dependabot';
const SOURCE_CODEQL = 'source:codeql';
const FAILING = new Set(['failure', 'timed_out', 'action_required', 'startup_failure']);
const ensureLabel = async (name, color, description) => {
try {
await github.rest.issues.createLabel({ owner, repo, name, color, description });
} catch (error) {
if (error.status !== 422) throw error;
}
};
const normalizeSeverity = (value) => {
const raw = String(value || 'unknown').toLowerCase();
if (['critical', 'high', 'medium', 'low'].includes(raw)) return raw;
return 'unknown';
};
const keyMarker = (key) => `<!-- alert-sync-key: ${key} -->`;
const keyRegex = /<!-- alert-sync-key: ([^>]+) -->/;
const buildIssueMap = async () => {
const issues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: 'open',
labels: AUTO_LABEL,
per_page: 100,
});
const byKey = new Map();
for (const issue of issues) {
const body = issue.body || '';
const match = body.match(keyRegex);
if (match) byKey.set(match[1], issue);
}
return byKey;
};
const upsertIssue = async ({ key, title, body, labels }) => {
const existing = openIssuesByKey.get(key);
const mergedLabels = Array.from(new Set([AUTO_LABEL, ...labels]));
if (existing) {
await github.rest.issues.update({
owner,
repo,
issue_number: existing.number,
title,
body,
labels: mergedLabels,
state: 'open',
});
return;
}
await github.rest.issues.create({
owner,
repo,
title,
body,
labels: mergedLabels,
});
};
const closeIssueIfOpen = async (key, reason) => {
const existing = openIssuesByKey.get(key);
if (!existing) return;
await github.rest.issues.createComment({
owner,
repo,
issue_number: existing.number,
body: `Auto-closed by alert sync: ${reason}`,
});
await github.rest.issues.update({
owner,
repo,
issue_number: existing.number,
state: 'closed',
});
};
const safePaginate = async (fn, params) => {
try {
return await github.paginate(fn, params);
} catch (error) {
if ([403, 404, 410, 451].includes(error.status)) {
core.warning(`Skipping unavailable endpoint: ${error.message}`);
return [];
}
throw error;
}
};
await ensureLabel(AUTO_LABEL, '4b5563', 'Managed by alert sync workflow');
await ensureLabel(SOURCE_CI, '1d4ed8', 'Imported from GitHub Actions failures');
await ensureLabel(SOURCE_DEP, 'b45309', 'Imported from Dependabot alerts');
await ensureLabel(SOURCE_CODEQL, '7c3aed', 'Imported from code scanning alerts');
await ensureLabel('severity:critical', 'b91c1c', 'Critical severity finding');
await ensureLabel('severity:high', 'dc2626', 'High severity finding');
await ensureLabel('severity:medium', 'f59e0b', 'Medium severity finding');
await ensureLabel('severity:low', '84cc16', 'Low severity finding');
await ensureLabel('severity:unknown', '6b7280', 'Unknown severity finding');
const repoMeta = await github.rest.repos.get({ owner, repo });
const defaultBranch = repoMeta.data.default_branch;
const openIssuesByKey = await buildIssueMap();
const desiredCiKeys = new Set();
const desiredDepKeys = new Set();
const desiredCodeQlKeys = new Set();
const runs = await safePaginate(github.rest.actions.listWorkflowRunsForRepo, {
owner,
repo,
per_page: 100,
});
const latestByWorkflow = new Map();
for (const run of runs) {
if (run.event !== 'push') continue;
if (run.head_branch !== defaultBranch) continue;
if (run.status !== 'completed') continue;
const prev = latestByWorkflow.get(run.workflow_id);
if (!prev || new Date(run.created_at) > new Date(prev.created_at)) {
latestByWorkflow.set(run.workflow_id, run);
}
}
for (const run of latestByWorkflow.values()) {
const key = `ci:${run.workflow_id}:${defaultBranch}`;
if (FAILING.has(run.conclusion)) {
desiredCiKeys.add(key);
const title = `[CI] ${run.name} failing on ${defaultBranch}`;
const body = `${keyMarker(key)}\n\n` +
`- Workflow: ${run.name}\n` +
`- Branch: ${defaultBranch}\n` +
`- Conclusion: ${run.conclusion}\n` +
`- Run: ${run.html_url}\n` +
`- Commit: ${run.head_sha}`;
await upsertIssue({
key,
title,
body,
labels: [SOURCE_CI],
});
}
}
const dependabotAlerts = await safePaginate(github.rest.dependabot.listAlertsForRepo, {
owner,
repo,
state: 'open',
per_page: 100,
});
for (const alert of dependabotAlerts) {
const key = `dependabot:${alert.number}`;
desiredDepKeys.add(key);
const dependency = alert.dependency?.package?.name || 'unknown-dependency';
const ecosystem = alert.dependency?.package?.ecosystem || 'unknown';
const severity = normalizeSeverity(alert.security_advisory?.severity);
const manifestPath = alert.dependency?.manifest_path || 'unknown';
const title = `[Dependabot][${severity}] ${dependency}`;
const body = `${keyMarker(key)}\n\n` +
`- Dependency: ${dependency}\n` +
`- Ecosystem: ${ecosystem}\n` +
`- Severity: ${severity}\n` +
`- Manifest: ${manifestPath}\n` +
`- Alert: ${alert.html_url}`;
await upsertIssue({
key,
title,
body,
labels: [SOURCE_DEP, `severity:${severity}`],
});
}
const codeScanningAlerts = await safePaginate(github.rest.codeScanning.listAlertsForRepo, {
owner,
repo,
state: 'open',
per_page: 100,
});
for (const alert of codeScanningAlerts) {
const key = `codeql:${alert.number}`;
desiredCodeQlKeys.add(key);
const ruleId = alert.rule?.id || 'unknown-rule';
const ruleName = alert.rule?.name || ruleId;
const severity = normalizeSeverity(
alert.rule?.security_severity_level || alert.rule?.severity || 'unknown'
);
const title = `[CodeQL][${severity}] ${ruleName}`;
const body = `${keyMarker(key)}\n\n` +
`- Rule: ${ruleName} (${ruleId})\n` +
`- Severity: ${severity}\n` +
`- State: ${alert.state}\n` +
`- Tool: ${alert.tool?.name || 'unknown'}\n` +
`- Alert: ${alert.html_url}`;
await upsertIssue({
key,
title,
body,
labels: [SOURCE_CODEQL, `severity:${severity}`],
});
}
for (const [key] of openIssuesByKey.entries()) {
if (key.startsWith('ci:') && !desiredCiKeys.has(key)) {
await closeIssueIfOpen(key, 'latest default-branch workflow run is no longer failing');
}
if (key.startsWith('dependabot:') && !desiredDepKeys.has(key)) {
await closeIssueIfOpen(key, 'dependabot alert no longer open');
}
if (key.startsWith('codeql:') && !desiredCodeQlKeys.has(key)) {
await closeIssueIfOpen(key, 'code scanning alert no longer open');
}
}
core.summary
.addHeading('Alert sync completed')
.addTable([
[{ data: 'Type', header: true }, { data: 'Open alerts found', header: true }],
['CI failing workflows', String(desiredCiKeys.size)],
['Dependabot alerts', String(desiredDepKeys.size)],
['Code scanning alerts', String(desiredCodeQlKeys.size)],
])
.write();