Skip to content

Commit c2b990e

Browse files
authored
ci: stale bot via actions to use typed issues (#8208)
Probot stale does not understand GitHub issue types. Replace the legacy `.github/stale.yml` with an `actions/github-script` workflow that exempts issues whose type is `Bug` or `Feature` and keeps the prior 60/7 day window plus exempt labels (RFC, Hacktoberfest, EU-FOSSA Hackathon).
1 parent 5ddf94a commit c2b990e

8 files changed

Lines changed: 205 additions & 23 deletions

File tree

.github/scripts/stale.js

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
module.exports = async ({ github, context, core }) => {
2+
const DAYS_UNTIL_STALE = 60;
3+
const DAYS_UNTIL_CLOSE = 7;
4+
const STALE_LABEL = 'stale';
5+
const EXEMPT_LABELS = new Set([
6+
'Hacktoberfest',
7+
'RFC',
8+
'⭐ EU-FOSSA Hackathon',
9+
]);
10+
const EXEMPT_TYPES = new Set(['Bug', 'Feature']);
11+
const STALE_COMMENT = [
12+
'This issue has been automatically marked as stale because it has not had',
13+
'recent activity. It will be closed if no further activity occurs. Thank you',
14+
'for your contributions.',
15+
].join(' ');
16+
const BOT_LOGINS = new Set(['github-actions[bot]', 'github-actions']);
17+
18+
const DRY_RUN = /^(1|true|yes)$/i.test(process.env.DRY_RUN || '');
19+
const MAX_ACTIONS_PER_RUN = Number.parseInt(process.env.MAX_ACTIONS_PER_RUN || '25', 10);
20+
21+
const { owner, repo } = context.repo;
22+
const now = Date.now();
23+
const staleCutoff = new Date(now - DAYS_UNTIL_STALE * 86400000);
24+
const closeCutoff = new Date(now - DAYS_UNTIL_CLOSE * 86400000);
25+
26+
let actionsTaken = 0;
27+
const budgetExhausted = () => actionsTaken >= MAX_ACTIONS_PER_RUN;
28+
29+
async function* iterateOpenIssues() {
30+
let cursor = null;
31+
while (true) {
32+
const data = await github.graphql(`
33+
query($owner: String!, $name: String!, $cursor: String) {
34+
repository(owner: $owner, name: $name) {
35+
issues(first: 100, after: $cursor, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) {
36+
pageInfo { hasNextPage endCursor }
37+
nodes {
38+
number
39+
updatedAt
40+
issueType { name }
41+
labels(first: 50) { nodes { name } }
42+
timelineItems(last: 100, itemTypes: [LABELED_EVENT]) {
43+
nodes {
44+
... on LabeledEvent {
45+
createdAt
46+
label { name }
47+
}
48+
}
49+
}
50+
}
51+
}
52+
}
53+
}`, { owner, name: repo, cursor });
54+
55+
const page = data.repository.issues;
56+
for (const node of page.nodes) yield node;
57+
if (!page.pageInfo.hasNextPage) break;
58+
cursor = page.pageInfo.endCursor;
59+
}
60+
}
61+
62+
async function hasNonBotActivitySince(issue_number, since) {
63+
const events = await github.paginate(
64+
github.rest.issues.listEventsForTimeline,
65+
{ owner, repo, issue_number, per_page: 100 },
66+
);
67+
return events.some(e => {
68+
const ts = e.created_at || e.submitted_at;
69+
if (!ts) return false;
70+
if (new Date(ts) <= since) return false;
71+
const actor = e.actor?.login || e.user?.login;
72+
if (actor && BOT_LOGINS.has(actor)) return false;
73+
return true;
74+
});
75+
}
76+
77+
function mostRecentStaleAt(issue) {
78+
let latest = null;
79+
for (const e of issue.timelineItems.nodes) {
80+
if (e?.label?.name !== STALE_LABEL) continue;
81+
const at = new Date(e.createdAt);
82+
if (!latest || at > latest) latest = at;
83+
}
84+
return latest;
85+
}
86+
87+
async function addStale(issue_number) {
88+
if (DRY_RUN) {
89+
core.info(`DRY_RUN would stale #${issue_number}`);
90+
return;
91+
}
92+
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [STALE_LABEL] });
93+
await github.rest.issues.createComment({ owner, repo, issue_number, body: STALE_COMMENT });
94+
}
95+
96+
async function close(issue_number) {
97+
if (DRY_RUN) {
98+
core.info(`DRY_RUN would close #${issue_number}`);
99+
return;
100+
}
101+
await github.rest.issues.update({
102+
owner, repo, issue_number, state: 'closed', state_reason: 'not_planned',
103+
});
104+
}
105+
106+
async function unstale(issue_number) {
107+
if (DRY_RUN) {
108+
core.info(`DRY_RUN would unstale #${issue_number}`);
109+
return;
110+
}
111+
await github.rest.issues.removeLabel({
112+
owner, repo, issue_number, name: STALE_LABEL,
113+
}).catch(err => {
114+
if (err.status !== 404) throw err;
115+
});
116+
}
117+
118+
const summary = { staled: 0, closed: 0, unstaled: 0, exempt: 0, scanned: 0, skipped: 0 };
119+
120+
for await (const issue of iterateOpenIssues()) {
121+
summary.scanned++;
122+
const labels = new Set(issue.labels.nodes.map(l => l.name));
123+
const typeName = issue.issueType?.name;
124+
const exempt = (typeName && EXEMPT_TYPES.has(typeName))
125+
|| [...labels].some(l => EXEMPT_LABELS.has(l));
126+
const hasStale = labels.has(STALE_LABEL);
127+
128+
if (hasStale) {
129+
const staleAt = mostRecentStaleAt(issue);
130+
if (!staleAt) continue;
131+
132+
const interacted = await hasNonBotActivitySince(issue.number, staleAt);
133+
if (interacted) {
134+
if (budgetExhausted()) { summary.skipped++; continue; }
135+
await unstale(issue.number);
136+
summary.unstaled++;
137+
actionsTaken++;
138+
} else if (staleAt <= closeCutoff) {
139+
if (budgetExhausted()) { summary.skipped++; continue; }
140+
await close(issue.number);
141+
summary.closed++;
142+
actionsTaken++;
143+
}
144+
continue;
145+
}
146+
147+
if (exempt) {
148+
summary.exempt++;
149+
continue;
150+
}
151+
152+
if (new Date(issue.updatedAt) <= staleCutoff) {
153+
if (budgetExhausted()) { summary.skipped++; continue; }
154+
await addStale(issue.number);
155+
summary.staled++;
156+
actionsTaken++;
157+
}
158+
}
159+
160+
const prefix = DRY_RUN ? 'DRY_RUN ' : '';
161+
core.info(
162+
`${prefix}scanned=${summary.scanned} staled=${summary.staled} ` +
163+
`closed=${summary.closed} unstaled=${summary.unstaled} ` +
164+
`exempt=${summary.exempt} skipped=${summary.skipped} ` +
165+
`budget=${MAX_ACTIONS_PER_RUN}`,
166+
);
167+
};

.github/stale.yml

Lines changed: 0 additions & 20 deletions
This file was deleted.

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
echo "$(pwd)" >> $GITHUB_PATH
4343
4444
- name: Split to manyrepo
45-
run: find src -maxdepth 3 -name composer.json -print0 | xargs -I '{}' -n 1 -0 bash subtree.sh {} ${{ github.ref }}
45+
run: find src -maxdepth 3 -name composer.json -print0 | xargs -I '{}' -n 1 -0 bash tools/subtree.sh {} ${{ github.ref }}
4646

4747
dispatch-distribution-update:
4848
name: Dispatch Distribution Update

.github/workflows/stale.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Mark stale issues
2+
3+
on:
4+
schedule:
5+
- cron: '0 1 * * *'
6+
workflow_dispatch:
7+
inputs:
8+
dry_run:
9+
description: 'Log actions without mutating issues'
10+
type: boolean
11+
default: true
12+
max_actions:
13+
description: 'Maximum mutating actions per run'
14+
type: string
15+
default: '25'
16+
17+
permissions:
18+
issues: write
19+
contents: read
20+
21+
jobs:
22+
stale:
23+
runs-on: ubuntu-latest
24+
env:
25+
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}
26+
MAX_ACTIONS_PER_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.max_actions || '25' }}
27+
steps:
28+
- uses: actions/checkout@v4
29+
- uses: actions/github-script@v7
30+
with:
31+
script: |
32+
const script = require('./.github/scripts/stale.js');
33+
await script({ github, context, core });

CONTRIBUTING.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,13 @@ If you include code from another project, please mention it in the Pull Request
250250

251251
This section is for maintainers.
252252

253-
1. Update the JavaScript dependencies by running `./update-js.sh` (always check if it works in a browser)
253+
Maintenance scripts live in [`tools/`](tools/). GitHub Actions helper scripts live in [`.github/scripts/`](.github/scripts/).
254+
255+
1. Update the JavaScript dependencies by running `./tools/update-js.sh` (always check if it works in a browser)
254256
2. Update the `CHANGELOG.md` file (be sure to include Pull Request numbers when appropriate) we use:
255257

256258
```bash
257-
bash generate-changelog.sh v4.1.11 v4.1.12 > CHANGELOG.new
259+
bash tools/generate-changelog.sh v4.1.11 v4.1.12 > CHANGELOG.new
258260
mv CHANGELOG.new CHANGELOG.md
259261
```
260262
4. Update `composer.json` `version` node and use
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)