From d595d8b6928f0ce6e69b751bc1371112d95cd6a0 Mon Sep 17 00:00:00 2001 From: Reece Appling <19520870+reeceappling@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:39:10 -0400 Subject: [PATCH 1/6] Revert "Revert "Also do created objectives (#42)" (#43)" This reverts commit aab080fb6077aacfb53c9fe328e3c3c98782a645. --- it/integration.test.js | 8 +++---- src/filter.js | 37 ++++++++++++++++++----------- src/index.js | 37 +++++++++++++++++++++-------- src/slack-service.js | 15 ++++++++---- src/slack.js | 15 ++++++++++-- src/small-improvements.js | 4 ++-- test/data-factory.js | 41 ++++++++++++++++++++++++++++++++- test/filter.test.js | 34 +++++++++++++++++++-------- test/index.test.js | 35 ++++++++++++++++------------ test/slack-service.test.js | 28 ++++++++++++++++++---- test/slack.test.js | 33 +++++++++++++++++++++----- test/small-improvements.test.js | 8 +++---- 12 files changed, 218 insertions(+), 77 deletions(-) diff --git a/it/integration.test.js b/it/integration.test.js index 01892ee..1810626 100644 --- a/it/integration.test.js +++ b/it/integration.test.js @@ -3,9 +3,9 @@ const index = require('../src/index'); describe('integration', () => { const event = { time: '2022-06-16T00:00:00Z' - } + }; test('process event', async () => { - const result = await index.handler(event); - }) -}) \ No newline at end of file + await index.handler(event); + }); +}); diff --git a/src/filter.js b/src/filter.js index 2d93649..c79d362 100644 --- a/src/filter.js +++ b/src/filter.js @@ -5,26 +5,35 @@ const threeDaysInMillis = 3 * 24 * 60 * 60 * 1000; Partially achieved status == 103 */ function filterActivities(activities, eventDate) { - const publicObjectiveStatusChanges = activities.items.flatMap(i => i.items || []) - .flatMap(i => i.activities || []) - .filter(a => a.type === 'OBJECTIVE_STATUS_CHANGED') + const allActivities = activities.items.flatMap(i => i.items || []) + .flatMap(i => i.activities || []); + const recentPublicActivitesCreatedOrChanged = allActivities.filter(a => a.type === 'OBJECTIVE_STATUS_CHANGED' || a.type === 'OBJECTIVE_CREATED') .filter(a => a.content.objective.visibility === 'PUBLIC') .filter(a => a.occurredAt >= eventDate.getTime() - threeDaysInMillis); - - const groupedObjectiveStatusChanges = publicObjectiveStatusChanges.reduce((acc, activity) => { + const reducer = (acc, activity) => { acc[activity.content.objective.id] = acc[activity.content.objective.id] || []; acc[activity.content.objective.id].push(activity); return acc; - }, {}); + }; + const groupedObjectivesCreated = recentPublicActivitesCreatedOrChanged.filter(a => a.type === 'OBJECTIVE_CREATED') + .reduce(reducer, {}); + const groupedObjectiveStatusChanges = recentPublicActivitesCreatedOrChanged.filter(a => a.type === 'OBJECTIVE_STATUS_CHANGED') + .reduce(reducer, {}); - return Object.entries(groupedObjectiveStatusChanges) - .flatMap(([objectiveId, activities]) => { - const mostRecentObjectiveChange = activities.sort((a, b) => b.occurredAt - a.occurredAt)[0]; - if (mostRecentObjectiveChange.change.newStatus.status === 100 || mostRecentObjectiveChange.change.newStatus.status === 103) { - return [mostRecentObjectiveChange]; - } - return []; - }); + return { + completed: Object.entries(groupedObjectiveStatusChanges) + .flatMap(([objectiveId, activities]) => { + const mostRecentObjectiveChange = activities.sort((a, b) => b.occurredAt - a.occurredAt)[0]; + if (mostRecentObjectiveChange.change.newStatus.status === 100 || mostRecentObjectiveChange.change.newStatus.status === 103) { + return [mostRecentObjectiveChange]; + } + return []; + }), + created: Object.entries(groupedObjectivesCreated) + .flatMap(([objectiveId, activities]) => { + return [activities.sort((a, b) => b.occurredAt - a.occurredAt)[0]]; + }) + }; } exports.filterActivities = filterActivities; diff --git a/src/index.js b/src/index.js index e9ec1bd..d96ceac 100644 --- a/src/index.js +++ b/src/index.js @@ -19,15 +19,15 @@ async function main(event, context) { */ const slackChannel = process.env.SlackChannel; const secrets = await secretsClient.getSecret(); - const objectiveActivities = await smallImprovementsClient.getObjectives(secrets.SIToken); - const recentlyCompletedObjectives = filter.filterActivities(objectiveActivities, new Date(event.time)); - console.log(`Found ${recentlyCompletedObjectives.length} recently completed objectives.`); - const results = await Promise.allSettled( - recentlyCompletedObjectives.map(async (activity) => { + const objectiveActivities = await smallImprovementsClient.GetObjectives(secrets.SIToken); + const { completed, created } = filter.filterActivities(objectiveActivities, new Date(event.time)); + console.log(`Found ${completed.length} recently completed and ${created.length} recently created objectives.`); + const completedResults = await Promise.allSettled( + completed.map(async (activity) => { const exisingEntry = await dynamodbClient.getRecord(activity.content.objective.id); if (!exisingEntry?.length) { - const SIEmail = await smallImprovementsClient.getEmail(activity.content.objective.owner.id, secrets.SIToken); - await slackService.postObjective( + const SIEmail = await smallImprovementsClient.GetEmail(activity.content.objective.owner.id, secrets.SIToken); + await slackService.PostCompletedObjective( secrets.SlackToken, slackChannel, activity.content, @@ -40,8 +40,27 @@ async function main(event, context) { return undefined; }) ); - const successfulPosts = results.filter(x => x.value); - const failedPosts = results.filter(x => x.status === 'rejected'); + const createdResults = await Promise.allSettled( + created.map(async (activity) => { + const exisingEntry = await dynamodbClient.getRecord(activity.content.objective.id + 'CREATED'); + if (!exisingEntry?.length) { + const SIEmail = await smallImprovementsClient.GetEmail(activity.content.objective.owner.id, secrets.SIToken); + await slackService.PostCreatedObjective( + secrets.SlackToken, + slackChannel, + activity.content, + SIEmail + ); + await dynamodbClient.insertRecord(activity); + return activity.content.objective; + } + return undefined; + }) + ); + const allPostResults = completedResults.concat(createdResults); + const successfulPosts = allPostResults.filter(x => x.value); + const failedPosts = allPostResults.filter(x => x.status === 'rejected'); + failedPosts.forEach(fail => console.log(fail.reason)); const message = `Finished ${successfulPosts.length} successfully. Failed ${failedPosts.length}`; console.log(message); diff --git a/src/slack-service.js b/src/slack-service.js index f9e3440..af021c1 100644 --- a/src/slack-service.js +++ b/src/slack-service.js @@ -1,11 +1,16 @@ const slackClient = require('./slack'); // Get Slack ID, Format Message, Post -async function postObjective(token, channelName, content, newStatus, email) { +async function PostCompletedObjective(token, channelName, content, newStatus, email) { const slackID = await slackClient.getSlackID(email, token); - const formattedMessage = await slackClient.formatSlackMessage(content.objective, newStatus, slackID, content.cycle.id); - const postResp = await slackClient.slackPost(token, channelName, formattedMessage); - return postResp; + const formattedMessage = await slackClient.formatSlackMessageForCompleted(content.objective, newStatus, slackID, content.cycle.id); + return await slackClient.slackPost(token, channelName, formattedMessage); +} +async function PostCreatedObjective(token, channelName, content, email) { + const slackID = await slackClient.getSlackID(email, token); + const formattedMessage = await slackClient.formatSlackMessageForCreated(content.objective, slackID, content.cycle.id); + return await slackClient.slackPost(token, channelName, formattedMessage); } -exports.postObjective = postObjective; +exports.PostCompletedObjective = PostCompletedObjective; +exports.PostCreatedObjective = PostCreatedObjective; diff --git a/src/slack.js b/src/slack.js index 71c20ac..d860de0 100644 --- a/src/slack.js +++ b/src/slack.js @@ -41,7 +41,7 @@ async function slackPost(authToken, channelName, formattedMessage) { // postData }); } -async function formatSlackMessage(objectiveItem, newStatus, slackUID, cycleId) { +async function formatSlackMessageForCompleted(objectiveItem, newStatus, slackUID, cycleId) { const toSend = messageVariables; toSend.text = `<@${slackUID}> has ${newStatus.toLowerCase()} their goal!\n*${objectiveItem.title}*\n`; if (objectiveItem.description) { @@ -51,6 +51,16 @@ async function formatSlackMessage(objectiveItem, newStatus, slackUID, cycleId) { return toSend;// return JSON format } +async function formatSlackMessageForCreated(objectiveItem, slackUID, cycleId) { + const toSend = messageVariables; + toSend.text = `<@${slackUID}> has created a new goal!\n*${objectiveItem.title}*\n`; + if (objectiveItem.description) { + toSend.text += `${formatDescription(objectiveItem.description)}\n`; + } + toSend.text += ``; + return toSend;// return JSON format +} + function formatDescription(description) { if (description.includes('')) { const bold = /<\/?strong>/gi; @@ -119,7 +129,8 @@ function getSlackID(email, token) { }); } -exports.formatSlackMessage = formatSlackMessage; +exports.formatSlackMessageForCompleted = formatSlackMessageForCompleted; +exports.formatSlackMessageForCreated = formatSlackMessageForCreated; exports.formatDescription = formatDescription; exports.slackPost = slackPost; exports.getSlackID = getSlackID; diff --git a/src/small-improvements.js b/src/small-improvements.js index ecf7f1c..67cb145 100644 --- a/src/small-improvements.js +++ b/src/small-improvements.js @@ -70,5 +70,5 @@ function getEmail(SIUID, token) { // SIUID (Small Improvements User ID) is in th }); } -exports.getObjectives = getObjectives; -exports.getEmail = getEmail; +exports.GetObjectives = getObjectives; +exports.GetEmail = getEmail; diff --git a/test/data-factory.js b/test/data-factory.js index 7971e32..fdc6320 100644 --- a/test/data-factory.js +++ b/test/data-factory.js @@ -4,7 +4,7 @@ const defaultNewStatus = { status: 100 }; -exports.createActivity = (activityProps, objectiveProps, newStatus = defaultNewStatus) => { +exports.createCompletedActivity = (activityProps, objectiveProps, newStatus = defaultNewStatus) => { return { actor: { firstName: 'Developer' @@ -49,3 +49,42 @@ exports.createActivity = (activityProps, objectiveProps, newStatus = defaultNewS ...activityProps }; }; + +exports.createCreatedActivity = (activityProps, objectiveProps) => { + return { + actor: { + firstName: 'Developer' + }, + occurredAt: 1651856682326, + change: {}, + id: 'w02bdXSdzFNre*n3plJQgQ', + type: 'OBJECTIVE_CREATED', + targets: [ + { + firstName: 'Developer' + } + ], + content: { + cycle: { + id: 'E0hlMiEuRi7T7Md8PWyQuQ', + name: 'Objective Cycle 2022' + }, + objective: { + id: 'w02bdXSdzFNre*n3plJQll', + icon: 'o_3goldstars', + title: 'Objective Title', + description: '

Description

', + dueDate: '2022-06-30T05:00:00.000Z', + owner: { + firstName: 'Developer', + id: 'V*jArA9pQbaK0U7grc9Qrw', + email: 'rappling@sourceallies.com' + }, + visibility: 'PUBLIC', + visibleTo: [], + ...objectiveProps + } + }, + ...activityProps + }; +}; diff --git a/test/filter.test.js b/test/filter.test.js index 06c9256..3fc8dcb 100644 --- a/test/filter.test.js +++ b/test/filter.test.js @@ -19,13 +19,20 @@ describe('filter activities', () => { { occurredAt: 1651856682326, activities: [ - dataFactory.createActivity( + dataFactory.createCompletedActivity( + { occurredAt: new Date(eventDateString).getTime() - (60 * 1000) }, + { id: objectiveId } + ), + dataFactory.createCreatedActivity( { occurredAt: new Date(eventDateString).getTime() - (60 * 1000) }, { id: objectiveId } ) ] } ] + }, + { + } ] }; @@ -36,7 +43,8 @@ describe('filter activities', () => { const result = filter.filterActivities(activities, new Date(eventDateString)); - expect(result).toHaveLength(1); + expect(result.completed).toHaveLength(1); + expect(result.created).toHaveLength(1); }); test('should ignore items with no activities', () => { @@ -46,7 +54,8 @@ describe('filter activities', () => { const result = filter.filterActivities(activities, new Date(eventDateString)); - expect(result).toHaveLength(1); + expect(result.completed).toHaveLength(1); + expect(result.created).toHaveLength(1); }); test('should remove activity with status change to in progress', () => { @@ -58,23 +67,28 @@ describe('filter activities', () => { const result = filter.filterActivities(activities, new Date(eventDateString)); - expect(result).toHaveLength(0); + expect(result.completed).toHaveLength(0); + expect(result.created).toHaveLength(1); }); test('should remove private objectives', () => { activities.items[0].items[0].activities[0].content.objective.visibility = 'PRIVATE'; + activities.items[0].items[0].activities[1].content.objective.visibility = 'PRIVATE'; const result = filter.filterActivities(activities, new Date(eventDateString)); - expect(result).toHaveLength(0); + expect(result.completed).toHaveLength(0); + expect(result.created).toHaveLength(0); }); test('should remove old objective changes', () => { activities.items[0].items[0].activities[0].occurredAt = new Date(eventDateString).getTime() - (3 * 24 * 60 * 60 * 1000) - 1; + activities.items[0].items[0].activities[1].occurredAt = new Date(eventDateString).getTime() - (3 * 24 * 60 * 60 * 1000) - 1; const result = filter.filterActivities(activities, new Date(eventDateString)); - expect(result).toHaveLength(0); + expect(result.completed).toHaveLength(0); + expect(result.created).toHaveLength(0); }); test('should keep objective when most recent activity is achieved', () => { @@ -87,7 +101,7 @@ describe('filter activities', () => { { occurredAt: 1651856682326, activities: [ - dataFactory.createActivity( + dataFactory.createCompletedActivity( { occurredAt: pastTime }, { id: objectiveId }, { @@ -103,7 +117,7 @@ describe('filter activities', () => { const result = filter.filterActivities(activities, new Date(eventDateString)); - expect(result).toHaveLength(1); + expect(result.completed).toHaveLength(1); }); test('should remove objective when most recent activity is not achieved or partially achieved', () => { @@ -116,7 +130,7 @@ describe('filter activities', () => { { occurredAt: 1651856682326, activities: [ - dataFactory.createActivity( + dataFactory.createCompletedActivity( { occurredAt: futureTime }, { id: objectiveId }, { @@ -132,6 +146,6 @@ describe('filter activities', () => { const result = filter.filterActivities(activities, new Date(eventDateString)); - expect(result).toHaveLength(0); + expect(result.completed).toHaveLength(0); }); }); diff --git a/test/index.test.js b/test/index.test.js index 4993651..7a19b6a 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -53,7 +53,11 @@ describe('index', () => { { occurredAt: 1651856682326, activities: [ - dataFactory.createActivity( + dataFactory.createCompletedActivity( + { occurredAt: activityDateMillis }, + { id: objectiveId } + ), + dataFactory.createCreatedActivity( { occurredAt: activityDateMillis }, { id: objectiveId } ) @@ -79,7 +83,7 @@ describe('index', () => { test('should not post previously existing objective', async () => { secretsClient.getSecret.mockResolvedValue(secrets); - smallImprovementsClient.getObjectives.mockResolvedValue(activities); + smallImprovementsClient.GetObjectives.mockResolvedValue(activities); dynamodbClient.getRecord.mockResolvedValue(dynamoRecords); const result = await index.handler(event); @@ -93,18 +97,18 @@ describe('index', () => { activities.items[0].items[0].activities[0].occurredAt = new Date(eventDateString).getTime() - (3 * 24 * 60 * 60 * 1000); secretsClient.getSecret.mockResolvedValue(secrets); - smallImprovementsClient.getObjectives.mockResolvedValue(activities); - smallImprovementsClient.getEmail.mockResolvedValue(mockEmail); + smallImprovementsClient.GetObjectives.mockResolvedValue(activities); + smallImprovementsClient.GetEmail.mockResolvedValue(mockEmail); dynamodbClient.getRecord.mockResolvedValue([]); dynamodbClient.insertRecord.mockResolvedValue({}); - slackClient.postObjective.mockResolvedValue({}); + slackClient.PostCompletedObjective.mockResolvedValue({}); const result = await index.handler(event); - expect(result).toBe('Finished 1 successfully. Failed 0'); + expect(result).toBe('Finished 2 successfully. Failed 0'); expect(dynamodbClient.getRecord).toHaveBeenCalledWith(objectiveId); expect(dynamodbClient.insertRecord).toHaveBeenCalledWith(activities.items[0].items[0].activities[0]); - expect(slackClient.postObjective).toHaveBeenCalledWith( + expect(slackClient.PostCompletedObjective).toHaveBeenCalledWith( secrets.SlackToken, slackChannel, activities.items[0].items[0].activities[0].content, @@ -117,32 +121,33 @@ describe('index', () => { const secondObjectiveId = 'second-objective-id'; activities.items[0].items[0].activities[0].occurredAt = new Date(eventDateString).getTime() - (3 * 24 * 60 * 60 * 1000); - activities.items[0].items[0].activities.push(dataFactory.createActivity( + activities.items[0].items[0].activities.push(dataFactory.createCompletedActivity( { occurredAt: new Date(eventDateString).getTime() }, { id: secondObjectiveId } )); secretsClient.getSecret.mockResolvedValue(secrets); - smallImprovementsClient.getObjectives.mockResolvedValue(activities); - smallImprovementsClient.getEmail.mockResolvedValue(mockEmail); + smallImprovementsClient.GetObjectives.mockResolvedValue(activities); + smallImprovementsClient.GetEmail.mockResolvedValue(mockEmail); dynamodbClient.getRecord.mockResolvedValue([]); - slackClient.postObjective + slackClient.PostCompletedObjective .mockRejectedValueOnce(new Error('failed to post to slack')) .mockResolvedValue({}); dynamodbClient.insertRecord.mockResolvedValue({}); const result = await index.handler(event); - expect(result).toBe('Finished 1 successfully. Failed 1'); + expect(result).toBe('Finished 2 successfully. Failed 1'); + expect(dynamodbClient.getRecord).toHaveBeenCalledWith(objectiveId); expect(dynamodbClient.getRecord).toHaveBeenCalledWith(objectiveId); expect(dynamodbClient.getRecord).toHaveBeenCalledWith(secondObjectiveId); expect(dynamodbClient.insertRecord).not.toHaveBeenCalledWith(activities.items[0].items[0].activities[0]); expect(dynamodbClient.insertRecord).toHaveBeenCalledWith(activities.items[0].items[0].activities[1]); - expect(slackClient.postObjective).toHaveBeenCalledWith( + expect(slackClient.PostCompletedObjective).toHaveBeenCalledWith( secrets.SlackToken, slackChannel, - activities.items[0].items[0].activities[1].content, - activities.items[0].items[0].activities[1].change.newStatus.description, + activities.items[0].items[0].activities[2].content, + activities.items[0].items[0].activities[2].change.newStatus.description, mockEmail ); }); diff --git a/test/slack-service.test.js b/test/slack-service.test.js index 7bf892c..90deb00 100644 --- a/test/slack-service.test.js +++ b/test/slack-service.test.js @@ -2,7 +2,7 @@ const postClient = require('../src/slack-service'); const slackClient = require('../src/slack'); jest.mock('../src/slack'); -describe('postObjective', () => { +describe('PostObjective', () => { let token, channelID, responseBody, @@ -51,10 +51,10 @@ describe('postObjective', () => { jest.resetAllMocks(); }); - test('postObjective', async () => { + test('PostObjective for created', async () => { const mockFormattedMessage = {}; slackClient.getSlackID.mockResolvedValue(mockSlackID); - slackClient.formatSlackMessage.mockResolvedValue(mockFormattedMessage); + slackClient.formatSlackMessageForCreated.mockResolvedValue(mockFormattedMessage); slackClient.slackPost.mockResolvedValue(responseBody); const mockContent = { cycle: { @@ -63,10 +63,28 @@ describe('postObjective', () => { objective: mockObjective }; - const response = await postClient.postObjective(token, channelID, mockContent, mockStatus, mockEmail); + const response = await postClient.PostCreatedObjective(token, channelID, mockContent, mockEmail); expect(response).toStrictEqual(responseBody); expect(slackClient.getSlackID).toHaveBeenCalledWith(mockEmail, token); - expect(slackClient.formatSlackMessage).toHaveBeenCalledWith(mockObjective, mockStatus, mockSlackID, mockObjectiveCycleId); + expect(slackClient.formatSlackMessageForCreated).toHaveBeenCalledWith(mockObjective, mockSlackID, mockObjectiveCycleId); + expect(slackClient.slackPost).toHaveBeenCalledWith(token, channelID, mockFormattedMessage); + }); + test('PostObjective for completed', async () => { + const mockFormattedMessage = {}; + slackClient.getSlackID.mockResolvedValue(mockSlackID); + slackClient.formatSlackMessageForCompleted.mockResolvedValue(mockFormattedMessage); + slackClient.slackPost.mockResolvedValue(responseBody); + const mockContent = { + cycle: { + id: mockObjectiveCycleId + }, + objective: mockObjective + }; + + const response = await postClient.PostCompletedObjective(token, channelID, mockContent, mockStatus, mockEmail); + expect(response).toStrictEqual(responseBody); + expect(slackClient.getSlackID).toHaveBeenCalledWith(mockEmail, token); + expect(slackClient.formatSlackMessageForCompleted).toHaveBeenCalledWith(mockObjective, mockStatus, mockSlackID, mockObjectiveCycleId); expect(slackClient.slackPost).toHaveBeenCalledWith(token, channelID, mockFormattedMessage); }); }); diff --git a/test/slack.test.js b/test/slack.test.js index 1d055dd..c4bcbd0 100644 --- a/test/slack.test.js +++ b/test/slack.test.js @@ -55,22 +55,43 @@ describe('Slack Requests', () => { jest.resetAllMocks(); }); describe('Slack formatting', () => { - test('Formats messages correctly *without* description', async () => { - const formattedText = await slackClient.formatSlackMessage(mockObjective, mockStatus, mockSlackID, mockCycleId); + test('Formats messages correctly *without* description for created objectives', async () => { + const formattedText = await slackClient.formatSlackMessageForCreated(mockObjective, mockSlackID, mockCycleId); expect(formattedText.text).toStrictEqual( - `<@${mockSlackID}> has achieved their goal! + `<@${mockSlackID}> has created a new goal! *${mockObjective.title}* ` ); }); - test('Formats messages correctly with description', async () => { + test('Formats messages correctly with description for created objectives', async () => { const description = 'Description of objective'; mockObjective.description = `${description}`; - const formattedText = await slackClient.formatSlackMessage(mockObjective, mockStatus, mockSlackID, mockCycleId); + const formattedText = await slackClient.formatSlackMessageForCreated(mockObjective, mockSlackID, mockCycleId); expect(formattedText.text).toStrictEqual( - `<@${mockSlackID}> has achieved their goal! + `<@${mockSlackID}> has created a new goal! +*${mockObjective.title}* +${description} +` + ); + }); + test('Formats messages correctly *without* description for completed objectives', async () => { + const formattedText = await slackClient.formatSlackMessageForCompleted(mockObjective, mockStatus, mockSlackID, mockCycleId); + expect(formattedText.text).toStrictEqual( + `<@${mockSlackID}> has achieved their goal! +*${mockObjective.title}* +` + ); + }); + + test('Formats messages correctly with description for completed objectives', async () => { + const description = 'Description of objective'; + mockObjective.description = `${description}`; + + const formattedText = await slackClient.formatSlackMessageForCompleted(mockObjective, mockStatus, mockSlackID, mockCycleId); + expect(formattedText.text).toStrictEqual( + `<@${mockSlackID}> has achieved their goal! *${mockObjective.title}* ${description} ` diff --git a/test/small-improvements.test.js b/test/small-improvements.test.js index 32e563b..8bf9070 100644 --- a/test/small-improvements.test.js +++ b/test/small-improvements.test.js @@ -120,7 +120,7 @@ describe('small-improvements', () => { }; }); - const response = await smallImprovementsClient.getEmail(mockSIUID, token); + const response = await smallImprovementsClient.GetEmail(mockSIUID, token); const expectedOptions = { hostname: 'allies.small-improvements.com', @@ -153,7 +153,7 @@ describe('small-improvements', () => { }; }); - const response = await smallImprovementsClient.getObjectives(token); + const response = await smallImprovementsClient.GetObjectives(token); const expectedOptions = { hostname: 'allies.small-improvements.com', @@ -179,7 +179,7 @@ describe('small-improvements', () => { let actualError; try { - await smallImprovementsClient.getObjectives(token); + await smallImprovementsClient.GetObjectives(token); } catch (e) { actualError = e; } @@ -195,7 +195,7 @@ describe('small-improvements', () => { let actualError; try { - await smallImprovementsClient.getEmail(mockEmail, token); + await smallImprovementsClient.GetEmail(mockEmail, token); } catch (e) { actualError = e; } From 7c71988601d6a6ed91d1609febf3a503be017fac Mon Sep 17 00:00:00 2001 From: Reece Appling <19520870+reeceappling@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:42:36 -0400 Subject: [PATCH 2/6] Update index.js postfix on new dynamo entries --- src/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index d96ceac..579d0ca 100644 --- a/src/index.js +++ b/src/index.js @@ -34,7 +34,7 @@ async function main(event, context) { activity.change.newStatus.description, SIEmail ); - await dynamodbClient.insertRecord(activity); + await dynamodbClient.insertRecord(activity, ''); return activity.content.objective; } return undefined; @@ -51,7 +51,7 @@ async function main(event, context) { activity.content, SIEmail ); - await dynamodbClient.insertRecord(activity); + await dynamodbClient.insertRecord(activity, 'CREATED'); return activity.content.objective; } return undefined; From 755434b2bcd44bb5d70291ccb4963cd3795551fe Mon Sep 17 00:00:00 2001 From: Reece Appling Date: Mon, 23 Jun 2025 17:43:57 -0400 Subject: [PATCH 3/6] fix dynamo insertRecord for postfix --- src/dynamodb.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dynamodb.js b/src/dynamodb.js index f5e3573..f800ea1 100644 --- a/src/dynamodb.js +++ b/src/dynamodb.js @@ -21,12 +21,12 @@ async function getRecord(key) { return response.Items; } -function insertRecord(activity) { +function insertRecord(activity, postfix) { const timeToLive = activity.occurredAt / 1000 + sevenDaysInSeconds; const params = { TableName: tableName, Item: { - ID: { S: activity.content.objective.id }, + ID: { S: activity.content.objective.id + postfix }, TIMESTAMP: { N: activity.occurredAt.toString() }, TTL: { N: timeToLive.toString() } } From be97dd5be6cc08ceca5e781eb18a04aa9d495524 Mon Sep 17 00:00:00 2001 From: Reece Appling Date: Mon, 23 Jun 2025 18:03:20 -0400 Subject: [PATCH 4/6] fix tests --- test/dynamodb.test.js | 2 +- test/index.test.js | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/test/dynamodb.test.js b/test/dynamodb.test.js index 739bae8..f1ecf1a 100644 --- a/test/dynamodb.test.js +++ b/test/dynamodb.test.js @@ -90,7 +90,7 @@ describe('dynamodb', () => { }); putItemPromise.mockResolvedValue({}); - const result = await dynamodbClient.insertRecord(activity); + const result = await dynamodbClient.insertRecord(activity, ''); expect(result).toStrictEqual({}); expect(mockPutItem).toBeCalledWith({ diff --git a/test/index.test.js b/test/index.test.js index 7a19b6a..725ae33 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -107,7 +107,9 @@ describe('index', () => { expect(result).toBe('Finished 2 successfully. Failed 0'); expect(dynamodbClient.getRecord).toHaveBeenCalledWith(objectiveId); - expect(dynamodbClient.insertRecord).toHaveBeenCalledWith(activities.items[0].items[0].activities[0]); + expect(dynamodbClient.getRecord).toHaveBeenCalledWith(objectiveId + 'CREATED'); + expect(dynamodbClient.insertRecord).toHaveBeenCalledWith(activities.items[0].items[0].activities[0], ''); + expect(dynamodbClient.insertRecord).toHaveBeenCalledWith(activities.items[0].items[0].activities[1], 'CREATED'); expect(slackClient.PostCompletedObjective).toHaveBeenCalledWith( secrets.SlackToken, slackChannel, @@ -139,10 +141,10 @@ describe('index', () => { expect(result).toBe('Finished 2 successfully. Failed 1'); expect(dynamodbClient.getRecord).toHaveBeenCalledWith(objectiveId); - expect(dynamodbClient.getRecord).toHaveBeenCalledWith(objectiveId); expect(dynamodbClient.getRecord).toHaveBeenCalledWith(secondObjectiveId); - expect(dynamodbClient.insertRecord).not.toHaveBeenCalledWith(activities.items[0].items[0].activities[0]); - expect(dynamodbClient.insertRecord).toHaveBeenCalledWith(activities.items[0].items[0].activities[1]); + expect(dynamodbClient.getRecord).toHaveBeenCalledWith(objectiveId + 'CREATED'); + expect(dynamodbClient.insertRecord).not.toHaveBeenCalledWith(activities.items[0].items[0].activities[0], ''); + expect(dynamodbClient.insertRecord).toHaveBeenCalledWith(activities.items[0].items[0].activities[1], 'CREATED'); expect(slackClient.PostCompletedObjective).toHaveBeenCalledWith( secrets.SlackToken, slackChannel, @@ -150,5 +152,11 @@ describe('index', () => { activities.items[0].items[0].activities[2].change.newStatus.description, mockEmail ); + expect(slackClient.PostCreatedObjective).toHaveBeenCalledWith( + secrets.SlackToken, + slackChannel, + activities.items[0].items[0].activities[1].content, + mockEmail + ); }); }); From 2123475f1dbc9ff22e48d6939f7bc1f791c09051 Mon Sep 17 00:00:00 2001 From: Reece Appling Date: Thu, 30 Oct 2025 16:33:12 -0400 Subject: [PATCH 5/6] fix broken bot, plus some niceties --- .github/workflows/deploy.yml | 2 +- it/integration.test.js | 2 +- src/dynamodb.js | 1 - src/index.js | 6 ++++-- src/slack-service.js | 8 ++++---- src/slack.js | 20 +++++++++++++++----- src/small-improvements.js | 5 ++--- test/index.test.js | 8 ++++---- test/slack.test.js | 36 ++++++++++++++++++------------------ 9 files changed, 49 insertions(+), 39 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7e8c43e..e43fccf 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -69,4 +69,4 @@ jobs: # sam build - run: sam build --use-container # sam deploy - - run: sam deploy --no-confirm-changeset --no-fail-on-empty-changeset --parameter-overrides SlackTokenSecret=${{ secrets.SLACKTOKEN }} SmallImprovementsTokenSecret=${{ secrets.SITOKEN }} SlackChannel=goals ScheduleEnabled=true + - run: sam deploy --no-confirm-changeset --no-fail-on-empty-changeset --parameter-overrides SlackTokenSecret=${{ secrets.SLACKTOKEN }} SmallImprovementsTokenSecret=${{ secrets.SITOKEN }} SlackChannel=CF4U95FN0 ScheduleEnabled=true diff --git a/it/integration.test.js b/it/integration.test.js index 1810626..a6b62e6 100644 --- a/it/integration.test.js +++ b/it/integration.test.js @@ -2,7 +2,7 @@ const index = require('../src/index'); describe('integration', () => { const event = { - time: '2022-06-16T00:00:00Z' + time: '2025-10-30T00:00:00Z' }; test('process event', async () => { diff --git a/src/dynamodb.js b/src/dynamodb.js index f800ea1..84df6d0 100644 --- a/src/dynamodb.js +++ b/src/dynamodb.js @@ -17,7 +17,6 @@ async function getRecord(key) { }; const response = await dbClient.query(params).promise(); - return response.Items; } diff --git a/src/index.js b/src/index.js index 579d0ca..1767752 100644 --- a/src/index.js +++ b/src/index.js @@ -58,11 +58,13 @@ async function main(event, context) { }) ); const allPostResults = completedResults.concat(createdResults); - const successfulPosts = allPostResults.filter(x => x.value); const failedPosts = allPostResults.filter(x => x.status === 'rejected'); + const nonErrorPosts = allPostResults.filter(x => x.status === 'fulfilled'); + const successfulPosts = nonErrorPosts.filter(x => x.value !== undefined); + const skippedPosts = nonErrorPosts.filter(x => x.value === undefined); failedPosts.forEach(fail => console.log(fail.reason)); - const message = `Finished ${successfulPosts.length} successfully. Failed ${failedPosts.length}`; + const message = `Finished ${successfulPosts.length} successfully. Failed ${failedPosts.length}. ${skippedPosts.length} already existed (skipped)`; console.log(message); return message; } diff --git a/src/slack-service.js b/src/slack-service.js index af021c1..00d382b 100644 --- a/src/slack-service.js +++ b/src/slack-service.js @@ -1,15 +1,15 @@ const slackClient = require('./slack'); // Get Slack ID, Format Message, Post -async function PostCompletedObjective(token, channelName, content, newStatus, email) { +async function PostCompletedObjective(token, channelId, content, newStatus, email) { const slackID = await slackClient.getSlackID(email, token); const formattedMessage = await slackClient.formatSlackMessageForCompleted(content.objective, newStatus, slackID, content.cycle.id); - return await slackClient.slackPost(token, channelName, formattedMessage); + return await slackClient.slackPost(token, channelId, formattedMessage); } -async function PostCreatedObjective(token, channelName, content, email) { +async function PostCreatedObjective(token, channelId, content, email) { const slackID = await slackClient.getSlackID(email, token); const formattedMessage = await slackClient.formatSlackMessageForCreated(content.objective, slackID, content.cycle.id); - return await slackClient.slackPost(token, channelName, formattedMessage); + return await slackClient.slackPost(token, channelId, formattedMessage); } exports.PostCompletedObjective = PostCompletedObjective; diff --git a/src/slack.js b/src/slack.js index d860de0..172f395 100644 --- a/src/slack.js +++ b/src/slack.js @@ -4,8 +4,8 @@ const messageVariables = { }; // Post a message to a channel your app is in using ID and message text -async function slackPost(authToken, channelName, formattedMessage) { // postData should be JSON, e.g. { channel:"#channel", text:'message' } - formattedMessage.channel = '' + channelName; +async function slackPost(authToken, channelId, formattedMessage) { // postData should be JSON, e.g. { channel:"#channel", text:'message' } + formattedMessage.channel = '' + channelId; const options = { hostname: 'sourceallies.slack.com', @@ -30,7 +30,12 @@ async function slackPost(authToken, channelName, formattedMessage) { // postData }); res.on('end', () => { const responseBody = Buffer.concat(body).toString(); - resolve(responseBody); + const jsonResponse = JSON.parse(responseBody); + if (jsonResponse.ok === true) { + resolve(responseBody); + } else { + reject(new Error(`Slack post failed with error: ${jsonResponse.error}`)); + } }); }); req.on('error', (e) => { @@ -118,8 +123,13 @@ function getSlackID(email, token) { res.on('data', d => { responsePayload += d; }); - res.on('close', () => { - resolve(JSON.parse(responsePayload).user.id); + res.on('end', () => { + const responseObject = JSON.parse(responsePayload); + if (typeof responseObject.user === 'undefined' || responseObject.user.id === 'undefined') { + reject(new Error(`Unexpected slack ID response: ${responsePayload}`)); + } else { + resolve(responseObject.user.id); + } }); }); req.on('error', err => { diff --git a/src/small-improvements.js b/src/small-improvements.js index 67cb145..4397c1b 100644 --- a/src/small-improvements.js +++ b/src/small-improvements.js @@ -24,7 +24,7 @@ function getObjectives(token) { res.on('data', d => { responsePayload += d; }); - res.on('close', () => { + res.on('end', () => { // TODO: was close resolve(JSON.parse(responsePayload)); }); }); @@ -54,12 +54,11 @@ function getEmail(SIUID, token) { // SIUID (Small Improvements User ID) is in th if (res.statusCode !== 200) { console.log(`status logged ${res.statusCode}`); reject(new Error(`Could not get email: ${res.statusCode}`)); - return; } res.on('data', d => { responsePayload += d; }); - res.on('close', () => { + res.on('end', () => { resolve(JSON.parse(responsePayload).loginname); }); }); diff --git a/test/index.test.js b/test/index.test.js index 725ae33..e6daa1c 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -81,14 +81,14 @@ describe('index', () => { }]; }); - test('should not post previously existing objective', async () => { + test('should not post previously existing objectives', async () => { secretsClient.getSecret.mockResolvedValue(secrets); smallImprovementsClient.GetObjectives.mockResolvedValue(activities); dynamodbClient.getRecord.mockResolvedValue(dynamoRecords); const result = await index.handler(event); - expect(result).toBe('Finished 0 successfully. Failed 0'); + expect(result).toBe('Finished 0 successfully. Failed 0. 2 already existed (skipped)'); expect(dynamodbClient.getRecord).toHaveBeenCalledWith(objectiveId); expect(dynamodbClient.insertRecord).not.toHaveBeenCalled(); }); @@ -105,7 +105,7 @@ describe('index', () => { const result = await index.handler(event); - expect(result).toBe('Finished 2 successfully. Failed 0'); + expect(result).toBe('Finished 2 successfully. Failed 0. 0 already existed (skipped)'); expect(dynamodbClient.getRecord).toHaveBeenCalledWith(objectiveId); expect(dynamodbClient.getRecord).toHaveBeenCalledWith(objectiveId + 'CREATED'); expect(dynamodbClient.insertRecord).toHaveBeenCalledWith(activities.items[0].items[0].activities[0], ''); @@ -139,7 +139,7 @@ describe('index', () => { const result = await index.handler(event); - expect(result).toBe('Finished 2 successfully. Failed 1'); + expect(result).toBe('Finished 2 successfully. Failed 1. 0 already existed (skipped)'); expect(dynamodbClient.getRecord).toHaveBeenCalledWith(objectiveId); expect(dynamodbClient.getRecord).toHaveBeenCalledWith(secondObjectiveId); expect(dynamodbClient.getRecord).toHaveBeenCalledWith(objectiveId + 'CREATED'); diff --git a/test/slack.test.js b/test/slack.test.js index c4bcbd0..1a15509 100644 --- a/test/slack.test.js +++ b/test/slack.test.js @@ -24,24 +24,24 @@ describe('Slack Requests', () => { mockSlackID = 'Reece'; mockCycleId = 'CycleId'; responseBody = `{ - ok: true, - channel: 'C0179PL5K8E', - ts: '1595354927.001300', - message: { - bot_id: 'B017GED1UEN', - type: 'message', - text: 'Hello, World!', - user: 'U0171MZ51E3', - ts: '1595354927.001300', - team: 'T2CA1AURM', - bot_profile: { - id: 'B017GED1UEN', - deleted: false, - name: 'My Test App', - updated: 1595353545, - app_id: 'A017NKGAKHA', - icons: [Object], - team_id: 'T2CA1AURM' + "ok": true, + "channel": "C0179PL5K8E", + "ts": "1595354927.001300", + "message": { + "bot_id": "B017GED1UEN", + "type": "message", + "text": "Hello, World!", + "user": "U0171MZ51E3", + "ts": "1595354927.001300", + "team": "T2CA1AURM", + "bot_profile": { + "id": "B017GED1UEN", + "deleted": false, + "name": "My Test App", + "updated": 1595353545, + "app_id": "A017NKGAKHA", + "icons": [], + "team_id": "T2CA1AURM" } } }`; From fbc20a610106b258848c08d7f2a365a071d1bb3f Mon Sep 17 00:00:00 2001 From: Reece Appling Date: Thu, 30 Oct 2025 16:37:24 -0400 Subject: [PATCH 6/6] cleanup --- src/dynamodb.js | 1 + src/small-improvements.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/dynamodb.js b/src/dynamodb.js index 84df6d0..f800ea1 100644 --- a/src/dynamodb.js +++ b/src/dynamodb.js @@ -17,6 +17,7 @@ async function getRecord(key) { }; const response = await dbClient.query(params).promise(); + return response.Items; } diff --git a/src/small-improvements.js b/src/small-improvements.js index 4397c1b..80be0a3 100644 --- a/src/small-improvements.js +++ b/src/small-improvements.js @@ -24,7 +24,7 @@ function getObjectives(token) { res.on('data', d => { responsePayload += d; }); - res.on('end', () => { // TODO: was close + res.on('end', () => { resolve(JSON.parse(responsePayload)); }); });