Skip to content
Closed
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
58 changes: 58 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# GitHub Workflows Documentation

## PR Review Labels Management

This repository uses an automated workflow to manage PR labels based on review status.

### Workflow: `pr-review-label-manager.yml`

**Triggers:**
- When a review is submitted or dismissed on a PR
- When new commits are pushed to a PR (synchronize)
- When a PR is opened or marked ready for review
- When review requests are added or removed

**Purpose:** Automatically manages review status labels based on the current state of PR reviews

**Label Logic:**
- **"ready for peer review"**: Applied when:
- PR has no approvals
- PR has changes requested
- New commits are pushed (resets review state)
- **"ready for 2nd review"**: Applied when:
- PR has exactly 1 approval
- No changes are requested
- **"ready for merge"**: Applied when:
- PR has 2 or more approvals
- No changes are requested

**Special Behaviors:**
- Draft PRs are ignored
- Reviews from the PR author are not counted
- Only the most recent review from each reviewer is considered
- Comment-only reviews are ignored
- Labels are automatically created if they don't exist

### Required Labels

Make sure these labels exist in your repository:
- `ready for peer review` (suggested color: #d4c5f9)
- `ready for 2nd review` (suggested color: #fbca04)
- `ready for merge` (suggested color: #0e8a16)

### Creating Labels

To create these labels, go to your repository's Issues → Labels page and create them manually, or use the GitHub CLI:

```bash
gh label create "ready for peer review" --color d4c5f9 --description "PR needs initial review"
gh label create "ready for 2nd review" --color fbca04 --description "PR has one approval, needs second review"
gh label create "ready for merge" --color 0e8a16 --description "PR has sufficient approvals and is ready to merge"
```

### Notes

- The workflows only count reviews from users other than the PR author
- Only the most recent review from each reviewer is considered
- Reviews with "COMMENTED" state are ignored (only APPROVED and CHANGES_REQUESTED matter)
- The workflows require the `GITHUB_TOKEN` with `pull-requests: write` permission
195 changes: 195 additions & 0 deletions .github/workflows/pr-review-label-manager.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
name: PR Review Label Manager

on:
pull_request_review:
types: [submitted, dismissed]
pull_request:
types: [synchronize, opened, ready_for_review]
pull_request_target:
types: [review_requested, review_request_removed]

permissions:
pull-requests: write

jobs:
manage-review-labels:
runs-on: ubuntu-latest
# Skip if PR is a draft
if: ${{ !github.event.pull_request.draft }}
steps:
- name: Manage Review Labels
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const pr_number = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
const event_name = context.eventName;
const action = context.payload.action;

console.log(`Event: ${event_name}, Action: ${action}, PR: #${pr_number}`);

// Define our review labels
const LABELS = {
PEER_REVIEW: 'ready for peer review',
SECOND_REVIEW: 'ready for 2nd review',
READY_MERGE: 'ready for merge'
};

// Helper function to ensure labels exist
async function ensureLabelsExist() {
const labelConfigs = [
{ name: LABELS.PEER_REVIEW, color: 'd4c5f9', description: 'PR needs initial review' },
{ name: LABELS.SECOND_REVIEW, color: 'fbca04', description: 'PR has one approval, needs second review' },
{ name: LABELS.READY_MERGE, color: '0e8a16', description: 'PR has sufficient approvals and is ready to merge' }
];

for (const config of labelConfigs) {
try {
await github.rest.issues.getLabel({
owner,
repo,
name: config.name
});
} catch (error) {
if (error.status === 404) {
console.log(`Creating label: ${config.name}`);
await github.rest.issues.createLabel({
owner,
repo,
name: config.name,
color: config.color,
description: config.description
});
}
}
}
}

// Ensure labels exist before proceeding
await ensureLabelsExist();

// If PR was just synchronized (new commits), reset to peer review
if (event_name === 'pull_request' && action === 'synchronize') {
console.log('New commits detected, resetting to peer review state');

// Remove all review labels
for (const label of Object.values(LABELS)) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: pr_number,
name: label
});
} catch (error) {
// Label might not exist on PR, which is fine
}
}

// Add peer review label
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pr_number,
labels: [LABELS.PEER_REVIEW]
});

return; // Exit early for synchronize events
}

// Get all reviews for the PR
const reviews = await github.rest.pulls.listReviews({
owner,
repo,
pull_number: pr_number
});

// Get PR author
const pr = await github.rest.pulls.get({
owner,
repo,
pull_number: pr_number
});
const pr_author = pr.data.user.login;

// Get unique reviewers and their latest review state
const reviewerStates = {};
reviews.data.forEach(review => {
// Only consider reviews from users other than the PR author
if (review.user.login !== pr_author && review.state !== 'COMMENTED') {
// Store the latest review state for each reviewer
if (!reviewerStates[review.user.login] ||
new Date(review.submitted_at) > new Date(reviewerStates[review.user.login].submitted_at)) {
reviewerStates[review.user.login] = {
state: review.state,
submitted_at: review.submitted_at
};
}
}
});

// Count approvals and changes requested
let approvals = 0;
let changesRequested = 0;

Object.values(reviewerStates).forEach(review => {
if (review.state === 'APPROVED') {
approvals++;
} else if (review.state === 'CHANGES_REQUESTED') {
changesRequested++;
}
});

console.log(`Review summary: ${approvals} approvals, ${changesRequested} changes requested`);

// Get current labels
const labels = await github.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: pr_number
});

const currentLabels = labels.data.map(label => label.name);

// Determine target state
let targetLabel;
if (approvals >= 2 && changesRequested === 0) {
targetLabel = LABELS.READY_MERGE;
} else if (approvals === 1 && changesRequested === 0) {
targetLabel = LABELS.SECOND_REVIEW;
} else {
targetLabel = LABELS.PEER_REVIEW;
}

// Remove other review labels and add the target label
for (const [key, label] of Object.entries(LABELS)) {
if (label === targetLabel) {
// Add target label if not present
if (!currentLabels.includes(label)) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pr_number,
labels: [label]
});
console.log(`Added label: ${label}`);
}
} else {
// Remove non-target labels if present
if (currentLabels.includes(label)) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: pr_number,
name: label
});
console.log(`Removed label: ${label}`);
} catch (error) {
// Label might have been removed already
}
}
}
}
Loading