summaryrefslogtreecommitdiff
path: root/archived/projt-launcher/ci/github-script/prepare.js
blob: 2c60314f116e828ef09781d975165c5449bd2361 (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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
/**
 * ProjT Launcher - PR Preparation Script
 * Validates PR structure and prepares merge information
 */

const { classify } = require('../supportedBranches.js')
const { postReview } = require('./reviews.js')

const SIGNOFF_MARKER = '<!-- bot:missing-signed-off-by -->'

function stripNoise(body = '') {
  return String(body)
    .replace(/\r/g, '')
    .replace(/<!--.*?-->/gms, '')
    .replace(/(^`{3,})[^`].*?\1/gms, '')
}

function hasSignedOffBy(body = '') {
  const cleaned = stripNoise(body)
  return /^signed-off-by:\s+.+<[^<>]+>\s*$/im.test(cleaned)
}

async function dismissSignoffReviews({ github, context, pull_number }) {
  const reviews = await github.paginate(github.rest.pulls.listReviews, {
    ...context.repo,
    pull_number,
  })

  const signoffReviews = reviews.filter(
    (r) =>
      r.user?.login === 'github-actions[bot]' &&
      r.state === 'CHANGES_REQUESTED' &&
      typeof r.body === 'string' &&
      r.body.includes(SIGNOFF_MARKER),
  )

  for (const review of signoffReviews) {
    await github.rest.pulls.dismissReview({
      ...context.repo,
      pull_number,
      review_id: review.id,
      message: 'Signed-off-by found, thank you!',
    })
  }
}

/**
 * Main PR preparation function
 * Validates that the PR targets the correct branch and can be merged
 */
module.exports = async ({ github, context, core, dry }) => {
  const payload = context.payload || {}
  const pull_number =
    payload?.pull_request?.number ??
    (Array.isArray(payload?.merge_group?.pull_requests) &&
      payload.merge_group.pull_requests[0]?.number)

  if (typeof pull_number !== 'number') {
    core.info('No pull request found on this event; skipping prepare step.')
    return { ok: true, skipped: true, reason: 'no-pull-request' }
  }

  // Wait for GitHub to compute merge status
  for (const retryInterval of [5, 10, 20, 40]) {
    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 merge status, waiting ${retryInterval} seconds...`,
      )
      await new Promise((resolve) => setTimeout(resolve, retryInterval * 1000))
      continue
    }

    const { base, head, user } = prInfo

    const authorLogin = user?.login ?? ''
    const isBotAuthor =
      (user?.type ?? '').toLowerCase() === 'bot' || /\[bot\]$/i.test(authorLogin)

    // Enforce PR template sign-off (Signed-off-by: Name <email>)
    if (isBotAuthor) {
      core.info(`Skipping Signed-off-by requirement for bot author: ${authorLogin}`)
      if (!dry) {
        await dismissSignoffReviews({ github, context, pull_number })
      }
    } else if (!hasSignedOffBy(prInfo.body)) {
      const body = [
        SIGNOFF_MARKER,
        '',
        '## Missing Signed-off-by',
        '',
        'This repository requires a DCO-style sign-off line in the PR description.',
        '',
        'Add a line like this to the PR description (under “Signed-off-by”):',
        '',
        '```',
        'Signed-off-by: Your Name <you@example.com>',
        '```',
        '',
        'After updating the PR description, this check will re-run automatically.',
      ].join('\n')

      await postReview({ github, context, core, dry, body })
      throw new Error('Missing Signed-off-by in PR description')
    } else if (!dry) {
      await dismissSignoffReviews({ github, context, pull_number })
    }

    // Classify base branch
    const baseClassification = classify(base.ref)
    core.setOutput('base', baseClassification)
    core.info(`Base branch classification: ${JSON.stringify(baseClassification)}`)

    // Classify head branch
    const headClassification =
      base.repo.full_name === head.repo.full_name
        ? classify(head.ref)
        : { type: ['wip'] } // PRs from forks are WIP
    core.setOutput('head', headClassification)
    core.info(`Head branch classification: ${JSON.stringify(headClassification)}`)

    // Validate base branch targeting
    if (!baseClassification.type.includes('development') && 
        !baseClassification.type.includes('release')) {
      const body = [
        '## Invalid Target Branch',
        '',
        `This PR targets \`${base.ref}\`, which is not a valid target branch.`,
        '',
        '### Valid target branches for ProjT Launcher:',
        '',
        '| Branch | Purpose |',
        '|--------|---------|',
        '| `develop` | Main development branch |',
        '| `master` / `main` | Stable branch |',
        '| `release-X.Y.Z` | Release branches |',
        '',
        'Please [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 the appropriate target.',
      ].join('\n')

      await postReview({ github, context, core, dry, body })
      throw new Error('PR targets invalid branch.')
    }

    // Check for release branch targeting from wrong branch
    if (baseClassification.isRelease) {
      // For release branches, typically only hotfixes and backports should target them
      const isBackport = head.ref.startsWith('backport-')
      const isHotfix = head.ref.startsWith('hotfix-') || head.ref.startsWith('hotfix/')
      
      if (!isBackport && !isHotfix && headClassification.type.includes('wip')) {
        const body = [
          '## Release Branch Warning',
          '',
          `This PR targets the release branch \`${base.ref}\`.`,
          '',
          'Release branches should only receive:',
          '- **Backports** from the development branch',
          '- **Hotfixes** for critical bugs',
          '',
          'If this is a regular feature/fix, please target `develop` instead.',
          '',
          'If this is intentionally a hotfix, consider naming your branch `hotfix/description`.',
        ].join('\n')

        await postReview({ github, context, core, dry, body })
        // This is a warning, not an error
        core.warning('PR targets release branch from non-hotfix/backport branch')
      }
    }

    // Validate feature branches target develop
    if (headClassification.isFeature && 
        !['develop'].includes(base.ref)) {
      const body = [
        '## Feature Branch Target',
        '',
        `Feature branches should typically target \`develop\`, not \`${base.ref}\`.`,
        '',
        'Please verify this is the correct target branch.',
      ].join('\n')

      core.warning(body)
      // Don't block, just warn
    }

    // Process merge state
    let mergedSha, targetSha

    if (prInfo.mergeable) {
      core.info('✓ 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('⚠ PR has merge conflicts.')

      mergedSha = head.sha
      targetSha = (
        await github.rest.repos.compareCommitsWithBasehead({
          ...context.repo,
          basehead: `${base.sha}...${head.sha}`,
        })
      ).data.merge_base_commit.sha
    }

    // Set outputs for downstream jobs
    core.setOutput('mergedSha', mergedSha)
    core.setOutput('targetSha', targetSha)
    core.setOutput('mergeable', prInfo.mergeable)
    core.setOutput('headSha', head.sha)
    core.setOutput('baseSha', base.sha)

    // Get changed files for analysis
    const files = await github.paginate(github.rest.pulls.listFiles, {
      ...context.repo,
      pull_number,
      per_page: 100,
    })

    // Categorize changes
    const categories = {
      source: files.filter(f => 
        f.filename.startsWith('launcher/')
      ).length,
      ui: files.filter(f => 
        f.filename.includes('/ui/')
      ).length,
      build: files.filter(f => 
        f.filename.includes('CMake') || 
        f.filename.includes('vcpkg') ||
        f.filename.endsWith('.cmake')
      ).length,
      ci: files.filter(f => 
        f.filename.startsWith('.github/') || 
        f.filename.startsWith('ci/')
      ).length,
      docs: files.filter(f => 
        f.filename.startsWith('docs/') || 
        f.filename.endsWith('.md')
      ).length,
      translations: files.filter(f => 
        f.filename.includes('translations/')
      ).length,
    }

    core.info(`Changes summary:`)
    core.info(`  Source files: ${categories.source}`)
    core.info(`  UI files: ${categories.ui}`)
    core.info(`  Build files: ${categories.build}`)
    core.info(`  CI files: ${categories.ci}`)
    core.info(`  Documentation: ${categories.docs}`)
    core.info(`  Translations: ${categories.translations}`)

    core.setOutput('categories', JSON.stringify(categories))
    core.setOutput('totalFiles', files.length)

    // Write step summary
    if (process.env.GITHUB_STEP_SUMMARY) {
      const fs = require('node:fs')
      const summary = [
        '## PR Preparation Summary',
        '',
        `| Property | Value |`,
        `|----------|-------|`,
        `| PR Number | #${pull_number} |`,
        `| Base Branch | \`${base.ref}\` |`,
        `| Head Branch | \`${head.ref}\` |`,
        `| Mergeable | ${prInfo.mergeable ? '✅ Yes' : '❌ No'} |`,
        `| Total Files | ${files.length} |`,
        '',
        '### Change Categories',
        '',
        `| Category | Files |`,
        `|----------|-------|`,
        ...Object.entries(categories).map(([cat, count]) => 
          `| ${cat.charAt(0).toUpperCase() + cat.slice(1)} | ${count} |`
        ),
      ].join('\n')

      fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary)
    }

    return {
      mergeable: prInfo.mergeable,
      mergedSha,
      targetSha,
      headSha: head.sha,
      baseSha: base.sha,
      base: baseClassification,
      head: headClassification,
      files: files.length,
      categories,
    }
  }

  throw new Error('Timeout waiting for merge status computation')
}