From 473b17e95c3c7f8d319bfaaee245922430ee20ca Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Fri, 23 Feb 2024 22:52:38 -0400 Subject: [PATCH 1/4] feat: support hyperlink to workflow action --- src/utils/github-api.ts | 4 +++ src/utils/helpers.ts | 55 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/utils/github-api.ts b/src/utils/github-api.ts index eff854549..f09b92c12 100644 --- a/src/utils/github-api.ts +++ b/src/utils/github-api.ts @@ -194,6 +194,10 @@ export function inferCheckSuiteStatus(title: string): CheckSuiteStatus { if (lowerTitle.includes('succeeded for')) { return 'success'; } + + if (lowerTitle.includes('review to deploy to')) { + return 'waiting'; + } } return null; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 48cd13476..ffcf807a1 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -3,10 +3,12 @@ import { Notification, GraphQLSearch, DiscussionCommentEdge, + SubjectType, } from '../typesGithub'; import { apiRequestAuth } from '../utils/api-requests'; import { openExternalLink } from '../utils/comms'; import { Constants } from './constants'; +import { inferCheckSuiteStatus } from './github-api'; export function getEnterpriseAccountToken( hostname: string, @@ -148,6 +150,53 @@ export const getLatestDiscussionCommentId = ( .reduce((a, b) => (a.node.createdAt > b.node.createdAt ? a : b))?.node .databaseId; +export function getCheckSuiteUrl(notification: Notification) { + let url = `${notification.repository.html_url}/actions`; + let filters = []; + + const regexPattern = + /^(?.*?) workflow run(, Attempt #(?\d+))? (?.*?) for (?.*?) branch$/; + const matches = regexPattern.exec(notification.subject.title); + + if (matches) { + const { groups } = matches; + + if (groups.workflowName) { + filters.push(`workflow:"${groups.workflowName.replaceAll(' ', '+')}"`); + } + + if (groups.workflowStatus) { + const workflowStatus = inferCheckSuiteStatus(notification.subject.title); + + if (workflowStatus) { + filters.push(`is:${workflowStatus}`); + } + } + + if (groups.branchName) { + filters.push(`branch:${groups.branchName}`); + } + } + + if (filters.length > 0) { + url += `?query=${filters.join('+')}`; + } + + return url; +} + +export function getWorkflowRunUrl(notification: Notification) { + const workflowStatus = inferCheckSuiteStatus(notification.subject.title); + + let url = `${notification.repository.html_url}/actions`; + + if (workflowStatus) { + url += `?query=is:${workflowStatus}`; + } + + return url; +} + export async function generateGitHubWebUrl( notification: Notification, accounts: AuthState, @@ -164,12 +213,18 @@ export async function generateGitHubWebUrl( } else { // Perform any specific notification type handling (only required for a few special notification scenarios) switch (notification.subject.type) { + case 'CheckSuite': + url = getCheckSuiteUrl(notification); + break; case 'Discussion': url = await getDiscussionUrl(notification, accounts.token); break; case 'RepositoryInvitation': url = `${notification.repository.html_url}/invitations`; break; + case 'WorkflowRun' as SubjectType: //TODO - remove this cast + url = getWorkflowRunUrl(notification); + break; default: url = notification.repository.html_url; break; From 1c06b121f838abcc87ddf34fa95e7e0a3ee1dfe4 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sat, 24 Feb 2024 07:46:48 -0400 Subject: [PATCH 2/4] feat: support hyperlink to workflow action --- src/utils/helpers.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index ffcf807a1..94004ffba 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -3,7 +3,6 @@ import { Notification, GraphQLSearch, DiscussionCommentEdge, - SubjectType, } from '../typesGithub'; import { apiRequestAuth } from '../utils/api-requests'; import { openExternalLink } from '../utils/comms'; @@ -222,7 +221,7 @@ export async function generateGitHubWebUrl( case 'RepositoryInvitation': url = `${notification.repository.html_url}/invitations`; break; - case 'WorkflowRun' as SubjectType: //TODO - remove this cast + case 'WorkflowRun': url = getWorkflowRunUrl(notification); break; default: From 6d9e4184555f16c5de243410c6ea1ebf478073b4 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sat, 24 Feb 2024 07:56:18 -0400 Subject: [PATCH 3/4] feat: support hyperlink to workflow action --- src/utils/helpers.test.ts | 110 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src/utils/helpers.test.ts b/src/utils/helpers.test.ts index 8cef1c3cc..b46ac3e01 100644 --- a/src/utils/helpers.test.ts +++ b/src/utils/helpers.test.ts @@ -230,6 +230,94 @@ describe('utils/helpers.ts', () => { expect(result).toBe(`${mockedHtmlUrl}?${mockedNotificationReferrer}`); }); + it('Check Suite: successful workflow', async () => { + const subject = { + title: 'Demo workflow run succeeded for main branch', + url: null, + latest_comment_url: null, + type: 'CheckSuite' as SubjectType, + }; + + const result = await generateGitHubWebUrl( + { + ...mockedSingleNotification, + subject: subject, + }, + mockAccounts, + ); + + expect(apiRequestAuthMock).toHaveBeenCalledTimes(0); + expect(result).toBe( + `https://github.com/manosim/notifications-test/actions?query=workflow%3A%22Demo%22+is%3Asuccess+branch%3Amain&${mockedNotificationReferrer}`, + ); + }); + + it('Check Suite: failed workflow', async () => { + const subject = { + title: 'Demo workflow run failed for main branch', + url: null, + latest_comment_url: null, + type: 'CheckSuite' as SubjectType, + }; + + const result = await generateGitHubWebUrl( + { + ...mockedSingleNotification, + subject: subject, + }, + mockAccounts, + ); + + expect(apiRequestAuthMock).toHaveBeenCalledTimes(0); + expect(result).toBe( + `https://github.com/manosim/notifications-test/actions?query=workflow%3A%22Demo%22+is%3Afailure+branch%3Amain&${mockedNotificationReferrer}`, + ); + }); + + it('Check Suite: failed workflow multiple attempts', async () => { + const subject = { + title: 'Demo workflow run, Attempt #3 failed for main branch', + url: null, + latest_comment_url: null, + type: 'CheckSuite' as SubjectType, + }; + + const result = await generateGitHubWebUrl( + { + ...mockedSingleNotification, + subject: subject, + }, + mockAccounts, + ); + + expect(apiRequestAuthMock).toHaveBeenCalledTimes(0); + expect(result).toBe( + `https://github.com/manosim/notifications-test/actions?query=workflow%3A%22Demo%22+is%3Afailure+branch%3Amain&${mockedNotificationReferrer}`, + ); + }); + + it('Check Suite: skipped workflow', async () => { + const subject = { + title: 'Demo workflow run skipped for main branch', + url: null, + latest_comment_url: null, + type: 'CheckSuite' as SubjectType, + }; + + const result = await generateGitHubWebUrl( + { + ...mockedSingleNotification, + subject: subject, + }, + mockAccounts, + ); + + expect(apiRequestAuthMock).toHaveBeenCalledTimes(0); + expect(result).toBe( + `https://github.com/manosim/notifications-test/actions?query=workflow%3A%22Demo%22+is%3Askipped+branch%3Amain&${mockedNotificationReferrer}`, + ); + }); + it('Discussions: when no subject urls and no discussions found via query, default to linking to repository discussions', async () => { const subject = { title: 'generate github web url unit tests', @@ -310,6 +398,28 @@ describe('utils/helpers.ts', () => { ); }); + it('Workflow Run: approval requested', async () => { + const subject = { + title: 'some-user requested your review to deploy to an environment', + url: null, + latest_comment_url: null, + type: 'WorkflowRun' as SubjectType, + }; + + const result = await generateGitHubWebUrl( + { + ...mockedSingleNotification, + subject: subject, + }, + mockAccounts, + ); + + expect(apiRequestAuthMock).toHaveBeenCalledTimes(0); + expect(result).toBe( + `https://github.com/manosim/notifications-test/actions?query=is%3Awaiting&${mockedNotificationReferrer}`, + ); + }); + it('defaults to repository url', async () => { const subject = { title: 'generate github web url unit tests', From ee7d984ee93d7fd52e552ab49af8f921e675a8cf Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Sun, 25 Feb 2024 16:56:36 -0500 Subject: [PATCH 4/4] refactor: use regex conditions for more precise matching and feature extraction --- src/types.ts | 16 ++++- src/utils/github-api.test.ts | 16 ++--- src/utils/github-api.ts | 41 ++----------- src/utils/helpers.test.ts | 89 +++++++++++++++++++++++++++ src/utils/helpers.ts | 113 ++++++++++++++++++++++++++++------- 5 files changed, 208 insertions(+), 67 deletions(-) diff --git a/src/types.ts b/src/types.ts index dc28c33a3..b311b8f5d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { Notification, User } from './typesGithub'; +import { CheckSuiteStatus, Notification, User } from './typesGithub'; export interface AuthState { token?: string; @@ -56,3 +56,17 @@ export interface AuthTokenResponse { hostname: string; token: string; } + +export interface CheckSuiteParts { + workflowName: string; + attemptNumber?: number; + statusDisplayName: string; + statusCode: CheckSuiteStatus | null; + branchName: string; +} + +export interface WorkflowRunParts { + user: string; + statusCode: CheckSuiteStatus | null; + statusDisplayName: string; +} diff --git a/src/utils/github-api.test.ts b/src/utils/github-api.test.ts index 019f3c4e2..28284ac0a 100644 --- a/src/utils/github-api.test.ts +++ b/src/utils/github-api.test.ts @@ -36,7 +36,7 @@ describe('getNotificationTypeIcon', () => { getNotificationTypeIcon( createSubjectMock({ type: 'CheckSuite', - title: 'Workflow cancelled for main branch', + title: 'Demonstration workflow run cancelled for main branch', }), ).displayName, ).toBe('StopIcon'); @@ -44,7 +44,7 @@ describe('getNotificationTypeIcon', () => { getNotificationTypeIcon( createSubjectMock({ type: 'CheckSuite', - title: 'Workflow failed for main branch', + title: 'Demonstration workflow run failed for main branch', }), ).displayName, ).toBe('XIcon'); @@ -52,7 +52,7 @@ describe('getNotificationTypeIcon', () => { getNotificationTypeIcon( createSubjectMock({ type: 'CheckSuite', - title: 'Workflow skipped for main branch', + title: 'Demonstration workflow run skipped for main branch', }), ).displayName, ).toBe('SkipIcon'); @@ -60,7 +60,7 @@ describe('getNotificationTypeIcon', () => { getNotificationTypeIcon( createSubjectMock({ type: 'CheckSuite', - title: 'Workflow succeeded for main branch', + title: 'Demonstration workflow run succeeded for main branch', }), ).displayName, ).toBe('CheckIcon'); @@ -172,7 +172,7 @@ describe('getNotificationTypeIconColor', () => { getNotificationTypeIconColor( createSubjectMock({ type: 'CheckSuite', - title: 'Workflow cancelled for main branch', + title: 'Demonstration workflow run cancelled for main branch', }), ), ).toMatchSnapshot(); @@ -180,7 +180,7 @@ describe('getNotificationTypeIconColor', () => { getNotificationTypeIconColor( createSubjectMock({ type: 'CheckSuite', - title: 'Workflow failed for main branch', + title: 'Demonstration workflow run failed for main branch', }), ), ).toMatchSnapshot(); @@ -189,7 +189,7 @@ describe('getNotificationTypeIconColor', () => { getNotificationTypeIconColor( createSubjectMock({ type: 'CheckSuite', - title: 'Workflow skipped for main branch', + title: 'Demonstration workflow run skipped for main branch', }), ), ).toMatchSnapshot(); @@ -197,7 +197,7 @@ describe('getNotificationTypeIconColor', () => { getNotificationTypeIconColor( createSubjectMock({ type: 'CheckSuite', - title: 'Workflow succeeded for main branch', + title: 'Demonstration workflow run succeeded for main branch', }), ), ).toMatchSnapshot(); diff --git a/src/utils/github-api.ts b/src/utils/github-api.ts index f945a5ddb..ac22687cd 100644 --- a/src/utils/github-api.ts +++ b/src/utils/github-api.ts @@ -21,7 +21,8 @@ import { TagIcon, XIcon, } from '@primer/octicons-react'; -import { CheckSuiteStatus, Reason, Subject } from '../typesGithub'; +import { Reason, Subject } from '../typesGithub'; +import { parseCheckSuiteTitle } from './helpers'; // prettier-ignore const DESCRIPTIONS = { @@ -89,9 +90,8 @@ export function getNotificationTypeIcon( ): React.FC { switch (subject.type) { case 'CheckSuite': - const checkSuiteState = inferCheckSuiteStatus(subject.title); - - switch (checkSuiteState) { + const checkSuiteParts = parseCheckSuiteTitle(subject.title); + switch (checkSuiteParts?.statusCode) { case 'cancelled': return StopIcon; case 'failure': @@ -145,9 +145,8 @@ export function getNotificationTypeIcon( export function getNotificationTypeIconColor(subject: Subject): string { if (subject.type === 'CheckSuite') { - const checkSuiteState = inferCheckSuiteStatus(subject.title); - - switch (checkSuiteState) { + const checkSuiteParts = parseCheckSuiteTitle(subject.title); + switch (checkSuiteParts?.statusCode) { case 'cancelled': return 'text-gray-500'; case 'failure': @@ -180,31 +179,3 @@ export function getNotificationTypeIconColor(subject: Subject): string { return 'text-gray-300'; } } - -export function inferCheckSuiteStatus(title: string): CheckSuiteStatus { - if (title) { - const lowerTitle = title.toLowerCase(); - - if (lowerTitle.includes('cancelled for')) { - return 'cancelled'; - } - - if (lowerTitle.includes('failed for')) { - return 'failure'; - } - - if (lowerTitle.includes('skipped for')) { - return 'skipped'; - } - - if (lowerTitle.includes('succeeded for')) { - return 'success'; - } - - if (lowerTitle.includes('review to deploy to')) { - return 'waiting'; - } - } - - return null; -} diff --git a/src/utils/helpers.test.ts b/src/utils/helpers.test.ts index b46ac3e01..bd921d67e 100644 --- a/src/utils/helpers.test.ts +++ b/src/utils/helpers.test.ts @@ -318,6 +318,50 @@ describe('utils/helpers.ts', () => { ); }); + it('Check Suite: unhandled workflow scenario', async () => { + const subject = { + title: 'unhandled workflow scenario', + url: null, + latest_comment_url: null, + type: 'CheckSuite' as SubjectType, + }; + + const result = await generateGitHubWebUrl( + { + ...mockedSingleNotification, + subject: subject, + }, + mockAccounts, + ); + + expect(apiRequestAuthMock).toHaveBeenCalledTimes(0); + expect(result).toBe( + `https://github.com/manosim/notifications-test/actions?${mockedNotificationReferrer}`, + ); + }); + + it('Check Suite: unhandled status scenario', async () => { + const subject = { + title: 'Demo workflow run unhandled-status for main branch', + url: null, + latest_comment_url: null, + type: 'CheckSuite' as SubjectType, + }; + + const result = await generateGitHubWebUrl( + { + ...mockedSingleNotification, + subject: subject, + }, + mockAccounts, + ); + + expect(apiRequestAuthMock).toHaveBeenCalledTimes(0); + expect(result).toBe( + `https://github.com/manosim/notifications-test/actions?query=workflow%3A%22Demo%22+branch%3Amain&${mockedNotificationReferrer}`, + ); + }); + it('Discussions: when no subject urls and no discussions found via query, default to linking to repository discussions', async () => { const subject = { title: 'generate github web url unit tests', @@ -420,6 +464,51 @@ describe('utils/helpers.ts', () => { ); }); + it('Workflow Run: unhandled status/action scenario', async () => { + const subject = { + title: + 'some-user requested your unhandled-action to deploy to an environment', + url: null, + latest_comment_url: null, + type: 'WorkflowRun' as SubjectType, + }; + + const result = await generateGitHubWebUrl( + { + ...mockedSingleNotification, + subject: subject, + }, + mockAccounts, + ); + + expect(apiRequestAuthMock).toHaveBeenCalledTimes(0); + expect(result).toBe( + `https://github.com/manosim/notifications-test/actions?${mockedNotificationReferrer}`, + ); + }); + + it('Workflow Run: unhandled workflow scenario', async () => { + const subject = { + title: 'some unhandled scenario', + url: null, + latest_comment_url: null, + type: 'WorkflowRun' as SubjectType, + }; + + const result = await generateGitHubWebUrl( + { + ...mockedSingleNotification, + subject: subject, + }, + mockAccounts, + ); + + expect(apiRequestAuthMock).toHaveBeenCalledTimes(0); + expect(result).toBe( + `https://github.com/manosim/notifications-test/actions?${mockedNotificationReferrer}`, + ); + }); + it('defaults to repository url', async () => { const subject = { title: 'generate github web url unit tests', diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 94004ffba..11629f324 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,13 +1,18 @@ -import { EnterpriseAccount, AuthState } from '../types'; +import { + EnterpriseAccount, + AuthState, + CheckSuiteParts, + WorkflowRunParts, +} from '../types'; import { Notification, GraphQLSearch, DiscussionCommentEdge, + CheckSuiteStatus, } from '../typesGithub'; import { apiRequestAuth } from '../utils/api-requests'; import { openExternalLink } from '../utils/comms'; import { Constants } from './constants'; -import { inferCheckSuiteStatus } from './github-api'; export function getEnterpriseAccountToken( hostname: string, @@ -149,32 +154,94 @@ export const getLatestDiscussionCommentId = ( .reduce((a, b) => (a.node.createdAt > b.node.createdAt ? a : b))?.node .databaseId; -export function getCheckSuiteUrl(notification: Notification) { - let url = `${notification.repository.html_url}/actions`; - let filters = []; +export function parseCheckSuiteTitle(title: string): CheckSuiteParts | null { + const regexPattern = + /^(?.*?) workflow run(, Attempt #(?\d+))? (?.*?) for (?.*?) branch$/; + const matches = regexPattern.exec(title); + + if (matches) { + const { groups } = matches; + + return { + workflowName: groups.workflowName, + attemptNumber: groups.attemptNumber + ? parseInt(groups.attemptNumber) + : undefined, + statusCode: getCheckSuiteStatus(groups.statusDisplayName), + statusDisplayName: groups.statusDisplayName, + branchName: groups.branchName, + }; + } + + return null; +} + +export function parseWorkflowRunTitle(title: string): WorkflowRunParts | null { const regexPattern = - /^(?.*?) workflow run(, Attempt #(?\d+))? (?.*?) for (?.*?) branch$/; - const matches = regexPattern.exec(notification.subject.title); + /^(?.*?) requested your (?.*?) to deploy to an environment$/; + + const matches = regexPattern.exec(title); if (matches) { const { groups } = matches; - if (groups.workflowName) { - filters.push(`workflow:"${groups.workflowName.replaceAll(' ', '+')}"`); - } + return { + user: groups.user, + statusCode: getWorkflowRunStatus(groups.statusDisplayName), + statusDisplayName: groups.statusDisplayName, + }; + } - if (groups.workflowStatus) { - const workflowStatus = inferCheckSuiteStatus(notification.subject.title); + return null; +} - if (workflowStatus) { - filters.push(`is:${workflowStatus}`); - } - } +export function getCheckSuiteStatus( + statusDisplayName: string, +): CheckSuiteStatus { + switch (statusDisplayName) { + case 'cancelled': + return 'cancelled'; + case 'failed': + return 'failure'; + case 'skipped': + return 'skipped'; + case 'succeeded': + return 'success'; + default: + return null; + } +} - if (groups.branchName) { - filters.push(`branch:${groups.branchName}`); - } +export function getWorkflowRunStatus( + statusDisplayName: string, +): CheckSuiteStatus { + switch (statusDisplayName) { + case 'review': + return 'waiting'; + default: + return null; + } +} + +export function getCheckSuiteUrl(notification: Notification) { + let url = `${notification.repository.html_url}/actions`; + let filters = []; + + const checkSuiteParts = parseCheckSuiteTitle(notification.subject.title); + + if (checkSuiteParts?.workflowName) { + filters.push( + `workflow:"${checkSuiteParts.workflowName.replaceAll(' ', '+')}"`, + ); + } + + if (checkSuiteParts?.statusCode) { + filters.push(`is:${checkSuiteParts.statusCode}`); + } + + if (checkSuiteParts?.branchName) { + filters.push(`branch:${checkSuiteParts.branchName}`); } if (filters.length > 0) { @@ -185,12 +252,12 @@ export function getCheckSuiteUrl(notification: Notification) { } export function getWorkflowRunUrl(notification: Notification) { - const workflowStatus = inferCheckSuiteStatus(notification.subject.title); - let url = `${notification.repository.html_url}/actions`; - if (workflowStatus) { - url += `?query=is:${workflowStatus}`; + const workflowRunParts = parseWorkflowRunTitle(notification.subject.title); + + if (workflowRunParts?.statusCode) { + url += `?query=is:${workflowRunParts.statusCode}`; } return url;