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 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57
2026-08-04 19:35:06 +03:00
committed by GitHub
co-authored by M57
parent 9370523ce9
commit ebba7ff654
+72 -41
View File
@@ -7,25 +7,22 @@ on:
push: push:
branches: [main] 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: concurrency:
group: merge-conflict-${{ github.event_name == 'pull_request' && github.ref || github.sha }} group: merge-conflict-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
detect-and-label: detect-and-label:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read
pull-requests: write pull-requests: write
issues: write issues: write
steps: steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Ensure label exists - name: Ensure label exists
uses: actions/github-script@v9 uses: actions/github-script@v9
with: with:
@@ -34,15 +31,21 @@ jobs:
const { data: labels } = await github.rest.issues.listLabelsForRepo({ const { data: labels } = await github.rest.issues.listLabelsForRepo({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
per_page: 100,
}); });
if (!labels.some(l => l.name === LABEL)) { if (!labels.some(l => l.name === LABEL)) {
await github.rest.issues.createLabel({ try {
owner: context.repo.owner, await github.rest.issues.createLabel({
repo: context.repo.repo, owner: context.repo.owner,
name: LABEL, repo: context.repo.repo,
color: 'd73a4a', name: LABEL,
description: 'This PR has merge conflicts that need to be resolved', 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 - name: Check PRs and label conflicts
@@ -51,21 +54,43 @@ jobs:
script: | script: |
const LABEL = 'has-conflicts'; const LABEL = 'has-conflicts';
async function checkAndLabel(prNumber) { // GitHub computes `mergeable` asynchronously. Immediately after a push
// Fetch PR with mergeable status // it returns `null` ("still computing") and can stay null for minutes.
const { data: pr } = await github.rest.pulls.get({ // The previous version treated `null` as "leave as-is" and never
owner: context.repo.owner, // re-checked, so conflicting PRs were never labeled. This polls until
repo: context.repo.repo, // GitHub resolves the merge state (or we time out), returning the full
pull_number: prNumber, // 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 async function checkAndLabel(pr) {
const hasConflicts = pr.mergeable === false; const prNumber = pr.number;
const currentLabels = pr.labels.map(l => l.name); 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); const hasLabel = currentLabels.includes(LABEL);
core.info( core.info(
`PR #${prNumber}: mergeable=${pr.mergeable}, ` + `PR #${prNumber}: mergeable=${resolved.mergeable}, ` +
`hasLabel=${hasLabel}, conflicts=${hasConflicts}` `hasLabel=${hasLabel}, conflicts=${hasConflicts}`
); );
@@ -78,28 +103,32 @@ jobs:
}); });
core.info(`✅ Added "${LABEL}" to PR #${prNumber}`); core.info(`✅ Added "${LABEL}" to PR #${prNumber}`);
} else if (!hasConflicts && hasLabel) { } else if (!hasConflicts && hasLabel) {
await github.rest.issues.removeLabel({ try {
owner: context.repo.owner, await github.rest.issues.removeLabel({
repo: context.repo.repo, owner: context.repo.owner,
issue_number: prNumber, repo: context.repo.repo,
name: LABEL, issue_number: prNumber,
}); name: LABEL,
core.info(`✅ Removed "${LABEL}" from PR #${prNumber}`); });
} else if (pr.mergeable === null) { core.info(`✅ Removed "${LABEL}" from PR #${prNumber}`);
core.warning( } catch (e) {
`PR #${prNumber}: mergeable is null (still computing). ` + // 404 = label already gone (race with another run) — fine.
`Label left as-is. The next event will recheck.` if (e.status !== 404) throw e;
); }
} }
} }
// ── Single PR (pull_request event) ───────────────────────── // ── Single PR (pull_request event) ─────────────────────────
if (context.eventName === 'pull_request') { 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; return;
} }
// ── Bulk check all open PRs (push to main event) ─────────── // ── 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') { if (context.eventName === 'push') {
const { data: prs } = await github.rest.pulls.list({ const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner, owner: context.repo.owner,
@@ -109,7 +138,9 @@ jobs:
per_page: 100, per_page: 100,
}); });
core.info(`Checking ${prs.length} open PR(s) base on main...`); core.info(`Checking ${prs.length} open PR(s) base on main...`);
for (const pr of prs) { const CHUNK = 5;
await checkAndLabel(pr.number); for (let i = 0; i < prs.length; i += CHUNK) {
const batch = prs.slice(i, i + CHUNK);
await Promise.all(batch.map(p => checkAndLabel(p)));
} }
} }