44 schedule :
55 - cron : " 17 3 * * *"
66 workflow_dispatch :
7+ inputs :
8+ backfill_stale_closures :
9+ description : " Close currently stale-eligible issues and PRs with the Barnacle app"
10+ required : false
11+ type : boolean
12+ default : false
13+ dry_run :
14+ description : " List matching stale-eligible items without closing them"
15+ required : false
16+ type : boolean
17+ default : true
18+ include_issues :
19+ description : " Include stale-eligible issues in the backfill"
20+ required : false
21+ type : boolean
22+ default : true
23+ include_prs :
24+ description : " Include stale-eligible pull requests in the backfill"
25+ required : false
26+ type : boolean
27+ default : true
28+ max_closures :
29+ description : " Maximum items to close when dry_run is false"
30+ required : false
31+ type : number
32+ default : 50
733
834env :
935 FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 : " true"
@@ -12,6 +38,7 @@ permissions: {}
1238
1339jobs :
1440 stale :
41+ if : ${{ github.event_name != 'workflow_dispatch' || inputs.backfill_stale_closures != true }}
1542 permissions :
1643 issues : write
1744 pull-requests : write
@@ -35,10 +62,10 @@ jobs:
3562 uses : actions/stale@v10
3663 with :
3764 repo-token : ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
38- days-before-issue-stale : 7
39- days-before-issue-close : 5
40- days-before-pr-stale : 5
41- days-before-pr-close : 3
65+ days-before-issue-stale : 14
66+ days-before-issue-close : 7
67+ days-before-pr-stale : 14
68+ days-before-pr-close : 7
4269 stale-issue-label : stale
4370 stale-pr-label : stale
4471 exempt-issue-labels : enhancement,maintainer,pinned,security,no-stale,bad-barnacle
95122 days-before-issue-stale : -1
96123 days-before-issue-close : -1
97124 days-before-pr-stale : 27
98- days-before-pr-close : 3
125+ days-before-pr-close : 7
99126 stale-pr-label : stale
100127 exempt-pr-labels : maintainer,no-stale,bad-barnacle
101128 operations-per-run : 2000
@@ -139,10 +166,10 @@ jobs:
139166 uses : actions/stale@v10
140167 with :
141168 repo-token : ${{ steps.app-token-fallback.outputs.token }}
142- days-before-issue-stale : 7
143- days-before-issue-close : 5
144- days-before-pr-stale : 5
145- days-before-pr-close : 3
169+ days-before-issue-stale : 14
170+ days-before-issue-close : 7
171+ days-before-pr-stale : 14
172+ days-before-pr-close : 7
146173 stale-issue-label : stale
147174 stale-pr-label : stale
148175 exempt-issue-labels : enhancement,maintainer,pinned,security,no-stale,bad-barnacle
@@ -197,7 +224,7 @@ jobs:
197224 days-before-issue-stale : -1
198225 days-before-issue-close : -1
199226 days-before-pr-stale : 27
200- days-before-pr-close : 3
227+ days-before-pr-close : 7
201228 stale-pr-label : stale
202229 exempt-pr-labels : maintainer,no-stale,bad-barnacle
203230 operations-per-run : 2000
@@ -213,7 +240,217 @@ jobs:
213240 If you believe this PR should be revived, post in #clawtributors on Discord to talk to a maintainer.
214241 That channel is the escape hatch for high-quality PRs that get auto-closed.
215242
243+ backfill-stale-closures :
244+ if : ${{ github.event_name == 'workflow_dispatch' && inputs.backfill_stale_closures == true }}
245+ permissions :
246+ issues : write
247+ pull-requests : write
248+ runs-on : blacksmith-16vcpu-ubuntu-2404
249+ steps :
250+ - uses : actions/create-github-app-token@v3
251+ id : app-token
252+ with :
253+ app-id : " 2971289"
254+ private-key : ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
255+ - name : Backfill stale closures
256+ uses : actions/github-script@v9
257+ env :
258+ DRY_RUN : ${{ inputs.dry_run }}
259+ INCLUDE_ISSUES : ${{ inputs.include_issues }}
260+ INCLUDE_PRS : ${{ inputs.include_prs }}
261+ MAX_CLOSURES : ${{ inputs.max_closures }}
262+ with :
263+ github-token : ${{ steps.app-token.outputs.token }}
264+ script : |
265+ const dayMs = 24 * 60 * 60 * 1000;
266+ const dryRun = process.env.DRY_RUN !== "false";
267+ const includeIssues = process.env.INCLUDE_ISSUES !== "false";
268+ const includePrs = process.env.INCLUDE_PRS !== "false";
269+ const maxClosures = Math.max(0, Number(process.env.MAX_CLOSURES || "50"));
270+ const nowMs = Date.now();
271+ const { owner, repo } = context.repo;
272+
273+ const issueExemptLabels = new Set([
274+ "enhancement",
275+ "maintainer",
276+ "pinned",
277+ "security",
278+ "no-stale",
279+ "bad-barnacle",
280+ ]);
281+ const prExemptLabels = new Set(["maintainer", "no-stale", "bad-barnacle"]);
282+ const maintainerAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
283+
284+ const issueCloseMessage = [
285+ "Closing due to inactivity.",
286+ "If this is still an issue, please retry on the latest OpenClaw release and share updated details.",
287+ "If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce.",
288+ ].join("\n");
289+ const prCloseMessage = [
290+ "Closing due to inactivity.",
291+ "If you believe this PR should be revived, post in #clawtributors on Discord to talk to a maintainer.",
292+ "That channel is the escape hatch for high-quality PRs that get auto-closed.",
293+ ].join("\n");
294+
295+ const hasAny = (labels, exemptLabels) => {
296+ for (const label of labels) {
297+ if (exemptLabels.has(label)) {
298+ return true;
299+ }
300+ }
301+ return false;
302+ };
303+ const isOlderThan = (dateString, days) => {
304+ const timestamp = Date.parse(dateString);
305+ return Number.isFinite(timestamp) && timestamp < nowMs - days * dayMs;
306+ };
307+
308+ const candidates = [];
309+ const skipped = {
310+ missingStale: 0,
311+ exemptLabel: 0,
312+ maintainerAuthor: 0,
313+ notOldEnough: 0,
314+ disabledType: 0,
315+ };
316+
317+ for await (const response of github.paginate.iterator(github.rest.issues.listForRepo, {
318+ owner,
319+ repo,
320+ state: "open",
321+ sort: "updated",
322+ direction: "asc",
323+ per_page: 100,
324+ })) {
325+ for (const item of response.data) {
326+ const isPr = Boolean(item.pull_request);
327+ if ((isPr && !includePrs) || (!isPr && !includeIssues)) {
328+ skipped.disabledType += 1;
329+ continue;
330+ }
331+
332+ const labels = new Set((item.labels || []).map(label => label.name));
333+ if (!labels.has("stale")) {
334+ skipped.missingStale += 1;
335+ continue;
336+ }
337+
338+ const exemptLabels = isPr ? prExemptLabels : issueExemptLabels;
339+ if (hasAny(labels, exemptLabels)) {
340+ skipped.exemptLabel += 1;
341+ continue;
342+ }
343+
344+ if (maintainerAssociations.has(item.author_association)) {
345+ skipped.maintainerAuthor += 1;
346+ continue;
347+ }
348+
349+ const assigned = (item.assignees || []).length > 0;
350+ let eligible = false;
351+ let lane = "";
352+ if (isPr && assigned) {
353+ lane = "assigned-pr";
354+ eligible = isOlderThan(item.created_at, 34);
355+ } else if (isPr) {
356+ lane = "unassigned-pr";
357+ eligible = isOlderThan(item.updated_at, 7);
358+ } else if (assigned) {
359+ lane = "assigned-issue";
360+ eligible = isOlderThan(item.updated_at, 10);
361+ } else {
362+ lane = "unassigned-issue";
363+ eligible = isOlderThan(item.updated_at, 7);
364+ }
365+
366+ if (!eligible) {
367+ skipped.notOldEnough += 1;
368+ continue;
369+ }
370+
371+ candidates.push({
372+ number: item.number,
373+ title: item.title,
374+ lane,
375+ isPr,
376+ assigned,
377+ createdAt: item.created_at,
378+ updatedAt: item.updated_at,
379+ authorAssociation: item.author_association,
380+ url: item.html_url,
381+ });
382+ }
383+ }
384+
385+ const countsByLane = candidates.reduce((counts, candidate) => {
386+ counts[candidate.lane] = (counts[candidate.lane] || 0) + 1;
387+ return counts;
388+ }, {});
389+ const selected = maxClosures === 0 ? candidates : candidates.slice(0, maxClosures);
390+
391+ core.info(`Dry run: ${dryRun}`);
392+ core.info(`Candidates: ${candidates.length}`);
393+ core.info(`Selected: ${selected.length}`);
394+ core.info(`Counts by lane: ${JSON.stringify(countsByLane)}`);
395+ core.info(`Skipped: ${JSON.stringify(skipped)}`);
396+ for (const candidate of selected) {
397+ core.info(`${dryRun ? "Would close" : "Closing"} ${candidate.lane} #${candidate.number}: ${candidate.title} (${candidate.url})`);
398+ }
399+
400+ await core.summary
401+ .addHeading("Stale Closure Backfill")
402+ .addRaw(`Dry run: ${dryRun}\n\n`)
403+ .addRaw(`Candidates: ${candidates.length}\n\n`)
404+ .addRaw(`Selected: ${selected.length}\n\n`)
405+ .addCodeBlock(JSON.stringify({ countsByLane, skipped }, null, 2), "json")
406+ .addTable([
407+ [
408+ { data: "Lane", header: true },
409+ { data: "Number", header: true },
410+ { data: "Title", header: true },
411+ { data: "URL", header: true },
412+ ],
413+ ...selected.map(candidate => [
414+ candidate.lane,
415+ String(candidate.number),
416+ candidate.title,
417+ candidate.url,
418+ ]),
419+ ])
420+ .write();
421+
422+ if (dryRun) {
423+ return;
424+ }
425+
426+ for (const candidate of selected) {
427+ await github.rest.issues.createComment({
428+ owner,
429+ repo,
430+ issue_number: candidate.number,
431+ body: candidate.isPr ? prCloseMessage : issueCloseMessage,
432+ });
433+
434+ if (candidate.isPr) {
435+ await github.rest.pulls.update({
436+ owner,
437+ repo,
438+ pull_number: candidate.number,
439+ state: "closed",
440+ });
441+ } else {
442+ await github.rest.issues.update({
443+ owner,
444+ repo,
445+ issue_number: candidate.number,
446+ state: "closed",
447+ state_reason: "not_planned",
448+ });
449+ }
450+ }
451+
216452 lock-closed-issues :
453+ if : ${{ github.event_name != 'workflow_dispatch' || inputs.backfill_stale_closures != true }}
217454 permissions :
218455 issues : write
219456 runs-on : blacksmith-16vcpu-ubuntu-2404
0 commit comments