From 5d22c270125c72e49ab4912129a6d1cecb215dc6 Mon Sep 17 00:00:00 2001 From: Laszlo <47461634+Lacah@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:04:41 +0200 Subject: [PATCH] Create pr-auto-unassign-stale.yml --- .github/workflows/pr-auto-unassign-stale.yml | 154 +++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 .github/workflows/pr-auto-unassign-stale.yml diff --git a/.github/workflows/pr-auto-unassign-stale.yml b/.github/workflows/pr-auto-unassign-stale.yml new file mode 100644 index 0000000000..5bc93bc475 --- /dev/null +++ b/.github/workflows/pr-auto-unassign-stale.yml @@ -0,0 +1,154 @@ +name: Auto-unassign stale PR assignees + +on: + schedule: + - cron: "*/15 * * * *" # run every 15 minutes + workflow_dispatch: + inputs: + enabled: + description: "Enable this automation" + type: boolean + default: true + max_age_minutes: + description: "Unassign if assigned longer than X minutes" + type: number + default: 60 + dry_run: + description: "Preview only; do not change assignees" + type: boolean + default: false + +permissions: + pull-requests: write + issues: write + +env: + # Defaults (can be overridden via workflow_dispatch inputs) + ENABLED: "true" + MAX_ASSIGN_AGE_MINUTES: "60" + DRY_RUN: "false" + +jobs: + sweep: + runs-on: ubuntu-latest + steps: + - name: Resolve inputs into env + run: | + # Prefer manual run inputs when present + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "ENABLED=${{ inputs.enabled }}" >> $GITHUB_ENV + echo "MAX_ASSIGN_AGE_MINUTES=${{ inputs.max_age_minutes }}" >> $GITHUB_ENV + echo "DRY_RUN=${{ inputs.dry_run }}" >> $GITHUB_ENV + fi + echo "Effective config: ENABLED=$ENABLED, MAX_ASSIGN_AGE_MINUTES=$MAX_ASSIGN_AGE_MINUTES, DRY_RUN=$DRY_RUN" + + - name: Exit if disabled + if: ${{ env.ENABLED != 'true' && env.ENABLED != 'True' && env.ENABLED != 'TRUE' }} + run: echo "Disabled via ENABLED=$ENABLED. Exiting." && exit 0 + + - name: Unassign stale assignees + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + + const MAX_MIN = parseInt(process.env.MAX_ASSIGN_AGE_MINUTES || "60", 10); + const DRY_RUN = ["true","True","TRUE","1","yes"].includes(String(process.env.DRY_RUN)); + const now = new Date(); + + core.info(`Scanning open PRs. Threshold = ${MAX_MIN} minutes. DRY_RUN=${DRY_RUN}`); + + // List all open PRs + const prs = await github.paginate(github.rest.pulls.list, { + owner, repo, state: "open", per_page: 100 + }); + + let totalUnassigned = 0; + + for (const pr of prs) { + if (!pr.assignees || pr.assignees.length === 0) continue; + + const number = pr.number; + core.info(`PR #${number}: "${pr.title}" — assignees: ${pr.assignees.map(a => a.login).join(", ")}`); + + // Pull reviews (to see if an assignee started a review) + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, repo, pull_number: number, per_page: 100 + }); + + // Issue comments (general comments) + const issueComments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: number, per_page: 100 + }); + + // Review comments (file-level) + const reviewComments = await github.paginate(github.rest.pulls.listReviewComments, { + owner, repo, pull_number: number, per_page: 100 + }); + + // Issue events (to find assignment timestamps) + const issueEvents = await github.paginate(github.rest.issues.listEvents, { + owner, repo, issue_number: number, per_page: 100 + }); + + for (const a of pr.assignees) { + const assignee = a.login; + + // Find the most recent "assigned" event for this assignee + const assignedEvents = issueEvents + .filter(e => e.event === "assigned" && e.assignee && e.assignee.login === assignee) + .sort((x, y) => new Date(y.created_at) - new Date(x.created_at)); + + if (assignedEvents.length === 0) { + core.info(` - @${assignee}: no 'assigned' event found; skipping.`); + continue; + } + + const assignedAt = new Date(assignedEvents[0].created_at); + const ageMin = (now - assignedAt) / 60000; + + // Has the assignee commented (issue or review comments) or reviewed? + const hasIssueComment = issueComments.some(c => c.user?.login === assignee); + const hasReviewComment = reviewComments.some(c => c.user?.login === assignee); + const hasReview = reviews.some(r => r.user?.login === assignee); + + const eligible = + ageMin >= MAX_MIN && + !hasIssueComment && + !hasReviewComment && + !hasReview && + pr.state === "open"; + + core.info(` - @${assignee}: assigned ${ageMin.toFixed(1)} min ago; commented=${hasIssueComment || hasReviewComment}; reviewed=${hasReview}; open=${pr.state==='open'} => ${eligible ? 'ELIGIBLE' : 'skip'}`); + + if (!eligible) continue; + + if (DRY_RUN) { + core.notice(`Would unassign @${assignee} from PR #${number}`); + } else { + try { + await github.rest.issues.removeAssignees({ + owner, repo, issue_number: number, assignees: [assignee] + }); + totalUnassigned += 1; + // Optional: leave a gentle heads-up comment + await github.rest.issues.createComment({ + owner, repo, issue_number: number, + body: `👋 Unassigning @${assignee} due to inactivity (> ${MAX_MIN} min without comments/reviews). This PR remains open for other reviewers.` + }); + core.info(` Unassigned @${assignee} from #${number}`); + } catch (err) { + core.warning(` Failed to unassign @${assignee} from #${number}: ${err.message}`); + } + } + } + } + + core.summary + .addHeading('Auto-unassign report') + .addRaw(`Threshold: ${MAX_MIN} minutes\n\n`) + .addRaw(`Total unassignments: ${totalUnassigned}\n`) + .write(); + + result-encoding: string