|
| 1 | +/** |
| 2 | + * CI Failure Reporter script. |
| 3 | + * |
| 4 | + * Creates GitHub issues for tests that fail on the develop branch. |
| 5 | + * For each failed job in the workflow run, it fetches check run annotations |
| 6 | + * to identify individual failing tests, then creates one issue per failing |
| 7 | + * test using the FLAKY_CI_FAILURE_TEMPLATE.md template. Existing open issues |
| 8 | + * with matching titles are skipped to avoid duplicates. |
| 9 | + * |
| 10 | + * Intended to be called from a GitHub Actions workflow via actions/github-script: |
| 11 | + * |
| 12 | + * const { default: run } = await import( |
| 13 | + * `${process.env.GITHUB_WORKSPACE}/scripts/report-ci-failures.mjs` |
| 14 | + * ); |
| 15 | + * await run({ github, context, core }); |
| 16 | + */ |
| 17 | + |
| 18 | +import { readFileSync } from 'node:fs'; |
| 19 | + |
| 20 | +export default async function run({ github, context, core }) { |
| 21 | + const { owner, repo } = context.repo; |
| 22 | + |
| 23 | + // Fetch actual job details from the API to get descriptive names |
| 24 | + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { |
| 25 | + owner, |
| 26 | + repo, |
| 27 | + run_id: context.runId, |
| 28 | + per_page: 100, |
| 29 | + }); |
| 30 | + |
| 31 | + const failedJobs = jobs.filter(job => job.conclusion === 'failure' && !job.name.includes('(optional)')); |
| 32 | + |
| 33 | + if (failedJobs.length === 0) { |
| 34 | + core.info('No failed jobs found'); |
| 35 | + return; |
| 36 | + } |
| 37 | + |
| 38 | + // Read and parse template |
| 39 | + const template = readFileSync('.github/FLAKY_CI_FAILURE_TEMPLATE.md', 'utf8'); |
| 40 | + const [, frontmatter, bodyTemplate] = template.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); |
| 41 | + |
| 42 | + // Get existing open issues with Tests label |
| 43 | + const existing = await github.paginate(github.rest.issues.listForRepo, { |
| 44 | + owner, |
| 45 | + repo, |
| 46 | + state: 'open', |
| 47 | + labels: 'Tests', |
| 48 | + per_page: 100, |
| 49 | + }); |
| 50 | + |
| 51 | + for (const job of failedJobs) { |
| 52 | + const jobName = job.name; |
| 53 | + const jobUrl = job.html_url; |
| 54 | + |
| 55 | + // Fetch annotations from the check run to extract failed test names |
| 56 | + let testNames = []; |
| 57 | + try { |
| 58 | + const annotations = await github.paginate(github.rest.checks.listAnnotations, { |
| 59 | + owner, |
| 60 | + repo, |
| 61 | + check_run_id: job.id, |
| 62 | + per_page: 100, |
| 63 | + }); |
| 64 | + |
| 65 | + const testAnnotations = annotations.filter(a => a.annotation_level === 'failure' && a.path !== '.github'); |
| 66 | + testNames = [...new Set(testAnnotations.map(a => a.title || a.path))]; |
| 67 | + } catch (e) { |
| 68 | + core.info(`Could not fetch annotations for ${jobName}: ${e.message}`); |
| 69 | + } |
| 70 | + |
| 71 | + // If no test names found, fall back to one issue per job |
| 72 | + if (testNames.length === 0) { |
| 73 | + testNames = ['Unknown test']; |
| 74 | + } |
| 75 | + |
| 76 | + // Create one issue per failing test for proper deduplication |
| 77 | + for (const testName of testNames) { |
| 78 | + const vars = { |
| 79 | + JOB_NAME: jobName, |
| 80 | + RUN_LINK: jobUrl, |
| 81 | + TEST_NAME: testName, |
| 82 | + }; |
| 83 | + |
| 84 | + let title = frontmatter.match(/title:\s*'(.*)'/)[1]; |
| 85 | + let issueBody = bodyTemplate; |
| 86 | + for (const [key, value] of Object.entries(vars)) { |
| 87 | + const pattern = new RegExp(`\\{\\{\\s*env\\.${key}\\s*\\}\\}`, 'g'); |
| 88 | + title = title.replace(pattern, value); |
| 89 | + issueBody = issueBody.replace(pattern, value); |
| 90 | + } |
| 91 | + |
| 92 | + const existingIssue = existing.find(i => i.title === title); |
| 93 | + |
| 94 | + if (existingIssue) { |
| 95 | + core.info(`Issue already exists for "${testName}" in ${jobName}: #${existingIssue.number}`); |
| 96 | + continue; |
| 97 | + } |
| 98 | + |
| 99 | + const newIssue = await github.rest.issues.create({ |
| 100 | + owner, |
| 101 | + repo, |
| 102 | + title, |
| 103 | + body: issueBody.trim(), |
| 104 | + labels: ['Tests'], |
| 105 | + }); |
| 106 | + core.info(`Created issue #${newIssue.data.number} for "${testName}" in ${jobName}`); |
| 107 | + } |
| 108 | + } |
| 109 | +} |
0 commit comments