-
Notifications
You must be signed in to change notification settings - Fork 287
feat: automated review queue label sync , Phase 1 (label sync only) #2242
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
exploreriii
merged 11 commits into
hiero-ledger:main
from
darshit2308:feature/review-sync-phase1
May 7, 2026
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
6e67c49
feat: add Phase 1 review queue label sync
darshit2308 222e06b
fix: address Copilot code review feedback
darshit2308 06fdf32
docs: update syncLabel JSDoc to match revised early-return logic
darshit2308 e0be030
feat: 4-stage review queue pipeline with maintainer gate
darshit2308 84a35d4
fix: require 2+ reviews for ready-to-merge, not just maintainer alone
darshit2308 0582679
fix: rename ready-to-merge to status: ready-to-merge
darshit2308 96acaa5
chore: fix lingering comment referring to ready-to-merge
darshit2308 5b87b89
fix: throw on non-404 stale label removal errors
darshit2308 8d48308
fix: address review feedback from Gourav
darshit2308 4a028e6
style: fix trailing whitespace caught by pre-commit hook
darshit2308 e544d59
Merge branch 'main' into feature/review-sync-phase1
darshit2308 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // | ||
| // helpers/constants.js | ||
| // | ||
| // Shared constants for the Review Queue Label Sync. | ||
|
|
||
| /** | ||
| * Safety floor for the GitHub API rate limit. | ||
| * The cron job will skip processing if remaining calls fall below this. | ||
| */ | ||
| const RATE_LIMIT_FLOOR = 200; | ||
|
|
||
| /** | ||
| * The four mutually exclusive queue labels managed by this bot. | ||
| * | ||
| * Flow: | ||
| * queue:junior-committer → queue:committers → queue:maintainers → status: ready-to-merge | ||
| */ | ||
| const QUEUE_LABELS = { | ||
| JUNIOR: { | ||
| name: 'queue:junior-committer', | ||
| color: 'e4e669', | ||
| description: 'PR awaiting initial quality review', | ||
| }, | ||
| COMMITTERS: { | ||
| name: 'queue:committers', | ||
| color: '0075ca', | ||
| description: 'PR awaiting committer technical review', | ||
| }, | ||
| MAINTAINERS: { | ||
| name: 'queue:maintainers', | ||
| color: 'd876e3', | ||
| description: 'PR awaiting maintainer final review', | ||
| }, | ||
| MERGE: { | ||
| name: 'status: ready-to-merge', | ||
| color: '0e8a16', | ||
| description: 'PR has 1+ maintainer and 2+ total approvals, ready to merge', | ||
| }, | ||
| }; | ||
|
|
||
| /** All queue label names, used for cleanup operations. */ | ||
| const ALL_QUEUE_LABEL_NAMES = Object.values(QUEUE_LABELS).map((l) => l.name); | ||
|
|
||
| module.exports = { RATE_LIMIT_FLOOR, QUEUE_LABELS, ALL_QUEUE_LABEL_NAMES }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // | ||
| // helpers/index.js | ||
| // | ||
| // Namespaced barrel export for review-sync helpers. | ||
| // Uses namespacing to prevent silent name collisions from spread exports. | ||
| // | ||
| // Usage: | ||
| // const helpers = require('./helpers'); | ||
| // const { QUEUE_LABELS } = helpers.constants; | ||
| // const { syncLabel } = helpers.labels; | ||
|
|
||
| module.exports = { | ||
| constants: require('./constants'), | ||
| permissions: require('./permissions'), | ||
| reviews: require('./reviews'), | ||
| labels: require('./labels'), | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // | ||
| // helpers/labels.js | ||
| // | ||
| // Label creation, determination, and synchronization. | ||
| // | ||
| // Key design decisions: | ||
| // - ensureLabel() silently handles 422 (race condition / concurrent run) | ||
| // - determineLabel() uses a 4-stage pipeline gated by maintainer approval | ||
| // - syncLabel() adds the correct label FIRST, then removes stale ones | ||
| // (crash-safe: PR never has zero queue labels) | ||
|
|
||
| const { QUEUE_LABELS, ALL_QUEUE_LABEL_NAMES } = require('./constants'); | ||
| const { countApprovals } = require('./permissions'); | ||
|
|
||
| /** | ||
| * Ensure a single label exists in the repo. | ||
| * Silently handles 422 (label already exists). | ||
| * | ||
| * Note: checks existence only. If a label already exists with the wrong | ||
| * colour or description, it will not be corrected in Phase 1. | ||
| */ | ||
| async function ensureLabel(github, owner, repo, label, dryRun) { | ||
| try { | ||
| await github.rest.issues.getLabel({ owner, repo, name: label.name }); | ||
| console.log(` Label "${label.name}" already exists. Skipping creation.`); | ||
| } catch (error) { | ||
| if (error.status === 404) { | ||
| if (dryRun) { | ||
| console.log(` [DRY RUN] Would create label "${label.name}" (${label.color}).`); | ||
| return; | ||
| } | ||
| try { | ||
| await github.rest.issues.createLabel({ | ||
| owner, | ||
| repo, | ||
| name: label.name, | ||
| color: label.color, | ||
| description: label.description, | ||
| }); | ||
| console.log(` Created label "${label.name}" (#${label.color}).`); | ||
| } catch (createError) { | ||
| // 422 = label already exists (race condition or concurrent run) | ||
| if (createError.status === 422) { | ||
| console.log(` Label "${label.name}" already exists (422). Skipping.`); | ||
| } else { | ||
| throw createError; | ||
| } | ||
| } | ||
| } else { | ||
| throw error; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Determine the correct queue label for a PR based on approval counts. | ||
| * | ||
| * Phase 1 logic (4-stage pipeline): | ||
| * maintainerApproval >= 1 AND (maintainerApproval + writeApproval) >= 2 → status: ready-to-merge (CODEOWNERS + min core reviews) | ||
| * writeApproval >= 1 OR maintainerApproval >= 1 → queue:maintainers (senior review present, needs more) | ||
| * anyApproval >= 1 → queue:committers (has any approval, needs committer) | ||
| * else → queue:junior-committer (no approvals yet) | ||
| * | ||
| * Note: status: ready-to-merge requires BOTH a maintainer approval AND at least 2 | ||
| * total core reviews (maintainer or write). This prevents a single maintainer approval | ||
| * + a soft approval from marking a PR as ready when branch protection requires 2+ core reviews. | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| * | ||
| * @param {{ maintainerApproval: number, writeApproval: number, softApproval: number, anyApproval: number }} approvals | ||
| * @returns {object} The correct QUEUE_LABELS entry | ||
| */ | ||
| function determineLabel(approvals) { | ||
| if (approvals.maintainerApproval >= 1 && (approvals.maintainerApproval + approvals.writeApproval) >= 2) { | ||
| return QUEUE_LABELS.MERGE; | ||
| } | ||
| if (approvals.writeApproval >= 1 || approvals.maintainerApproval >= 1) { | ||
| return QUEUE_LABELS.MAINTAINERS; | ||
| } | ||
| if (approvals.anyApproval >= 1) { | ||
|
exploreriii marked this conversation as resolved.
|
||
| return QUEUE_LABELS.COMMITTERS; | ||
| } | ||
| return QUEUE_LABELS.JUNIOR; | ||
| } | ||
|
|
||
| /** | ||
| * Sync the queue label on a single PR. | ||
| * | ||
| * Order of operations (non-negotiable): | ||
| * 1. Compute stale queue labels on the PR | ||
| * 2. Skip only if correct label is already present AND no stale labels exist | ||
| * 3. ADD the correct label first | ||
| * 4. THEN remove any stale queue labels | ||
| * | ||
| * This ensures a PR never has zero queue labels, even if the process | ||
| * crashes mid-run. Stale labels are always cleaned up, even when the | ||
| * correct label was already present. | ||
| * | ||
| * @param {object} github - Octokit instance | ||
| * @param {string} owner - Repository owner | ||
| * @param {string} repo - Repository name | ||
| * @param {object} pr - Pull request object from the list API | ||
| * @param {boolean} dryRun - If true, log without making changes | ||
| * @returns {boolean} true if the label was changed, false if already correct | ||
| */ | ||
| async function syncLabel(github, owner, repo, pr, dryRun) { | ||
| const prNumber = pr.number; | ||
| const currentLabels = (pr.labels || []).map((l) => l.name); | ||
|
|
||
| // Count approvals and determine the correct label | ||
| const approvals = await countApprovals(github, owner, repo, prNumber); | ||
| const correctLabel = determineLabel(approvals); | ||
|
|
||
| console.log( | ||
| ` PR #${prNumber}: maintainerApproval=${approvals.maintainerApproval}, ` + | ||
| `writeApproval=${approvals.writeApproval}, ` + | ||
| `softApproval=${approvals.softApproval}, anyApproval=${approvals.anyApproval} ` + | ||
| `→ ${correctLabel.name}` | ||
| ); | ||
|
|
||
| // Determine which stale queue labels to remove | ||
| const staleLabels = currentLabels.filter( | ||
| (name) => ALL_QUEUE_LABEL_NAMES.includes(name) && name !== correctLabel.name | ||
| ); | ||
|
|
||
| // Check if the correct label is already present AND there are no stale labels to remove | ||
| if (currentLabels.includes(correctLabel.name) && staleLabels.length === 0) { | ||
| console.log(` ✓ Already has "${correctLabel.name}". No change needed.`); | ||
| return false; | ||
| } | ||
|
|
||
| if (dryRun) { | ||
| console.log(` [DRY RUN] Would add "${correctLabel.name}".`); | ||
| if (staleLabels.length > 0) { | ||
| console.log(` [DRY RUN] Would remove: ${staleLabels.join(', ')}.`); | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| // Step 1: ADD the correct label FIRST (crash-safe: PR always has at least one label) | ||
| await github.rest.issues.addLabels({ | ||
| owner, | ||
| repo, | ||
| issue_number: prNumber, | ||
| labels: [correctLabel.name], | ||
| }); | ||
|
exploreriii marked this conversation as resolved.
|
||
| console.log(` + Added "${correctLabel.name}".`); | ||
|
|
||
| // Step 2: THEN remove stale queue labels one by one | ||
| for (const stale of staleLabels) { | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, | ||
| repo, | ||
| issue_number: prNumber, | ||
| name: stale, | ||
| }); | ||
| console.log(` - Removed "${stale}".`); | ||
| } catch (error) { | ||
| // 404 = label was already removed (race condition or manual action) | ||
| if (error.status === 404) { | ||
| console.log(` - Label "${stale}" already gone (404). Skipping.`); | ||
| } else { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| console.error(` ✗ Failed to remove "${stale}": ${message}`); | ||
|
exploreriii marked this conversation as resolved.
|
||
| throw error; // Re-throw to prevent silently leaving PR in a broken multi-label state | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
exploreriii marked this conversation as resolved.
|
||
|
|
||
| module.exports = { ensureLabel, determineLabel, syncLabel }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // | ||
| // helpers/permissions.js | ||
| // | ||
| // Permission level checks and approval counting. | ||
| // | ||
| // CRITICAL NOTE ON role_name vs permission: | ||
| // getCollaboratorPermissionLevel returns TWO fields: | ||
| // - permission (legacy): maps maintain → write, triage → read | ||
| // - role_name (accurate): returns admin | maintain | write | triage | read | ||
| // | ||
| // We MUST use role_name to distinguish maintainers from committers. | ||
| // If we used permission, Sophie (Maintain role per MAINTAINERS.md) would | ||
| // appear as 'write', and maintainerApproval would always be 0. | ||
| // PRs would be permanently stuck at queue:maintainers. | ||
| // | ||
| // Phase 3 will replace this with team membership checks | ||
| // (getMembershipForUserInOrg) for full accuracy. | ||
|
|
||
| const { getLatestReviewStates } = require('./reviews'); | ||
|
|
||
| /** | ||
| * Check the repository role for a given user. | ||
| * | ||
| * Uses role_name (not legacy permission) to correctly detect the maintain role. | ||
| * | ||
| * @param {object} github - Octokit instance | ||
| * @param {string} owner - Repository owner | ||
| * @param {string} repo - Repository name | ||
| * @param {string} username - GitHub username | ||
| * @returns {string} 'admin' | 'maintain' | 'write' | 'triage' | 'read' | 'none' | ||
| */ | ||
| async function getPermissionLevel(github, owner, repo, username) { | ||
| try { | ||
| const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ | ||
| owner, | ||
| repo, | ||
| username, | ||
| }); | ||
| // CRITICAL: Use role_name, NOT permission. | ||
| // The legacy 'permission' field maps maintain → write, triage → read. | ||
| // role_name correctly returns: admin | maintain | write | triage | read | ||
| return data.role_name || data.permission || 'none'; | ||
| } catch (error) { | ||
| if (error.status === 404) { | ||
|
exploreriii marked this conversation as resolved.
|
||
| // External contributor — not a collaborator | ||
| return 'none'; | ||
| } | ||
| // Log unexpected errors but don't crash the run | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| console.log(` ⚠ Permission check failed for ${username}: ${message}. Treating as "none".`); | ||
| return 'none'; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Count approvals on a PR, split by permission level. | ||
| * | ||
| * Returns three counters: | ||
| * - maintainerApproval: admin or maintain (maps to CODEOWNERS maintainer teams) | ||
| * - writeApproval: write (committers) | ||
| * - softApproval: triage, read, none, external contributors | ||
| * | ||
| * @param {object} github - Octokit instance | ||
| * @param {string} owner - Repository owner | ||
| * @param {string} repo - Repository name | ||
| * @param {number} prNumber - Pull request number | ||
| * @returns {{ maintainerApproval: number, writeApproval: number, softApproval: number, anyApproval: number }} | ||
| */ | ||
| async function countApprovals(github, owner, repo, prNumber) { | ||
| const latestStates = await getLatestReviewStates(github, owner, repo, prNumber); | ||
|
|
||
| let maintainerApproval = 0; | ||
| let writeApproval = 0; | ||
| let softApproval = 0; | ||
|
|
||
| for (const [username, state] of latestStates) { | ||
| if (state !== 'APPROVED') continue; | ||
|
|
||
| const role = await getPermissionLevel(github, owner, repo, username); | ||
|
|
||
| if (role === 'admin' || role === 'maintain') { | ||
| maintainerApproval++; | ||
| } else if (role === 'write') { | ||
| writeApproval++; | ||
| } else { | ||
| // triage, read, none, or any unexpected value → soft approval | ||
| softApproval++; | ||
|
exploreriii marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| return { | ||
| maintainerApproval, | ||
| writeApproval, | ||
| softApproval, | ||
| anyApproval: maintainerApproval + writeApproval + softApproval, | ||
| }; | ||
| } | ||
|
exploreriii marked this conversation as resolved.
|
||
|
|
||
| module.exports = { getPermissionLevel, countApprovals }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.