Skip to content

Commit 1403262

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 ee7ef89 commit 1403262

File tree

11 files changed

+323
-17
lines changed

11 files changed

+323
-17
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ Every argument is optional.
7979
| [ascending](#ascending) | Order to get issues/PRs | `false` |
8080
| [start-date](#start-date) | Skip stale action for issues/PRs created before it | |
8181
| [delete-branch](#delete-branch) | Delete branch after closing a stale PR | `false` |
82+
| [exclude-weekdays](#exclude-weekdays) | Weekdays to exclude when calculating elapsed days | |
8283
| [exempt-milestones](#exempt-milestones) | Milestones on issues/PRs exempted from stale | |
8384
| [exempt-issue-milestones](#exempt-issue-milestones) | Override [exempt-milestones](#exempt-milestones) for issues only | |
8485
| [exempt-pr-milestones](#exempt-pr-milestones) | Override [exempt-milestones](#exempt-milestones) for PRs only | |
@@ -541,6 +542,15 @@ Useful to override [ignore-updates](#ignore-updates) but only to ignore the upda
541542

542543
Default value: unset
543544

545+
#### exclude-weekdays
546+
547+
A comma separated list of weekdays (0-6, where 0 is Sunday and 6 is Saturday) to exclude when calculating elapsed days.
548+
This is useful when you want to count only business days for stale calculations.
549+
550+
For example, to exclude weekends, set this to `0,6`.
551+
552+
Default value: unset
553+
544554
#### include-only-assigned
545555

546556
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
@@ -55,5 +55,6 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({
5555
ignorePrUpdates: undefined,
5656
exemptDraftPr: false,
5757
closeIssueReason: 'not_planned',
58-
includeOnlyAssigned: false
58+
includeOnlyAssigned: false,
59+
excludeWeekdays: []
5960
});

__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
@@ -204,6 +204,10 @@ inputs:
204204
description: 'Only the issues or the pull requests with an assignee will be marked as stale automatically.'
205205
default: 'false'
206206
required: false
207+
exclude-weekdays:
208+
description: 'Comma-separated list of weekdays to exclude from elapsed days calculation (0=Sunday, 6=Saturday)'
209+
required: false
210+
default: ''
207211
outputs:
208212
closed-issues-prs:
209213
description: 'List of all closed issues and pull requests.'

dist/index.js

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ const is_valid_date_1 = __nccwpck_require__(891);
368368
const is_boolean_1 = __nccwpck_require__(8236);
369369
const is_labeled_1 = __nccwpck_require__(6792);
370370
const clean_label_1 = __nccwpck_require__(7752);
371+
const elapsed_millis_excluding_days_1 = __nccwpck_require__(4101);
371372
const should_mark_when_stale_1 = __nccwpck_require__(2461);
372373
const words_to_list_1 = __nccwpck_require__(1883);
373374
const assignees_1 = __nccwpck_require__(7236);
@@ -386,9 +387,9 @@ const rate_limit_1 = __nccwpck_require__(7069);
386387
* Handle processing of issues for staleness/closure.
387388
*/
388389
class IssuesProcessor {
389-
static _updatedSince(timestamp, num_days) {
390-
const daysInMillis = 1000 * 60 * 60 * 24 * num_days;
391-
const millisSinceLastUpdated = new Date().getTime() - new Date(timestamp).getTime();
390+
static _updatedSince(timestamp, numDays, excludeWeekdays) {
391+
const daysInMillis = 1000 * 60 * 60 * 24 * numDays;
392+
const millisSinceLastUpdated = (0, elapsed_millis_excluding_days_1.elapsedMillisExcludingDays)(new Date(timestamp), new Date(), excludeWeekdays);
392393
return millisSinceLastUpdated <= daysInMillis;
393394
}
394395
static _endIssueProcessing(issue) {
@@ -609,11 +610,11 @@ class IssuesProcessor {
609610
let shouldBeStale;
610611
// Ignore the last update and only use the creation date
611612
if (shouldIgnoreUpdates) {
612-
shouldBeStale = !IssuesProcessor._updatedSince(issue.created_at, daysBeforeStale);
613+
shouldBeStale = !IssuesProcessor._updatedSince(issue.created_at, daysBeforeStale, this.options.excludeWeekdays);
613614
}
614615
// Use the last update to check if we need to stale
615616
else {
616-
shouldBeStale = !IssuesProcessor._updatedSince(issue.updated_at, daysBeforeStale);
617+
shouldBeStale = !IssuesProcessor._updatedSince(issue.updated_at, daysBeforeStale, this.options.excludeWeekdays);
617618
}
618619
if (shouldBeStale) {
619620
if (shouldIgnoreUpdates) {
@@ -795,7 +796,7 @@ class IssuesProcessor {
795796
if (daysBeforeClose < 0) {
796797
return; // Nothing to do because we aren't closing stale issues
797798
}
798-
const issueHasUpdateInCloseWindow = IssuesProcessor._updatedSince(issue.updated_at, daysBeforeClose);
799+
const issueHasUpdateInCloseWindow = IssuesProcessor._updatedSince(issue.updated_at, daysBeforeClose, this.options.excludeWeekdays);
799800
issueLogger.info(`$$type has been updated in the last ${daysBeforeClose} days: ${logger_service_1.LoggerService.cyan(issueHasUpdateInCloseWindow)}`);
800801
if (!issueHasCommentsSinceStale && !issueHasUpdateInCloseWindow) {
801802
issueLogger.info(`Closing $$type because it was last updated on: ${logger_service_1.LoggerService.cyan(issue.updated_at)}`);
@@ -2333,6 +2334,62 @@ function isValidDate(date) {
23332334
exports.isValidDate = isValidDate;
23342335

23352336

2337+
/***/ }),
2338+
2339+
/***/ 4101:
2340+
/***/ ((__unused_webpack_module, exports) => {
2341+
2342+
"use strict";
2343+
2344+
Object.defineProperty(exports, "__esModule", ({ value: true }));
2345+
exports.elapsedMillisExcludingDays = void 0;
2346+
const DAY = 1000 * 60 * 60 * 24;
2347+
function startOfDay(date) {
2348+
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
2349+
}
2350+
function countWeekdaysBetweenDates(start, end, excludeWeekdays) {
2351+
const totalDays = Math.floor((end.getTime() - start.getTime()) / DAY);
2352+
const startDayOfWeek = start.getDay();
2353+
const excludeWeekdaysMap = new Set(excludeWeekdays);
2354+
const fullWeeks = Math.floor(totalDays / 7);
2355+
const remainingDays = totalDays % 7;
2356+
// Count the number of excluded days in a full week (0-6)
2357+
let weeklyExcludedCount = 0;
2358+
for (let day = 0; day < 7; day++) {
2359+
if (excludeWeekdaysMap.has(day)) {
2360+
weeklyExcludedCount++;
2361+
}
2362+
}
2363+
// Count excluded days in the remaining days after full weeks
2364+
let extraExcludedCount = 0;
2365+
for (let i = 0; i < remainingDays; i++) {
2366+
const currentDay = (startDayOfWeek + i) % 7;
2367+
if (excludeWeekdaysMap.has(currentDay)) {
2368+
extraExcludedCount++;
2369+
}
2370+
}
2371+
// Compute the total excluded days
2372+
return fullWeeks * weeklyExcludedCount + extraExcludedCount;
2373+
}
2374+
const elapsedMillisExcludingDays = (from, to, excludeWeekdays) => {
2375+
let elapsedMillis = to.getTime() - from.getTime();
2376+
if (excludeWeekdays.length > 0) {
2377+
const startOfNextDayFrom = startOfDay(new Date(from.getTime() + DAY));
2378+
const startOfDayTo = startOfDay(to);
2379+
if (excludeWeekdays.includes(from.getDay())) {
2380+
elapsedMillis -= startOfNextDayFrom.getTime() - from.getTime();
2381+
}
2382+
if (excludeWeekdays.includes(to.getDay())) {
2383+
elapsedMillis -= to.getTime() - startOfDayTo.getTime();
2384+
}
2385+
const excludeWeekdaysCount = countWeekdaysBetweenDates(startOfNextDayFrom, startOfDayTo, excludeWeekdays);
2386+
elapsedMillis -= excludeWeekdaysCount * DAY;
2387+
}
2388+
return elapsedMillis;
2389+
};
2390+
exports.elapsedMillisExcludingDays = elapsedMillisExcludingDays;
2391+
2392+
23362393
/***/ }),
23372394

23382395
/***/ 8236:
@@ -2567,7 +2624,13 @@ function _getAndValidateArgs() {
25672624
ignorePrUpdates: _toOptionalBoolean('ignore-pr-updates'),
25682625
exemptDraftPr: core.getInput('exempt-draft-pr') === 'true',
25692626
closeIssueReason: core.getInput('close-issue-reason'),
2570-
includeOnlyAssigned: core.getInput('include-only-assigned') === 'true'
2627+
includeOnlyAssigned: core.getInput('include-only-assigned') === 'true',
2628+
excludeWeekdays: core.getInput('exclude-weekdays')
2629+
? core
2630+
.getInput('exclude-weekdays')
2631+
.split(',')
2632+
.map(day => parseInt(day.trim(), 10))
2633+
: []
25712634
};
25722635
for (const numberInput of ['days-before-stale']) {
25732636
if (isNaN(parseFloat(core.getInput(numberInput)))) {
@@ -2599,6 +2662,13 @@ function _getAndValidateArgs() {
25992662
core.setFailed(errorMessage);
26002663
throw new Error(errorMessage);
26012664
}
2665+
// Validate weekdays
2666+
if (args.excludeWeekdays &&
2667+
args.excludeWeekdays.some(day => isNaN(day) || day < 0 || day > 6)) {
2668+
const errorMessage = 'Option "exclude-weekdays" must be comma-separated integers between 0 (Sunday) and 6 (Saturday)';
2669+
core.setFailed(errorMessage);
2670+
throw new Error(errorMessage);
2671+
}
26022672
return args;
26032673
}
26042674
function processOutput(staledIssues, closedIssues) {

src/classes/issue.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ describe('Issue', (): void => {
6464
ignorePrUpdates: undefined,
6565
exemptDraftPr: false,
6666
closeIssueReason: '',
67-
includeOnlyAssigned: false
67+
includeOnlyAssigned: false,
68+
excludeWeekdays: []
6869
};
6970
issueInterface = {
7071
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';
@@ -35,10 +36,17 @@ import {RateLimit} from './rate-limit';
3536
*/
3637

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

4351
return millisSinceLastUpdated <= daysInMillis;
4452
}
@@ -461,14 +469,16 @@ export class IssuesProcessor {
461469
if (shouldIgnoreUpdates) {
462470
shouldBeStale = !IssuesProcessor._updatedSince(
463471
issue.created_at,
464-
daysBeforeStale
472+
daysBeforeStale,
473+
this.options.excludeWeekdays
465474
);
466475
}
467476
// Use the last update to check if we need to stale
468477
else {
469478
shouldBeStale = !IssuesProcessor._updatedSince(
470479
issue.updated_at,
471-
daysBeforeStale
480+
daysBeforeStale,
481+
this.options.excludeWeekdays
472482
);
473483
}
474484

@@ -757,7 +767,8 @@ export class IssuesProcessor {
757767

758768
const issueHasUpdateInCloseWindow: boolean = IssuesProcessor._updatedSince(
759769
issue.updated_at,
760-
daysBeforeClose
770+
daysBeforeClose,
771+
this.options.excludeWeekdays
761772
);
762773
issueLogger.info(
763774
`$$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)