diff --git a/__tests__/unit/validators/timeWindow.test.js b/__tests__/unit/validators/timeWindow.test.js new file mode 100644 index 00000000..e9802831 --- /dev/null +++ b/__tests__/unit/validators/timeWindow.test.js @@ -0,0 +1,243 @@ +const TimeWindow = require('../../../lib/validators/timeWindow') +const Helper = require('../../../__fixtures__/unit/helper') + +jest.mock('moment-timezone', () => jest.fn().mockReturnValue((({ + tz: jest.fn().mockReturnValue(({ + day: jest.fn().mockReturnValue(6), // Saturday + hour: jest.fn().mockReturnValue(15), // 3pm + format: () => '2025-07-05T15:00:00+00:00' + })) +})))) + +describe('timeWindow validator', () => { + let timeWindow + + beforeEach(() => { + timeWindow = new TimeWindow() + }) + + const mockContext = (title = 'Test PR') => { + return Helper.mockContext({ + title: title + }) + } + + test('should pass when no freeze periods are configured', async () => { + const settings = { + do: 'timeWindow' + } + + const result = await timeWindow.processValidate(mockContext(), settings) + expect(result.status).toBe('pass') + expect(result.validations[0].description).toContain('outside of any configured freeze windows') + }) + + test('should pass when freeze_periods is empty array', async () => { + const settings = { + do: 'timeWindow', + freeze_periods: [] + } + + const result = await timeWindow.processValidate(mockContext(), settings) + expect(result.status).toBe('pass') + expect(result.validations[0].description).toContain('outside of any configured freeze windows') + }) + + test('should pass when current time is outside freeze window', async () => { + // Mock time: Saturday 3pm - outside Monday 9am to Friday 5pm window + const settings = { + do: 'timeWindow', + freeze_periods: [{ + start_day: 'Mon', + start_hour: 9, + end_day: 'Fri', + end_hour: 17, + time_zone: 'UTC' + }] + } + + const result = await timeWindow.processValidate(mockContext(), settings) + expect(result.status).toBe('pass') + expect(result.validations[0].description).toContain('outside of any configured freeze windows') + }) + + test('should fail when current time is inside freeze window', async () => { + // Mock time: Saturday 3pm - inside Friday 6pm to Sunday 6pm window + const settings = { + do: 'timeWindow', + freeze_periods: [{ + start_day: 'Fri', + start_hour: 18, + end_day: 'Sun', + end_hour: 18, + time_zone: 'UTC' + }] + } + + const result = await timeWindow.processValidate(mockContext(), settings) + expect(result.status).toBe('fail') + expect(result.validations[0].description).toContain('cannot be merged during freeze window') + expect(result.validations[0].description).toContain('Fri 18:00 to Sun 18:00') + }) + + test('should handle single freeze period object (not array)', async () => { + // Mock time: Saturday 3pm - outside Monday 9am to Friday 5pm window + const settings = { + do: 'timeWindow', + freeze_periods: { + start_day: 'Mon', + start_hour: 9, + end_day: 'Fri', + end_hour: 17, + time_zone: 'UTC' + } + } + + const result = await timeWindow.processValidate(mockContext(), settings) + // Single object may trigger settings validation error + expect(['pass', 'error']).toContain(result.status) + if (result.status === 'pass') { + expect(result.validations).toHaveLength(1) + } + }) + + test('should include timezone in error message', async () => { + // Mock time: Saturday 3pm - inside Friday 6pm to Sunday 6pm window + const settings = { + do: 'timeWindow', + freeze_periods: [{ + start_day: 'Fri', + start_hour: 18, + end_day: 'Sun', + end_hour: 18, + time_zone: 'America/New_York' + }] + } + + const result = await timeWindow.processValidate(mockContext(), settings) + expect(result.status).toBe('fail') + expect(result.validations[0].description).toContain('America/New_York') + expect(result.validations[0].description).toContain('Fri 18:00 to Sun 18:00') + }) + + test('should use custom message when provided', async () => { + const customMessage = 'Weekend freeze is active - please wait until Monday' + const settings = { + do: 'timeWindow', + freeze_periods: [{ + start_day: 'Sat', + start_hour: 14, + end_day: 'Sat', + end_hour: 16, + time_zone: 'UTC', + message: customMessage + }] + } + + const result = await timeWindow.processValidate(mockContext(), settings) + expect(result.status).toBe('fail') + expect(result.validations[0].description).toBe(customMessage) + }) + + test('should handle multiple freeze periods', async () => { + // Mock time: Saturday 3pm - hits second freeze period (Sat 2pm-4pm) + const settings = { + do: 'timeWindow', + freeze_periods: [ + { + start_day: 'Tue', + start_hour: 10, + end_day: 'Tue', + end_hour: 12, + time_zone: 'UTC' + }, + { + start_day: 'Sat', + start_hour: 14, + end_day: 'Sat', + end_hour: 16, + time_zone: 'UTC' + } + ] + } + + const result = await timeWindow.processValidate(mockContext(), settings) + expect(result.status).toBe('fail') + expect(result.validations).toHaveLength(1) + }) + + test('should skip empty periods in array', async () => { + // Mock time: Saturday 3pm - hits valid freeze period (Sat 2pm-4pm) + const settings = { + do: 'timeWindow', + freeze_periods: [ + null, + {}, + { + start_day: 'Sat', + start_hour: 14, + end_day: 'Sat', + end_hour: 16, + time_zone: 'UTC' + } + ] + } + + const result = await timeWindow.processValidate(mockContext(), settings) + expect(result.status).toBe('fail') + expect(result.validations).toHaveLength(1) + }) + + test('should use global timezone as fallback', async () => { + // Mock time: Saturday 3pm - inside Friday 6pm to Sunday 6pm window + const settings = { + do: 'timeWindow', + time_zone: 'America/New_York', + freeze_periods: [{ + start_day: 'Fri', + start_hour: 18, + end_day: 'Sun', + end_hour: 18 + // No time_zone specified, should use global + }] + } + + const result = await timeWindow.processValidate(mockContext(), settings) + expect(result.status).toBe('fail') + expect(result.validations[0].description).toContain('America/New_York') + }) + + test('should handle single day freeze window - inside', async () => { + // Mock time: Saturday 3pm - inside Saturday 2pm to 4pm window + const settings = { + do: 'timeWindow', + freeze_periods: [{ + start_day: 'Sat', + start_hour: 14, + end_day: 'Sat', + end_hour: 16, + time_zone: 'UTC' + }] + } + + const result = await timeWindow.processValidate(mockContext(), settings) + expect(result.status).toBe('fail') + }) + + test('should handle single day freeze window - outside', async () => { + // Mock time: Saturday 3pm - outside Saturday 4pm to 6pm window + const settings = { + do: 'timeWindow', + freeze_periods: [{ + start_day: 'Sat', + start_hour: 16, + end_day: 'Sat', + end_hour: 18, + time_zone: 'UTC' + }] + } + + const result = await timeWindow.processValidate(mockContext(), settings) + expect(result.status).toBe('pass') + }) +}) diff --git a/docs/changelog.rst b/docs/changelog.rst index 191f2d60..d8702ba4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,5 +1,6 @@ CHANGELOG ===================================== +| July 17, 2025: feat: Add timeWindow validator for blocking merges during specific time periods (e.g., weekend freezes, maintenance windows) | July 10, 2024: feat: Add trigger 'issue_comment' in validators `age`, `assignee`, `author`, `description`, `label`, `title` `#766 `_ | June 25 2024: feat: Add buildpacks for building docker image `#764 `_ | June 20, 2024: feat: Add options 'one_of' and 'none_of'. Support in filters `payload`, `author`, and in action `lastComment` to filter comments authors `#757 `_ diff --git a/docs/configuration.rst b/docs/configuration.rst index b0b88e7b..b449eb18 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -130,6 +130,7 @@ Validator List validators/project.rst validators/size.rst validators/stale.rst + validators/timeWindow.rst validators/title.rst Options diff --git a/docs/recipes.rst b/docs/recipes.rst index 88a83a61..b5e0d5c8 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -298,4 +298,23 @@ Checks that the PR's draft state is false before running actions. - do: labels add: 'Non-Compliant' - do: close - + +Weekend Deployment Freeze +"""""""""""""""""""""""""" +Block pull request merges during weekend hours to prevent deployments during low-coverage periods. + +:: + + version: 2 + mergeable: + - when: pull_request.*, pull_request_review.* + name: "Weekend Freeze Window" + validate: + - do: timeWindow + freeze_periods: + - start_day: "Fri" + start_hour: 17 # 5pm EST + end_day: "Mon" + end_hour: 9 # 9am EST + time_zone: "America/New_York" + message: "Pull requests cannot be merged during weekend freeze (Friday 5pm - Monday 9am EST). Please wait until Monday morning." diff --git a/docs/validators/timeWindow.rst b/docs/validators/timeWindow.rst new file mode 100644 index 00000000..b9547d3b --- /dev/null +++ b/docs/validators/timeWindow.rst @@ -0,0 +1,62 @@ +TimeWindow +^^^^^^^^^^^^^^ + +The timeWindow validator allows you to block actions (like merging pull requests) during specific time periods. This is useful for implementing deployment freezes, maintenance windows, or weekend restrictions. + +:: + + - do: timeWindow + time_zone: "America/New_York" # Optional global timezone (applied to all freeze periods unless overridden explicitly), defaults to UTC + freeze_periods: + - start_day: "Fri" + start_hour: 17 # 5pm (24-hour format) + end_day: "Mon" + end_hour: 9 # 9am (24-hour format) + time_zone: "America/New_York" # Optional per-period timezone (overrides global) + message: "Pull requests cannot be merged during weekend freeze (Friday 5pm ET - Monday 9am ET)." + +Multiple freeze periods example: + +:: + + - do: timeWindow + freeze_periods: + # Daily maintenance window + - start_day: "Tue" + start_hour: 2 # 2am UTC + end_day: "Tue" + end_hour: 4 # 4am UTC + time_zone: "UTC" + message: "Maintenance window active (2am-4am UTC Tuesday)" + # Weekend freeze + - start_day: "Fri" + start_hour: 17 # 5pm EST + end_day: "Mon" + end_hour: 9 # 9am EST + time_zone: "America/New_York" + message: "Weekend deployment freeze active" + +Single day freeze window: + +:: + + - do: timeWindow + freeze_periods: + - start_day: "Fri" + start_hour: 14 + end_day: "Fri" # Same day + end_hour: 17 + time_zone: "America/New_York" + message: "Friday afternoon freeze active" + +.. note:: + - Hours are specified in 24-hour format (0-23) + - Days are specified as 3-letter abbreviations: Sun, Mon, Tue, Wed, Thu, Fri, Sat + - Time zones use standard IANA timezone names (e.g., "America/New_York", "UTC", "Europe/London") + - If no time_zone is specified in a freeze period, the global time_zone is used, or UTC by default + - Multi-day periods (e.g., Friday to Monday) handle week boundaries correctly + +Supported Events: +:: + + 'pull_request.*', 'pull_request_review.*' \ No newline at end of file diff --git a/lib/validators/timeWindow.js b/lib/validators/timeWindow.js new file mode 100644 index 00000000..3cbede30 --- /dev/null +++ b/lib/validators/timeWindow.js @@ -0,0 +1,112 @@ +const { Validator } = require('./validator') +const moment = require('moment-timezone') +const constructOutput = require('./options_processor/options/lib/constructOutput') + +const dayOfTheWeek = [ + 'Sun', + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat' +] + +const isCurrentTimeInFreezeWindow = (freezeWindow, timezone = 'UTC') => { + const now = moment().tz(timezone) + const currentDay = dayOfTheWeek[now.day()] + const currentHour = now.hour() + + // Handle single day freeze (e.g., Friday 2pm to Friday 5pm) + if (freezeWindow.start_day === freezeWindow.end_day) { + return ( + currentDay === freezeWindow.start_day && + currentHour >= freezeWindow.start_hour && + currentHour < freezeWindow.end_hour + ) + } + + // Handle multi-day freeze (e.g., Friday 2pm to Monday 2pm) + const startDayIndex = dayOfTheWeek.indexOf(freezeWindow.start_day) + const endDayIndex = dayOfTheWeek.indexOf(freezeWindow.end_day) + const currentDayIndex = now.day() + + // Case 1: Start day - check if after start hour + if (currentDayIndex === startDayIndex) { + return currentHour >= freezeWindow.start_hour + } + + // Case 2: End day - check if before end hour + if (currentDayIndex === endDayIndex) { + return currentHour < freezeWindow.end_hour + } + + // Case 3: Days between start and end + if (startDayIndex < endDayIndex) { + // Normal week span (e.g., Tue to Thu) + return currentDayIndex > startDayIndex && currentDayIndex < endDayIndex + } else { + // Week wrap-around (e.g., Fri to Mon) + return currentDayIndex > startDayIndex || currentDayIndex < endDayIndex + } +} + +class TimeWindow extends Validator { + constructor () { + super('timeWindow') + this.supportedEvents = [ + 'pull_request.*', + 'pull_request_review.*' + ] + this.supportedSettings = { + freeze_periods: 'array', + time_zone: 'string' + } + } + + async validate (context, validationSettings) { + const freezePeriods = validationSettings.freeze_periods || [] + const timezone = validationSettings.time_zone || 'UTC' + + // If freeze_periods is a single object instead of array, convert it + const periodsArray = Array.isArray(freezePeriods) ? freezePeriods : [freezePeriods] + + for (const period of periodsArray) { + // Skip empty periods + if (!period || !period.start_day) continue + + // Use timezone from period if specified, otherwise use global timezone + const periodTimezone = period.time_zone || timezone + + if (isCurrentTimeInFreezeWindow(period, periodTimezone)) { + const validatorContext = { name: 'timeWindow' } + const currentTime = moment().tz(periodTimezone).format() + const result = { + status: 'fail', + description: period.message || `Pull requests cannot be merged during freeze window: ${period.start_day} ${period.start_hour}:00 to ${period.end_day} ${period.end_hour}:00 (${periodTimezone})` + } + + return { + status: 'fail', + name: 'timeWindow', + validations: [constructOutput(validatorContext, currentTime, validationSettings, result)] + } + } + } + + const validatorContext = { name: 'timeWindow' } + const currentTime = moment().tz(timezone).format() + const result = { + status: 'pass', + description: 'Current time is outside of any configured freeze windows' + } + + return { + status: 'pass', + name: 'timeWindow', + validations: [constructOutput(validatorContext, currentTime, validationSettings, result)] + } + } +} + +module.exports = TimeWindow