From db9b3be94567136c403a5e61a42d63d1ec644a43 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 29 May 2026 10:49:35 +0200 Subject: [PATCH] ci: stale bot via actions, exempt typed issues 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). --- .github/scripts/stale.js | 167 ++++++++++++++++++ .github/stale.yml | 20 --- .github/workflows/release.yml | 2 +- .github/workflows/stale.yml | 33 ++++ CONTRIBUTING.md | 6 +- .../generate-changelog.sh | 0 subtree.sh => tools/subtree.sh | 0 update-js.sh => tools/update-js.sh | 0 8 files changed, 205 insertions(+), 23 deletions(-) create mode 100644 .github/scripts/stale.js delete mode 100644 .github/stale.yml create mode 100644 .github/workflows/stale.yml rename generate-changelog.sh => tools/generate-changelog.sh (100%) rename subtree.sh => tools/subtree.sh (100%) rename update-js.sh => tools/update-js.sh (100%) diff --git a/.github/scripts/stale.js b/.github/scripts/stale.js new file mode 100644 index 00000000000..d48818477d2 --- /dev/null +++ b/.github/scripts/stale.js @@ -0,0 +1,167 @@ +module.exports = async ({ github, context, core }) => { + const DAYS_UNTIL_STALE = 60; + const DAYS_UNTIL_CLOSE = 7; + const STALE_LABEL = 'stale'; + const EXEMPT_LABELS = new Set([ + 'Hacktoberfest', + 'RFC', + '⭐ EU-FOSSA Hackathon', + ]); + const EXEMPT_TYPES = new Set(['Bug', 'Feature']); + const STALE_COMMENT = [ + 'This issue has been automatically marked as stale because it has not had', + 'recent activity. It will be closed if no further activity occurs. Thank you', + 'for your contributions.', + ].join(' '); + const BOT_LOGINS = new Set(['github-actions[bot]', 'github-actions']); + + const DRY_RUN = /^(1|true|yes)$/i.test(process.env.DRY_RUN || ''); + const MAX_ACTIONS_PER_RUN = Number.parseInt(process.env.MAX_ACTIONS_PER_RUN || '25', 10); + + const { owner, repo } = context.repo; + const now = Date.now(); + const staleCutoff = new Date(now - DAYS_UNTIL_STALE * 86400000); + const closeCutoff = new Date(now - DAYS_UNTIL_CLOSE * 86400000); + + let actionsTaken = 0; + const budgetExhausted = () => actionsTaken >= MAX_ACTIONS_PER_RUN; + + async function* iterateOpenIssues() { + let cursor = null; + while (true) { + const data = await github.graphql(` + query($owner: String!, $name: String!, $cursor: String) { + repository(owner: $owner, name: $name) { + issues(first: 100, after: $cursor, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) { + pageInfo { hasNextPage endCursor } + nodes { + number + updatedAt + issueType { name } + labels(first: 50) { nodes { name } } + timelineItems(last: 100, itemTypes: [LABELED_EVENT]) { + nodes { + ... on LabeledEvent { + createdAt + label { name } + } + } + } + } + } + } + }`, { owner, name: repo, cursor }); + + const page = data.repository.issues; + for (const node of page.nodes) yield node; + if (!page.pageInfo.hasNextPage) break; + cursor = page.pageInfo.endCursor; + } + } + + async function hasNonBotActivitySince(issue_number, since) { + const events = await github.paginate( + github.rest.issues.listEventsForTimeline, + { owner, repo, issue_number, per_page: 100 }, + ); + return events.some(e => { + const ts = e.created_at || e.submitted_at; + if (!ts) return false; + if (new Date(ts) <= since) return false; + const actor = e.actor?.login || e.user?.login; + if (actor && BOT_LOGINS.has(actor)) return false; + return true; + }); + } + + function mostRecentStaleAt(issue) { + let latest = null; + for (const e of issue.timelineItems.nodes) { + if (e?.label?.name !== STALE_LABEL) continue; + const at = new Date(e.createdAt); + if (!latest || at > latest) latest = at; + } + return latest; + } + + async function addStale(issue_number) { + if (DRY_RUN) { + core.info(`DRY_RUN would stale #${issue_number}`); + return; + } + await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [STALE_LABEL] }); + await github.rest.issues.createComment({ owner, repo, issue_number, body: STALE_COMMENT }); + } + + async function close(issue_number) { + if (DRY_RUN) { + core.info(`DRY_RUN would close #${issue_number}`); + return; + } + await github.rest.issues.update({ + owner, repo, issue_number, state: 'closed', state_reason: 'not_planned', + }); + } + + async function unstale(issue_number) { + if (DRY_RUN) { + core.info(`DRY_RUN would unstale #${issue_number}`); + return; + } + await github.rest.issues.removeLabel({ + owner, repo, issue_number, name: STALE_LABEL, + }).catch(err => { + if (err.status !== 404) throw err; + }); + } + + const summary = { staled: 0, closed: 0, unstaled: 0, exempt: 0, scanned: 0, skipped: 0 }; + + for await (const issue of iterateOpenIssues()) { + summary.scanned++; + const labels = new Set(issue.labels.nodes.map(l => l.name)); + const typeName = issue.issueType?.name; + const exempt = (typeName && EXEMPT_TYPES.has(typeName)) + || [...labels].some(l => EXEMPT_LABELS.has(l)); + const hasStale = labels.has(STALE_LABEL); + + if (hasStale) { + const staleAt = mostRecentStaleAt(issue); + if (!staleAt) continue; + + const interacted = await hasNonBotActivitySince(issue.number, staleAt); + if (interacted) { + if (budgetExhausted()) { summary.skipped++; continue; } + await unstale(issue.number); + summary.unstaled++; + actionsTaken++; + } else if (staleAt <= closeCutoff) { + if (budgetExhausted()) { summary.skipped++; continue; } + await close(issue.number); + summary.closed++; + actionsTaken++; + } + continue; + } + + if (exempt) { + summary.exempt++; + continue; + } + + if (new Date(issue.updatedAt) <= staleCutoff) { + if (budgetExhausted()) { summary.skipped++; continue; } + await addStale(issue.number); + summary.staled++; + actionsTaken++; + } + } + + const prefix = DRY_RUN ? 'DRY_RUN ' : ''; + core.info( + `${prefix}scanned=${summary.scanned} staled=${summary.staled} ` + + `closed=${summary.closed} unstaled=${summary.unstaled} ` + + `exempt=${summary.exempt} skipped=${summary.skipped} ` + + `budget=${MAX_ACTIONS_PER_RUN}`, + ); +}; diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 6ad93d1570f..00000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 60 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 -# Issues with these labels will never be considered stale -exemptLabels: - - Hacktoberfest - - bug - - enhancement - - RFC - - ⭐ EU-FOSSA Hackathon -# Label to use when marking an issue as stale -staleLabel: stale -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 187e44039c9..8b0669ec0cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: echo "$(pwd)" >> $GITHUB_PATH - name: Split to manyrepo - run: find src -maxdepth 3 -name composer.json -print0 | xargs -I '{}' -n 1 -0 bash subtree.sh {} ${{ github.ref }} + run: find src -maxdepth 3 -name composer.json -print0 | xargs -I '{}' -n 1 -0 bash tools/subtree.sh {} ${{ github.ref }} dispatch-distribution-update: name: Dispatch Distribution Update diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000000..c30b7c664bf --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,33 @@ +name: Mark stale issues + +on: + schedule: + - cron: '0 1 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Log actions without mutating issues' + type: boolean + default: true + max_actions: + description: 'Maximum mutating actions per run' + type: string + default: '25' + +permissions: + issues: write + contents: read + +jobs: + stale: + runs-on: ubuntu-latest + env: + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} + MAX_ACTIONS_PER_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.max_actions || '25' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/github-script@v7 + with: + script: | + const script = require('./.github/scripts/stale.js'); + await script({ github, context, core }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff0d70a8cd9..89ba9542382 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -250,11 +250,13 @@ If you include code from another project, please mention it in the Pull Request This section is for maintainers. -1. Update the JavaScript dependencies by running `./update-js.sh` (always check if it works in a browser) +Maintenance scripts live in [`tools/`](tools/). GitHub Actions helper scripts live in [`.github/scripts/`](.github/scripts/). + +1. Update the JavaScript dependencies by running `./tools/update-js.sh` (always check if it works in a browser) 2. Update the `CHANGELOG.md` file (be sure to include Pull Request numbers when appropriate) we use: ```bash -bash generate-changelog.sh v4.1.11 v4.1.12 > CHANGELOG.new +bash tools/generate-changelog.sh v4.1.11 v4.1.12 > CHANGELOG.new mv CHANGELOG.new CHANGELOG.md ``` 4. Update `composer.json` `version` node and use diff --git a/generate-changelog.sh b/tools/generate-changelog.sh similarity index 100% rename from generate-changelog.sh rename to tools/generate-changelog.sh diff --git a/subtree.sh b/tools/subtree.sh similarity index 100% rename from subtree.sh rename to tools/subtree.sh diff --git a/update-js.sh b/tools/update-js.sh similarity index 100% rename from update-js.sh rename to tools/update-js.sh