From 38a8a46d1f2803d61f3abfd947ac725305b8b860 Mon Sep 17 00:00:00 2001 From: alpaca-tc Date: Thu, 6 Mar 2025 23:48:36 +0900 Subject: [PATCH] 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 ``` --- README.md | 10 +++ .../constants/default-processor-options.ts | 3 +- __tests__/main.spec.ts | 42 +++++++++++ action.yml | 4 + dist/index.js | 73 +++++++++++++++++-- src/classes/issue.spec.ts | 3 +- src/classes/issues-processor.ts | 25 +++++-- .../elapsed-millis-excluding-days.spec.ts | 71 ++++++++++++++++++ .../elapsed-millis-excluding-days.ts | 60 +++++++++++++++ src/interfaces/issues-processor-options.ts | 1 + src/main.ts | 20 ++++- 11 files changed, 295 insertions(+), 17 deletions(-) create mode 100644 src/functions/elapsed-millis-excluding-days.spec.ts create mode 100644 src/functions/elapsed-millis-excluding-days.ts diff --git a/README.md b/README.md index 4710e9478..b9d8acfe4 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,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 | | @@ -551,6 +552,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. diff --git a/__tests__/constants/default-processor-options.ts b/__tests__/constants/default-processor-options.ts index 4ea04cca8..41768b192 100644 --- a/__tests__/constants/default-processor-options.ts +++ b/__tests__/constants/default-processor-options.ts @@ -56,5 +56,6 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({ ignorePrUpdates: undefined, exemptDraftPr: false, closeIssueReason: 'not_planned', - includeOnlyAssigned: false + includeOnlyAssigned: false, + excludeWeekdays: [] }); diff --git a/__tests__/main.spec.ts b/__tests__/main.spec.ts index 80d660e88..7febe65ad 100644 --- a/__tests__/main.spec.ts +++ b/__tests__/main.spec.ts @@ -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); +}); diff --git a/action.yml b/action.yml index b3354e9d5..4638418f7 100644 --- a/action.yml +++ b/action.yml @@ -212,6 +212,10 @@ inputs: 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.' default: '' 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.' diff --git a/dist/index.js b/dist/index.js index 07039601f..fb3d45baf 100644 --- a/dist/index.js +++ b/dist/index.js @@ -375,6 +375,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); @@ -394,9 +395,9 @@ const get_sort_field_1 = __nccwpck_require__(9551); * 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) { @@ -629,11 +630,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) { @@ -823,7 +824,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)}`); @@ -2363,6 +2364,51 @@ 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, weekdays) { + const totalDays = Math.floor((end.getTime() - start.getTime()) / DAY); + const startDayOfWeek = start.getDay(); + const weekdaysMap = new Set(weekdays); + let count = 0; + for (let i = 0; i < totalDays; i++) { + const currentDay = (startDayOfWeek + i) % 7; + if (weekdaysMap.has(currentDay)) { + count++; + } + } + return count; +} +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; +}; +exports.elapsedMillisExcludingDays = elapsedMillisExcludingDays; + + /***/ }), /***/ 9551: @@ -2618,7 +2664,13 @@ function _getAndValidateArgs() { exemptDraftPr: core.getInput('exempt-draft-pr') === 'true', closeIssueReason: core.getInput('close-issue-reason'), includeOnlyAssigned: core.getInput('include-only-assigned') === 'true', - onlyIssueTypes: core.getInput('only-issue-types') + onlyIssueTypes: core.getInput('only-issue-types'), + 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)))) { @@ -2650,6 +2702,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) { diff --git a/src/classes/issue.spec.ts b/src/classes/issue.spec.ts index 5a5bf32df..68f2b84fe 100644 --- a/src/classes/issue.spec.ts +++ b/src/classes/issue.spec.ts @@ -65,7 +65,8 @@ describe('Issue', (): void => { ignorePrUpdates: undefined, exemptDraftPr: false, closeIssueReason: '', - includeOnlyAssigned: false + includeOnlyAssigned: false, + excludeWeekdays: [] }; issueInterface = { title: 'dummy-title', diff --git a/src/classes/issues-processor.ts b/src/classes/issues-processor.ts index 26419173d..09c2fc0e8 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -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'; @@ -36,10 +37,17 @@ import {getSortField} from '../functions/get-sort-field'; */ 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; } @@ -479,14 +487,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 ); } @@ -787,7 +797,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( diff --git a/src/functions/elapsed-millis-excluding-days.spec.ts b/src/functions/elapsed-millis-excluding-days.spec.ts new file mode 100644 index 000000000..9aaf1157f --- /dev/null +++ b/src/functions/elapsed-millis-excluding-days.spec.ts @@ -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); + }); +}); diff --git a/src/functions/elapsed-millis-excluding-days.ts b/src/functions/elapsed-millis-excluding-days.ts new file mode 100644 index 000000000..aeabdc73e --- /dev/null +++ b/src/functions/elapsed-millis-excluding-days.ts @@ -0,0 +1,60 @@ +const DAY = 1000 * 60 * 60 * 24; + +function startOfDay(date: Date): Date { + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + 0, + 0, + 0, + 0 + ); +} + +function countWeekdaysBetweenDates(start: Date, end: Date, weekdays: number[]) { + const totalDays = Math.floor((end.getTime() - start.getTime()) / DAY); + const startDayOfWeek = start.getDay(); + const weekdaysMap = new Set(weekdays); + + let count = 0; + for (let i = 0; i < totalDays; i++) { + const currentDay = (startDayOfWeek + i) % 7; + if (weekdaysMap.has(currentDay)) { + count++; + } + } + + return count; +} + +export const elapsedMillisExcludingDays = ( + from: Date, + to: Date, + excludeWeekdays: number[] +): number => { + 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; +}; diff --git a/src/interfaces/issues-processor-options.ts b/src/interfaces/issues-processor-options.ts index 273ae461c..633bc9605 100644 --- a/src/interfaces/issues-processor-options.ts +++ b/src/interfaces/issues-processor-options.ts @@ -56,4 +56,5 @@ export interface IIssuesProcessorOptions { closeIssueReason: string; includeOnlyAssigned: boolean; onlyIssueTypes?: string; + excludeWeekdays: number[]; } diff --git a/src/main.ts b/src/main.ts index 92a33ab58..bf6b8d3b5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -125,7 +125,14 @@ function _getAndValidateArgs(): IIssuesProcessorOptions { exemptDraftPr: core.getInput('exempt-draft-pr') === 'true', closeIssueReason: core.getInput('close-issue-reason'), includeOnlyAssigned: core.getInput('include-only-assigned') === 'true', - onlyIssueTypes: core.getInput('only-issue-types') + onlyIssueTypes: core.getInput('only-issue-types'), + excludeWeekdays: + core.getInput('exclude-weekdays') + ? core + .getInput('exclude-weekdays') + .split(',') + .map(day => parseInt(day.trim(), 10)) + : [] }; for (const numberInput of ['days-before-stale']) { @@ -164,6 +171,17 @@ function _getAndValidateArgs(): IIssuesProcessorOptions { 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; }