From ebba7ff654ad4a573a4bedeb890f9c0745b2bda4 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:35:06 +0300 Subject: [PATCH] fix(ci): poll mergeable until resolved in conflict detector (#814) * fix(ci): poll mergeable until resolved in conflict detector GitHub computes pr.mergeable asynchronously and returns null ('still computing') for minutes after a push. The detector treated null as 'leave as-is' and never re-checked, so conflicting PRs were never labeled has-conflicts. Add a retry loop (getMergeable) that polls until GitHub resolves the merge state (or times out), so conflicts are reliably labeled. * fix(ci): poll mergeable, dedupe concurrency, drop checkout, batch PRs Refine merge-conflict-detector after review: - concurrency group uses github.ref (stable for both PR + push triggers) so cancel-in-progress actually fires on the bulk path. - drop actions/checkout + contents:read (pure API, no FS needed). - getMergeable returns the full PR object (no re-fetch in checkAndLabel). - swallow 422 on createLabel and 404 on removeLabel (race-safe). - batch open PRs in chunks of 5 via Promise.all to bound wall-clock. --------- Co-authored-by: M57 --- .github/workflows/merge-conflict-detector.yml | 113 +++++++++++------- 1 file changed, 72 insertions(+), 41 deletions(-) diff --git a/.github/workflows/merge-conflict-detector.yml b/.github/workflows/merge-conflict-detector.yml index 26bcaab7..bee865c4 100644 --- a/.github/workflows/merge-conflict-detector.yml +++ b/.github/workflows/merge-conflict-detector.yml @@ -7,25 +7,22 @@ on: push: branches: [main] -# Cancel in-progress runs when a new commit lands — no point checking stale state +# Cancel in-progress runs when a new event lands — protects the expensive +# bulk-check path. github.ref is stable per PR (refs/pull/N/merge) AND stable +# for pushes to main (refs/heads/main), so one expression covers both triggers +# and actually cancels a stale bulk run when a newer push arrives. concurrency: - group: merge-conflict-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + group: merge-conflict-${{ github.ref }} cancel-in-progress: true jobs: detect-and-label: runs-on: ubuntu-latest permissions: - contents: read pull-requests: write issues: write steps: - - name: Checkout - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - name: Ensure label exists uses: actions/github-script@v9 with: @@ -34,15 +31,21 @@ jobs: const { data: labels } = await github.rest.issues.listLabelsForRepo({ owner: context.repo.owner, repo: context.repo.repo, + per_page: 100, }); if (!labels.some(l => l.name === LABEL)) { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: LABEL, - color: 'd73a4a', - description: 'This PR has merge conflicts that need to be resolved', - }); + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: LABEL, + color: 'd73a4a', + description: 'This PR has merge conflicts that need to be resolved', + }); + } catch (e) { + // 422 = label already exists (race with another run) — fine. + if (e.status !== 422) throw e; + } } - name: Check PRs and label conflicts @@ -51,21 +54,43 @@ jobs: script: | const LABEL = 'has-conflicts'; - async function checkAndLabel(prNumber) { - // Fetch PR with mergeable status - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - }); + // GitHub computes `mergeable` asynchronously. Immediately after a push + // it returns `null` ("still computing") and can stay null for minutes. + // The previous version treated `null` as "leave as-is" and never + // re-checked, so conflicting PRs were never labeled. This polls until + // GitHub resolves the merge state (or we time out), returning the full + // PR object so callers don't need to re-fetch for labels. + async function getMergeable(prNumber, retries = 12, delayMs = 5000) { + for (let i = 0; i < retries; i++) { + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + if (pr.mergeable !== null) return pr; + core.info(`PR #${prNumber}: mergeable still null, retrying (${i + 1}/${retries})...`); + await new Promise(r => setTimeout(r, delayMs)); + } + core.warning(`PR #${prNumber}: mergeable never resolved after ${retries} tries.`); + return null; + } - // mergeable: true = clean, false = conflicts, null = still computing - const hasConflicts = pr.mergeable === false; - const currentLabels = pr.labels.map(l => l.name); + async function checkAndLabel(pr) { + const prNumber = pr.number; + const resolved = await getMergeable(prNumber); + + if (resolved === null) { + // Couldn't determine state in time — leave label as-is. + core.info(`PR #${prNumber}: mergeable unknown, label left unchanged.`); + return; + } + + const hasConflicts = resolved.mergeable === false; + const currentLabels = resolved.labels.map(l => l.name); const hasLabel = currentLabels.includes(LABEL); core.info( - `PR #${prNumber}: mergeable=${pr.mergeable}, ` + + `PR #${prNumber}: mergeable=${resolved.mergeable}, ` + `hasLabel=${hasLabel}, conflicts=${hasConflicts}` ); @@ -78,28 +103,32 @@ jobs: }); core.info(`✅ Added "${LABEL}" to PR #${prNumber}`); } else if (!hasConflicts && hasLabel) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - name: LABEL, - }); - core.info(`✅ Removed "${LABEL}" from PR #${prNumber}`); - } else if (pr.mergeable === null) { - core.warning( - `PR #${prNumber}: mergeable is null (still computing). ` + - `Label left as-is. The next event will recheck.` - ); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: LABEL, + }); + core.info(`✅ Removed "${LABEL}" from PR #${prNumber}`); + } catch (e) { + // 404 = label already gone (race with another run) — fine. + if (e.status !== 404) throw e; + } } } // ── Single PR (pull_request event) ───────────────────────── if (context.eventName === 'pull_request') { - await checkAndLabel(context.issue.number); + // For a single PR we already have the number; fetch resolves mergeable. + await checkAndLabel({ number: context.issue.number }); return; } // ── Bulk check all open PRs (push to main event) ─────────── + // GitHub usually recomputes mergeable for every open PR at once after a + // push, so most hit the retry loop simultaneously. Batch a few at a time + // with Promise.all to bound wall-clock without hammering the API. if (context.eventName === 'push') { const { data: prs } = await github.rest.pulls.list({ owner: context.repo.owner, @@ -109,7 +138,9 @@ jobs: per_page: 100, }); core.info(`Checking ${prs.length} open PR(s) base on main...`); - for (const pr of prs) { - await checkAndLabel(pr.number); + const CHUNK = 5; + for (let i = 0; i < prs.length; i += CHUNK) { + const batch = prs.slice(i, i + CHUNK); + await Promise.all(batch.map(p => checkAndLabel(p))); } }