forked from sugarlabs/musicblocks
-
Notifications
You must be signed in to change notification settings - Fork 0
324 lines (287 loc) · 17 KB
/
Copy pathauto-rebase.yml
File metadata and controls
324 lines (287 loc) · 17 KB
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
315
316
317
318
319
320
321
322
323
324
name: Auto Rebase PRs
on:
push:
branches: [master]
workflow_dispatch:
permissions:
contents: write
pull-requests: write
# Prevent two rebase runs from fighting over the same PR branches
concurrency:
group: auto-rebase
cancel-in-progress: true
jobs:
rebase:
name: Auto-rebase all open PRs onto latest master
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Rebase open PRs
uses: actions/github-script@v7
with:
script: |
const { execFileSync } = require('child_process');
const excludeLabels = ['no-rebase', 'wip', 'do-not-merge'];
const RECENT_PUSH_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
const FAILURE_COMMENT_DEDUPE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// Covers ~99% of forks; unshallow fallback handles longer divergence
const FORK_FETCH_DEPTH = 100;
// Hidden marker so we can identify our own past comments
const COMMENT_MARKER = '<!-- auto-rebase-bot:failure -->';
// Future-proof: don't hardcode 'master' for cleanup checkouts.
// The PR list filter and rebase target are still 'master' on purpose —
// that's user configuration — but the cleanup just needs *some* branch
// to switch to before deleting the temp branch.
const defaultBranch =
context.payload.repository?.default_branch || 'master';
// Safe git helper — no shell, prevents injection via branch names
// or committer fields
const git = (...args) =>
execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' });
// Safe cleanup helpers — never throw
const tryGit = (...args) => {
try { git(...args); } catch {}
};
const cleanupIteration = (remoteName, tempBranch) => {
tryGit('rebase', '--abort');
tryGit('checkout', defaultBranch);
if (tempBranch) tryGit('branch', '-D', tempBranch);
if (remoteName) tryGit('remote', 'remove', remoteName);
};
// Fetch all open, non-draft PRs targeting master
const pulls = await github.paginate(
github.rest.pulls.list,
{
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
base: 'master',
per_page: 100
}
);
// Filter out drafts and excluded labels
const eligible = pulls.filter(pr => {
if (pr.draft) return false;
const labels = pr.labels.map(l => l.name);
return !excludeLabels.some(ex => labels.includes(ex));
});
// Sort oldest → newest to reduce churn when PRs touch the same code
eligible.sort(
(a, b) => new Date(a.created_at) - new Date(b.created_at)
);
// Counters: skipped (intentional non-attempt) vs failed (attempted, errored)
let rebased = 0;
let skipped = 0;
let failed = 0;
const skips = [];
const failures = [];
// Outputs for downstream CI: rebase counts and totals for observability
const emitOutputs = () => {
core.setOutput('rebased-count', rebased);
core.setOutput('skipped-count', skipped);
core.setOutput('failed-count', failed);
core.setOutput('total-count', eligible.length);
};
if (eligible.length === 0) {
core.info('No eligible pull requests found.');
emitOutputs();
return;
}
core.info(`Found ${eligible.length} eligible PR(s) to rebase.`);
for (const pr of eligible) {
const headRef = pr.head.ref;
const headRepo = pr.head.repo;
const prNumber = pr.number;
const remoteName = `__pr${prNumber}`;
const tempBranch = `__rebase_tmp_${prNumber}`;
core.info(`\n--- PR #${prNumber}: ${pr.title} (${headRef}) ---`);
// Skip PRs whose fork has been deleted (head.repo becomes null)
if (!headRepo) {
core.warning(
`PR #${prNumber}: skipped — source repository was deleted.`
);
skipped++;
skips.push({ pr: prNumber, reason: 'source repository deleted' });
continue;
}
// Skip PRs from forks that don't allow maintainer edits
if (headRepo.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
if (!pr.maintainer_can_modify) {
core.warning(
`PR #${prNumber}: skipped — fork does not allow maintainer edits.`
);
skipped++;
skips.push({ pr: prNumber, reason: 'fork does not allow maintainer edits' });
continue;
}
}
// Skip PRs with very recent *commits* to avoid racing the contributor.
// Using head-commit time rather than pr.updated_at, since updated_at
// also fires for comments, labels, reviews, and title edits.
let headCommitTime;
try {
const { data: headCommit } = await github.rest.repos.getCommit({
owner: headRepo.owner.login,
repo: headRepo.name,
ref: pr.head.sha
});
headCommitTime = new Date(
headCommit.commit.committer.date
).getTime();
} catch (e) {
core.warning(
`PR #${prNumber}: could not read head commit time ` +
`(${e.message}); falling back to pr.updated_at.`
);
headCommitTime = new Date(pr.updated_at).getTime();
}
if (Date.now() - headCommitTime < RECENT_PUSH_WINDOW_MS) {
core.info(
`PR #${prNumber}: skipped — head commit is less than ` +
`5 minutes old.`
);
skipped++;
skips.push({ pr: prNumber, reason: 'head commit too recent' });
continue;
}
try {
// Clean up any leftover state from a previous iteration
cleanupIteration(null, tempBranch);
// Refresh master before each rebase — master may have moved
// since the workflow started, especially on long runs. The
// explicit refspec guarantees refs/remotes/origin/master is
// updated regardless of the remote's configured fetch refspec.
git('fetch', 'origin', 'master:refs/remotes/origin/master');
const remoteUrl = headRepo.clone_url;
// Add remote for this PR (remove first if it exists from a prior run)
tryGit('remote', 'remove', remoteName);
git('remote', 'add', remoteName, remoteUrl);
// Shallow-fetch the PR branch for speed. Long-lived branches whose
// merge-base falls outside this depth are handled below by
// unshallowing on demand — no false failures for valid rebases.
git('fetch', `--depth=${FORK_FETCH_DEPTH}`, remoteName, headRef);
// Checkout the PR branch locally. -B creates or resets, so a
// leftover branch from a failed cleanup won't block us. The
// branch name is also unique per PR to eliminate collisions.
git('checkout', '-B', tempBranch, `${remoteName}/${headRef}`);
// Verify we have enough fork history to compute the merge-base.
// If the shallow fetch missed the divergence point, deepen the
// fork branch to full history and retry once.
let mergeBase;
try {
mergeBase = git('merge-base', 'HEAD', 'origin/master').trim();
} catch {
core.info(
`PR #${prNumber}: shallow history insufficient for ` +
`merge-base, refetching with full history.`
);
git('fetch', '--unshallow', remoteName, headRef);
mergeBase = git('merge-base', 'HEAD', 'origin/master').trim();
}
const masterHead = git('rev-parse', 'origin/master').trim();
if (mergeBase === masterHead) {
core.info(`PR #${prNumber}: already up to date.`);
cleanupIteration(remoteName, tempBranch);
continue;
}
// Preserve the committer identity from the PR's last commit.
// Passed via -c flags so we don't pollute repo config between PRs.
const committerName = git('log', '-1', '--format=%cn').trim();
const committerEmail = git('log', '-1', '--format=%ce').trim();
core.info(` Committer: ${committerName} <${committerEmail}>`);
// Attempt rebase onto latest master
git(
'-c', `user.name=${committerName}`,
'-c', `user.email=${committerEmail}`,
'rebase', 'origin/master'
);
// Push rebased branch back (force-with-lease for safety)
git('push', '--force-with-lease', remoteName, `HEAD:${headRef}`);
core.info(`PR #${prNumber}: successfully rebased.`);
rebased++;
// Clean up this iteration's remote and branch
cleanupIteration(remoteName, tempBranch);
} catch (err) {
// Rebase failed (conflicts, permissions, etc.) — skip and continue
const reason = (err.message || 'unknown error')
.split('\n')[0]
.slice(0, 200);
core.warning(
`PR #${prNumber}: rebase failed, skipping. Error: ${reason}`
);
failed++;
failures.push({ pr: prNumber, reason });
// Best-effort cleanup so the next iteration starts clean
cleanupIteration(remoteName, tempBranch);
// Notify the PR author — but dedupe so the same stuck PR
// doesn't get a fresh comment on every master push. We bound
// the scan by `since` (the dedupe window) and paginate within
// it: listComments is oldest-first and accepts no sort param,
// so on a noisy PR our marker can fall outside the first page
// and we'd double-post. Paginating over the window is correct
// and stays cheap because `since` caps how much we fetch.
try {
const since = new Date(
Date.now() - FAILURE_COMMENT_DEDUPE_MS
).toISOString();
const recentComments = await github.paginate(
github.rest.issues.listComments,
{
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
since,
per_page: 100
}
);
const alreadyNotified = recentComments.some(c =>
(c.body || '').includes(COMMENT_MARKER)
);
if (alreadyNotified) {
core.info(
`PR #${prNumber}: recent failure comment already ` +
`exists, not posting another.`
);
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body:
COMMENT_MARKER + '\n\n' +
'Automatic rebase onto `master` failed ' +
'(likely due to merge conflicts). Please rebase ' +
'this branch manually.\n\n' +
'<details><summary>Details</summary>\n\n' +
'```\n' + reason + '\n```\n\n</details>'
});
}
} catch (commentErr) {
core.warning(
`PR #${prNumber}: could not post failure comment: ${commentErr.message}`
);
}
}
}
core.info(`\n=== Summary ===`);
core.info(`Rebased: ${rebased}`);
core.info(`Skipped: ${skipped}`);
core.info(`Failed: ${failed}`);
if (skips.length > 0) {
core.info('Skipped PRs:');
for (const s of skips) {
core.info(` #${s.pr} — ${s.reason}`);
}
}
if (failures.length > 0) {
core.info('Failed PRs:');
for (const f of failures) {
core.info(` #${f.pr} — ${f.reason}`);
}
}
core.info(`Total eligible: ${eligible.length}`);
emitOutputs();