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
167 changes: 167 additions & 0 deletions .github/scripts/stale.js
Original file line number Diff line number Diff line change
@@ -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}`,
);
};
20 changes: 0 additions & 20 deletions .github/stale.yml

This file was deleted.

2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/stale.yml
Original file line number Diff line number Diff line change
@@ -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 });
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loading