diff --git a/.github/workflows/_stale-pull-requests.yml b/.github/workflows/_stale-pull-requests.yml new file mode 100644 index 0000000..5628cd5 --- /dev/null +++ b/.github/workflows/_stale-pull-requests.yml @@ -0,0 +1,151 @@ +--- +name: stale-pull-requests + +on: + workflow_call: + inputs: + dry-run: + description: Report eligible pull requests without closing or deleting them. + required: false + default: true + type: boolean + +permissions: + contents: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-24.04 + steps: + - name: Close inactive pull requests and delete recoverable branches + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + DRY_RUN: ${{ inputs.dry-run }} + with: + script: | + const inactiveForMs = 30 * 24 * 60 * 60 * 1000; + const cutoff = Date.now() - inactiveForMs; + const dryRun = process.env.DRY_RUN === 'true'; + const exemptLabels = new Set(['do-not-close', 'blocked', 'security']); + const pullRequests = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'updated', + direction: 'asc', + per_page: 100, + }); + const { data: repository } = await github.rest.repos.get({ + owner: context.repo.owner, + repo: context.repo.repo, + }); + + for (const { number } of pullRequests) { + try { + const [{ data: pullRequest }, { data: issue }] = await Promise.all([ + github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: number, + }), + github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + }), + ]); + const hasExemptLabel = issue.labels.some( + ({ name }) => exemptLabels.has(name.toLowerCase()), + ); + + if ( + pullRequest.state !== 'open' || + pullRequest.draft || + issue.milestone || + issue.assignees.length > 0 || + hasExemptLabel || + Date.parse(pullRequest.updated_at) > cutoff + ) { + continue; + } + + if (dryRun) { + core.info(`Would close inactive PR #${number}: ${pullRequest.title}`); + continue; + } + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: number, + state: 'closed', + }); + const { data: closedPullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: number, + }); + if (closedPullRequest.state !== 'closed') { + core.warning(`Skipping #${number}: the pull request did not close.`); + continue; + } + + const branch = closedPullRequest.head.ref; + if (closedPullRequest.head.repo?.full_name !== repository.full_name) { + core.info(`Skipping branch deletion for #${number}: head branch is not in this repository.`); + continue; + } + if (branch === repository.default_branch) { + core.info(`Skipping branch deletion for #${number}: head branch is the default branch.`); + continue; + } + + let branchDetails; + try { + ({ data: branchDetails } = await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch, + })); + } catch (error) { + core.warning(`Skipping branch deletion for #${number}: cannot inspect ${branch}: ${error.message}`); + continue; + } + if (branchDetails.protected) { + core.info(`Skipping branch deletion for #${number}: ${branch} is protected.`); + continue; + } + + const [{ data: openHeadPullRequests }, { data: openBasePullRequests }] = + await Promise.all([ + github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${branch}`, + per_page: 1, + }), + github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + base: branch, + per_page: 1, + }), + ]); + if (openHeadPullRequests.length > 0 || openBasePullRequests.length > 0) { + core.info(`Skipping branch deletion for #${number}: ${branch} is used by an open pull request.`); + continue; + } + + await github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${branch}`, + }); + core.info(`Closed PR #${number} and deleted recoverable head branch ${branch}.`); + } catch (error) { + core.warning(`Skipping PR #${number} after an API error: ${error.message}`); + } + } diff --git a/README.md b/README.md index d937b2f..570c10b 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,49 @@ pre-commit drift fails the `test` job and the pull request branch must be updated manually. Fork pull requests never receive secrets and always fail on drift. +### Stale pull request lifecycle + +`tfroot-github` owns the scheduled caller at +`.github/workflows/stale-pull-requests.yml`; do not hand-maintain that path in +a consumer repository. The reusable callee is +`_stale-pull-requests.yml`. + +```yaml +name: stale-pull-requests + +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + stale: + uses: makeitworkcloud/shared-workflows/.github/workflows/_stale-pull-requests.yml@main + with: + dry-run: true +``` + +The caller must be on the consumer's default branch for scheduled execution. +It accepts no secrets. The workflow reports only to its Actions log, and when +`dry-run` is `false`, closes non-draft, unassigned, unmilestoned pull requests +whose `pullRequest.updated_at` is at least 30 days old. Labels `do-not-close`, +`blocked`, and `security` are exempt. + +After a successful close, it deletes only an unprotected same-repository head +branch that is neither the default branch nor used as the head or base of any +open pull request. GitHub's closed-pull-request **Restore branch** path is the +recovery mechanism. Enable live mode only after a reviewed dry-run pilot. + ## Available Workflows | Workflow | Description | |---|---| | `opentofu.yml` | OpenTofu/Terraform CI/CD with PR validation and an environment-gated apply on every push to `main` | +| `_stale-pull-requests.yml` | Dry-run-first reusable lifecycle for closing pull requests inactive for at least 30 days and deleting only recoverable eligible head branches. | Same-repository PRs run tests and a credentialed plan; fork PRs run tests only. A push to `main` runs tests followed by a fresh apply, which does not reuse the PR plan. The apply job uses the caller's `environment` input (default `production`); repository owners must configure that GitHub Environment with the required protection rules.