Skip to content

Commit 38a8a46

Browse files
committed
Allow option to exclude specific weekdays from total time
Fixed #564 Add support for excluding specific weekdays when calculating elapsed days for stale issues and PRs. This is particularly useful for organizations that want to consider only business days. - Add new `exclude-weekdays` option to specify which days to exclude (0-6, where 0 is Sunday) - Implement weekday exclusion logic in elapsed days calculation - Add tests to verify business days calculation ```yaml - uses: actions/stale@v9 with: days-before-stale: 5 days-before-close: 2 exclude-weekdays: '0,6' # Exclude weekends ```
1 parent 9971854 commit 38a8a46

File tree

11 files changed

+295
-17
lines changed

11 files changed

+295
-17
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ Every argument is optional.
8787
| [ascending](#ascending) | Order to get issues/PRs | `false` |
8888
| [start-date](#start-date) | Skip stale action for issues/PRs created before it | |
8989
| [delete-branch](#delete-branch) | Delete branch after closing a stale PR | `false` |
90+
| [exclude-weekdays](#exclude-weekdays) | Weekdays to exclude when calculating elapsed days | |
9091
| [exempt-milestones](#exempt-milestones) | Milestones on issues/PRs exempted from stale | |
9192
| [exempt-issue-milestones](#exempt-issue-milestones) | Override [exempt-milestones](#exempt-milestones) for issues only | |
9293
| [exempt-pr-milestones](#exempt-pr-milestones) | Override [exempt-milestones](#exempt-milestones) for PRs only | |
@@ -551,6 +552,15 @@ Useful to override [ignore-updates](#ignore-updates) but only to ignore the upda
551552

552553
Default value: unset
553554

555+
#### exclude-weekdays
556+
557+
A comma separated list of weekdays (0-6, where 0 is Sunday and 6 is Saturday) to exclude when calculating elapsed days.
558+
This is useful when you want to count only business days for stale calculations.
559+
560+
For example, to exclude weekends, set this to `0,6`.
561+
562+
Default value: unset
563+
554564
#### include-only-assigned
555565

556566
If set to `true`, only the issues or the pull requests with an assignee will be marked as stale automatically.

__tests__/constants/default-processor-options.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,6 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({
5656
ignorePrUpdates: undefined,
5757
exemptDraftPr: false,
5858
closeIssueReason: 'not_planned',
59-
includeOnlyAssigned: false
59+
includeOnlyAssigned: false,
60+
excludeWeekdays: []
6061
});

__tests__/main.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2743,3 +2743,45 @@ test('processing an issue with the "includeOnlyAssigned" option set and no assig
27432743
expect(processor.staleIssues).toHaveLength(0);
27442744
expect(processor.closedIssues).toHaveLength(0);
27452745
});
2746+
2747+
test('processing an issue should not count specific weekdays when calculating stale days', async () => {
2748+
const now: Date = new Date();
2749+
const day = 1000 * 60 * 60 * 24;
2750+
const yesterday: Date = new Date(now.getTime() - day);
2751+
2752+
const opts: IIssuesProcessorOptions = {
2753+
...DefaultProcessorOptions,
2754+
daysBeforeStale: 5,
2755+
daysBeforeClose: 2,
2756+
excludeWeekdays: [yesterday.getDay()]
2757+
};
2758+
2759+
const TestIssueList: Issue[] = [
2760+
generateIssue(
2761+
opts,
2762+
1,
2763+
'not stale yet',
2764+
new Date(now.getTime() - 5 * day).toDateString()
2765+
),
2766+
generateIssue(
2767+
opts,
2768+
2,
2769+
'stale',
2770+
new Date(now.getTime() - 6 * day).toDateString()
2771+
)
2772+
];
2773+
2774+
const processor = new IssuesProcessorMock(
2775+
opts,
2776+
alwaysFalseStateMock,
2777+
async p => (p === 1 ? TestIssueList : []),
2778+
async () => [],
2779+
async () => new Date().toDateString()
2780+
);
2781+
2782+
await processor.processIssues(1);
2783+
2784+
expect(processor.staleIssues).toHaveLength(1);
2785+
expect(processor.staleIssues[0]!.number).toEqual(2);
2786+
expect(processor.closedIssues).toHaveLength(0);
2787+
});

action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@ inputs:
212212
description: 'Only issues with a matching type are processed as stale/closed. Defaults to `[]` (disabled) and can be a comma-separated list of issue types.'
213213
default: ''
214214
required: false
215+
exclude-weekdays:
216+
description: 'Comma-separated list of weekdays to exclude from elapsed days calculation (0=Sunday, 6=Saturday)'
217+
required: false
218+
default: ''
215219
outputs:
216220
closed-issues-prs:
217221
description: 'List of all closed issues and pull requests.'

dist/index.js

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ const is_valid_date_1 = __nccwpck_require__(891);
375375
const is_boolean_1 = __nccwpck_require__(8236);
376376
const is_labeled_1 = __nccwpck_require__(6792);
377377
const clean_label_1 = __nccwpck_require__(7752);
378+
const elapsed_millis_excluding_days_1 = __nccwpck_require__(4101);
378379
const should_mark_when_stale_1 = __nccwpck_require__(2461);
379380
const words_to_list_1 = __nccwpck_require__(1883);
380381
const assignees_1 = __nccwpck_require__(7236);
@@ -394,9 +395,9 @@ const get_sort_field_1 = __nccwpck_require__(9551);
394395
* Handle processing of issues for staleness/closure.
395396
*/
396397
class IssuesProcessor {
397-
static _updatedSince(timestamp, num_days) {
398-
const daysInMillis = 1000 * 60 * 60 * 24 * num_days;
399-
const millisSinceLastUpdated = new Date().getTime() - new Date(timestamp).getTime();
398+
static _updatedSince(timestamp, numDays, excludeWeekdays) {
399+
const daysInMillis = 1000 * 60 * 60 * 24 * numDays;
400+
const millisSinceLastUpdated = (0, elapsed_millis_excluding_days_1.elapsedMillisExcludingDays)(new Date(timestamp), new Date(), excludeWeekdays);
400401
return millisSinceLastUpdated <= daysInMillis;
401402
}
402403
static _endIssueProcessing(issue) {
@@ -629,11 +630,11 @@ class IssuesProcessor {
629630
let shouldBeStale;
630631
// Ignore the last update and only use the creation date
631632
if (shouldIgnoreUpdates) {
632-
shouldBeStale = !IssuesProcessor._updatedSince(issue.created_at, daysBeforeStale);
633+
shouldBeStale = !IssuesProcessor._updatedSince(issue.created_at, daysBeforeStale, this.options.excludeWeekdays);
633634
}
634635
// Use the last update to check if we need to stale
635636
else {
636-
shouldBeStale = !IssuesProcessor._updatedSince(issue.updated_at, daysBeforeStale);
637+
shouldBeStale = !IssuesProcessor._updatedSince(issue.updated_at, daysBeforeStale, this.options.excludeWeekdays);
637638
}
638639
if (shouldBeStale) {
639640
if (shouldIgnoreUpdates) {
@@ -823,7 +824,7 @@ class IssuesProcessor {
823824
if (daysBeforeClose < 0) {
824825
return; // Nothing to do because we aren't closing stale issues
825826
}
826-
const issueHasUpdateInCloseWindow = IssuesProcessor._updatedSince(issue.updated_at, daysBeforeClose);
827+
const issueHasUpdateInCloseWindow = IssuesProcessor._updatedSince(issue.updated_at, daysBeforeClose, this.options.excludeWeekdays);
827828
issueLogger.info(`$$type has been updated in the last ${daysBeforeClose} days: ${logger_service_1.LoggerService.cyan(issueHasUpdateInCloseWindow)}`);
828829
if (!issueHasCommentsSinceStale && !issueHasUpdateInCloseWindow) {
829830
issueLogger.info(`Closing $$type because it was last updated on: ${logger_service_1.LoggerService.cyan(issue.updated_at)}`);
@@ -2363,6 +2364,51 @@ function isValidDate(date) {
23632364
exports.isValidDate = isValidDate;
23642365

23652366

2367+
/***/ }),
2368+
2369+
/***/ 4101:
2370+
/***/ ((__unused_webpack_module, exports) => {
2371+
2372+
"use strict";
2373+
2374+
Object.defineProperty(exports, "__esModule", ({ value: true }));
2375+
exports.elapsedMillisExcludingDays = void 0;
2376+
const DAY = 1000 * 60 * 60 * 24;
2377+
function startOfDay(date) {
2378+
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
2379+
}
2380+
function countWeekdaysBetweenDates(start, end, weekdays) {
2381+
const totalDays = Math.floor((end.getTime() - start.getTime()) / DAY);
2382+
const startDayOfWeek = start.getDay();
2383+
const weekdaysMap = new Set(weekdays);
2384+
let count = 0;
2385+
for (let i = 0; i < totalDays; i++) {
2386+
const currentDay = (startDayOfWeek + i) % 7;
2387+
if (weekdaysMap.has(currentDay)) {
2388+
count++;
2389+
}
2390+
}
2391+
return count;
2392+
}
2393+
const elapsedMillisExcludingDays = (from, to, excludeWeekdays) => {
2394+
let elapsedMillis = to.getTime() - from.getTime();
2395+
if (excludeWeekdays.length > 0) {
2396+
const startOfNextDayFrom = startOfDay(new Date(from.getTime() + DAY));
2397+
const startOfDayTo = startOfDay(to);
2398+
if (excludeWeekdays.includes(from.getDay())) {
2399+
elapsedMillis -= startOfNextDayFrom.getTime() - from.getTime();
2400+
}
2401+
if (excludeWeekdays.includes(to.getDay())) {
2402+
elapsedMillis -= to.getTime() - startOfDayTo.getTime();
2403+
}
2404+
const excludeWeekdaysCount = countWeekdaysBetweenDates(startOfNextDayFrom, startOfDayTo, excludeWeekdays);
2405+
elapsedMillis -= excludeWeekdaysCount * DAY;
2406+
}
2407+
return elapsedMillis;
2408+
};
2409+
exports.elapsedMillisExcludingDays = elapsedMillisExcludingDays;
2410+
2411+
23662412
/***/ }),
23672413

23682414
/***/ 9551:
@@ -2618,7 +2664,13 @@ function _getAndValidateArgs() {
26182664
exemptDraftPr: core.getInput('exempt-draft-pr') === 'true',
26192665
closeIssueReason: core.getInput('close-issue-reason'),
26202666
includeOnlyAssigned: core.getInput('include-only-assigned') === 'true',
2621-
onlyIssueTypes: core.getInput('only-issue-types')
2667+
onlyIssueTypes: core.getInput('only-issue-types'),
2668+
excludeWeekdays: core.getInput('exclude-weekdays')
2669+
? core
2670+
.getInput('exclude-weekdays')
2671+
.split(',')
2672+
.map(day => parseInt(day.trim(), 10))
2673+
: []
26222674
};
26232675
for (const numberInput of ['days-before-stale']) {
26242676
if (isNaN(parseFloat(core.getInput(numberInput)))) {
@@ -2650,6 +2702,13 @@ function _getAndValidateArgs() {
26502702
core.setFailed(errorMessage);
26512703
throw new Error(errorMessage);
26522704
}
2705+
// Validate weekdays
2706+
if (args.excludeWeekdays &&
2707+
args.excludeWeekdays.some(day => isNaN(day) || day < 0 || day > 6)) {
2708+
const errorMessage = 'Option "exclude-weekdays" must be comma-separated integers between 0 (Sunday) and 6 (Saturday)';
2709+
core.setFailed(errorMessage);
2710+
throw new Error(errorMessage);
2711+
}
26532712
return args;
26542713
}
26552714
function processOutput(staledIssues, closedIssues) {

src/classes/issue.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ describe('Issue', (): void => {
6565
ignorePrUpdates: undefined,
6666
exemptDraftPr: false,
6767
closeIssueReason: '',
68-
includeOnlyAssigned: false
68+
includeOnlyAssigned: false,
69+
excludeWeekdays: []
6970
};
7071
issueInterface = {
7172
title: 'dummy-title',

src/classes/issues-processor.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {isValidDate} from '../functions/dates/is-valid-date';
88
import {isBoolean} from '../functions/is-boolean';
99
import {isLabeled} from '../functions/is-labeled';
1010
import {cleanLabel} from '../functions/clean-label';
11+
import {elapsedMillisExcludingDays} from '../functions/elapsed-millis-excluding-days';
1112
import {shouldMarkWhenStale} from '../functions/should-mark-when-stale';
1213
import {wordsToList} from '../functions/words-to-list';
1314
import {IComment} from '../interfaces/comment';
@@ -36,10 +37,17 @@ import {getSortField} from '../functions/get-sort-field';
3637
*/
3738

3839
export class IssuesProcessor {
39-
private static _updatedSince(timestamp: string, num_days: number): boolean {
40-
const daysInMillis = 1000 * 60 * 60 * 24 * num_days;
41-
const millisSinceLastUpdated =
42-
new Date().getTime() - new Date(timestamp).getTime();
40+
private static _updatedSince(
41+
timestamp: string,
42+
numDays: number,
43+
excludeWeekdays: number[]
44+
): boolean {
45+
const daysInMillis = 1000 * 60 * 60 * 24 * numDays;
46+
const millisSinceLastUpdated = elapsedMillisExcludingDays(
47+
new Date(timestamp),
48+
new Date(),
49+
excludeWeekdays
50+
);
4351

4452
return millisSinceLastUpdated <= daysInMillis;
4553
}
@@ -479,14 +487,16 @@ export class IssuesProcessor {
479487
if (shouldIgnoreUpdates) {
480488
shouldBeStale = !IssuesProcessor._updatedSince(
481489
issue.created_at,
482-
daysBeforeStale
490+
daysBeforeStale,
491+
this.options.excludeWeekdays
483492
);
484493
}
485494
// Use the last update to check if we need to stale
486495
else {
487496
shouldBeStale = !IssuesProcessor._updatedSince(
488497
issue.updated_at,
489-
daysBeforeStale
498+
daysBeforeStale,
499+
this.options.excludeWeekdays
490500
);
491501
}
492502

@@ -787,7 +797,8 @@ export class IssuesProcessor {
787797

788798
const issueHasUpdateInCloseWindow: boolean = IssuesProcessor._updatedSince(
789799
issue.updated_at,
790-
daysBeforeClose
800+
daysBeforeClose,
801+
this.options.excludeWeekdays
791802
);
792803
issueLogger.info(
793804
`$$type has been updated in the last ${daysBeforeClose} days: ${LoggerService.cyan(
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {elapsedMillisExcludingDays} from './elapsed-millis-excluding-days';
2+
3+
describe('elapsedMillisExcludingDays', () => {
4+
const HOUR = 1000 * 60 * 60;
5+
const DAY = HOUR * 24;
6+
7+
it('calculates elapsed days when no weekdays are excluded', () => {
8+
const from = new Date();
9+
const lessThan = new Date(from.getTime() - 1);
10+
const equal = from;
11+
const greaterThan = new Date(from.getTime() + 1);
12+
13+
expect(elapsedMillisExcludingDays(from, lessThan, [])).toEqual(-1);
14+
expect(elapsedMillisExcludingDays(from, equal, [])).toEqual(0);
15+
expect(elapsedMillisExcludingDays(from, greaterThan, [])).toEqual(1);
16+
});
17+
18+
it('calculates elapsed days with specified weekdays excluded', () => {
19+
const date = new Date('2025-03-03 09:00:00'); // Monday
20+
21+
const tomorrow = new Date('2025-03-04 09:00:00');
22+
expect(elapsedMillisExcludingDays(date, tomorrow, [])).toEqual(DAY);
23+
expect(elapsedMillisExcludingDays(date, tomorrow, [1])).toEqual(9 * HOUR);
24+
25+
const dayAfterTomorrow = new Date('2025-03-05 10:00:00');
26+
const full = 2 * DAY + HOUR;
27+
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [])).toEqual(
28+
full
29+
);
30+
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [0])).toEqual(
31+
full
32+
);
33+
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [1])).toEqual(
34+
full - 15 * HOUR
35+
);
36+
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [2])).toEqual(
37+
full - DAY
38+
);
39+
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [3])).toEqual(
40+
full - 10 * HOUR
41+
);
42+
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [4])).toEqual(
43+
full
44+
);
45+
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [1, 2])).toEqual(
46+
10 * HOUR
47+
);
48+
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [2, 3])).toEqual(
49+
15 * HOUR
50+
);
51+
});
52+
53+
it('handles week spanning periods correctly', () => {
54+
const friday = new Date('2025-03-07 09:00:00');
55+
const nextMonday = new Date('2025-03-10 09:00:00');
56+
expect(elapsedMillisExcludingDays(friday, nextMonday, [0, 6])).toEqual(DAY);
57+
});
58+
59+
it('handles long periods with multiple weeks', () => {
60+
const start = new Date('2025-03-03 09:00:00');
61+
const twoWeeksLater = new Date('2025-03-17 09:00:00');
62+
expect(elapsedMillisExcludingDays(start, twoWeeksLater, [0, 6])).toEqual(
63+
10 * DAY
64+
);
65+
66+
const lessThanTwoWeeksLater = new Date('2025-03-17 08:59:59');
67+
expect(
68+
elapsedMillisExcludingDays(start, lessThanTwoWeeksLater, [0, 6])
69+
).toEqual(10 * DAY - 1000);
70+
});
71+
});

0 commit comments

Comments
 (0)