diff --git a/.github/scripts/merge_conflict_helpers.js b/.github/scripts/merge_conflict_helpers.js new file mode 100644 index 000000000..4a0485f88 --- /dev/null +++ b/.github/scripts/merge_conflict_helpers.js @@ -0,0 +1,92 @@ +// scripts/merge_conflict_helpers.js + +const BOT_SIGNATURE = '[merge-conflict bot]'; + +module.exports = async ({ github, context, core }) => { + const { owner, repo } = context.repo; + + // fetch PR with retry logic for unknown state + async function getPrWithRetry(prNumber) { + for (let i = 0; i < 10; i++) { + const { data: pr } = await github.rest.pulls.get({ + owner, repo, pull_number: prNumber + }); + + if (pr.mergeable_state !== 'unknown') return pr; + + console.log(`PR #${prNumber} state is 'unknown'. Retrying (${i+1}/10)...`); + await new Promise(r => setTimeout(r, 2000)); + } + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); + return pr; + } + + // post comment + async function notifyUser(prNumber) { + const { data: comments } = await github.rest.issues.listComments({ + owner, repo, issue_number: prNumber, + }); + + if (comments.some(c => c.body.includes(BOT_SIGNATURE))) { + console.log(`Already commented on PR #${prNumber}. Skipping.`); + return; + } + + const body = `Hi, this is MergeConflictBot.\nYour pull request cannot be merged because it contains **merge conflicts**.\n\nPlease resolve these conflicts locally and push the changes.\n\nTo assist you, please read:\n- [Resolving Merge Conflicts](docs/sdk_developers/merge_conflicts.md)\n- [Rebasing Guide](docs/sdk_developers/rebasing.md)\n\nThank you for contributing!\nFrom the Hiero Python SDK Team\n\n${BOT_SIGNATURE}`; + + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, body: body + }); + } + + //set commit status + async function setCommitStatus(sha, state, description) { + await github.rest.repos.createCommitStatus({ + owner, repo, sha: sha, state: state, + context: 'Merge Conflict Detector', + description: description, + target_url: `${process.env.GITHUB_SERVER_URL}/${owner}/${repo}/actions/runs/${context.runId}` + }); + } + + //main + let prsToCheck = []; + + //push to main + if (context.eventName === 'push') { + console.log("Triggered by Push to Main. Fetching all open PRs..."); + const { data: openPrs } = await github.rest.pulls.list({ + owner, repo, state: 'open', base: 'main' + }); + prsToCheck = openPrs.map(pr => pr.number); + } + //PR update + else { + console.log("Triggered by PR update."); + prsToCheck.push(context.payload.pull_request.number); + } + + let hasFailure = false; + + for (const prNumber of prsToCheck) { + console.log(`Checking PR #${prNumber}...`); + const pr = await getPrWithRetry(prNumber); + + if (pr.mergeable_state === 'dirty') { + console.log(`Conflict detected in PR #${prNumber}`); + await notifyUser(prNumber); + + if (context.eventName === 'push') { + await setCommitStatus(pr.head.sha, 'failure', 'Conflicts detected with main'); + } else { + core.setFailed(`Merge conflicts detected in PR #${prNumber}.`); + hasFailure = true; + } + } else { + console.log(`PR #${prNumber} is clean.`); + if (context.eventName === 'push') { + await setCommitStatus(pr.head.sha, 'success', 'No conflicts detected'); + } + } + } +}; diff --git a/.github/workflows/merge-conflict-bot.yml b/.github/workflows/merge-conflict-bot.yml index 3fead7a7d..acfcc6e1f 100644 --- a/.github/workflows/merge-conflict-bot.yml +++ b/.github/workflows/merge-conflict-bot.yml @@ -1,71 +1,41 @@ -name: PythonBot - Check Merge Conflicts +name: Merge Conflict Bot on: pull_request_target: types: [opened, synchronize, reopened] + push: + branches: + - main permissions: contents: read pull-requests: write + issues: write + statuses: write concurrency: - group: "check-conflicts-${{ github.event.pull_request.number }}" + group: "check-conflicts-${{ github.event.pull_request.number || github.sha }}" cancel-in-progress: true jobs: check-conflicts: runs-on: ubuntu-latest - steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 + uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 with: egress-policy: audit - - - name: Check for merge conflicts - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - PR_NUMBER=${{ github.event.pull_request.number }} - REPO="${{ github.repository }}" - - echo "Checking merge status for PR #$PR_NUMBER in repository $REPO..." - - for i in {1..10}; do - PR_JSON=$(gh api repos/$REPO/pulls/$PR_NUMBER) - MERGEABLE_STATE=$(echo "$PR_JSON" | jq -r '.mergeable_state') - - echo "Attempt $i: Current mergeable state: $MERGEABLE_STATE" - - if [ "$MERGEABLE_STATE" != "unknown" ]; then - break - fi - - echo "State is 'unknown', waiting 2 seconds..." - sleep 2 - done - if [ "$MERGEABLE_STATE" = "dirty" ]; then - COMMENT=$(cat </dev/null || \ - (gh pr comment $PR_NUMBER --repo $REPO --body "$COMMENT" && echo "Comment added to PR #$PR_NUMBER") + - name: Check for merge conflicts + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd + with: + script: | + const path = require('path') + const scriptPath = path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge_conflict_helpers.js') - exit 1 - else - echo "No merge conflicts detected (State: $MERGEABLE_STATE)." - fi + console.log(`Loading script from: ${scriptPath}`) + const script = require(scriptPath) + await script({github, context, core})