diff --git a/.pnp.cjs b/.pnp.cjs index 908d0d86..5c85c439 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -69,6 +69,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["markdownlint-cli", "npm:0.27.1"], ["middie", "npm:5.3.0"], ["module-alias", "npm:2.2.2"], + ["moment-timezone", "npm:0.5.39"], ["nodemon", "npm:2.0.7"], ["pg", "virtual:b77bd565da35d59b6f0008d506d805f28323c88637cb50db85f7c4cea9d5e9a8b88634ab36a9cc065ba1abf08ff10bedc951fc7fd19b783f44971cd1457b8daf#npm:8.5.1"], ["pino-pretty", "npm:4.5.0"], @@ -8188,6 +8189,25 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD", }] ]], + ["moment", [ + ["npm:2.29.4", { + "packageLocation": "./.yarn/cache/moment-npm-2.29.4-902943305d-0ec3f9c2bc.zip/node_modules/moment/", + "packageDependencies": [ + ["moment", "npm:2.29.4"] + ], + "linkType": "HARD", + }] + ]], + ["moment-timezone", [ + ["npm:0.5.39", { + "packageLocation": "./.yarn/cache/moment-timezone-npm-0.5.39-e9aea4996d-9f972d3a29.zip/node_modules/moment-timezone/", + "packageDependencies": [ + ["moment-timezone", "npm:0.5.39"], + ["moment", "npm:2.29.4"] + ], + "linkType": "HARD", + }] + ]], ["mri", [ ["npm:1.1.4", { "packageLocation": "./.yarn/cache/mri-npm-1.1.4-d22a399f26-e65b9aed3b.zip/node_modules/mri/", @@ -9757,6 +9777,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["markdownlint-cli", "npm:0.27.1"], ["middie", "npm:5.3.0"], ["module-alias", "npm:2.2.2"], + ["moment-timezone", "npm:0.5.39"], ["nodemon", "npm:2.0.7"], ["pg", "virtual:b77bd565da35d59b6f0008d506d805f28323c88637cb50db85f7c4cea9d5e9a8b88634ab36a9cc065ba1abf08ff10bedc951fc7fd19b783f44971cd1457b8daf#npm:8.5.1"], ["pino-pretty", "npm:4.5.0"], diff --git a/.yarn/cache/moment-npm-2.29.4-902943305d-0ec3f9c2bc.zip b/.yarn/cache/moment-npm-2.29.4-902943305d-0ec3f9c2bc.zip new file mode 100644 index 00000000..78acd14b Binary files /dev/null and b/.yarn/cache/moment-npm-2.29.4-902943305d-0ec3f9c2bc.zip differ diff --git a/.yarn/cache/moment-timezone-npm-0.5.39-e9aea4996d-9f972d3a29.zip b/.yarn/cache/moment-timezone-npm-0.5.39-e9aea4996d-9f972d3a29.zip new file mode 100644 index 00000000..6cde6ad3 Binary files /dev/null and b/.yarn/cache/moment-timezone-npm-0.5.39-e9aea4996d-9f972d3a29.zip differ diff --git a/package.json b/package.json index f0892098..a6fe0ede 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "knex": "^0.95.12", "middie": "^5.3.0", "module-alias": "^2.2.2", + "moment-timezone": "^0.5.39", "pg": "^8.5.1", "source-map-support": "^0.5.21", "tar": "^6.1.11", diff --git a/src/brain/issueNotifier/index.test.ts b/src/brain/issueNotifier/index.test.ts index 18d5cdbe..9ddad1be 100644 --- a/src/brain/issueNotifier/index.test.ts +++ b/src/brain/issueNotifier/index.test.ts @@ -182,10 +182,9 @@ describe('issueNotifier Tests', function () { expect(say).lastCalledWith( 'This channel is set to receive notifications for: Team: Test (no office specified)' ); - expect(await getLabelsTable().where({ channel_id })).toEqual([ + expect(await getLabelsTable().where({ channel_id })).toMatchObject([ { channel_id: 'CHNLIDRND1', - id: 1, label_name: 'Team: Test', offices: null, }, @@ -202,10 +201,9 @@ describe('issueNotifier Tests', function () { expect(say).lastCalledWith( 'Add office location sfo on the current channel (test) for Team: Test' ); - expect(await getLabelsTable().where({ channel_id })).toEqual([ + expect(await getLabelsTable().where({ channel_id })).toMatchObject([ { channel_id: 'CHNLIDRND1', - id: 1, label_name: 'Team: Test', offices: ['sfo'], }, @@ -222,10 +220,9 @@ describe('issueNotifier Tests', function () { expect(say).lastCalledWith( 'This channel is set to receive notifications for: Team: Test (sfo)' ); - expect(await getLabelsTable().where({ channel_id })).toEqual([ + expect(await getLabelsTable().where({ channel_id })).toMatchObject([ { channel_id: 'CHNLIDRND1', - id: 1, label_name: 'Team: Test', offices: ['sfo'], }, @@ -242,10 +239,9 @@ describe('issueNotifier Tests', function () { expect(say).lastCalledWith( 'Add office location sea on the current channel (test) for Team: Test' ); - expect(await getLabelsTable().where({ channel_id })).toEqual([ + expect(await getLabelsTable().where({ channel_id })).toMatchObject([ { channel_id: 'CHNLIDRND1', - id: 1, label_name: 'Team: Test', offices: ['sfo', 'sea'], }, @@ -262,10 +258,9 @@ describe('issueNotifier Tests', function () { expect(say).lastCalledWith( 'This channel is set to receive notifications for: Team: Test (sfo, sea)' ); - expect(await getLabelsTable().where({ channel_id })).toEqual([ + expect(await getLabelsTable().where({ channel_id })).toMatchObject([ { channel_id: 'CHNLIDRND1', - id: 1, label_name: 'Team: Test', offices: ['sfo', 'sea'], }, @@ -282,10 +277,9 @@ describe('issueNotifier Tests', function () { expect(say).lastCalledWith( 'Add office location vie on the current channel (test) for Team: Test' ); - expect(await getLabelsTable().where({ channel_id })).toEqual([ + expect(await getLabelsTable().where({ channel_id })).toMatchObject([ { channel_id: 'CHNLIDRND1', - id: 1, label_name: 'Team: Test', offices: ['sfo', 'sea', 'vie'], }, @@ -302,10 +296,9 @@ describe('issueNotifier Tests', function () { expect(say).lastCalledWith( 'Add office location yyz on the current channel (test) for Team: Test' ); - expect(await getLabelsTable().where({ channel_id })).toEqual([ + expect(await getLabelsTable().where({ channel_id })).toMatchObject([ { channel_id: 'CHNLIDRND1', - id: 1, label_name: 'Team: Test', offices: ['sfo', 'sea', 'vie', 'yyz'], }, @@ -322,10 +315,9 @@ describe('issueNotifier Tests', function () { expect(say).lastCalledWith( 'This channel (test) will no longer get notifications for Team: Test during sea business hours.' ); - expect(await getLabelsTable().where({ channel_id })).toEqual([ + expect(await getLabelsTable().where({ channel_id })).toMatchObject([ { channel_id: 'CHNLIDRND1', - id: 1, label_name: 'Team: Test', offices: ['sfo', 'vie', 'yyz'], }, @@ -342,10 +334,9 @@ describe('issueNotifier Tests', function () { expect(say).lastCalledWith( 'This channel (test) is not subscribed to Team: Test during sea business hours.' ); - expect(await getLabelsTable().where({ channel_id })).toEqual([ + expect(await getLabelsTable().where({ channel_id })).toMatchObject([ { channel_id: 'CHNLIDRND1', - id: 1, label_name: 'Team: Test', offices: ['sfo', 'vie', 'yyz'], }, @@ -362,10 +353,9 @@ describe('issueNotifier Tests', function () { expect(say).lastCalledWith( 'This channel (test) will no longer get notifications for Team: Test during yyz business hours.' ); - expect(await getLabelsTable().where({ channel_id })).toEqual([ + expect(await getLabelsTable().where({ channel_id })).toMatchObject([ { channel_id: 'CHNLIDRND1', - id: 1, label_name: 'Team: Test', offices: ['sfo', 'vie'], }, @@ -382,10 +372,9 @@ describe('issueNotifier Tests', function () { expect(say).lastCalledWith( 'This channel (test) will no longer get notifications for Team: Test during sfo business hours.' ); - expect(await getLabelsTable().where({ channel_id })).toEqual([ + expect(await getLabelsTable().where({ channel_id })).toMatchObject([ { channel_id: 'CHNLIDRND1', - id: 1, label_name: 'Team: Test', offices: ['vie'], }, diff --git a/src/brain/issueNotifier/index.ts b/src/brain/issueNotifier/index.ts index 25415db0..915fe5c2 100644 --- a/src/brain/issueNotifier/index.ts +++ b/src/brain/issueNotifier/index.ts @@ -3,7 +3,7 @@ import { EmitterWebhookEvent } from '@octokit/webhooks'; import { TEAM_LABEL_PREFIX, UNROUTED_LABEL, UNTRIAGED_LABEL } from '@/config'; import { githubEvents } from '@api/github'; import { bolt } from '@api/slack'; -import { cacheOfficesForTeam } from '@utils/businessHours'; +import { cacheOffices } from '@utils/businessHours'; import { db } from '@utils/db'; import { wrapHandler } from '@utils/wrapHandler'; @@ -212,7 +212,7 @@ export const slackHandler = async ({ command, ack, say, respond, client }) => { break; } // Update cache for the offices mapped to each team - await cacheOfficesForTeam(label_name); + await cacheOffices(label_name); } await Promise.all(pending); }; diff --git a/src/config/index.ts b/src/config/index.ts index cbef684d..dca27260 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -86,3 +86,15 @@ export const MAX_ROUTE_DAYS = 1; * Personal Access Token for the Sentry bot used to do things that aren't possible with the App account, e.g. querying org membership */ export const GH_USER_TOKEN = process.env.GH_USER_TOKEN || ''; + +/** + * Business Hours by Office + */ + +export const OFFICE_TIME_ZONES = { + vie: 'Europe/Vienna', + ams: 'Europe/Amsterdam', + yyz: 'America/Toronto', + sfo: 'America/Los_Angeles', + sea: 'America/Los_Angeles', +}; diff --git a/src/utils/businessHours.test.ts b/src/utils/businessHours.test.ts index a6a404fb..246d2c9f 100644 --- a/src/utils/businessHours.test.ts +++ b/src/utils/businessHours.test.ts @@ -15,6 +15,8 @@ jest.mock('@google-cloud/bigquery', () => ({ }, })); +import moment from 'moment-timezone'; + import { getLabelsTable, slackHandler } from '@/brain/issueNotifier'; import { MAX_ROUTE_DAYS, @@ -28,7 +30,8 @@ import { calculateSLOViolationRoute, calculateSLOViolationTriage, calculateTimeToRespondBy, - getOfficesForTeam, + getNextAvailableBusinessHourWindow, + getOffices, } from './businessHours'; describe('businessHours tests', function () { @@ -40,6 +43,21 @@ describe('businessHours tests', function () { channel_id: 'CHNLIDRND1', offices: ['sfo'], }); + await getLabelsTable().insert({ + label_name: 'Team: Undefined', + channel_id: 'CHNLIDRND1', + offices: undefined, + }); + await getLabelsTable().insert({ + label_name: 'Team: Null', + channel_id: 'CHNLIDRND1', + offices: null, + }); + await getLabelsTable().insert({ + label_name: 'Team: Open Source', + channel_id: 'CHNLIDRND1', + offices: ['sfo'], + }); say = jest.fn(); respond = jest.fn(); client = { @@ -73,8 +91,8 @@ describe('businessHours tests', function () { '2022-11-18T23:36:00.000Z', '2022-11-21T23:36:00.000Z', '2022-11-22T23:36:00.000Z', - '2022-11-22T23:36:00.000Z', - '2022-11-22T23:36:00.000Z', + '2022-11-23T01:00:00.000Z', + '2022-11-23T01:00:00.000Z', ]; const routingResults = [ @@ -83,16 +101,16 @@ describe('businessHours tests', function () { '2022-11-17T23:36:00.000Z', '2022-11-18T23:36:00.000Z', '2022-11-21T23:36:00.000Z', - '2022-11-21T23:36:00.000Z', - '2022-11-21T23:36:00.000Z', + '2022-11-22T01:00:00.000Z', + '2022-11-22T01:00:00.000Z', ]; for (let i = 0; i < 7; i++) { it(`should calculate TTT SLO violation for ${testTimestamps[i].day}`, async function () { const result = await calculateTimeToRespondBy( MAX_TRIAGE_DAYS, - testTimestamps[i].timestamp, - 'Team: Test' + 'Team: Test', + testTimestamps[i].timestamp ); expect(result).toEqual(triageResults[i]); }); @@ -100,60 +118,68 @@ describe('businessHours tests', function () { it(`should calculate TTR SLO violation for ${testTimestamps[i].day}`, async function () { const result = await calculateTimeToRespondBy( MAX_ROUTE_DAYS, - testTimestamps[i].timestamp, - 'Team: Test' + 'Team: Test', + testTimestamps[i].timestamp ); expect(result).toEqual(routingResults[i]); }); } it('should handle case when offices is undefined', async function () { - await getLabelsTable().insert({ - label_name: 'Team: Undefined', - channel_id: 'CHNLIDRND1', - offices: undefined, - }); const result = await calculateTimeToRespondBy( MAX_TRIAGE_DAYS, - '2023-12-18T00:00:00.000Z', - 'Team: Undefined' + 'Team: Undefined', + '2023-12-18T00:00:00.000Z' ); - expect(result).toEqual('2023-12-20T00:00:00.000Z'); - }) + expect(result).toEqual('2023-12-20T01:00:00.000Z'); + }); it('should handle case when offices is null', async function () { - await getLabelsTable().insert({ - label_name: 'Team: Null', - channel_id: 'CHNLIDRND1', - offices: null, - }); const result = await calculateTimeToRespondBy( MAX_TRIAGE_DAYS, - '2023-12-18T00:00:00.000Z', - 'Team: Null' + 'Team: Null', + '2023-12-18T00:00:00.000Z' + ); + expect(result).toEqual('2023-12-20T01:00:00.000Z'); + }); + + it('should handle the last day of the month for TTR', async function () { + const result = await calculateTimeToRespondBy( + MAX_ROUTE_DAYS, + 'Team: Test', + '2023-01-31T00:00:00.000Z' + ); + expect(result).toEqual('2023-02-01T00:00:00.000Z'); + }); + + it('should handle the last day of the year for TTR', async function () { + const result = await calculateTimeToRespondBy( + MAX_ROUTE_DAYS, + 'Team: Test', + '2022-12-31T00:00:00.000Z' ); - expect(result).toEqual('2023-12-20T00:00:00.000Z'); - }) + expect(result).toEqual('2023-01-04T00:00:00.000Z'); + }); describe('holiday tests', function () { it('should calculate TTT SLO violation for Christmas', async function () { const result = await calculateTimeToRespondBy( MAX_TRIAGE_DAYS, - '2023-12-24T00:00:00.000Z', - 'Team: Test' + 'Team: Test', + '2023-12-24T00:00:00.000Z' ); // 2023-12-24 is Sunday, 2023-12-25/2022-12-26 are holidays - expect(result).toEqual('2023-12-28T00:00:00.000Z'); + expect(result).toEqual('2023-12-29T01:00:00.000Z'); }); it('should calculate TTR SLO violation for Christmas', async function () { const result = await calculateTimeToRespondBy( MAX_ROUTE_DAYS, - '2023-12-24T00:00:00.000Z', - 'Team: Test' + 'Team: Test', + '2023-12-24T00:00:00.000Z' ); // 2023-12-24 is Sunday, 2023-12-25/2022-12-26 are holidays - expect(result).toEqual('2023-12-27T00:00:00.000Z'); + expect(result).toEqual('2023-12-28T01:00:00.000Z'); }); it('should not include holiday in TTR if at least one office is still open', async function () { @@ -164,112 +190,295 @@ describe('businessHours tests', function () { await slackHandler({ command, ack, say, respond, client }); const result = await calculateTimeToRespondBy( MAX_ROUTE_DAYS, - '2023-10-02T00:00:00.000Z', - 'Team: Test' + 'Team: Test', + '2023-10-02T00:00:00.000Z' ); expect(result).toEqual('2023-10-03T00:00:00.000Z'); command.text = '-Test yyz'; await slackHandler({ command, ack, say, respond, client }); }); + + it('should triage on the same day if two office timezones do not overlap', async function () { + const command = { + channel_id: 'CHNLIDRND2', + text: 'Test vie', + }; + await slackHandler({ command, ack, say, respond, client }); + const result = await calculateTimeToRespondBy( + MAX_TRIAGE_DAYS, + 'Team: Test', + '2023-10-02T00:00:00.000Z' + ); + expect(result).toEqual('2023-10-03T00:00:00.000Z'); + }); + + it('should calculate weekends properly for friday in sfo, weekend in vie', async function () { + const result = await calculateTimeToRespondBy( + MAX_TRIAGE_DAYS, + 'Team: Test', + '2022-12-17T00:00:00.000Z' + ); + expect(result).toEqual('2022-12-20T00:00:00.000Z'); + }); + + it('should route properly when team is subscribed to sfo, vie, and yyz', async function () { + const command = { + channel_id: 'CHNLIDRND2', + text: 'Test yyz', + }; + await slackHandler({ command, ack, say, respond, client }); + const result = await calculateTimeToRespondBy( + MAX_ROUTE_DAYS, + 'Team: Test', + '2022-12-20T15:30:00.000Z' + ); + expect(result).toEqual('2022-12-20T23:30:00.000Z'); + }); + + it('should triage properly when team is subscribed to sfo, vie, and yyz', async function () { + const command = { + channel_id: 'CHNLIDRND2', + text: 'Test yyz', + }; + await slackHandler({ command, ack, say, respond, client }); + const result = await calculateTimeToRespondBy( + MAX_TRIAGE_DAYS, + 'Team: Test', + '2022-12-20T15:30:00.000Z' + ); + expect(result).toEqual('2022-12-21T14:30:00.000Z'); + command.text = '-Test yyz'; + await slackHandler({ command, ack, say, respond, client }); + command.text = '-Test vie'; + await slackHandler({ command, ack, say, respond, client }); + }); }); }); describe('calculateSLOViolationRoute', function () { it('should not calculate SLO violation if label is not unrouted', async function () { - const timestamp = '2022-11-14T23:36:00.000Z'; - const result = await calculateSLOViolationRoute( - 'Status: Test', - timestamp - ); + const result = await calculateSLOViolationRoute('Status: Test'); expect(result).toEqual(null); }); it('should not calculate SLO violation if label is untriaged', async function () { - const timestamp = '2022-11-14T23:36:00.000Z'; - const result = await calculateSLOViolationRoute( - UNTRIAGED_LABEL, - timestamp - ); + const result = await calculateSLOViolationRoute(UNTRIAGED_LABEL); expect(result).toEqual(null); }); it('should calculate SLO violation if label is unrouted', async function () { - const timestamp = '2022-11-14T23:36:00.000Z'; - const result = await calculateSLOViolationRoute( - UNROUTED_LABEL, - timestamp - ); - expect(result).toEqual('2022-11-15T23:36:00.000Z'); + const result = await calculateSLOViolationRoute(UNROUTED_LABEL); + expect(result).not.toEqual(null); }); }); describe('calculateSLOViolationTriage', function () { it('should not calculate SLO violation if label is not untriaged', async function () { - const timestamp = '2022-11-14T23:36:00.000Z'; - const result = await calculateSLOViolationTriage( - 'Status: Test', - timestamp, - [{ name: 'Team: Test' }] - ); + const result = await calculateSLOViolationTriage('Status: Test', [ + { name: 'Team: Test' }, + ]); expect(result).toEqual(null); }); it('should not calculate SLO violation if label is unrouted', async function () { - const timestamp = '2022-11-14T23:36:00.000Z'; - const result = await calculateSLOViolationTriage( - UNROUTED_LABEL, - timestamp, - [{ name: 'Team: Test' }] - ); + const result = await calculateSLOViolationTriage(UNROUTED_LABEL, [ + { name: 'Team: Test' }, + ]); expect(result).toEqual(null); }); it('should calculate SLO violation if label is untriaged', async function () { - const timestamp = '2022-11-14T23:36:00.000Z'; - const result = await calculateSLOViolationTriage( - UNTRIAGED_LABEL, - timestamp, - [{ name: 'Team: Test' }] - ); - expect(result).toEqual('2022-11-16T23:36:00.000Z'); + const result = await calculateSLOViolationTriage(UNTRIAGED_LABEL, [ + { name: 'Team: Test' }, + ]); + expect(result).not.toEqual(null); }); it('should calculate SLO violation if label is assigned to another team', async function () { - const timestamp = '2022-11-14T23:36:00.000Z'; - const result = await calculateSLOViolationTriage( - 'Team: Rerouted', - timestamp, - [{ name: 'Status: Untriaged' }] + const result = await calculateSLOViolationTriage('Team: Rerouted', [ + { name: 'Status: Untriaged' }, + ]); + expect(result).not.toEqual(null); + }); + }); + + describe('getNextAvailableBusinessHourWindow', function () { + it('should get open source team timezones if team does not have offices', async function () { + const { start, end } = await getNextAvailableBusinessHourWindow( + 'Team: Does not exist', + moment('2022-12-08T12:00:00.000Z').utc() + ); + expect(start.valueOf()).toEqual( + moment('2022-12-08T17:00:00.000Z').valueOf() + ); + expect(end.valueOf()).toEqual( + moment('2022-12-09T01:00:00.000Z').valueOf() + ); + }); + + it('should get sfo timezones for Team: Test', async function () { + const { start, end } = await getNextAvailableBusinessHourWindow( + 'Team: Test', + moment('2022-12-08T12:00:00.000Z').utc() + ); + expect(start.valueOf()).toEqual( + moment('2022-12-08T17:00:00.000Z').valueOf() + ); + expect(end.valueOf()).toEqual( + moment('2022-12-09T01:00:00.000Z').valueOf() + ); + }); + + it('should get vie timezone for Team: Test if it has the closest business hours available', async function () { + const command = { + channel_id: 'CHNLIDRND2', + text: 'Test vie', + }; + await slackHandler({ command, ack, say, respond, client }); + const { start, end } = await getNextAvailableBusinessHourWindow( + 'Team: Test', + moment('2022-12-08T12:00:00.000Z').utc() + ); + expect(start.valueOf()).toEqual( + moment('2022-12-08T12:00:00.000Z').valueOf() + ); + expect(end.valueOf()).toEqual( + moment('2022-12-08T16:00:00.000Z').valueOf() + ); + }); + + it('should get sfo timezone for Team: Test if it has the closest business hours available', async function () { + const command = { + channel_id: 'CHNLIDRND2', + text: 'Test vie', + }; + await slackHandler({ command, ack, say, respond, client }); + const { start, end } = await getNextAvailableBusinessHourWindow( + 'Team: Test', + moment('2022-12-08T16:30:00.000Z').utc() + ); + expect(start.valueOf()).toEqual( + moment('2022-12-08T17:00:00.000Z').valueOf() + ); + expect(end.valueOf()).toEqual( + moment('2022-12-09T01:00:00.000Z').valueOf() + ); + }); + + it('should get yyz timezone for Team: Test if it has the closest business hours available', async function () { + const command = { + channel_id: 'CHNLIDRND2', + text: 'Test yyz', + }; + await slackHandler({ command, ack, say, respond, client }); + const { start, end } = await getNextAvailableBusinessHourWindow( + 'Team: Test', + moment('2022-12-08T16:30:00.000Z').utc() + ); + expect(start.valueOf()).toEqual( + moment('2022-12-08T16:30:00.000Z').valueOf() + ); + expect(end.valueOf()).toEqual( + moment('2022-12-08T22:00:00.000Z').valueOf() + ); + }); + + it('should return vie hours for Christmas for team subscribed to vie, yyz, sfo', async function () { + const { start, end } = await getNextAvailableBusinessHourWindow( + 'Team: Test', + moment('2023-12-23T12:00:00.000Z').utc() + ); + expect(start.valueOf()).toEqual( + moment('2023-12-27T08:00:00.000Z').valueOf() + ); + expect(end.valueOf()).toEqual( + moment('2023-12-27T16:00:00.000Z').valueOf() + ); + }); + + it('should return vie hours for Saturday for team subscribed to vie, yyz, sfo', async function () { + const { start, end } = await getNextAvailableBusinessHourWindow( + 'Team: Test', + moment('2022-12-17T12:00:00.000Z').utc() + ); + expect(start.valueOf()).toEqual( + moment('2022-12-19T08:00:00.000Z').valueOf() + ); + expect(end.valueOf()).toEqual( + moment('2022-12-19T16:00:00.000Z').valueOf() + ); + }); + + it('should return vie hours for Sunday for team subscribed to vie, yyz, sfo', async function () { + const { start, end } = await getNextAvailableBusinessHourWindow( + 'Team: Test', + moment('2022-12-18T12:00:00.000Z').utc() + ); + expect(start.valueOf()).toEqual( + moment('2022-12-19T08:00:00.000Z').valueOf() ); - expect(result).toEqual('2022-11-16T23:36:00.000Z'); + expect(end.valueOf()).toEqual( + moment('2022-12-19T16:00:00.000Z').valueOf() + ); + }); + + it('should return yyz hours for Saturday for team subscribed to yyz, sfo', async function () { + let command = { + channel_id: 'CHNLIDRND2', + text: '-Test vie', + }; + await slackHandler({ command, ack, say, respond, client }); + const { start, end } = await getNextAvailableBusinessHourWindow( + 'Team: Test', + moment('2022-12-17T12:00:00.000Z').utc() + ); + expect(start.valueOf()).toEqual( + moment('2022-12-19T14:00:00.000Z').valueOf() + ); + expect(end.valueOf()).toEqual( + moment('2022-12-19T22:00:00.000Z').valueOf() + ); + command = { + channel_id: 'CHNLIDRND2', + text: '-Test yyz', + }; + await slackHandler({ command, ack, say, respond, client }); }); }); - describe('getOfficesForTeam', function () { + describe('getOffices', function () { it('should return empty array if team label is undefined', async function () { - expect(await getOfficesForTeam(undefined)).toEqual([]); + expect(await getOffices(undefined)).toEqual([]); + }); + + it('should return empty array if team offices value is undefined', async function () { + expect(await getOffices('Team: Undefined')).toEqual([]); + }); + + it('should return empty array if team offices value is null', async function () { + expect(await getOffices('Team: Null')).toEqual([]); }); it('should get sfo office for team test', async function () { - expect(await getOfficesForTeam('Team: Test')).toEqual(['sfo']); + expect(await getOffices('Team: Test')).toEqual(['sfo']); }); - it('should get sfo and vie office in sorted order for team test if new office is added', async function () { + it('should get sfo and vie office for team test if new office is added', async function () { const command = { channel_id: 'CHNLIDRND1', text: 'Test vie', }; await slackHandler({ command, ack, say, respond, client }); - expect(await getOfficesForTeam('Team: Test')).toEqual(['vie', 'sfo']); + expect(await getOffices('Team: Test')).toEqual(['sfo', 'vie']); }); - it('should get vie office in sorted order for team test if existing office is removed', async function () { + it('should get vie office for team test if existing office is removed', async function () { const command = { channel_id: 'CHNLIDRND1', text: '-Test sfo', }; await slackHandler({ command, ack, say, respond, client }); - expect(await getOfficesForTeam('Team: Test')).toEqual(['vie']); + expect(await getOffices('Team: Test')).toEqual(['vie']); }); it('should get offices from multiple channels', async function () { @@ -278,12 +487,7 @@ describe('businessHours tests', function () { text: 'Test yyz', }; await slackHandler({ command, ack, say, respond, client }); - expect(await getOfficesForTeam('Team: Test')).toEqual(['vie', 'yyz']); + expect(await getOffices('Team: Test')).toEqual(['vie', 'yyz']); }); }); - - // TODO: hubertdeng123 - // describe('getBusinessHoursForTeam', function () { - - // }) }); diff --git a/src/utils/businessHours.ts b/src/utils/businessHours.ts index 3b2083a7..9278f66d 100644 --- a/src/utils/businessHours.ts +++ b/src/utils/businessHours.ts @@ -1,114 +1,154 @@ import fs from 'fs'; import yaml from 'js-yaml'; +import moment from 'moment-timezone'; import { getLabelsTable } from '@/brain/issueNotifier'; import { MAX_ROUTE_DAYS, MAX_TRIAGE_DAYS, + OFFICE_TIME_ZONES, TEAM_LABEL_PREFIX, UNROUTED_LABEL, UNTRIAGED_LABEL, } from '@/config'; +const HOUR_IN_MS = 60 * 60 * 1000; +const BUSINESS_DAY_IN_MS = 8 * HOUR_IN_MS; + const holidayFile = fs.readFileSync('holidays.yml'); const HOLIDAY_CONFIG = yaml.load(holidayFile); -const officeHourOrdering: Record = { - vie: 1, - ams: 2, - yyz: 3, - sfo: 4, - sea: 5, -}; const officesCache = {}; +interface BusinessHourWindow { + start: moment.Moment; + end: moment.Moment; +} -export async function calculateTimeToRespondBy(numDays, timestamp, team) { - const dateObj = new Date(timestamp); - const offices = await getOfficesForTeam(team); - for (let i = 1; i <= numDays; i++) { - dateObj.setDate(dateObj.getDate() + 1); - // Saturday: Day 6 - // Sunday: Day 0 - if (dateObj.getUTCDay() === 6) { - dateObj.setDate(dateObj.getDate() + 2); - } else if (dateObj.getUTCDay() === 0) { - dateObj.setDate(dateObj.getDate() + 1); - } - // If all offices are all on holiday, we skip the day. - // Otherwise, we count the day for our SLA's - let shouldSkipDate = false; - offices.forEach((office) => { - if ( - HOLIDAY_CONFIG[office]?.dates.includes( - // slicing the string here since we only care about YYYY/MM/DD - dateObj.toISOString().slice(0, 10) - ) - ) { - shouldSkipDate = true; - } else { - shouldSkipDate = false; - } - }); - if (shouldSkipDate) { - i -= 1; - } +export async function calculateTimeToRespondBy(numDays, team, testTimestamp?) { + let cursor = + testTimestamp !== undefined ? moment(testTimestamp).utc() : moment().utc(); + let msRemaining = numDays * BUSINESS_DAY_IN_MS; + while (msRemaining > 0) { + const nextBusinessHours = await getNextAvailableBusinessHourWindow( + team, + cursor + ); + const { start, end }: BusinessHourWindow = nextBusinessHours; + cursor = start; + const msAvailable = end.valueOf() - start.valueOf(); + const msToAdd = Math.min(msAvailable, msRemaining); + cursor.add(msToAdd, 'milliseconds'); + msRemaining -= msToAdd; } - return dateObj.toISOString(); + return cursor.toISOString(); } -export async function calculateSLOViolationTriage( - target_name, - timestamp, - labels -) { +export async function calculateSLOViolationTriage(target_name, labels) { // calculate time to triage for issues that come in with untriaged label if (target_name === UNTRIAGED_LABEL) { const team = labels?.find((label) => label.name.startsWith(TEAM_LABEL_PREFIX) )?.name; - return calculateTimeToRespondBy(MAX_TRIAGE_DAYS, timestamp, team); + return calculateTimeToRespondBy(MAX_TRIAGE_DAYS, team); } // calculate time to triage for issues that are rerouted else if ( target_name.startsWith(TEAM_LABEL_PREFIX) && labels?.some((label) => label.name === UNTRIAGED_LABEL) ) { - return calculateTimeToRespondBy(MAX_TRIAGE_DAYS, timestamp, target_name); + return calculateTimeToRespondBy(MAX_TRIAGE_DAYS, target_name); } return null; } -export async function calculateSLOViolationRoute(target_name, timestamp) { +export async function calculateSLOViolationRoute(target_name) { if (target_name === UNROUTED_LABEL) { - return calculateTimeToRespondBy(MAX_ROUTE_DAYS, timestamp, 'Team: Support'); + return calculateTimeToRespondBy(MAX_ROUTE_DAYS, 'Team: Support'); } return null; } -export async function cacheOfficesForTeam(team) { - const officesSet = new Set( - ( - await getLabelsTable() - .where({ - label_name: team, - }) - .select('offices') - ).reduce((acc, item) => acc.concat(item.offices), []) - ); - // Sorting from which office timezone comes earlier in the day in UTC, makes calculations easier later on - const orderedOffices = [...officesSet].sort( - (a: any, b: any) => officeHourOrdering[a] - officeHourOrdering[b] - ); - officesCache[team] = orderedOffices; +export async function cacheOffices(team) { + const offices = [ + ...new Set( + ( + await getLabelsTable() + .where({ + label_name: team, + }) + .select('offices') + ) + .reduce((acc, item) => acc.concat(item.offices), []) + .filter((office) => office != null) + ), + ]; + officesCache[team] = offices; + return offices; +} + +export async function getNextAvailableBusinessHourWindow( + team, + momentTime +): Promise { + let offices = await getOffices(team); + if (offices.length === 0) { + offices = await getOffices('Team: Open Source'); + if (offices.length === 0) { + throw new Error('Open Source team not subscribed to any offices.'); + } + } + const businessHourWindows: BusinessHourWindow[] = []; + offices.forEach((office) => { + const momentIterator = moment(momentTime.valueOf()).utc(); + let isWeekend, + dayOfTheWeek, + date, + isHoliday, + isTimestampOutsideBusinessHourWindow, + end; + do { + dayOfTheWeek = momentIterator.tz(OFFICE_TIME_ZONES[office]).day(); + // Saturday is 6, Sunday is 0 + isWeekend = dayOfTheWeek === 6 || dayOfTheWeek === 0; + date = momentIterator.tz(OFFICE_TIME_ZONES[office]).format('YYYY-MM-DD'); + end = moment + .tz(`${date} 17:00`, 'YYYY-MM-DD hh:mm', OFFICE_TIME_ZONES[office]) + .utc(); + isTimestampOutsideBusinessHourWindow = momentTime >= end; + isHoliday = HOLIDAY_CONFIG[office]?.dates.includes(date); + momentIterator.add(1, 'days'); + /* + We want to iterate until we find the first business hours for each office. + Three cases to consider here before incrementing the momentIterator obj + 1. momentIterator date is a holiday + 2. momentIterator date is a weekend + 3. business hours for an office on momentIterator date has passed by + */ + } while (isHoliday || isWeekend || isTimestampOutsideBusinessHourWindow); + // Start window will be the max of the start of the workday or the moment time passed in + const start = moment.max( + moment + .tz(`${date} 09:00`, 'YYYY-MM-DD hh:mm', OFFICE_TIME_ZONES[office]) + .utc(), + momentTime + ); + businessHourWindows.push({ + start, + end, + }); + }); + // Sort the business hours by the starting date, we only care about the closest business hour window + businessHourWindows.sort((a: any, b: any) => a.start - b.start); + return businessHourWindows[0]; } -export async function getOfficesForTeam(team) { +export async function getOffices(team) { if (!team) { return []; } if (!officesCache[team]) { - await cacheOfficesForTeam(team); + await cacheOffices(team); } return officesCache[team]; } diff --git a/src/utils/metrics.test.ts b/src/utils/metrics.test.ts index 20fdfd0c..3038c142 100644 --- a/src/utils/metrics.test.ts +++ b/src/utils/metrics.test.ts @@ -58,6 +58,11 @@ describe('metrics tests', function () { channel_id: 'CHNLIDRND1', offices: ['sfo'], }); + await getLabelsTable().insert({ + label_name: 'Team: Open Source', + channel_id: 'CHNLIDRND1', + offices: ['sfo'], + }); }); afterAll(async () => { @@ -72,7 +77,7 @@ describe('metrics tests', function () { const result = await insertOss('issues', testPayload); expect(result).toMatchObject({ timeToRouteBy: null, - timeToTriageBy: '2017-02-16T12:51:48.000Z', + timeToTriageBy: '2017-02-16T01:00:00.000Z', }); }); @@ -81,7 +86,7 @@ describe('metrics tests', function () { testPayload.label.name = UNROUTED_LABEL; const result = await insertOss('issues', testPayload); expect(result).toMatchObject({ - timeToRouteBy: '2017-02-15T12:51:48.000Z', + timeToRouteBy: '2017-02-15T01:00:00.000Z', timeToTriageBy: null, }); }); diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts index e842ad90..cffe0566 100644 --- a/src/utils/metrics.ts +++ b/src/utils/metrics.ts @@ -256,13 +256,9 @@ export async function insertOss( data.target_name = label.name; data.target_type = 'label'; if (data.action === 'labeled') { - data.timeToRouteBy = await calculateSLOViolationRoute( - data.target_name, - Date.now() - ); + data.timeToRouteBy = await calculateSLOViolationRoute(data.target_name); data.timeToTriageBy = await calculateSLOViolationTriage( data.target_name, - Date.now(), issue.labels ); } diff --git a/yarn.lock b/yarn.lock index e451a533..c0adb58e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6727,6 +6727,22 @@ fsevents@~2.1.2: languageName: node linkType: hard +"moment-timezone@npm:^0.5.39": + version: 0.5.39 + resolution: "moment-timezone@npm:0.5.39" + dependencies: + moment: ">= 2.9.0" + checksum: 9f972d3a29b2726d4fd1464df27738b756441fe57575f087cda91b7716a5a31d2cfd274255e3edfb15eb60af3ccf33fd339527b456092cac1a2a4124e4369c8b + languageName: node + linkType: hard + +"moment@npm:>= 2.9.0": + version: 2.29.4 + resolution: "moment@npm:2.29.4" + checksum: 0ec3f9c2bcba38dc2451b1daed5daded747f17610b92427bebe1d08d48d8b7bdd8d9197500b072d14e326dd0ccf3e326b9e3d07c5895d3d49e39b6803b76e80e + languageName: node + linkType: hard + "mri@npm:1.1.4": version: 1.1.4 resolution: "mri@npm:1.1.4" @@ -8134,6 +8150,7 @@ resolve@^1.3.2: markdownlint-cli: ^0.27.1 middie: ^5.3.0 module-alias: ^2.2.2 + moment-timezone: ^0.5.39 nodemon: ^2.0.7 pg: ^8.5.1 pino-pretty: ^4.5.0