From a099a4674d82d976e347c19d9411a7d44d14f4bf Mon Sep 17 00:00:00 2001 From: Paige Patton <64206430+paigerube14@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:45:45 -0500 Subject: [PATCH] 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 Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/needs-rebase.yml | 170 ++++++++++++++++++ .../node_actions/vmware_node_scenarios.py | 1 - 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/needs-rebase.yml diff --git a/.github/workflows/needs-rebase.yml b/.github/workflows/needs-rebase.yml new file mode 100644 index 00000000..6430752c --- /dev/null +++ b/.github/workflows/needs-rebase.yml @@ -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 = ''; + + // 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}`) + ) + )); + } + } diff --git a/krkn/scenario_plugins/node_actions/vmware_node_scenarios.py b/krkn/scenario_plugins/node_actions/vmware_node_scenarios.py index 7a654023..aae9ea72 100644 --- a/krkn/scenario_plugins/node_actions/vmware_node_scenarios.py +++ b/krkn/scenario_plugins/node_actions/vmware_node_scenarios.py @@ -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,