Repository navigation
224 lines (214 loc) · 10.1 KB
/
Copy pathstale.yml
File metadata and controls
224 lines (214 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
name: Stale PRs (reusable)
# Reusable stale-PR sweeper. Keeps a repo's PR queue tidy and posts a Slack
# digest of what it touched, so the same logic isn't copy-pasted (and left to
# drift) across repos. Powered by https://github.com/actions/stale
# • PR inactive `days_before_pr_stale` days → labeled `stale` + reminder comment
# • Still inactive `days_before_pr_close` days later → closed
# • Any new commit/comment removes `stale` and resets the clock
# • Issues are left untouched (days-before-issue-* = -1)
#
# The Slack digest names the source repo (`github.repository`) in the header AND
# on every individual PR line (`owner/repo#158`), so two repos' batches
# posted to the same channel are unambiguous — a bare "#158" looks identical
# whichever repo it came from, and a line quoted or read on its own
# still says which repo it came from.
#
# Caller pattern (place in source repo at .github/workflows/stale.yml). The
# caller owns the schedule + the workflow_dispatch dry-run toggle; this reusable
# does the sweep + the Slack post:
#
# name: Stale PRs
# on:
# schedule:
# - cron: "0 3 * * *" # daily 03:00 UTC
# workflow_dispatch:
# inputs:
# dry-run:
# description: "Preview without applying"
# type: boolean
# default: false
# permissions:
# pull-requests: write
# issues: write # required by actions/stale even for PR-only runs
# concurrency:
# group: stale
# cancel-in-progress: false
# jobs:
# stale:
# uses: Comfy-Org/github-workflows/.github/workflows/stale.yml@<sha> # v1
# with:
# # Optional overrides — see inputs below. Defaults post to #proj-cloud.
# dry_run: ${{ inputs.dry-run || false }}
# secrets:
# SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
on:
workflow_call:
inputs:
slack_channel:
description: >-
Slack channel ID the digest is posted to. The bot behind
SLACK_BOT_TOKEN must be a member. Defaults to #proj-cloud.
type: string
required: false
default: C08V9NDB3B4
days_before_pr_stale:
description: Days of PR inactivity before it's marked stale.
type: number
required: false
default: 14
days_before_pr_close:
description: Days after being marked stale before the PR is closed.
type: number
required: false
default: 14
stale_pr_label:
description: Label applied to a stale PR (and watched for removal to reset the clock).
type: string
required: false
default: stale
exempt_pr_labels:
description: >-
Comma-separated labels that exempt a PR from being marked stale.
type: string
required: false
default: pinned,security,work-in-progress,wip,dependencies,do-not-merge
stale_pr_message:
description: Comment posted when a PR is marked stale.
type: string
required: false
# Intentionally omits specific day counts / the label name so it stays
# correct when a caller overrides days_before_pr_* or stale_pr_label
# without also overriding this message (the exact deadline lives in the
# Slack digest, which is derived from the actual inputs).
default: >
This PR has been automatically marked **stale** due to inactivity and
will be closed if there's no further activity. Push a commit or leave
a comment to keep it open.
close_pr_message:
description: Comment posted when a stale PR is closed.
type: string
required: false
default: >
Closing this PR as stale after an extended period of inactivity. Feel
free to reopen when you pick it back up.
dry_run:
description: >-
Preview only — actions/stale runs in debug-only mode and no Slack
digest is sent. Wire the caller's workflow_dispatch dry-run input here.
type: boolean
required: false
default: false
secrets:
SLACK_BOT_TOKEN:
description: >-
Bot token used to post the digest via chat.postMessage. If omitted,
the sweep still runs; only the Slack heads-up is skipped.
required: false
permissions:
pull-requests: write
issues: write # required by actions/stale even for PR-only runs
jobs:
stale:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- id: stale
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-issue-stale: -1 # issues disabled
days-before-issue-close: -1
days-before-pr-stale: ${{ inputs.days_before_pr_stale }}
days-before-pr-close: ${{ inputs.days_before_pr_close }}
stale-pr-label: ${{ inputs.stale_pr_label }}
exempt-pr-labels: ${{ inputs.exempt_pr_labels }}
exempt-draft-pr: true
exempt-all-milestones: true
remove-stale-when-updated: true
operations-per-run: 30
ascending: true
stale-pr-message: ${{ inputs.stale_pr_message }}
close-pr-message: ${{ inputs.close_pr_message }}
debug-only: ${{ inputs.dry_run || false }}
# Surface what the bot did in Slack, not just in-PR. The header and every
# PR line name the source repo so batches from different repos posted to
# the same channel are unambiguous line-by-line.
- name: Slack heads-up (marked stale / closed)
# Skip during dry-run previews and when no PR was touched this run. If
# no SLACK_BOT_TOKEN is configured the curl below degrades to a warning
# rather than failing the run.
if: >-
${{ inputs.dry_run != true &&
( (steps.stale.outputs.staled-issues-prs != '' && steps.stale.outputs.staled-issues-prs != '[]') ||
(steps.stale.outputs.closed-issues-prs != '' && steps.stale.outputs.closed-issues-prs != '[]') ) }}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
SLACK_CHANNEL: ${{ inputs.slack_channel }}
REPO: ${{ github.repository }}
SERVER_URL: ${{ github.server_url }}
DAYS_TO_CLOSE: ${{ inputs.days_before_pr_close }}
STALED: ${{ steps.stale.outputs.staled-issues-prs }}
CLOSED: ${{ steps.stale.outputs.closed-issues-prs }}
run: |
set -euo pipefail
# No token configured → skip entirely rather than firing a curl with an
# empty `Authorization: Bearer` header, which would still ship the repo
# name + PR titles to Slack's API. The secret is documented as optional
# ("only the Slack heads-up is skipped"), so honor that here.
if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
echo "SLACK_BOT_TOKEN not set — skipping Slack heads-up"
exit 0
fi
# actions/stale outputs JSON arrays of the PRs it staled/closed this run.
# Build "• <url|owner/repo#N> title" lines; the repo name is carried on
# EVERY line (not just the header) so a line read, quoted, or forwarded
# on its own still says which repo it belongs to. Derive the URL from
# repo + number so we don't depend on html_url being present in the
# output objects. Escape Slack mrkdwn control chars (& < >) in the title
# so a crafted title can't inject <!channel>/<!here> pings or masked
# <url|label> links. ($REPO is `github.repository`, which GitHub
# constrains to [A-Za-z0-9._-]+/[A-Za-z0-9._-]+, so it needs no escaping.)
fmt() {
echo "${1:-[]}" | jq -r --arg base "$SERVER_URL/$REPO/pull" --arg repo "$REPO" '
def slack_escape: gsub("&";"&") | gsub("<";"<") | gsub(">";">");
.[]? | "• <\($base)/\(.number)|\($repo)#\(.number)> \((.title // "") | slack_escape)"'
}
WARN_LINES=$(fmt "$STALED")
CLOSE_LINES=$(fmt "$CLOSED")
# Name the repo in the header so two repos' batches in the same channel
# aren't confused (a bare "#158" is ambiguous). Every PR line repeats it
# (see fmt() above) so no single line is ambiguous on its own either.
TEXT=":hourglass_flowing_sand: *Stale PR bot — ${REPO}*"
if [ -n "$WARN_LINES" ]; then
# actions/stale treats days-before-pr-close <= 0 as "never close", so
# only promise a deadline when the close window is a positive count.
if [ "${DAYS_TO_CLOSE}" -gt 0 ] 2>/dev/null; then
STALE_HEADER="*Marked stale* — auto-closes in ${DAYS_TO_CLOSE} days without activity:"
else
STALE_HEADER="*Marked stale* (auto-close disabled):"
fi
TEXT="${TEXT}"$'\n\n'"${STALE_HEADER}"$'\n'"${WARN_LINES}"
fi
if [ -n "$CLOSE_LINES" ]; then
TEXT="${TEXT}"$'\n\n'"*Closed as stale:*"$'\n'"${CLOSE_LINES}"
fi
PAYLOAD=$(jq -nc --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \
'{channel: $channel, text: $text}')
# Capture body + HTTP status separately with connect/total timeouts so a
# stalled Slack endpoint can't hang the runner for hours, and so a
# transport failure yields a real reason instead of an empty warning.
HTTP_CODE=$(curl -sS -X POST \
--connect-timeout 10 --max-time 30 \
-H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \
-H 'Content-Type: application/json; charset=utf-8' \
--data "$PAYLOAD" \
-o /tmp/slack_response.json -w '%{http_code}' \
https://slack.com/api/chat.postMessage || echo "000")
RESPONSE=$(cat /tmp/slack_response.json 2>/dev/null || true)
if [ "$(echo "$RESPONSE" | jq -r '.ok // false' 2>/dev/null)" = "true" ]; then
echo "Slack heads-up sent"
else
REASON=$(echo "$RESPONSE" | jq -r '.error // empty' 2>/dev/null || true)
[ -z "$REASON" ] && REASON="transport/HTTP failure (http_code=${HTTP_CODE})"
echo "::warning::Slack notification failed: ${REASON}"
fi