Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/scripts/review-sync/helpers/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: Apache-2.0
Comment thread
exploreriii marked this conversation as resolved.
//
// 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 };
18 changes: 18 additions & 0 deletions .github/scripts/review-sync/helpers/index.js
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'),
};
173 changes: 173 additions & 0 deletions .github/scripts/review-sync/helpers/labels.js
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.
Comment thread
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) {
Comment thread
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],
});
Comment thread
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}`);
Comment thread
exploreriii marked this conversation as resolved.
throw error; // Re-throw to prevent silently leaving PR in a broken multi-label state
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

return true;
}
Comment thread
exploreriii marked this conversation as resolved.

module.exports = { ensureLabel, determineLabel, syncLabel };
100 changes: 100 additions & 0 deletions .github/scripts/review-sync/helpers/permissions.js
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) {
Comment thread
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++;
Comment thread
exploreriii marked this conversation as resolved.
}
}

return {
maintainerApproval,
writeApproval,
softApproval,
anyApproval: maintainerApproval + writeApproval + softApproval,
};
}
Comment thread
exploreriii marked this conversation as resolved.

module.exports = { getPermissionLevel, countApprovals };
Loading
Loading