From 8f4afefbf40898e398dc083febc329d7cf573e23 Mon Sep 17 00:00:00 2001 From: MonsterDruide1 <5958456@gmail.com> Date: Fri, 31 Oct 2025 17:34:04 +0100 Subject: [PATCH] workflow: Rework permission system of `status:` labels (#790) --- .github/workflows/pr-status-label-new.yml | 24 +++ .../pr-status-label-review-trigger.yml | 36 ++++ .github/workflows/pr-status-label-review.yml | 86 +++++++++ .../workflows/pr-status-label-schedule.yml | 85 +++++++++ .github/workflows/pr-status-label.yml | 170 ------------------ 5 files changed, 231 insertions(+), 170 deletions(-) create mode 100644 .github/workflows/pr-status-label-new.yml create mode 100644 .github/workflows/pr-status-label-review-trigger.yml create mode 100644 .github/workflows/pr-status-label-review.yml create mode 100644 .github/workflows/pr-status-label-schedule.yml delete mode 100644 .github/workflows/pr-status-label.yml diff --git a/.github/workflows/pr-status-label-new.yml b/.github/workflows/pr-status-label-new.yml new file mode 100644 index 00000000..b248035f --- /dev/null +++ b/.github/workflows/pr-status-label-new.yml @@ -0,0 +1,24 @@ +name: Add "status:waiting for review"-label to new PRs + +on: + pull_request_target: + types: [opened] + +permissions: + pull-requests: write + issues: write + +jobs: + new_pr: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request_target' && github.event.action == 'opened' + steps: + - name: Label new PR as "status:waiting for review" + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.addLabels({ + ...context.repo, + issue_number: context.payload.pull_request.number, + labels: ['status:waiting for review'] + }) diff --git a/.github/workflows/pr-status-label-review-trigger.yml b/.github/workflows/pr-status-label-review-trigger.yml new file mode 100644 index 00000000..2c5655c1 --- /dev/null +++ b/.github/workflows/pr-status-label-review-trigger.yml @@ -0,0 +1,36 @@ +name: PR-Review submitted + +on: + pull_request_review: + types: [submitted] + +jobs: + upload: + runs-on: ubuntu-latest + steps: + - name: Write review info to artifact + run: | + mkdir -p review-data + cat > review-data/event.json <<'EOF' + { + "repository": { + "owner": "${{ github.repository_owner }}", + "name": "${{ github.event.repository.name }}" + }, + "pull_request": { + "number": ${{ github.event.pull_request.number }}, + "author": "${{ github.event.pull_request.user.login }}" + }, + "review": { + "state": "${{ github.event.review.state }}", + "body": "${{ github.event.review.body || '' }}", + "reviewer": "${{ github.event.review.user.login }}" + } + } + EOF + + - name: Upload review info + uses: actions/upload-artifact@v4 + with: + name: review-event + path: review-data/event.json diff --git a/.github/workflows/pr-status-label-review.yml b/.github/workflows/pr-status-label-review.yml new file mode 100644 index 00000000..606e3f30 --- /dev/null +++ b/.github/workflows/pr-status-label-review.yml @@ -0,0 +1,86 @@ +name: Add "status:"-label to PRs based on current state + +on: + workflow_run: + workflows: + - PR-Review submitted + types: [completed] + +permissions: + pull-requests: write + issues: write + +jobs: + review_update: + runs-on: ubuntu-latest + steps: + - name: Download review info + uses: actions/download-artifact@v4 + with: + name: review-event + path: . + + - name: Update status labels based on review + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const data = JSON.parse(fs.readFileSync('event.json', 'utf8')); + + const owner = data.repository.owner; + const repo = data.repository.name; + const prNumber = data.pull_request.number; + const reviewState = data.review.state; + const reviewBody = data.review.body; + const R = data.review.reviewer; // reviewer + const P = data.pull_request.author; // PR author + const O = owner; // repo owner (by repo name) + + // 1. Remove all "status:[...]" labels + const { data: existingLabels } = await github.rest.issues.listLabelsOnIssue({ + owner, + repo, + issue_number: prNumber + }) + + const statusLabels = existingLabels.filter(l => l.name.startsWith('status:')) + for (const label of statusLabels) { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: label.name + }).catch(() => {}) // ignore if already removed + } + + // 2. Determine new label + let newLabel = null + + // https://github.com/Reviewable/Reviewable/issues/1163 + // other users cannot approve reviewers, requires permissions to actually say `reviewState == 'approved'` + const is_approved = reviewState === 'approved' || reviewBody.includes("complete! all files reviewed, all discussions resolved") + + if (is_approved && R === O) { + newLabel = 'status:approved' // standard way of approval: owner appoves PR + } else if (is_approved && P === O && R !== O) { + newLabel = 'status:approved' // if owner is author, someone else must approve + } else if (P === R) { + newLabel = 'status:waiting for review' + } else if (P !== R) { + newLabel = 'status:waiting for author' + } else { + core.info('No matching condition for new label.') + core.info(`Review state: ${reviewState}, PR author: ${P}, Reviewer: ${R}, Repo owner: ${O}`) + } + + if (newLabel) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: [newLabel] + }) + core.info(`Added label: ${newLabel}`) + } else { + core.info('No new status label added.') + } diff --git a/.github/workflows/pr-status-label-schedule.yml b/.github/workflows/pr-status-label-schedule.yml new file mode 100644 index 00000000..b7ff1fc2 --- /dev/null +++ b/.github/workflows/pr-status-label-schedule.yml @@ -0,0 +1,85 @@ +name: Promote "status:"-label to "ready to merge" if soaked for long enough + +on: + schedule: + - cron: '0 * * * *' # every hour, on the hour + +permissions: + pull-requests: write + issues: write + +jobs: + promote_ready_to_merge: + runs-on: ubuntu-latest + steps: + - name: Promote approved PRs to "ready to merge" + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner + const repo = context.repo.repo + const now = new Date() + + // 1. Search for open PRs with "status:approved" + const searchQuery = `repo:${owner}/${repo} is:pr is:open label:"status:approved"` + const { data: searchResults } = await github.rest.search.issuesAndPullRequests({ q: searchQuery }) + + for (const pr of searchResults.items) { + const prNumber = pr.number + + // Get full PR info (for timestamps) + const { data: prData } = await github.rest.pulls.get({ + owner, + repo, + pull_number: prNumber + }) + + const createdAt = new Date(prData.created_at) + const hoursSinceCreation = (now - createdAt) / (1000 * 60 * 60) + if (hoursSinceCreation < 24) { + core.info(`Skipping #${prNumber} (created ${hoursSinceCreation.toFixed(1)}h ago)`) + continue + } + + // Find the last "status:" label change (via timeline events) + const { data: events } = await github.rest.issues.listEvents({ + owner, + repo, + issue_number: prNumber, + per_page: 100 + }) + + const lastStatusChange = events + .filter(e => e.event === 'labeled' || e.event === 'unlabeled') + .filter(e => e.label && e.label.name.startsWith('status:')) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0] + + if (lastStatusChange) { + const hoursSinceStatusChange = (now - new Date(lastStatusChange.created_at)) / (1000 * 60 * 60) + if (hoursSinceStatusChange < 12) { + core.info(`Skipping #${prNumber} (status changed ${hoursSinceStatusChange.toFixed(1)}h ago)`) + continue + } + } + + // 2. Conditions met → remove "status:approved", add "status:ready to merge" + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: 'status:approved' + }).catch(() => {}) + + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: ['status:ready to merge'] + }) + + core.info(`Promoted #${prNumber} to status:ready to merge`) + } catch (err) { + core.warning(`Failed to update #${prNumber}: ${err.message}`) + } + } diff --git a/.github/workflows/pr-status-label.yml b/.github/workflows/pr-status-label.yml deleted file mode 100644 index c71cfa32..00000000 --- a/.github/workflows/pr-status-label.yml +++ /dev/null @@ -1,170 +0,0 @@ -name: Add "status:"-label to PRs based on current state - -on: - pull_request_review: - types: [submitted] - pull_request: - types: [opened] - schedule: - - cron: '0 * * * *' # every hour, on the hour - -permissions: - pull-requests: write - issues: write - -jobs: - new_pr: - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' && github.event.action == 'opened' - steps: - - name: Label new PR as "status:waiting for review" - uses: actions/github-script@v7 - with: - script: | - github.rest.issues.addLabels({ - ...context.repo, - issue_number: context.payload.pull_request.number, - labels: ['status:waiting for review'] - }) - - review_update: - runs-on: ubuntu-latest - if: github.event_name == 'pull_request_review' && github.event.action == 'submitted' - steps: - - name: Update status labels based on review - uses: actions/github-script@v7 - with: - script: | - const pr = context.payload.pull_request - const review = context.payload.review - const owner = context.repo.owner - const repo = context.repo.repo - - const R = review.user.login // reviewer - const P = pr.user.login // PR author - const O = owner // repo owner (by repo name) - - // 1. Remove all "status:[...]" labels - const { data: existingLabels } = await github.rest.issues.listLabelsOnIssue({ - owner, - repo, - issue_number: pr.number - }) - - const statusLabels = existingLabels.filter(l => l.name.startsWith('status:')) - for (const label of statusLabels) { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: pr.number, - name: label.name - }).catch(() => {}) // ignore if already removed - } - - // 2. Determine new label - let newLabel = null - - // https://github.com/Reviewable/Reviewable/issues/1163 - // other users cannot approve reviewers, requires permissions to actually say `review.state == 'approved'` - const is_approved = review.state === 'approved' || review.body.includes("complete! all files reviewed, all discussions resolved") - - if (is_approved && R === O) { - newLabel = 'status:approved' // standard way of approval: owner appoves PR - } else if (is_approved && P === O && R !== O) { - newLabel = 'status:approved' // if owner is author, someone else must approve - } else if (P === R) { - newLabel = 'status:waiting for review' - } else if (P !== R) { - newLabel = 'status:waiting for author' - } else { - core.info('No matching condition for new label.') - core.info(`Review state: ${review.state}, PR author: ${P}, Reviewer: ${R}, Repo owner: ${O}`) - } - - if (newLabel) { - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: pr.number, - labels: [newLabel] - }) - core.info(`Added label: ${newLabel}`) - } else { - core.info('No new status label added.') - } - - promote_ready_to_merge: - runs-on: ubuntu-latest - if: github.event_name == 'schedule' - steps: - - name: Promote approved PRs to "ready to merge" - uses: actions/github-script@v7 - with: - script: | - const owner = context.repo.owner - const repo = context.repo.repo - const now = new Date() - - // 1. Search for open PRs with "status:approved" - const searchQuery = `repo:${owner}/${repo} is:pr is:open label:"status:approved"` - const { data: searchResults } = await github.rest.search.issuesAndPullRequests({ q: searchQuery }) - - for (const pr of searchResults.items) { - const prNumber = pr.number - - // Get full PR info (for timestamps) - const { data: prData } = await github.rest.pulls.get({ - owner, - repo, - pull_number: prNumber - }) - - const createdAt = new Date(prData.created_at) - const hoursSinceCreation = (now - createdAt) / (1000 * 60 * 60) - if (hoursSinceCreation < 24) { - core.info(`Skipping #${prNumber} (created ${hoursSinceCreation.toFixed(1)}h ago)`) - continue - } - - // Find the last "status:" label change (via timeline events) - const { data: events } = await github.rest.issues.listEvents({ - owner, - repo, - issue_number: prNumber, - per_page: 100 - }) - - const lastStatusChange = events - .filter(e => e.event === 'labeled' || e.event === 'unlabeled') - .filter(e => e.label && e.label.name.startsWith('status:')) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0] - - if (lastStatusChange) { - const hoursSinceStatusChange = (now - new Date(lastStatusChange.created_at)) / (1000 * 60 * 60) - if (hoursSinceStatusChange < 12) { - core.info(`Skipping #${prNumber} (status changed ${hoursSinceStatusChange.toFixed(1)}h ago)`) - continue - } - } - - // 2. Conditions met → remove "status:approved", add "status:ready to merge" - try { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: prNumber, - name: 'status:approved' - }).catch(() => {}) - - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: prNumber, - labels: ['status:ready to merge'] - }) - - core.info(`Promoted #${prNumber} to status:ready to merge`) - } catch (err) { - core.warning(`Failed to update #${prNumber}: ${err.message}`) - } - }