mirror of
https://github.com/krkn-chaos/krkn.git
synced 2026-08-25 09:27:36 +00:00
feat(ci): add needs-rebase bot workflow (#1232)
Automatically detects merge conflicts on PRs, adds the `needs-rebase` label, and posts a comment with rebase instructions. Cleans up both the label and comment automatically once conflicts are resolved. Triggers on PR open/sync and on push to main (to re-check all open PRs when the base branch moves). Each PR check is wrapped in try/catch so a single failure logs a warning and continues to the next PR. Signed-off-by: Paige Patton <prubenda@redhat.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
dfc3a1d716
commit
a099a4674d
@@ -0,0 +1,170 @@
|
||||
name: Needs Rebase
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
needs-rebase:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for merge conflicts and label PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const LABEL = 'needs-rebase';
|
||||
const COMMENT_MARKER = '<!-- needs-rebase-bot -->';
|
||||
|
||||
// Poll until GitHub has computed the mergeable status (it's async).
|
||||
// maxAttempts=1 skips retries for bulk push scans to avoid per-PR sleeps.
|
||||
async function getMergeableStatus(prNumber, maxAttempts = 6) {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.mergeable !== null) return { mergeable: pr.mergeable, author: pr.user.login };
|
||||
if (attempt + 1 < maxAttempts) {
|
||||
core.info(`PR #${prNumber}: mergeable not computed yet, retrying in 5s (attempt ${attempt + 1}/${maxAttempts})`);
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
}
|
||||
}
|
||||
core.warning(`PR #${prNumber}: could not determine mergeable status after retries`);
|
||||
return { mergeable: null, author: null };
|
||||
}
|
||||
|
||||
async function findBotComment(prNumber) {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
});
|
||||
return comments.find(c => c.body.includes(COMMENT_MARKER));
|
||||
}
|
||||
|
||||
async function hasLabel(prNumber) {
|
||||
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
});
|
||||
return labels.some(l => l.name === LABEL);
|
||||
}
|
||||
|
||||
async function handlePR(prNumber, knownAuthor, maxAttempts = 6) {
|
||||
const { mergeable, author } = await getMergeableStatus(prNumber, maxAttempts);
|
||||
const prAuthor = knownAuthor || author;
|
||||
|
||||
if (mergeable === null) return; // unknown — leave the PR alone
|
||||
|
||||
const alreadyLabeled = await hasLabel(prNumber);
|
||||
const existingComment = await findBotComment(prNumber);
|
||||
|
||||
if (!mergeable) {
|
||||
// PR has conflicts — add label + comment if not already done
|
||||
if (!alreadyLabeled) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
labels: [LABEL],
|
||||
});
|
||||
core.info(`PR #${prNumber}: added '${LABEL}' label`);
|
||||
}
|
||||
|
||||
if (!existingComment) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: [
|
||||
COMMENT_MARKER,
|
||||
`Hi @${prAuthor}! :wave: This PR has merge conflicts with \`main\` and needs to be rebased before it can be merged.`,
|
||||
``,
|
||||
`**To rebase your branch:**`,
|
||||
`\`\`\`bash`,
|
||||
`git fetch upstream`,
|
||||
`git rebase upstream/main`,
|
||||
`# resolve any conflicts, then:`,
|
||||
`git add .`,
|
||||
`git rebase --continue`,
|
||||
`git push --force-with-lease`,
|
||||
`\`\`\``,
|
||||
``,
|
||||
`> **Tip:** \`--force-with-lease\` is safer than \`--force\` — it will fail if someone else has pushed to your branch since your last fetch.`,
|
||||
``,
|
||||
`For more details, see the [Rebase with upstream](https://krkn-chaos.dev/docs/contribution-guidelines/git-pointers/#rebase-with-upstream) guide.`,
|
||||
``,
|
||||
`The \`${LABEL}\` label and this comment will be removed automatically once conflicts are resolved.`,
|
||||
].join('\n'),
|
||||
});
|
||||
core.info(`PR #${prNumber}: posted needs-rebase comment`);
|
||||
}
|
||||
} else {
|
||||
// PR is clean — remove label and comment
|
||||
if (alreadyLabeled) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: LABEL,
|
||||
});
|
||||
core.info(`PR #${prNumber}: removed '${LABEL}' label`);
|
||||
}
|
||||
|
||||
if (existingComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existingComment.id,
|
||||
});
|
||||
core.info(`PR #${prNumber}: removed needs-rebase comment`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (context.eventName === 'pull_request_target') {
|
||||
// A PR was opened or updated — check just this PR
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const author = context.payload.pull_request.user.login;
|
||||
core.info(`Triggered by PR #${prNumber} (${context.payload.action})`);
|
||||
try {
|
||||
await handlePR(prNumber, author);
|
||||
} catch (e) {
|
||||
core.warning(`PR #${prNumber}: unexpected error — ${e.message}`);
|
||||
}
|
||||
|
||||
} else if (context.eventName === 'push') {
|
||||
// main was pushed to — re-check all open PRs targeting main.
|
||||
// Use a single read attempt per PR (no retry sleep): mergeable is
|
||||
// likely null for all PRs right after a push and will be recomputed
|
||||
// by GitHub asynchronously. PRs with null status are skipped here
|
||||
// and will be picked up on the next pull_request_target event.
|
||||
// PRs are processed in concurrent batches to reduce elapsed time.
|
||||
core.info('Triggered by push to main — checking all open PRs');
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
base: 'main',
|
||||
});
|
||||
core.info(`Found ${prs.length} open PRs targeting main`);
|
||||
const CONCURRENCY = 5;
|
||||
for (let i = 0; i < prs.length; i += CONCURRENCY) {
|
||||
const batch = prs.slice(i, i + CONCURRENCY);
|
||||
await Promise.all(batch.map(pr =>
|
||||
handlePR(pr.number, pr.user.login, 1).catch(e =>
|
||||
core.warning(`PR #${pr.number}: unexpected error — ${e.message}`)
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,6 @@ class vSphere:
|
||||
"'VSPHERE_IP', 'VSPHERE_USERNAME', "
|
||||
"'VSPHERE_PASSWORD' are not set"
|
||||
)
|
||||
|
||||
self.client = create_vsphere_client(
|
||||
server=self.server,
|
||||
username=self.username,
|
||||
|
||||
Reference in New Issue
Block a user