Skip to content
Merged
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
336 changes: 336 additions & 0 deletions .github/workflows/pr-management.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@
# PR Management Workflow
# Handles PR automation for the hybrid workflow
name: PR Management

on:
pull_request:
types: [opened, edited, synchronize, ready_for_review]
branches: [dev, main]
pull_request_review:
types: [submitted]
workflow_dispatch:

permissions:
contents: read
pull-requests: write
issues: write

jobs:
auto-label:
name: Auto Label PR
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'

steps:
- name: Label PR by Size
uses: actions/github-script@v7
with:
script: |
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number
});

const additions = pr.additions;
const deletions = pr.deletions;
const changes = additions + deletions;

// Remove existing size labels
const currentLabels = pr.labels.map(l => l.name);
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
for (const label of sizeLabels) {
if (currentLabels.includes(label)) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: label
});
}
}

// Add appropriate size label
let sizeLabel;
if (changes < 10) sizeLabel = 'size/XS';
else if (changes < 50) sizeLabel = 'size/S';
else if (changes < 200) sizeLabel = 'size/M';
else if (changes < 500) sizeLabel = 'size/L';
else sizeLabel = 'size/XL';

await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [sizeLabel]
});

// Add branch-specific labels
const targetBranch = pr.base.ref;
const sourceBranch = pr.head.ref;

if (targetBranch === 'main') {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: ['target/main', 'needs-careful-review']
});
} else if (targetBranch === 'dev') {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: ['target/dev']
});
}

// Add feature type labels based on branch name
if (sourceBranch.startsWith('feat/')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: ['type/feature']
});
} else if (sourceBranch.startsWith('fix/')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: ['type/bugfix']
});
} else if (sourceBranch.startsWith('docs/')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: ['type/documentation']
});
}

pr-feedback:
name: PR Feedback
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.action == 'opened'

steps:
- name: Post PR Guidelines
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const isToMain = pr.base.ref === 'main';
const isFromDev = pr.head.ref === 'dev';
const prSize = pr.additions + pr.deletions;

let comment = `## 👋 Welcome to the Hybrid Workflow!\n\n`;

if (isToMain && !isFromDev) {
comment += `⚠️ **Important**: This PR targets \`main\` directly. Please ensure:\n`;
comment += `- This is a critical fix or thoroughly tested feature\n`;
comment += `- All tests pass\n`;
comment += `- You've considered creating this PR to \`dev\` first\n\n`;
}

if (prSize > 500) {
comment += `📏 **Large PR detected** (${prSize} lines changed)\n`;
comment += `Consider breaking this into smaller, focused PRs for easier review.\n\n`;
}

comment += `### PR Guidelines\n`;
comment += `- Keep PRs focused on a single feature or fix\n`;
comment += `- Ensure all tests pass before review\n`;
comment += `- Update documentation if needed\n`;
comment += `- Respond to review feedback promptly\n\n`;

comment += `### Review Process\n`;
if (isToMain) {
comment += `PRs to \`main\` require:\n`;
comment += `- Approved review from maintainer\n`;
comment += `- All status checks passing\n`;
comment += `- No merge conflicts\n`;
} else {
comment += `PRs to \`dev\` are reviewed more quickly and merged frequently.\n`;
}

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: comment
});

auto-merge-small-prs:
name: Auto-merge Small PRs to Dev
runs-on: ubuntu-latest
if: |
github.event_name == 'pull_request_review' &&
github.event.review.state == 'approved' &&
github.event.pull_request.base.ref == 'dev'

steps:
- name: Check Auto-merge Eligibility
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;

// Check PR size
if (pr.additions + pr.deletions > 200) {
console.log('PR too large for auto-merge');
return;
}

// Check if all checks passed
const { data: checks } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: pr.head.sha
});

const allChecksPassed = checks.check_runs.every(
check => check.status === 'completed' && check.conclusion === 'success'
);
Comment on lines +192 to +194
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auto-merge logic only checks check_runs but ignores status checks from the legacy status API. This could cause auto-merge to proceed when some status checks haven't passed. Consider also checking github.rest.repos.getCombinedStatusForRef() for complete validation.

Suggested change
const allChecksPassed = checks.check_runs.every(
check => check.status === 'completed' && check.conclusion === 'success'
);
// Fetch combined status from the legacy status API
const { data: combinedStatus } = await github.rest.repos.getCombinedStatusForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: pr.head.sha
});
const allChecksPassed = checks.check_runs.every(
check => check.status === 'completed' && check.conclusion === 'success'
) && combinedStatus.state === 'success';

Copilot uses AI. Check for mistakes.

if (!allChecksPassed) {
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When there are no check runs, checks.check_runs.every() will return true, potentially allowing auto-merge when no checks have actually run. Consider checking if checks.check_runs.length > 0 before evaluating the results.

Copilot uses AI. Check for mistakes.
console.log('Not all checks have passed');
return;
}

// Auto-merge eligible small PRs to dev
try {
await github.rest.pulls.merge({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
commit_title: `Auto-merge: ${pr.title}`,
commit_message: pr.body || '',
merge_method: 'squash'
});

console.log(`Successfully auto-merged PR #${pr.number}`);
} catch (error) {
console.log(`Failed to auto-merge: ${error.message}`);

// Post comment about why auto-merge failed
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: `🤖 Auto-merge was attempted but failed. Please merge manually.\n\nError: ${error.message}`
});
}

conflict-detection:
name: Detect Merge Conflicts
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.action == 'synchronize'

steps:
- name: Check for Conflicts
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;

// Get PR details with mergeable state
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number
});

if (pullRequest.mergeable === false) {
// Post conflict warning
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: `⚠️ **Merge conflicts detected!**\n\nThis PR has conflicts with the base branch that must be resolved.\n\n### To resolve:\n\`\`\`bash\ngit checkout ${pr.head.ref}\ngit fetch origin\ngit rebase origin/${pr.base.ref}\n# Fix conflicts\ngit push --force-with-lease\n\`\`\`\n\nNeed help? Check our [conflict resolution guide](../docs/CONFLICT_RESOLUTION.md).`
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The link to the conflict resolution guide uses a relative path ../docs/CONFLICT_RESOLUTION.md which may not resolve correctly from a GitHub comment. Consider using an absolute URL or removing the link if the documentation doesn't exist.

Suggested change
body: `⚠️ **Merge conflicts detected!**\n\nThis PR has conflicts with the base branch that must be resolved.\n\n### To resolve:\n\`\`\`bash\ngit checkout ${pr.head.ref}\ngit fetch origin\ngit rebase origin/${pr.base.ref}\n# Fix conflicts\ngit push --force-with-lease\n\`\`\`\n\nNeed help? Check our [conflict resolution guide](../docs/CONFLICT_RESOLUTION.md).`
body: `⚠️ **Merge conflicts detected!**\n\nThis PR has conflicts with the base branch that must be resolved.\n\n### To resolve:\n\`\`\`bash\ngit checkout ${pr.head.ref}\ngit fetch origin\ngit rebase origin/${pr.base.ref}\n# Fix conflicts\ngit push --force-with-lease\n\`\`\`\n\nNeed help? Check our [conflict resolution guide](https://github.com/<owner>/<repo>/blob/main/docs/CONFLICT_RESOLUTION.md).`

Copilot uses AI. Check for mistakes.
});

// Add conflict label
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: ['has-conflicts']
});
} else if (pullRequest.mergeable === true) {
// Remove conflict label if resolved
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: 'has-conflicts'
});
} catch (error) {
// Label might not exist, ignore
}
}

stale-pr-management:
name: Manage Stale PRs
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'

steps:
- name: Mark Stale PRs
uses: actions/github-script@v7
with:
script: |
const daysUntilStale = 7;
const daysUntilClose = 14;
const staleLabel = 'stale';

const { data: pullRequests } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'updated',
direction: 'asc'
});

const now = new Date();

for (const pr of pullRequests) {
const updatedAt = new Date(pr.updated_at);
const daysSinceUpdate = (now - updatedAt) / (1000 * 60 * 60 * 24);
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The magic number (1000 * 60 * 60 * 24) for milliseconds in a day should be extracted to a named constant for better readability and maintainability.

Suggested change
const daysSinceUpdate = (now - updatedAt) / (1000 * 60 * 60 * 24);
const daysSinceUpdate = (now - updatedAt) / MILLISECONDS_IN_A_DAY;

Copilot uses AI. Check for mistakes.

const labels = pr.labels.map(l => l.name);
const isStale = labels.includes(staleLabel);

if (daysSinceUpdate > daysUntilClose && isStale) {
// Close very stale PRs
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: 'closed'
});

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: `🔒 This PR has been automatically closed due to inactivity. If you'd like to continue work on this, please open a new PR with the latest changes.`
});
} else if (daysSinceUpdate > daysUntilStale && !isStale) {
// Mark as stale
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [staleLabel]
});

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: `👋 This PR has been marked as stale due to ${Math.floor(daysSinceUpdate)} days of inactivity. It will be closed in ${daysUntilClose - daysUntilStale} days if there's no further activity.\n\nTo keep this PR open:\n- Push new commits\n- Leave a comment\n- Request a review`
});
}
}
Loading