Stale #30
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Stale | |
| on: | |
| schedule: | |
| - cron: "17 3 * * *" | |
| workflow_dispatch: | |
| inputs: | |
| backfill_stale_closures: | |
| description: "Close currently stale-eligible issues and PRs with the Barnacle app" | |
| required: false | |
| type: boolean | |
| default: false | |
| dry_run: | |
| description: "List matching stale-eligible items without closing them" | |
| required: false | |
| type: boolean | |
| default: true | |
| include_issues: | |
| description: "Include stale-eligible issues in the backfill" | |
| required: false | |
| type: boolean | |
| default: true | |
| include_prs: | |
| description: "Include stale-eligible pull requests in the backfill" | |
| required: false | |
| type: boolean | |
| default: true | |
| max_closures: | |
| description: "Maximum items to close when dry_run is false" | |
| required: false | |
| type: number | |
| default: 50 | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" | |
| permissions: {} | |
| jobs: | |
| stale: | |
| if: ${{ github.event_name != 'workflow_dispatch' || inputs.backfill_stale_closures != true }} | |
| permissions: | |
| issues: write | |
| pull-requests: write | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| steps: | |
| - uses: actions/create-github-app-token@v3 | |
| id: app-token | |
| continue-on-error: true | |
| with: | |
| app-id: "2729701" | |
| private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} | |
| - uses: actions/create-github-app-token@v3 | |
| id: app-token-fallback | |
| continue-on-error: true | |
| with: | |
| app-id: "2971289" | |
| private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} | |
| - name: Mark stale unassigned issues and pull requests (primary) | |
| id: stale-primary | |
| continue-on-error: true | |
| uses: actions/stale@v10 | |
| with: | |
| repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} | |
| days-before-issue-stale: 14 | |
| days-before-issue-close: 7 | |
| days-before-pr-stale: 14 | |
| days-before-pr-close: 7 | |
| stale-issue-label: stale | |
| stale-pr-label: stale | |
| exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle | |
| exempt-pr-labels: maintainer,no-stale,bad-barnacle | |
| operations-per-run: 2000 | |
| ascending: true | |
| exempt-all-assignees: true | |
| remove-stale-when-updated: true | |
| stale-issue-message: | | |
| This issue has been automatically marked as stale due to inactivity. | |
| Please add updates or it will be closed. | |
| stale-pr-message: | | |
| This pull request has been automatically marked as stale due to inactivity. | |
| Please add updates or it will be closed. | |
| close-issue-message: | | |
| Closing due to inactivity. | |
| If this is still an issue, please retry on the latest OpenClaw release and share updated details. | |
| If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce. | |
| close-issue-reason: not_planned | |
| close-pr-message: | | |
| Closing due to inactivity. | |
| If you believe this PR should be revived, post in #clawtributors on Discord to talk to a maintainer. | |
| That channel is the escape hatch for high-quality PRs that get auto-closed. | |
| - name: Mark stale assigned issues (primary) | |
| id: assigned-issue-stale-primary | |
| continue-on-error: true | |
| uses: actions/stale@v10 | |
| with: | |
| repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} | |
| days-before-issue-stale: 30 | |
| days-before-issue-close: 10 | |
| days-before-pr-stale: -1 | |
| days-before-pr-close: -1 | |
| stale-issue-label: stale | |
| exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle | |
| operations-per-run: 2000 | |
| ascending: true | |
| include-only-assigned: true | |
| remove-stale-when-updated: true | |
| stale-issue-message: | | |
| This assigned issue has been automatically marked as stale after 30 days of inactivity. | |
| Please add updates or it will be closed. | |
| close-issue-message: | | |
| Closing due to inactivity. | |
| If this is still an issue, please retry on the latest OpenClaw release and share updated details. | |
| If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce. | |
| close-issue-reason: not_planned | |
| - name: Mark stale assigned pull requests (primary) | |
| id: assigned-stale-primary | |
| continue-on-error: true | |
| uses: actions/stale@v10 | |
| with: | |
| repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} | |
| days-before-issue-stale: -1 | |
| days-before-issue-close: -1 | |
| days-before-pr-stale: 27 | |
| days-before-pr-close: 7 | |
| stale-pr-label: stale | |
| exempt-pr-labels: maintainer,no-stale,bad-barnacle | |
| operations-per-run: 2000 | |
| ascending: true | |
| include-only-assigned: true | |
| ignore-pr-updates: true | |
| remove-stale-when-updated: true | |
| stale-pr-message: | | |
| This assigned pull request has been automatically marked as stale after being open for 27 days. | |
| Please add updates or it will be closed. | |
| close-pr-message: | | |
| Closing due to inactivity. | |
| If you believe this PR should be revived, post in #clawtributors on Discord to talk to a maintainer. | |
| That channel is the escape hatch for high-quality PRs that get auto-closed. | |
| - name: Check stale state cache | |
| id: stale-state | |
| if: always() | |
| uses: actions/github-script@v9 | |
| with: | |
| github-token: ${{ steps.app-token-fallback.outputs.token || steps.app-token.outputs.token }} | |
| script: | | |
| const cacheKey = "_state"; | |
| const { owner, repo } = context.repo; | |
| try { | |
| const { data } = await github.rest.actions.getActionsCacheList({ | |
| owner, | |
| repo, | |
| key: cacheKey, | |
| }); | |
| const caches = data.actions_caches ?? []; | |
| const hasState = caches.some(cache => cache.key === cacheKey); | |
| core.setOutput("has_state", hasState ? "true" : "false"); | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : String(error); | |
| core.warning(`Failed to check stale state cache: ${message}`); | |
| core.setOutput("has_state", "false"); | |
| } | |
| - name: Mark stale unassigned issues and pull requests (fallback) | |
| if: (steps.stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != '' | |
| uses: actions/stale@v10 | |
| with: | |
| repo-token: ${{ steps.app-token-fallback.outputs.token }} | |
| days-before-issue-stale: 14 | |
| days-before-issue-close: 7 | |
| days-before-pr-stale: 14 | |
| days-before-pr-close: 7 | |
| stale-issue-label: stale | |
| stale-pr-label: stale | |
| exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle | |
| exempt-pr-labels: maintainer,no-stale,bad-barnacle | |
| operations-per-run: 2000 | |
| ascending: true | |
| exempt-all-assignees: true | |
| remove-stale-when-updated: true | |
| stale-issue-message: | | |
| This issue has been automatically marked as stale due to inactivity. | |
| Please add updates or it will be closed. | |
| stale-pr-message: | | |
| This pull request has been automatically marked as stale due to inactivity. | |
| Please add updates or it will be closed. | |
| close-issue-message: | | |
| Closing due to inactivity. | |
| If this is still an issue, please retry on the latest OpenClaw release and share updated details. | |
| If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce. | |
| close-issue-reason: not_planned | |
| close-pr-message: | | |
| Closing due to inactivity. | |
| If you believe this PR should be revived, post in #clawtributors on Discord to talk to a maintainer. | |
| That channel is the escape hatch for high-quality PRs that get auto-closed. | |
| - name: Mark stale assigned issues (fallback) | |
| if: (steps.assigned-issue-stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != '' | |
| uses: actions/stale@v10 | |
| with: | |
| repo-token: ${{ steps.app-token-fallback.outputs.token }} | |
| days-before-issue-stale: 30 | |
| days-before-issue-close: 10 | |
| days-before-pr-stale: -1 | |
| days-before-pr-close: -1 | |
| stale-issue-label: stale | |
| exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle | |
| operations-per-run: 2000 | |
| ascending: true | |
| include-only-assigned: true | |
| remove-stale-when-updated: true | |
| stale-issue-message: | | |
| This assigned issue has been automatically marked as stale after 30 days of inactivity. | |
| Please add updates or it will be closed. | |
| close-issue-message: | | |
| Closing due to inactivity. | |
| If this is still an issue, please retry on the latest OpenClaw release and share updated details. | |
| If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce. | |
| close-issue-reason: not_planned | |
| - name: Mark stale assigned pull requests (fallback) | |
| if: (steps.assigned-stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != '' | |
| uses: actions/stale@v10 | |
| with: | |
| repo-token: ${{ steps.app-token-fallback.outputs.token }} | |
| days-before-issue-stale: -1 | |
| days-before-issue-close: -1 | |
| days-before-pr-stale: 27 | |
| days-before-pr-close: 7 | |
| stale-pr-label: stale | |
| exempt-pr-labels: maintainer,no-stale,bad-barnacle | |
| operations-per-run: 2000 | |
| ascending: true | |
| include-only-assigned: true | |
| ignore-pr-updates: true | |
| remove-stale-when-updated: true | |
| stale-pr-message: | | |
| This assigned pull request has been automatically marked as stale after being open for 27 days. | |
| Please add updates or it will be closed. | |
| close-pr-message: | | |
| Closing due to inactivity. | |
| If you believe this PR should be revived, post in #clawtributors on Discord to talk to a maintainer. | |
| That channel is the escape hatch for high-quality PRs that get auto-closed. | |
| backfill-stale-closures: | |
| if: ${{ github.event_name == 'workflow_dispatch' && inputs.backfill_stale_closures == true }} | |
| permissions: | |
| issues: write | |
| pull-requests: write | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| steps: | |
| - uses: actions/create-github-app-token@v3 | |
| id: app-token | |
| with: | |
| app-id: "2971289" | |
| private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} | |
| - name: Backfill stale closures | |
| uses: actions/github-script@v9 | |
| env: | |
| DRY_RUN: ${{ inputs.dry_run }} | |
| INCLUDE_ISSUES: ${{ inputs.include_issues }} | |
| INCLUDE_PRS: ${{ inputs.include_prs }} | |
| MAX_CLOSURES: ${{ inputs.max_closures }} | |
| with: | |
| github-token: ${{ steps.app-token.outputs.token }} | |
| script: | | |
| const dayMs = 24 * 60 * 60 * 1000; | |
| const dryRun = process.env.DRY_RUN !== "false"; | |
| const includeIssues = process.env.INCLUDE_ISSUES !== "false"; | |
| const includePrs = process.env.INCLUDE_PRS !== "false"; | |
| const maxClosures = Math.max(0, Number(process.env.MAX_CLOSURES || "50")); | |
| const nowMs = Date.now(); | |
| const { owner, repo } = context.repo; | |
| const issueExemptLabels = new Set([ | |
| "enhancement", | |
| "maintainer", | |
| "pinned", | |
| "security", | |
| "no-stale", | |
| "bad-barnacle", | |
| ]); | |
| const prExemptLabels = new Set(["maintainer", "no-stale", "bad-barnacle"]); | |
| const maintainerAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); | |
| const maintainerLogins = new Set([ | |
| "altaywtf", | |
| "BunsDev", | |
| "cpojer", | |
| "gumadeiras", | |
| "hydro13", | |
| "hxy91819", | |
| "jalehman", | |
| "joshavant", | |
| "joshp123", | |
| "mbelinky", | |
| "mukhtharcm", | |
| "ngutman", | |
| "obviyus", | |
| "odysseus0", | |
| "onutc", | |
| "osolmaz", | |
| "sebslight", | |
| "sliverp", | |
| "steipete", | |
| "thewilloftheshadow", | |
| "tyler6204", | |
| "velvet-shark", | |
| "vignesh07", | |
| "vincentkoc", | |
| "visionik", | |
| ].map(login => login.toLowerCase())); | |
| const issueCloseMessage = [ | |
| "Closing due to inactivity.", | |
| "If this is still an issue, please retry on the latest OpenClaw release and share updated details.", | |
| "If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce.", | |
| ].join("\n"); | |
| const prCloseMessage = [ | |
| "Closing due to inactivity.", | |
| "If you believe this PR should be revived, post in #clawtributors on Discord to talk to a maintainer.", | |
| "That channel is the escape hatch for high-quality PRs that get auto-closed.", | |
| ].join("\n"); | |
| const hasAny = (labels, exemptLabels) => { | |
| for (const label of labels) { | |
| if (exemptLabels.has(label)) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| }; | |
| const isOlderThan = (dateString, days) => { | |
| const timestamp = Date.parse(dateString); | |
| return Number.isFinite(timestamp) && timestamp < nowMs - days * dayMs; | |
| }; | |
| const candidates = []; | |
| const skipped = { | |
| missingStale: 0, | |
| exemptLabel: 0, | |
| maintainerAuthor: 0, | |
| maintainerAssignee: 0, | |
| notOldEnough: 0, | |
| disabledType: 0, | |
| }; | |
| for await (const response of github.paginate.iterator(github.rest.issues.listForRepo, { | |
| owner, | |
| repo, | |
| state: "open", | |
| sort: "updated", | |
| direction: "asc", | |
| per_page: 100, | |
| })) { | |
| for (const item of response.data) { | |
| const isPr = Boolean(item.pull_request); | |
| if ((isPr && !includePrs) || (!isPr && !includeIssues)) { | |
| skipped.disabledType += 1; | |
| continue; | |
| } | |
| const labels = new Set((item.labels || []).map(label => label.name)); | |
| if (!labels.has("stale")) { | |
| skipped.missingStale += 1; | |
| continue; | |
| } | |
| const exemptLabels = isPr ? prExemptLabels : issueExemptLabels; | |
| if (hasAny(labels, exemptLabels)) { | |
| skipped.exemptLabel += 1; | |
| continue; | |
| } | |
| if (maintainerAssociations.has(item.author_association)) { | |
| skipped.maintainerAuthor += 1; | |
| continue; | |
| } | |
| const assigned = (item.assignees || []).length > 0; | |
| const assignedToMaintainer = (item.assignees || []).some(assignee => | |
| maintainerLogins.has(assignee.login.toLowerCase()), | |
| ); | |
| if (assignedToMaintainer) { | |
| skipped.maintainerAssignee += 1; | |
| continue; | |
| } | |
| let eligible = false; | |
| let lane = ""; | |
| if (isPr && assigned) { | |
| lane = "assigned-pr"; | |
| eligible = isOlderThan(item.created_at, 34) && isOlderThan(item.updated_at, 7); | |
| } else if (isPr) { | |
| lane = "unassigned-pr"; | |
| eligible = isOlderThan(item.updated_at, 7); | |
| } else if (assigned) { | |
| lane = "assigned-issue"; | |
| eligible = isOlderThan(item.updated_at, 10); | |
| } else { | |
| lane = "unassigned-issue"; | |
| eligible = isOlderThan(item.updated_at, 7); | |
| } | |
| if (!eligible) { | |
| skipped.notOldEnough += 1; | |
| continue; | |
| } | |
| candidates.push({ | |
| number: item.number, | |
| title: item.title, | |
| lane, | |
| isPr, | |
| assigned, | |
| createdAt: item.created_at, | |
| updatedAt: item.updated_at, | |
| authorAssociation: item.author_association, | |
| url: item.html_url, | |
| }); | |
| } | |
| } | |
| const countsByLane = candidates.reduce((counts, candidate) => { | |
| counts[candidate.lane] = (counts[candidate.lane] || 0) + 1; | |
| return counts; | |
| }, {}); | |
| const selected = candidates.slice(0, maxClosures); | |
| core.info(`Dry run: ${dryRun}`); | |
| core.info(`Candidates: ${candidates.length}`); | |
| core.info(`Selected: ${selected.length}`); | |
| core.info(`Counts by lane: ${JSON.stringify(countsByLane)}`); | |
| core.info(`Skipped: ${JSON.stringify(skipped)}`); | |
| for (const candidate of selected) { | |
| core.info(`${dryRun ? "Would close" : "Closing"} ${candidate.lane} #${candidate.number}: ${candidate.title} (${candidate.url})`); | |
| } | |
| await core.summary | |
| .addHeading("Stale Closure Backfill") | |
| .addRaw(`Dry run: ${dryRun}\n\n`) | |
| .addRaw(`Candidates: ${candidates.length}\n\n`) | |
| .addRaw(`Selected: ${selected.length}\n\n`) | |
| .addCodeBlock(JSON.stringify({ countsByLane, skipped }, null, 2), "json") | |
| .addTable([ | |
| [ | |
| { data: "Lane", header: true }, | |
| { data: "Number", header: true }, | |
| { data: "Title", header: true }, | |
| { data: "URL", header: true }, | |
| ], | |
| ...selected.map(candidate => [ | |
| candidate.lane, | |
| String(candidate.number), | |
| candidate.title, | |
| candidate.url, | |
| ]), | |
| ]) | |
| .write(); | |
| if (dryRun) { | |
| return; | |
| } | |
| for (const candidate of selected) { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: candidate.number, | |
| body: candidate.isPr ? prCloseMessage : issueCloseMessage, | |
| }); | |
| if (candidate.isPr) { | |
| await github.rest.pulls.update({ | |
| owner, | |
| repo, | |
| pull_number: candidate.number, | |
| state: "closed", | |
| }); | |
| } else { | |
| await github.rest.issues.update({ | |
| owner, | |
| repo, | |
| issue_number: candidate.number, | |
| state: "closed", | |
| state_reason: "not_planned", | |
| }); | |
| } | |
| } | |
| lock-closed-issues: | |
| if: ${{ github.event_name != 'workflow_dispatch' || inputs.backfill_stale_closures != true }} | |
| permissions: | |
| issues: write | |
| runs-on: blacksmith-16vcpu-ubuntu-2404 | |
| steps: | |
| - uses: actions/create-github-app-token@v3 | |
| id: app-token | |
| with: | |
| app-id: "2729701" | |
| private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} | |
| - name: Lock closed issues after 48h of no comments | |
| uses: actions/github-script@v9 | |
| with: | |
| github-token: ${{ steps.app-token.outputs.token }} | |
| script: | | |
| const lockAfterHours = 48; | |
| const lockAfterMs = lockAfterHours * 60 * 60 * 1000; | |
| const perPage = 100; | |
| const cutoffMs = Date.now() - lockAfterMs; | |
| const { owner, repo } = context.repo; | |
| let locked = 0; | |
| let inspected = 0; | |
| let page = 1; | |
| while (true) { | |
| const { data: issues } = await github.rest.issues.listForRepo({ | |
| owner, | |
| repo, | |
| state: "closed", | |
| sort: "updated", | |
| direction: "desc", | |
| per_page: perPage, | |
| page, | |
| }); | |
| if (issues.length === 0) { | |
| break; | |
| } | |
| for (const issue of issues) { | |
| if (issue.pull_request) { | |
| continue; | |
| } | |
| if (issue.locked) { | |
| continue; | |
| } | |
| if (!issue.closed_at) { | |
| continue; | |
| } | |
| inspected += 1; | |
| const closedAtMs = Date.parse(issue.closed_at); | |
| if (!Number.isFinite(closedAtMs)) { | |
| continue; | |
| } | |
| if (closedAtMs > cutoffMs) { | |
| continue; | |
| } | |
| let lastCommentMs = 0; | |
| if (issue.comments > 0) { | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| per_page: 1, | |
| page: 1, | |
| sort: "created", | |
| direction: "desc", | |
| }); | |
| if (comments.length > 0) { | |
| lastCommentMs = Date.parse(comments[0].created_at); | |
| } | |
| } | |
| const lastActivityMs = Math.max(closedAtMs, lastCommentMs || 0); | |
| if (lastActivityMs > cutoffMs) { | |
| continue; | |
| } | |
| await github.rest.issues.lock({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| lock_reason: "resolved", | |
| }); | |
| locked += 1; | |
| } | |
| page += 1; | |
| } | |
| core.info(`Inspected ${inspected} closed issues; locked ${locked}.`); |