Skip to content

CI Failure Notifier

CI Failure Notifier #1

name: CI Failure Notifier
on:
workflow_run:
workflows: ['CI Pipeline']
types: [completed]
workflow_dispatch: # Manually scan ALL open PRs from the Actions tab
permissions:
issues: write
pull-requests: write
jobs:
notify-ci-failure:
# Act when CI completes and was triggered by a pull_request or push event
if: github.event.workflow_run.event == 'pull_request' || github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
steps:
- name: Notify CI Failure/Success
uses: actions/github-script@v7
with:
script: |
const label = 'status:blocked';
const run = context.payload.workflow_run;
const conclusion = run.conclusion;
let prNumber = null;
let pr = null;
// 1. Try retrieving from run.pull_requests
const prs = run.pull_requests;
if (prs && prs.length > 0) {
pr = prs[0];
prNumber = pr.number;
core.info(`Found PR #${prNumber} directly in workflow run payload.`);
} else {
core.info(`workflow_run.pull_requests is empty. Falling back to search open PRs...`);
// 2. Fallback: search open PRs for matching head SHA or head branch
const { data: openPRs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
// Try finding by exact SHA match
let matchingPR = openPRs.find(p => p.head.sha === run.head_sha);
// If not found by SHA, try matching by head branch name
if (!matchingPR && run.head_branch) {
matchingPR = openPRs.find(p => p.head.ref === run.head_branch);
}
if (matchingPR) {
pr = matchingPR;
prNumber = matchingPR.number;
core.info(`Resolved PR #${prNumber} using open PR search fallback (SHA/branch match).`);
}
}
if (!prNumber || !pr) {
core.info('No open pull request associated with this workflow run. Skipping.');
return;
}
// Fetch full PR data to get author and current labels
const { data: fullPR } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
const author = fullPR.user.login;
const currentLabels = fullPR.labels.map(l => l.name);
if (conclusion === 'failure') {
core.info(`PR #${prNumber}: CI failed. Ensuring label '${label}' and comment are present.`);
// Ensure the label exists in the repo
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
});
} catch {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
color: 'dc2626',
description: 'This PR is blocked due to a failing CI check.',
});
}
// Apply the label if not already present
if (!currentLabels.includes(label)) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: [label],
});
}
// Check for an existing failure comment to avoid spamming
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
per_page: 100,
});
const alreadyCommented = comments.some(c =>
c.user?.login === 'github-actions[bot]' &&
c.body?.includes('CI Pipeline is failing')
);
if (!alreadyCommented) {
const runUrl = run.html_url;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `🚨 Hey @${author}, the **CI Pipeline is failing** on this PR and it has been marked as \`status:blocked\`.
Please fix the issues before this can be reviewed. Here's how:
**1. Run checks locally before pushing:**
\`\`\`bash
npm run format:check # Check Prettier formatting
npm run lint # Run ESLint
npm run typecheck # TypeScript type check
npm run test # Run unit tests (Vitest)
npm run build # Verify production build passes
\`\`\`
**2. Auto-fix common issues:**
\`\`\`bash
npm run format # Auto-fix formatting with Prettier
npm run lint -- --fix # Auto-fix lint errors where possible
\`\`\`
**3. Check the full failure log here:**
👉 [View CI Run](${runUrl})
Once you push a fix and the CI passes, the \`status:blocked\` label will be removed automatically. 💪`,
});
}
} else if (conclusion === 'success') {
core.info(`PR #${prNumber}: CI succeeded. Removing '${label}' label if present.`);
const hasLabel = currentLabels.includes(label);
if (hasLabel) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: label,
});
core.info(`Removed '${label}' from PR #${prNumber} — CI is now passing.`);
}
}
# ── Manual scan: runs when triggered from the Actions tab ─────────────────
scan-all-prs:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Scan all open PRs for CI failures
uses: actions/github-script@v7
with:
script: |
const label = 'status:blocked';
// Ensure the label exists
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
});
} catch {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
color: 'dc2626',
description: 'This PR is blocked due to a failing CI check.',
});
}
// Fetch all open PRs
const { data: openPRs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
core.info(`Scanning ${openPRs.length} open PRs...`);
for (const pr of openPRs) {
// Get the latest CI Pipeline run for this PR's HEAD commit
const { data: runsData } = await github.rest.actions.listWorkflowRunsForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
head_sha: pr.head.sha,
per_page: 20,
});
// Find the most recent completed run of our CI pipeline
const ciRun = runsData.workflow_runs
.filter(r => r.name === 'CI Pipeline' && r.status === 'completed')
.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at))[0];
const currentLabels = pr.labels.map(l => l.name);
const hasBlockedLabel = currentLabels.includes(label);
if (!ciRun) {
core.info(`PR #${pr.number}: No completed CI run found. Skipping.`);
continue;
}
core.info(`PR #${pr.number} (@${pr.user.login}): CI conclusion = ${ciRun.conclusion}`);
if (ciRun.conclusion === 'failure') {
// Apply label
if (!hasBlockedLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [label],
});
}
// Avoid duplicate comments
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
per_page: 100,
});
const alreadyCommented = comments.some(c =>
c.user?.login === 'github-actions[bot]' &&
c.body?.includes('CI Pipeline is failing')
);
if (!alreadyCommented) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: `🚨 Hey @${pr.user.login}, the **CI Pipeline is failing** on this PR and it has been marked as \`status:blocked\`.
Please fix the issues before this can be reviewed. Here's how:
**1. Run checks locally before pushing:**
\`\`\`bash
npm run format:check # Check Prettier formatting
npm run lint # Run ESLint
npm run typecheck # TypeScript type check
npm run test # Run unit tests (Vitest)
npm run build # Verify production build passes
\`\`\`
**2. Auto-fix common issues:**
\`\`\`bash
npm run format # Auto-fix formatting with Prettier
npm run lint -- --fix # Auto-fix lint errors where possible
\`\`\`
**3. Check the full failure log here:**
👉 [View CI Run](${ciRun.html_url})
Once you push a fix and the CI passes, the \`status:blocked\` label will be removed automatically. 💪`,
});
}
} else if (ciRun.conclusion === 'success' && hasBlockedLabel) {
// CI is now passing — remove the stale blocked label
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: label,
});
core.info(`PR #${pr.number}: CI passing — removed '${label}'.`);
}
}