diff --git a/.github/workflows/pr-management.yml b/.github/workflows/pr-management.yml new file mode 100644 index 00000000..7280b0c7 --- /dev/null +++ b/.github/workflows/pr-management.yml @@ -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' + ); + + if (!allChecksPassed) { + 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).` + }); + + // 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); + + 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` + }); + } + } \ No newline at end of file