diff --git a/lib/pr_checker.js b/lib/pr_checker.js index 273d0fe7..6884fb3a 100644 --- a/lib/pr_checker.js +++ b/lib/pr_checker.js @@ -359,8 +359,13 @@ export default class PRChecker { return false; } + let hasFailures = false; + const failedJobs = []; + const cancelledJobs = []; + const pendingJobs = []; + // GitHub new Check API - for (const { status, conclusion, app } of checkSuites.nodes) { + for (const { status, conclusion, app, checkRuns } of checkSuites.nodes) { if (app.slug !== 'github-actions') { // Ignore all non-github check suites, such as Dependabot and Codecov. // They are expected to show up on PRs whose head branch is not on a @@ -369,16 +374,80 @@ export default class PRChecker { } if (status !== 'COMPLETED') { - cli.error('GitHub CI is still running'); - return false; + pendingJobs.push({ app: app.slug, status, conclusion }); + continue; } if (!GITHUB_SUCCESS_CONCLUSIONS.includes(conclusion)) { - cli.error('Last GitHub CI failed'); - return false; + hasFailures = true; + + // If we have detailed checkRuns, show specific failing jobs + if (checkRuns && checkRuns.nodes && checkRuns.nodes.length > 0) { + for (const checkRun of checkRuns.nodes) { + if (checkRun.status === 'COMPLETED' && + !GITHUB_SUCCESS_CONCLUSIONS.includes(checkRun.conclusion)) { + if (checkRun.conclusion === 'CANCELLED') { + cancelledJobs.push({ + name: checkRun.name, + conclusion: checkRun.conclusion, + url: checkRun.detailsUrl + }); + } else { + failedJobs.push({ + name: checkRun.name, + conclusion: checkRun.conclusion, + url: checkRun.detailsUrl + }); + } + } + } + } else { + // Fallback to check suite level information if no checkRuns + if (conclusion === 'CANCELLED') { + cancelledJobs.push({ + name: app.slug, + conclusion, + url: null + }); + } else { + failedJobs.push({ + name: app.slug, + conclusion, + url: null + }); + } + } + } + } + + // Report pending jobs + if (pendingJobs.length > 0) { + cli.error('GitHub CI is still running'); + return false; + } + + // Report failed jobs + if (failedJobs.length > 0) { + cli.error(`${failedJobs.length} GitHub CI job(s) failed:`); + for (const job of failedJobs) { + const urlInfo = job.url ? ` (${job.url})` : ''; + cli.error(` - ${job.name}: ${job.conclusion}${urlInfo}`); } } + // Report cancelled jobs + if (cancelledJobs.length > 0) { + cli.error(`${cancelledJobs.length} GitHub CI job(s) cancelled:`); + for (const job of cancelledJobs) { + const urlInfo = job.url ? ` (${job.url})` : ''; + cli.error(` - ${job.name}: ${job.conclusion}${urlInfo}`); + } + } + + if (hasFailures) { + return false; + } + // GitHub old commit status API if (commit.status) { const { state } = commit.status; @@ -388,7 +457,7 @@ export default class PRChecker { } if (!['SUCCESS', 'EXPECTED'].includes(state)) { - cli.error('Last GitHub CI failed'); + cli.error(`GitHub CI failed with status: ${state}`); return false; } } diff --git a/lib/queries/PRCommits.gql b/lib/queries/PRCommits.gql index d8a7db64..70cf2e4e 100644 --- a/lib/queries/PRCommits.gql +++ b/lib/queries/PRCommits.gql @@ -31,7 +31,15 @@ query Commits($prid: Int!, $owner: String!, $repo: String!, $after: String) { slug } conclusion, - status + status, + checkRuns(first: 40) { + nodes { + name + status + conclusion + detailsUrl + } + } } } status { diff --git a/test/fixtures/github-ci/check-suite-cancelled.json b/test/fixtures/github-ci/check-suite-cancelled.json new file mode 100644 index 00000000..b42d16d6 --- /dev/null +++ b/test/fixtures/github-ci/check-suite-cancelled.json @@ -0,0 +1,37 @@ +[ + { + "commit": { + "committedDate": "2017-10-26T12:10:20Z", + "oid": "9d098ssiskj8dhd39js0sjd0cn2ng4is9n40sj12d", + "messageHeadline": "doc: add api description README", + "author": { + "login": "foo" + }, + "checkSuites": { + "nodes": [ + { + "app": { "slug": "github-actions" }, + "status": "COMPLETED", + "conclusion": "CANCELLED", + "checkRuns": { + "nodes": [ + { + "name": "test-linux", + "status": "COMPLETED", + "conclusion": "CANCELLED", + "detailsUrl": "https://github.com/nodejs/node/runs/12345" + }, + { + "name": "lint", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/nodejs/node/runs/12346" + } + ] + } + } + ] + } + } + } +] diff --git a/test/fixtures/github-ci/check-suite-failure.json b/test/fixtures/github-ci/check-suite-failure.json index b299e1e7..d07172f0 100644 --- a/test/fixtures/github-ci/check-suite-failure.json +++ b/test/fixtures/github-ci/check-suite-failure.json @@ -12,7 +12,23 @@ { "app": { "slug": "github-actions" }, "status": "COMPLETED", - "conclusion": "FAILURE" + "conclusion": "FAILURE", + "checkRuns": { + "nodes": [ + { + "name": "test-linux", + "status": "COMPLETED", + "conclusion": "FAILURE", + "detailsUrl": "https://github.com/nodejs/node/runs/12345" + }, + { + "name": "test-macos", + "status": "COMPLETED", + "conclusion": "SUCCESS", + "detailsUrl": "https://github.com/nodejs/node/runs/12346" + } + ] + } } ] } diff --git a/test/unit/pr_checker.test.js b/test/unit/pr_checker.test.js index bd98c6e4..c39249ec 100644 --- a/test/unit/pr_checker.test.js +++ b/test/unit/pr_checker.test.js @@ -1267,7 +1267,8 @@ describe('PRChecker', () => { ['Last Jenkins CI successful'] ], error: [ - ['Last GitHub CI failed'] + ['1 GitHub CI job(s) failed:'], + [' - test-linux: FAILURE (https://github.com/nodejs/node/runs/12345)'] ], info: [ [`Last Full PR CI on 2018-10-22T04:16:36.458Z: ${jenkins.url}`] @@ -1674,7 +1675,8 @@ describe('PRChecker', () => { const expectedLogs = { error: [ - ['Last GitHub CI failed'] + ['1 GitHub CI job(s) failed:'], + [' - github-actions: FAILURE'] ] }; @@ -1734,7 +1736,8 @@ describe('PRChecker', () => { const expectedLogs = { error: [ - ['Last GitHub CI failed'] + ['1 GitHub CI job(s) failed:'], + [' - test-linux: FAILURE (https://github.com/nodejs/node/runs/12345)'] ] }; @@ -1791,7 +1794,7 @@ describe('PRChecker', () => { const expectedLogs = { error: [ - ['Last GitHub CI failed'] + ['GitHub CI failed with status: FAILURE'] ] }; @@ -1867,7 +1870,7 @@ describe('PRChecker', () => { const expectedLogs = { error: [ - ['Last GitHub CI failed'] + ['GitHub CI failed with status: FAILURE'] ] }; @@ -1886,7 +1889,8 @@ describe('PRChecker', () => { const expectedLogs = { error: [ - ['Last GitHub CI failed'] + ['1 GitHub CI job(s) failed:'], + [' - github-actions: FAILURE'] ] }; @@ -1937,6 +1941,297 @@ describe('PRChecker', () => { assert(status); cli.assertCalledWith(expectedLogs); }); + + it('should error if Check suite cancelled', async() => { + const cli = new TestCLI(); + + const expectedLogs = { + error: [ + ['1 GitHub CI job(s) cancelled:'], + [' - test-linux: CANCELLED (https://github.com/nodejs/node/runs/12345)'] + ] + }; + + const commits = githubCI['check-suite-cancelled']; + const data = Object.assign({}, baseData, { commits }); + + const checker = new PRChecker(cli, data, {}, testArgv); + + const status = await checker.checkCI(); + assert(!status); + cli.assertCalledWith(expectedLogs); + }); + + it('should handle multiple failed jobs with detailed output', async() => { + const cli = new TestCLI(); + + const multipleFailures = [{ + commit: { + committedDate: '2017-10-26T12:10:20Z', + oid: '9d098ssiskj8dhd39js0sjd0cn2ng4is9n40sj12d', + messageHeadline: 'doc: add api description README', + author: { login: 'foo' }, + checkSuites: { + nodes: [{ + app: { slug: 'github-actions' }, + status: 'COMPLETED', + conclusion: 'FAILURE', + checkRuns: { + nodes: [ + { + name: 'test-linux', + status: 'COMPLETED', + conclusion: 'FAILURE', + detailsUrl: 'https://github.com/nodejs/node/runs/1' + }, + { + name: 'test-windows', + status: 'COMPLETED', + conclusion: 'FAILURE', + detailsUrl: 'https://github.com/nodejs/node/runs/2' + }, + { + name: 'lint', + status: 'COMPLETED', + conclusion: 'SUCCESS', + detailsUrl: 'https://github.com/nodejs/node/runs/3' + } + ] + } + }] + } + } + }]; + + const expectedLogs = { + error: [ + ['2 GitHub CI job(s) failed:'], + [' - test-linux: FAILURE (https://github.com/nodejs/node/runs/1)'], + [' - test-windows: FAILURE (https://github.com/nodejs/node/runs/2)'] + ] + }; + + const data = Object.assign({}, baseData, { commits: multipleFailures }); + const checker = new PRChecker(cli, data, {}, testArgv); + + const status = await checker.checkCI(); + assert(!status); + cli.assertCalledWith(expectedLogs); + }); + + it('should handle mixed failed and cancelled jobs', async() => { + const cli = new TestCLI(); + + const mixedResults = [{ + commit: { + committedDate: '2017-10-26T12:10:20Z', + oid: '9d098ssiskj8dhd39js0sjd0cn2ng4is9n40sj12d', + messageHeadline: 'doc: add api description README', + author: { login: 'foo' }, + checkSuites: { + nodes: [{ + app: { slug: 'github-actions' }, + status: 'COMPLETED', + conclusion: 'FAILURE', + checkRuns: { + nodes: [ + { + name: 'test-linux', + status: 'COMPLETED', + conclusion: 'FAILURE', + detailsUrl: 'https://github.com/nodejs/node/runs/1' + }, + { + name: 'test-macos', + status: 'COMPLETED', + conclusion: 'CANCELLED', + detailsUrl: 'https://github.com/nodejs/node/runs/2' + } + ] + } + }] + } + } + }]; + + const expectedLogs = { + error: [ + ['1 GitHub CI job(s) failed:'], + [' - test-linux: FAILURE (https://github.com/nodejs/node/runs/1)'], + ['1 GitHub CI job(s) cancelled:'], + [' - test-macos: CANCELLED (https://github.com/nodejs/node/runs/2)'] + ] + }; + + const data = Object.assign({}, baseData, { commits: mixedResults }); + const checker = new PRChecker(cli, data, {}, testArgv); + + const status = await checker.checkCI(); + assert(!status); + cli.assertCalledWith(expectedLogs); + }); + + it('should fallback to checkSuite level when no checkRuns available', async() => { + const cli = new TestCLI(); + + const noCheckRuns = [{ + commit: { + committedDate: '2017-10-26T12:10:20Z', + oid: '9d098ssiskj8dhd39js0sjd0cn2ng4is9n40sj12d', + messageHeadline: 'doc: add api description README', + author: { login: 'foo' }, + checkSuites: { + nodes: [{ + app: { slug: 'github-actions' }, + status: 'COMPLETED', + conclusion: 'CANCELLED' + // No checkRuns field + }] + } + } + }]; + + const expectedLogs = { + error: [ + ['1 GitHub CI job(s) cancelled:'], + [' - github-actions: CANCELLED'] + ] + }; + + const data = Object.assign({}, baseData, { commits: noCheckRuns }); + const checker = new PRChecker(cli, data, {}, testArgv); + + const status = await checker.checkCI(); + assert(!status); + cli.assertCalledWith(expectedLogs); + }); + + it('should handle empty checkRuns array', async() => { + const cli = new TestCLI(); + + const emptyCheckRuns = [{ + commit: { + committedDate: '2017-10-26T12:10:20Z', + oid: '9d098ssiskj8dhd39js0sjd0cn2ng4is9n40sj12d', + messageHeadline: 'doc: add api description README', + author: { login: 'foo' }, + checkSuites: { + nodes: [{ + app: { slug: 'github-actions' }, + status: 'COMPLETED', + conclusion: 'FAILURE', + checkRuns: { nodes: [] } + }] + } + } + }]; + + const expectedLogs = { + error: [ + ['1 GitHub CI job(s) failed:'], + [' - github-actions: FAILURE'] + ] + }; + + const data = Object.assign({}, baseData, { commits: emptyCheckRuns }); + const checker = new PRChecker(cli, data, {}, testArgv); + + const status = await checker.checkCI(); + assert(!status); + cli.assertCalledWith(expectedLogs); + }); + + it('should handle jobs without URLs', async() => { + const cli = new TestCLI(); + + const noUrlJobs = [{ + commit: { + committedDate: '2017-10-26T12:10:20Z', + oid: '9d098ssiskj8dhd39js0sjd0cn2ng4is9n40sj12d', + messageHeadline: 'doc: add api description README', + author: { login: 'foo' }, + checkSuites: { + nodes: [{ + app: { slug: 'github-actions' }, + status: 'COMPLETED', + conclusion: 'FAILURE', + checkRuns: { + nodes: [{ + name: 'test-linux', + status: 'COMPLETED', + conclusion: 'FAILURE' + // No detailsUrl field + }] + } + }] + } + } + }]; + + const expectedLogs = { + error: [ + ['1 GitHub CI job(s) failed:'], + [' - test-linux: FAILURE'] + ] + }; + + const data = Object.assign({}, baseData, { commits: noUrlJobs }); + const checker = new PRChecker(cli, data, {}, testArgv); + + const status = await checker.checkCI(); + assert(!status); + cli.assertCalledWith(expectedLogs); + }); + + it('should ignore non-completed checkRuns when processing failures', async() => { + const cli = new TestCLI(); + + const mixedStatusJobs = [{ + commit: { + committedDate: '2017-10-26T12:10:20Z', + oid: '9d098ssiskj8dhd39js0sjd0cn2ng4is9n40sj12d', + messageHeadline: 'doc: add api description README', + author: { login: 'foo' }, + checkSuites: { + nodes: [{ + app: { slug: 'github-actions' }, + status: 'COMPLETED', + conclusion: 'FAILURE', + checkRuns: { + nodes: [ + { + name: 'test-linux', + status: 'COMPLETED', + conclusion: 'FAILURE', + detailsUrl: 'https://github.com/nodejs/node/runs/1' + }, + { + name: 'test-pending', + status: 'IN_PROGRESS', + conclusion: null, + detailsUrl: 'https://github.com/nodejs/node/runs/2' + } + ] + } + }] + } + } + }]; + + const expectedLogs = { + error: [ + ['1 GitHub CI job(s) failed:'], + [' - test-linux: FAILURE (https://github.com/nodejs/node/runs/1)'] + ] + }; + + const data = Object.assign({}, baseData, { commits: mixedStatusJobs }); + const checker = new PRChecker(cli, data, {}, testArgv); + + const status = await checker.checkCI(); + assert(!status); + cli.assertCalledWith(expectedLogs); + }); }); describe('checkAuthor', () => {