summaryrefslogtreecommitdiff
path: root/ci/github-script/prepare.js
blob: 9f98e8d3927035ebc06ce2c3dc189bc1cc4e2afc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// @ts-check
const { classify } = require('../supportedBranches.js')
const { postReview, dismissReviews } = require('./reviews.js')
const reviewKey = 'prepare'

/**
 * Prepares a PR for CI by checking mergeability and branch targeting.
 *
 * Outputs:
 *   - base: base branch classification
 *   - head: head branch classification
 *   - mergedSha: the merge commit SHA (or head SHA if conflict)
 *   - targetSha: the target comparison SHA
 *
 * @param {{
 *  github: InstanceType<import('@actions/github/lib/utils').GitHub>,
 *  context: import('@actions/github/lib/context').Context,
 *  core: import('@actions/core'),
 *  dry?: boolean,
 * }} PrepareProps
 */
module.exports = async ({ github, context, core, dry }) => {
  const pull_number = context.payload.pull_request.number

  for (const retryInterval of [5, 10, 20, 40, 80]) {
    core.info('Checking whether the pull request can be merged...')
    const prInfo = (
      await github.rest.pulls.get({
        ...context.repo,
        pull_number,
      })
    ).data

    if (prInfo.state !== 'open') throw new Error('PR is not open anymore.')

    if (prInfo.mergeable == null) {
      core.info(
        `GitHub is still computing mergeability, waiting ${retryInterval}s...`,
      )
      await new Promise((resolve) => setTimeout(resolve, retryInterval * 1000))
      continue
    }

    const { base, head } = prInfo

    const baseClassification = classify(base.ref)
    core.setOutput('base', baseClassification)
    console.log('base classification:', baseClassification)

    const headClassification =
      base.repo.full_name === head.repo.full_name
        ? classify(head.ref)
        : { type: ['wip'] }
    core.setOutput('head', headClassification)
    console.log('head classification:', headClassification)

    // Warn if targeting a release branch with a non-backport/fix branch
    if (
      baseClassification.stable &&
      baseClassification.type.includes('primary')
    ) {
      const headPrefix = head.ref.split('-')[0]
      if (!['backport', 'fix', 'revert'].includes(headPrefix)) {
        core.warning(
          `This PR targets release branch \`${base.ref}\`. ` +
            'New features should typically target \`master\`.',
        )
      }
    }

    // Check base branch targeting
    if (headClassification.type.includes('wip')) {
      // Determine the best base branch candidate
      const branches = (
        await github.paginate(github.rest.repos.listBranches, {
          ...context.repo,
          per_page: 100,
        })
      ).map(({ name }) => classify(name))

      const releases = branches
        .filter(({ stable, type }) => type.includes('primary') && stable)
        .sort((a, b) => b.version.localeCompare(a.version))

      async function mergeBase({ branch, order, version }) {
        const { data } = await github.rest.repos.compareCommitsWithBasehead({
          ...context.repo,
          basehead: `${branch}...${head.sha}`,
          per_page: 1,
          page: 2,
        })
        return {
          branch,
          order,
          version,
          commits: data.total_commits,
          sha: data.merge_base_commit.sha,
        }
      }

      let candidates = [await mergeBase(classify('master'))]
      for (const release of releases) {
        const nextCandidate = await mergeBase(release)
        if (candidates[0].commits === nextCandidate.commits)
          candidates.push(nextCandidate)
        if (candidates[0].commits > nextCandidate.commits)
          candidates = [nextCandidate]
        if (candidates[0].commits < 10000) break
      }

      const best = candidates.sort((a, b) => a.order - b.order).at(0)

      core.info(`Best base branch candidate: ${best.branch}`)

      if (best.branch !== base.ref) {
        const current = await mergeBase(classify(base.ref))
        const body = [
          `This PR targets \`${current.branch}\`, but based on the commit history ` +
            `\`${best.branch}\` appears to be a better fit ` +
            `(${current.commits - best.commits} fewer commits ahead).`,
          '',
          `If this is intentional, you can ignore this message. Otherwise:`,
          `- [Change the base branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-base-branch-of-a-pull-request) to \`${best.branch}\`.`,
        ].join('\n')

        await postReview({ github, context, core, dry, body, reviewKey })
        core.warning(`PR may target the wrong base branch.`)
      } else {
        await dismissReviews({ github, context, core, dry, reviewKey })
      }
    } else {
      await dismissReviews({ github, context, core, dry, reviewKey })
    }

    let mergedSha, targetSha

    if (prInfo.mergeable) {
      core.info('The PR can be merged.')
      mergedSha = prInfo.merge_commit_sha
      targetSha = (
        await github.rest.repos.getCommit({
          ...context.repo,
          ref: prInfo.merge_commit_sha,
        })
      ).data.parents[0].sha
    } else {
      core.warning('The PR has a merge conflict.')
      mergedSha = head.sha
      targetSha = (
        await github.rest.repos.compareCommitsWithBasehead({
          ...context.repo,
          basehead: `${base.sha}...${head.sha}`,
        })
      ).data.merge_base_commit.sha
    }

    core.info(`merged: ${mergedSha}\ntarget: ${targetSha}`)
    core.setOutput('mergedSha', mergedSha)
    core.setOutput('targetSha', targetSha)

    // Detect touched CI-relevant files
    const files = (
      await github.paginate(github.rest.pulls.listFiles, {
        ...context.repo,
        pull_number,
        per_page: 100,
      })
    ).map((file) => file.filename)

    const touched = []
    if (files.some((f) => f.startsWith('ci/'))) touched.push('ci')
    if (files.includes('ci/pinned.json')) touched.push('pinned')
    if (files.some((f) => f.startsWith('.github/'))) touched.push('github')
    core.setOutput('touched', touched)

    return
  }
  throw new Error(
    'Timed out waiting for GitHub to compute mergeability. Check https://www.githubstatus.com.',
  )
}