Skip to content
Open
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Every argument is optional.
| [ascending](#ascending) | Order to get issues/PRs | `false` |
| [start-date](#start-date) | Skip stale action for issues/PRs created before it | |
| [delete-branch](#delete-branch) | Delete branch after closing a stale PR | `false` |
| [exclude-weekdays](#exclude-weekdays) | Weekdays to exclude when calculating elapsed days | |
| [exempt-milestones](#exempt-milestones) | Milestones on issues/PRs exempted from stale | |
| [exempt-issue-milestones](#exempt-issue-milestones) | Override [exempt-milestones](#exempt-milestones) for issues only | |
| [exempt-pr-milestones](#exempt-pr-milestones) | Override [exempt-milestones](#exempt-milestones) for PRs only | |
Expand Down Expand Up @@ -541,6 +542,15 @@ Useful to override [ignore-updates](#ignore-updates) but only to ignore the upda

Default value: unset

#### exclude-weekdays

A comma separated list of weekdays (0-6, where 0 is Sunday and 6 is Saturday) to exclude when calculating elapsed days.
This is useful when you want to count only business days for stale calculations.

For example, to exclude weekends, set this to `0,6`.

Default value: unset

#### include-only-assigned

If set to `true`, only the issues or the pull requests with an assignee will be marked as stale automatically.
Expand Down
3 changes: 2 additions & 1 deletion __tests__/constants/default-processor-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,6 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({
ignorePrUpdates: undefined,
exemptDraftPr: false,
closeIssueReason: 'not_planned',
includeOnlyAssigned: false
includeOnlyAssigned: false,
excludeWeekdays: []
});
42 changes: 42 additions & 0 deletions __tests__/main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2743,3 +2743,45 @@ test('processing an issue with the "includeOnlyAssigned" option set and no assig
expect(processor.staleIssues).toHaveLength(0);
expect(processor.closedIssues).toHaveLength(0);
});

test('processing an issue should not count specific weekdays when calculating stale days', async () => {
const now: Date = new Date();
const day = 1000 * 60 * 60 * 24;
const yesterday: Date = new Date(now.getTime() - day);

const opts: IIssuesProcessorOptions = {
...DefaultProcessorOptions,
daysBeforeStale: 5,
daysBeforeClose: 2,
excludeWeekdays: [yesterday.getDay()]
};

const TestIssueList: Issue[] = [
generateIssue(
opts,
1,
'not stale yet',
new Date(now.getTime() - 5 * day).toDateString()
),
generateIssue(
opts,
2,
'stale',
new Date(now.getTime() - 6 * day).toDateString()
)
];

const processor = new IssuesProcessorMock(
opts,
alwaysFalseStateMock,
async p => (p === 1 ? TestIssueList : []),
async () => [],
async () => new Date().toDateString()
);

await processor.processIssues(1);

expect(processor.staleIssues).toHaveLength(1);
expect(processor.staleIssues[0]!.number).toEqual(2);
expect(processor.closedIssues).toHaveLength(0);
});
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ inputs:
description: 'Only the issues or the pull requests with an assignee will be marked as stale automatically.'
default: 'false'
required: false
exclude-weekdays:
description: 'Comma-separated list of weekdays to exclude from elapsed days calculation (0=Sunday, 6=Saturday)'
required: false
default: ''
outputs:
closed-issues-prs:
description: 'List of all closed issues and pull requests.'
Expand Down
84 changes: 77 additions & 7 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ const is_valid_date_1 = __nccwpck_require__(891);
const is_boolean_1 = __nccwpck_require__(8236);
const is_labeled_1 = __nccwpck_require__(6792);
const clean_label_1 = __nccwpck_require__(7752);
const elapsed_millis_excluding_days_1 = __nccwpck_require__(4101);
const should_mark_when_stale_1 = __nccwpck_require__(2461);
const words_to_list_1 = __nccwpck_require__(1883);
const assignees_1 = __nccwpck_require__(7236);
Expand All @@ -386,9 +387,9 @@ const rate_limit_1 = __nccwpck_require__(7069);
* Handle processing of issues for staleness/closure.
*/
class IssuesProcessor {
static _updatedSince(timestamp, num_days) {
const daysInMillis = 1000 * 60 * 60 * 24 * num_days;
const millisSinceLastUpdated = new Date().getTime() - new Date(timestamp).getTime();
static _updatedSince(timestamp, numDays, excludeWeekdays) {
const daysInMillis = 1000 * 60 * 60 * 24 * numDays;
const millisSinceLastUpdated = (0, elapsed_millis_excluding_days_1.elapsedMillisExcludingDays)(new Date(timestamp), new Date(), excludeWeekdays);
return millisSinceLastUpdated <= daysInMillis;
}
static _endIssueProcessing(issue) {
Expand Down Expand Up @@ -609,11 +610,11 @@ class IssuesProcessor {
let shouldBeStale;
// Ignore the last update and only use the creation date
if (shouldIgnoreUpdates) {
shouldBeStale = !IssuesProcessor._updatedSince(issue.created_at, daysBeforeStale);
shouldBeStale = !IssuesProcessor._updatedSince(issue.created_at, daysBeforeStale, this.options.excludeWeekdays);
}
// Use the last update to check if we need to stale
else {
shouldBeStale = !IssuesProcessor._updatedSince(issue.updated_at, daysBeforeStale);
shouldBeStale = !IssuesProcessor._updatedSince(issue.updated_at, daysBeforeStale, this.options.excludeWeekdays);
}
if (shouldBeStale) {
if (shouldIgnoreUpdates) {
Expand Down Expand Up @@ -795,7 +796,7 @@ class IssuesProcessor {
if (daysBeforeClose < 0) {
return; // Nothing to do because we aren't closing stale issues
}
const issueHasUpdateInCloseWindow = IssuesProcessor._updatedSince(issue.updated_at, daysBeforeClose);
const issueHasUpdateInCloseWindow = IssuesProcessor._updatedSince(issue.updated_at, daysBeforeClose, this.options.excludeWeekdays);
issueLogger.info(`$$type has been updated in the last ${daysBeforeClose} days: ${logger_service_1.LoggerService.cyan(issueHasUpdateInCloseWindow)}`);
if (!issueHasCommentsSinceStale && !issueHasUpdateInCloseWindow) {
issueLogger.info(`Closing $$type because it was last updated on: ${logger_service_1.LoggerService.cyan(issue.updated_at)}`);
Expand Down Expand Up @@ -2333,6 +2334,62 @@ function isValidDate(date) {
exports.isValidDate = isValidDate;


/***/ }),

/***/ 4101:
/***/ ((__unused_webpack_module, exports) => {

"use strict";

Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.elapsedMillisExcludingDays = void 0;
const DAY = 1000 * 60 * 60 * 24;
function startOfDay(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
}
function countWeekdaysBetweenDates(start, end, excludeWeekdays) {
const totalDays = Math.floor((end.getTime() - start.getTime()) / DAY);
const startDayOfWeek = start.getDay();
const excludeWeekdaysMap = new Set(excludeWeekdays);
const fullWeeks = Math.floor(totalDays / 7);
const remainingDays = totalDays % 7;
// Count the number of excluded days in a full week (0-6)
let weeklyExcludedCount = 0;
for (let day = 0; day < 7; day++) {
if (excludeWeekdaysMap.has(day)) {
weeklyExcludedCount++;
}
}
// Count excluded days in the remaining days after full weeks
let extraExcludedCount = 0;
for (let i = 0; i < remainingDays; i++) {
const currentDay = (startDayOfWeek + i) % 7;
if (excludeWeekdaysMap.has(currentDay)) {
extraExcludedCount++;
}
}
// Compute the total excluded days
return fullWeeks * weeklyExcludedCount + extraExcludedCount;
}
const elapsedMillisExcludingDays = (from, to, excludeWeekdays) => {
let elapsedMillis = to.getTime() - from.getTime();
if (excludeWeekdays.length > 0) {
const startOfNextDayFrom = startOfDay(new Date(from.getTime() + DAY));
const startOfDayTo = startOfDay(to);
if (excludeWeekdays.includes(from.getDay())) {
elapsedMillis -= startOfNextDayFrom.getTime() - from.getTime();
}
if (excludeWeekdays.includes(to.getDay())) {
elapsedMillis -= to.getTime() - startOfDayTo.getTime();
}
const excludeWeekdaysCount = countWeekdaysBetweenDates(startOfNextDayFrom, startOfDayTo, excludeWeekdays);
elapsedMillis -= excludeWeekdaysCount * DAY;
}
return elapsedMillis;
Comment on lines +2375 to +2388
Copy link
Author

@alpaca-tc alpaca-tc Mar 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you don't use options, the existing implementation is used, so even if there are bugs, the impact is minimal. A simple O(N) calculation logic is used. While an O(1) logic could also be possible, it would be a bit more complex.

};
exports.elapsedMillisExcludingDays = elapsedMillisExcludingDays;


/***/ }),

/***/ 8236:
Expand Down Expand Up @@ -2567,7 +2624,13 @@ function _getAndValidateArgs() {
ignorePrUpdates: _toOptionalBoolean('ignore-pr-updates'),
exemptDraftPr: core.getInput('exempt-draft-pr') === 'true',
closeIssueReason: core.getInput('close-issue-reason'),
includeOnlyAssigned: core.getInput('include-only-assigned') === 'true'
includeOnlyAssigned: core.getInput('include-only-assigned') === 'true',
excludeWeekdays: core.getInput('exclude-weekdays')
? core
.getInput('exclude-weekdays')
.split(',')
.map(day => parseInt(day.trim(), 10))
: []
};
for (const numberInput of ['days-before-stale']) {
if (isNaN(parseFloat(core.getInput(numberInput)))) {
Expand Down Expand Up @@ -2599,6 +2662,13 @@ function _getAndValidateArgs() {
core.setFailed(errorMessage);
throw new Error(errorMessage);
}
// Validate weekdays
if (args.excludeWeekdays &&
args.excludeWeekdays.some(day => isNaN(day) || day < 0 || day > 6)) {
const errorMessage = 'Option "exclude-weekdays" must be comma-separated integers between 0 (Sunday) and 6 (Saturday)';
core.setFailed(errorMessage);
throw new Error(errorMessage);
}
return args;
}
function processOutput(staledIssues, closedIssues) {
Expand Down
3 changes: 2 additions & 1 deletion src/classes/issue.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ describe('Issue', (): void => {
ignorePrUpdates: undefined,
exemptDraftPr: false,
closeIssueReason: '',
includeOnlyAssigned: false
includeOnlyAssigned: false,
excludeWeekdays: []
};
issueInterface = {
title: 'dummy-title',
Expand Down
25 changes: 18 additions & 7 deletions src/classes/issues-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {isValidDate} from '../functions/dates/is-valid-date';
import {isBoolean} from '../functions/is-boolean';
import {isLabeled} from '../functions/is-labeled';
import {cleanLabel} from '../functions/clean-label';
import {elapsedMillisExcludingDays} from '../functions/elapsed-millis-excluding-days';
import {shouldMarkWhenStale} from '../functions/should-mark-when-stale';
import {wordsToList} from '../functions/words-to-list';
import {IComment} from '../interfaces/comment';
Expand Down Expand Up @@ -35,10 +36,17 @@ import {RateLimit} from './rate-limit';
*/

export class IssuesProcessor {
private static _updatedSince(timestamp: string, num_days: number): boolean {
const daysInMillis = 1000 * 60 * 60 * 24 * num_days;
const millisSinceLastUpdated =
new Date().getTime() - new Date(timestamp).getTime();
private static _updatedSince(
timestamp: string,
numDays: number,
excludeWeekdays: number[]
): boolean {
const daysInMillis = 1000 * 60 * 60 * 24 * numDays;
const millisSinceLastUpdated = elapsedMillisExcludingDays(
new Date(timestamp),
new Date(),
excludeWeekdays
);

return millisSinceLastUpdated <= daysInMillis;
}
Expand Down Expand Up @@ -461,14 +469,16 @@ export class IssuesProcessor {
if (shouldIgnoreUpdates) {
shouldBeStale = !IssuesProcessor._updatedSince(
issue.created_at,
daysBeforeStale
daysBeforeStale,
this.options.excludeWeekdays
);
}
// Use the last update to check if we need to stale
else {
shouldBeStale = !IssuesProcessor._updatedSince(
issue.updated_at,
daysBeforeStale
daysBeforeStale,
this.options.excludeWeekdays
);
}

Expand Down Expand Up @@ -757,7 +767,8 @@ export class IssuesProcessor {

const issueHasUpdateInCloseWindow: boolean = IssuesProcessor._updatedSince(
issue.updated_at,
daysBeforeClose
daysBeforeClose,
this.options.excludeWeekdays
);
issueLogger.info(
`$$type has been updated in the last ${daysBeforeClose} days: ${LoggerService.cyan(
Expand Down
71 changes: 71 additions & 0 deletions src/functions/elapsed-millis-excluding-days.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {elapsedMillisExcludingDays} from './elapsed-millis-excluding-days';

describe('elapsedMillisExcludingDays', () => {
const HOUR = 1000 * 60 * 60;
const DAY = HOUR * 24;

it('calculates elapsed days when no weekdays are excluded', () => {
const from = new Date();
const lessThan = new Date(from.getTime() - 1);
const equal = from;
const greaterThan = new Date(from.getTime() + 1);

expect(elapsedMillisExcludingDays(from, lessThan, [])).toEqual(-1);
expect(elapsedMillisExcludingDays(from, equal, [])).toEqual(0);
expect(elapsedMillisExcludingDays(from, greaterThan, [])).toEqual(1);
});

it('calculates elapsed days with specified weekdays excluded', () => {
const date = new Date('2025-03-03 09:00:00'); // Monday

const tomorrow = new Date('2025-03-04 09:00:00');
expect(elapsedMillisExcludingDays(date, tomorrow, [])).toEqual(DAY);
expect(elapsedMillisExcludingDays(date, tomorrow, [1])).toEqual(9 * HOUR);

const dayAfterTomorrow = new Date('2025-03-05 10:00:00');
const full = 2 * DAY + HOUR;
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [])).toEqual(
full
);
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [0])).toEqual(
full
);
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [1])).toEqual(
full - 15 * HOUR
);
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [2])).toEqual(
full - DAY
);
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [3])).toEqual(
full - 10 * HOUR
);
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [4])).toEqual(
full
);
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [1, 2])).toEqual(
10 * HOUR
);
expect(elapsedMillisExcludingDays(date, dayAfterTomorrow, [2, 3])).toEqual(
15 * HOUR
);
});

it('handles week spanning periods correctly', () => {
const friday = new Date('2025-03-07 09:00:00');
const nextMonday = new Date('2025-03-10 09:00:00');
expect(elapsedMillisExcludingDays(friday, nextMonday, [0, 6])).toEqual(DAY);
});

it('handles long periods with multiple weeks', () => {
const start = new Date('2025-03-03 09:00:00');
const twoWeeksLater = new Date('2025-03-17 09:00:00');
expect(elapsedMillisExcludingDays(start, twoWeeksLater, [0, 6])).toEqual(
10 * DAY
);

const lessThanTwoWeeksLater = new Date('2025-03-17 08:59:59');
expect(
elapsedMillisExcludingDays(start, lessThanTwoWeeksLater, [0, 6])
).toEqual(10 * DAY - 1000);
});
});
Loading