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:
1 parent
9370523ce9
commit
ebba7ff654
1 file changed
+53
-22
@@ -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,8 +31,10 @@ 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)) {
|
||||||
|
try {
|
||||||
await github.rest.issues.createLabel({
|
await github.rest.issues.createLabel({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
@@ -43,6 +42,10 @@ jobs:
|
|||||||
color: 'd73a4a',
|
color: 'd73a4a',
|
||||||
description: 'This PR has merge conflicts that need to be resolved',
|
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.
|
||||||
|
// 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({
|
const { data: pr } = await github.rest.pulls.get({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
pull_number: prNumber,
|
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,6 +103,7 @@ jobs:
|
|||||||
});
|
});
|
||||||
core.info(`✅ Added "${LABEL}" to PR #${prNumber}`);
|
core.info(`✅ Added "${LABEL}" to PR #${prNumber}`);
|
||||||
} else if (!hasConflicts && hasLabel) {
|
} else if (!hasConflicts && hasLabel) {
|
||||||
|
try {
|
||||||
await github.rest.issues.removeLabel({
|
await github.rest.issues.removeLabel({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
@@ -85,21 +111,24 @@ jobs:
|
|||||||
name: LABEL,
|
name: LABEL,
|
||||||
});
|
});
|
||||||
core.info(`✅ Removed "${LABEL}" from PR #${prNumber}`);
|
core.info(`✅ Removed "${LABEL}" from PR #${prNumber}`);
|
||||||
} else if (pr.mergeable === null) {
|
} catch (e) {
|
||||||
core.warning(
|
// 404 = label already gone (race with another run) — fine.
|
||||||
`PR #${prNumber}: mergeable is null (still computing). ` +
|
if (e.status !== 404) throw e;
|
||||||
`Label left as-is. The next event will recheck.`
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user