|
| 1 | +import { JobParser, CI_TYPES_KEYS } from './ci_type_parser.js'; |
| 2 | +import { CI_CRUMB_URL } from './run_ci.js'; |
| 3 | +import { PRBuild } from './build-types/pr_build.js'; |
| 4 | +import { getPrURL } from '../links.js'; |
| 5 | +import { debuglog } from '../verbosity.js'; |
| 6 | + |
| 7 | +function referencesFile(failure, filename) { |
| 8 | + const paths = new Set([filename]); |
| 9 | + if (filename.startsWith('test/')) { |
| 10 | + paths.add(filename.slice(5)); |
| 11 | + // Node's test runner omits the test directory and file extension in TAP names. |
| 12 | + paths.add(filename.slice(5).replace(/\.(?:js|mjs|cjs|out)$/, '')); |
| 13 | + } else if (filename.startsWith('lib/')) { |
| 14 | + paths.add(filename.slice(4)); |
| 15 | + } |
| 16 | + // Diagnostics can contain both literal Windows paths and escaped string output. |
| 17 | + const text = `${failure.file ?? ''}\n${failure.reason}`.replace(/\\+/g, '/'); |
| 18 | + return [...paths].some(path => { |
| 19 | + const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 20 | + return new RegExp(`(?:^|[^\\w.-])${escaped}(?=$|[^\\w./-])`, 'm').test(text); |
| 21 | + }); |
| 22 | +} |
| 23 | + |
| 24 | +export class ResumePRJob { |
| 25 | + constructor(cli, request, owner, repo, prid) { |
| 26 | + this.cli = cli; |
| 27 | + this.request = request; |
| 28 | + this.owner = owner; |
| 29 | + this.repo = repo; |
| 30 | + this.prid = prid; |
| 31 | + } |
| 32 | + |
| 33 | + async checkFailures(jobid) { |
| 34 | + const { cli, request, owner, repo, prid } = this; |
| 35 | + const diagnostics = []; |
| 36 | + const buildRequest = Object.create(request); |
| 37 | + buildRequest.json = async(...args) => { |
| 38 | + const data = await request.json(...args); |
| 39 | + // Inspect failed descendants even when their parent job was aborted. |
| 40 | + return data?.result === 'ABORTED' ? { ...data, result: 'FAILURE' } : data; |
| 41 | + }; |
| 42 | + buildRequest.text = async(...args) => { |
| 43 | + const text = await request.text(...args); |
| 44 | + // The failure parser can trim the source location before a compiler error. |
| 45 | + // Keep complete diagnostic lines from failed builds for the file check. |
| 46 | + diagnostics.push(...text.match(/^.*(?:error:|error C\d+:).*$/mg) ?? []); |
| 47 | + // Infrastructure failures can take precedence over test failures in the parser. |
| 48 | + const tests = text.match(/^not ok \d+[\s\S]+? {2}\.\.\.\r?\n/mg) ?? []; |
| 49 | + diagnostics.push(...tests.filter(test => !test.includes('# TODO :'))); |
| 50 | + return text; |
| 51 | + }; |
| 52 | + const build = new PRBuild(cli, buildRequest, jobid, Infinity); |
| 53 | + try { |
| 54 | + await build.getResults(); |
| 55 | + } catch (err) { |
| 56 | + debuglog(err); |
| 57 | + } |
| 58 | + const failures = build.failures.filter(Boolean); |
| 59 | + failures.push({ reason: diagnostics.join('\n') }); |
| 60 | + |
| 61 | + cli.startSpinner('Checking failures against changed PR files'); |
| 62 | + const overlaps = new Set(); |
| 63 | + for await (const file of request.getPullRequestFiles({ owner, repo, prid })) { |
| 64 | + for (const filename of [file.filename, file.previous_filename].filter(Boolean)) { |
| 65 | + if (failures.some(failure => referencesFile(failure, filename))) { |
| 66 | + overlaps.add(filename); |
| 67 | + } |
| 68 | + } |
| 69 | + } |
| 70 | + if (overlaps.size) { |
| 71 | + cli.stopSpinner('Refusing to resume CI: failures reference files changed by this PR', |
| 72 | + cli.SPINNER_STATUS.FAILED); |
| 73 | + for (const filename of [...overlaps].sort()) { |
| 74 | + cli.error(filename); |
| 75 | + } |
| 76 | + return false; |
| 77 | + } |
| 78 | + cli.stopSpinner('No changed PR files found in available failure details'); |
| 79 | + return true; |
| 80 | + } |
| 81 | + |
| 82 | + async resume() { |
| 83 | + const { cli, request, prid } = this; |
| 84 | + let crumb; |
| 85 | + cli.startSpinner('Validating Jenkins credentials'); |
| 86 | + try { |
| 87 | + ({ crumb } = await request.json(CI_CRUMB_URL)); |
| 88 | + if (!crumb) { |
| 89 | + throw new Error('Missing Jenkins crumb'); |
| 90 | + } |
| 91 | + } catch (err) { |
| 92 | + debuglog(err); |
| 93 | + cli.stopSpinner('Jenkins credentials invalid', cli.SPINNER_STATUS.FAILED); |
| 94 | + return false; |
| 95 | + } |
| 96 | + cli.stopSpinner('Jenkins credentials valid'); |
| 97 | + |
| 98 | + try { |
| 99 | + cli.startSpinner(`Looking for CI runs for pull request ${prid}`); |
| 100 | + const parser = await JobParser.fromPR(getPrURL(this), cli, request); |
| 101 | + const job = parser.parse().get(CI_TYPES_KEYS.PR); |
| 102 | + if (!job) { |
| 103 | + cli.stopSpinner(`No CI run detected from pull request ${prid}`, |
| 104 | + cli.SPINNER_STATUS.FAILED); |
| 105 | + return false; |
| 106 | + } |
| 107 | + cli.stopSpinner(`Found PR CI job ${job.jobid}`); |
| 108 | + |
| 109 | + const build = new PRBuild(cli, request, job.jobid, undefined, |
| 110 | + 'result,building,actions[_class,parameters[name,value]]'); |
| 111 | + const { result, building, actions = [] } = await build.getBuildData(); |
| 112 | + if (building || (result !== 'FAILURE' && result !== 'ABORTED')) { |
| 113 | + const status = building ? 'RUNNING' : result ?? 'RUNNING'; |
| 114 | + cli.error(`CI job ${job.jobid} is in status ${status}, skipping resume`); |
| 115 | + return false; |
| 116 | + } |
| 117 | + if (!actions.some(action => |
| 118 | + action._class === 'com.tikal.jenkins.plugins.multijob.MultiJobResumeBuild')) { |
| 119 | + cli.error(`CI job ${job.jobid} is not resumable`); |
| 120 | + return false; |
| 121 | + } |
| 122 | + |
| 123 | + const approvedSHAs = new Set(actions.flatMap(action => action.parameters ?? []) |
| 124 | + .filter(parameter => parameter.name === 'COMMIT_SHA_CHECK') |
| 125 | + .map(parameter => parameter.value)); |
| 126 | + const [approvedSHA] = approvedSHAs; |
| 127 | + if (approvedSHAs.size !== 1 || typeof approvedSHA !== 'string' || !approvedSHA) { |
| 128 | + cli.error(`Refusing to resume CI job ${job.jobid}: cannot determine its approved commit`); |
| 129 | + return false; |
| 130 | + } |
| 131 | + |
| 132 | + if (!(await this.checkFailures(job.jobid))) { |
| 133 | + return false; |
| 134 | + } |
| 135 | + |
| 136 | + // Read HEAD after inspecting failures so the comparison is fresh when resuming. |
| 137 | + const pr = await request.getPullRequest(getPrURL(this)); |
| 138 | + if (pr.head?.sha !== approvedSHA) { |
| 139 | + cli.error(`Refusing to resume CI job ${job.jobid}: ` + |
| 140 | + 'its approved commit does not match the current PR HEAD'); |
| 141 | + return false; |
| 142 | + } |
| 143 | + |
| 144 | + cli.startSpinner(`Resuming PR CI job ${job.jobid}`); |
| 145 | + const response = await request.fetch(`${build.jobUrl}resume`, { |
| 146 | + method: 'POST', |
| 147 | + headers: { |
| 148 | + 'Jenkins-Crumb': crumb |
| 149 | + } |
| 150 | + }); |
| 151 | + if (response.status !== 200) { |
| 152 | + cli.stopSpinner( |
| 153 | + `Failed to resume PR CI: ${response.status} ${response.statusText}`, |
| 154 | + cli.SPINNER_STATUS.FAILED); |
| 155 | + return false; |
| 156 | + } |
| 157 | + cli.stopSpinner('PR CI job successfully resumed'); |
| 158 | + } catch (err) { |
| 159 | + debuglog(err); |
| 160 | + cli.stopSpinner('Failed to resume CI', cli.SPINNER_STATUS.FAILED); |
| 161 | + return false; |
| 162 | + } |
| 163 | + return true; |
| 164 | + } |
| 165 | +} |
0 commit comments