Skip to content
Merged
Show file tree
Hide file tree
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
4 changes: 4 additions & 0 deletions .github/release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
"actions/release-please": {
"component": "release-please",
"initial-version": "0.0.1"
},
"actions/stale": {
"component": "stale",
"initial-version": "0.0.1"
}
},
"plugins": [
Expand Down
3 changes: 2 additions & 1 deletion .github/release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"actions/semconv/pull-request": "0.0.1",
"actions/release-please": "0.0.2"
"actions/release-please": "0.0.2",
"actions/stale": "0.0.1"
}
129 changes: 129 additions & 0 deletions actions/stale/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# stale

Marks issues and pull requests that have gone quiet, then closes them if they
stay quiet. A thin, opinionated wrapper around
[`actions/stale`](https://github.com/actions/stale).

## Usage

The caller owns the schedule.

```yaml
name: Stale

on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:

permissions: {}

jobs:
stale:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: write
issues: write
pull-requests: write
Comment thread
coderabbitai[bot] marked this conversation as resolved.
steps:
- uses: TrogonStack/github-actions/actions/stale@<sha> # vX.Y.Z
```

`actions: write` is not optional. Upstream keeps its progress in the Actions
cache so a run capped by `operations-per-run` resumes where the last one
stopped, and managing that cache entry needs the permission. Without it the
cache delete fails with a 403, every run replays the same prefix of the
backlog, and anything past that prefix is never closed.

Run it once with `debug-only: true` before pointing it at a repository with
years of backlog, so the first real run is not a surprise.

## Labels

| Label | Meaning |
| --- | --- |
| `stale:discard` | Applied by this action. Any activity removes it. |
| `stale:keep` | Applied by a human. Exempts the issue or pull request forever. |

Neither label needs to exist beforehand; `stale:discard` is created on first
use. Create `stale:keep` yourself so it is available in the label picker.

The names are not inputs. One vocabulary across every repository is the reason
this action exists, and a rename in one repository is the drift it was built to
remove.

## Inputs

| Input | Default | Description |
| --- | --- | --- |
| `days-before-stale` | `60` | Days without activity before marking stale. |
| `days-before-close` | `7` | Days after marking before closing. `-1` marks but never closes. |
| `operations-per-run` | `100` | API operations budget for one run. A run that hits the cap resumes from where it stopped. |
| `debug-only` | `false` | Report what would happen and change nothing. |
| `token` | `github.token` | Needs write access to actions, issues, and pull requests. |

## Fixed behaviour

These are not inputs, on purpose.

- The label names are `stale:discard` and `stale:keep` everywhere.
- Activity removes the stale label. Activity is the entire signal the action
runs on, so it has to be able to clear it.
- Issues close as `not_planned`, which avoids the completed badge on something
nobody did.
- Anything on a milestone is exempt. Work on a milestone is planned work,
however quiet it has gone.
- Oldest first, so a capped operations budget is spent on the worst offenders
rather than on whatever is newest.
- Branches are never deleted. Deleting someone else's branch on a timer is not
ours to do.

If you need one of these to differ, that is a conversation about the default
rather than an input to add.

## Outputs

| Output | Description |
| --- | --- |
| `staled-issues-prs` | JSON array of what was marked stale this run. |
| `closed-issues-prs` | JSON array of what was closed this run. |

## Messages

Closing is not a verdict, and the posted messages say so. What they ask for
differs by kind, on purpose.

An issue is closed because nobody is building it, so the message asks for a
pull request. It deliberately does not offer "leave a comment" as the way to
revive one: a comment clears the stale label without moving the issue any
closer to done, which is how a backlog gets kept alive without getting
shipped.

A pull request is already the contribution, so the message asks for a commit
instead, and points at `stale:keep` for the case where the hold-up is a review
on our side rather than the author.

Keep that split if you change the wording.

## Bumping

A comment clears the stale label and buys another full cycle, on issues and on
pull requests alike. That is upstream behaviour and it is not configurable:

- `remove-stale-when-updated` controls whether an update removes the label, and
a comment counts as an update.
- Turning it off does not help. The close gate in `actions/stale` is
`if (!issueHasCommentsSinceStale && !issueHasUpdateInCloseWindow)`, so any
human comment after the label blocks closing whatever that option is set to.
- `ignore-pr-updates` measures from `created_at` instead of `updated_at`, which
would mark every long lived pull request stale no matter how much work it is
getting.

So a determined bump wins, and the messages say plainly that it is a limitation
rather than the intended path: on a pull request the thing that counts is a
commit, and a bump leaves the work exactly as unfinished as it was.

Making commits the real currency would mean taking the pull request lifecycle
off upstream and measuring it from the head commit date. That is a bigger
change than this action wants to be today.
100 changes: 100 additions & 0 deletions actions/stale/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
name: Stale
description: >-
Marks issues and pull requests that have gone quiet, then closes them if they
stay quiet.
author: TrogonStack

inputs:
days-before-stale:
description: Days without activity before an issue or pull request is marked stale.
required: false
default: '60'
days-before-close:
description: >-
Days to wait after marking stale before closing. Set to -1 to mark stale
but never close.
required: false
default: '7'
operations-per-run:
description: >-
API operations budget for a single run. Raise it for a busy repository,
lower it to stay well inside the rate limit.
required: false
default: '100'
debug-only:
description: >-
Report what would happen without commenting, labelling, or closing
anything. Worth one run before enabling this on an old repository.
required: false
default: 'false'
token:
description: >-
Token used to comment, label, and close. Needs write access to issues and
pull requests.
required: false
default: ${{ github.token }}

outputs:
staled-issues-prs:
description: JSON array of the issues and pull requests marked stale this run.
value: ${{ steps.stale.outputs.staled-issues-prs }}
closed-issues-prs:
description: JSON array of the issues and pull requests closed this run.
value: ${{ steps.stale.outputs.closed-issues-prs }}

runs:
using: composite
steps:
- name: Mark and close stale issues and pull requests
id: stale
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ inputs.token }}

days-before-stale: ${{ inputs.days-before-stale }}
days-before-close: ${{ inputs.days-before-close }}

# The label names are deliberately not inputs: one vocabulary across
# every repository is the reason this action exists.
stale-issue-label: 'stale:discard'
stale-pr-label: 'stale:discard'
exempt-issue-labels: 'stale:keep'
exempt-pr-labels: 'stale:keep'

stale-issue-message: |
No activity for ${{ inputs.days-before-stale }} days. Labelled `stale:discard`, closes in ${{ inputs.days-before-close }} days. Closing is not a no.

- Do: send a pull request. Add `stale:keep` if you are working on it.
- Don't: comment to keep it alive. That resets the clock without getting it built.

stale-pr-message: |
No activity for ${{ inputs.days-before-stale }} days. Labelled `stale:discard`, closes in ${{ inputs.days-before-close }} days.

- Do: push a commit. It clears the label. If it is blocked on us, say what it is waiting on and add `stale:keep`.
- Don't: bump it. That clears the label too, but the work stays unfinished. Let it close and reopen when you can finish.

close-issue-message: |
Closed as inactive, not rejected. Still readable, still reopenable.

- Do: send a pull request and link it here. No need to reopen first.

close-pr-message: |
Closed as inactive. The branch is untouched, nothing is lost.

- Do: reopen it when you are ready to push, or open a fresh pull request from the same branch.
- Don't: reopen it to say you will get to it soon. That just restarts the countdown.

operations-per-run: ${{ inputs.operations-per-run }}
debug-only: ${{ inputs.debug-only }}

# Opinions this action does not hand back to the caller.
# Activity is the whole signal, so activity has to clear the label.
remove-stale-when-updated: true
# "not_planned" avoids the completed badge on something nobody did.
close-issue-reason: not_planned
# Work on a milestone is planned work, however quiet it has gone.
exempt-all-milestones: true
# Oldest first, so the operations budget is spent on the worst offenders.
ascending: true
# Deleting someone else's branch on a timer is not ours to do.
delete-branch: false
Loading