Skip to content

feat: add timeWindow validator #785

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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
243 changes: 243 additions & 0 deletions __tests__/unit/validators/timeWindow.test.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/mergeability/mergeable/pull/766>`_
| June 25 2024: feat: Add buildpacks for building docker image `#764 <https://github.com/mergeability/mergeable/pull/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 <https://github.com/mergeability/mergeable/pull/757>`_
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ Validator List
validators/project.rst
validators/size.rst
validators/stale.rst
validators/timeWindow.rst
validators/title.rst

Options
Expand Down
21 changes: 20 additions & 1 deletion docs/recipes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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."
62 changes: 62 additions & 0 deletions docs/validators/timeWindow.rst
Original file line number Diff line number Diff line change
@@ -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.*'
Loading