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')
}
|