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 2fa8d09f9..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,27 +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'; - } - } - - return null; -} diff --git a/src/utils/helpers.test.ts b/src/utils/helpers.test.ts index 8cef1c3cc..bd921d67e 100644 --- a/src/utils/helpers.test.ts +++ b/src/utils/helpers.test.ts @@ -230,6 +230,138 @@ 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('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', @@ -310,6 +442,73 @@ 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('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 48cd13476..11629f324 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,8 +1,14 @@ -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'; @@ -148,6 +154,115 @@ export const getLatestDiscussionCommentId = ( .reduce((a, b) => (a.node.createdAt > b.node.createdAt ? a : b))?.node .databaseId; +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 = + /^(?.*?) requested your (?.*?) to deploy to an environment$/; + + const matches = regexPattern.exec(title); + + if (matches) { + const { groups } = matches; + + return { + user: groups.user, + statusCode: getWorkflowRunStatus(groups.statusDisplayName), + statusDisplayName: groups.statusDisplayName, + }; + } + + return null; +} + +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; + } +} + +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) { + url += `?query=${filters.join('+')}`; + } + + return url; +} + +export function getWorkflowRunUrl(notification: Notification) { + let url = `${notification.repository.html_url}/actions`; + + const workflowRunParts = parseWorkflowRunTitle(notification.subject.title); + + if (workflowRunParts?.statusCode) { + url += `?query=is:${workflowRunParts.statusCode}`; + } + + return url; +} + export async function generateGitHubWebUrl( notification: Notification, accounts: AuthState, @@ -164,12 +279,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': + url = getWorkflowRunUrl(notification); + break; default: url = notification.repository.html_url; break;