diff --git a/src/typesGithub.ts b/src/typesGithub.ts index 3f4ac637f..84604a3a1 100644 --- a/src/typesGithub.ts +++ b/src/typesGithub.ts @@ -1,6 +1,8 @@ export type Reason = + | 'approval_requested' | 'assign' | 'author' + | 'ci_activity' | 'comment' | 'invitation' | 'manual' @@ -9,8 +11,7 @@ export type Reason = | 'security_alert' | 'state_change' | 'subscribed' - | 'team_mention' - | 'ci_activity'; + | 'team_mention'; export type SubjectType = | 'CheckSuite' @@ -20,7 +21,8 @@ export type SubjectType = | 'PullRequest' | 'Release' | 'RepositoryInvitation' - | 'RepositoryVulnerabilityAlert'; + | 'RepositoryVulnerabilityAlert' + | 'WorkflowRun'; export type IssueStateType = | 'closed' @@ -28,10 +30,17 @@ export type IssueStateType = | 'completed' | 'reopened' | 'not_planned'; + export type PullRequestStateType = 'closed' | 'open' | 'merged' | 'draft'; + export type StateType = IssueStateType | PullRequestStateType; + export type ViewerSubscription = 'IGNORED' | 'SUBSCRIBED' | 'UNSUBSCRIBED'; +// TODO - the following additional statuses are to be implemented #767 +// queued, in progress, neutral, action required, timed out, skipped, stale +export type WorkflowType = 'success' | 'failure' | 'cancelled' | 'waiting'; + export interface Notification { id: string; unread: boolean; diff --git a/src/utils/__snapshots__/github-api.test.ts.snap b/src/utils/__snapshots__/github-api.test.ts.snap index 8b1b1033a..a7a563406 100644 --- a/src/utils/__snapshots__/github-api.test.ts.snap +++ b/src/utils/__snapshots__/github-api.test.ts.snap @@ -1,90 +1,97 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`./utils/github-api.ts should format the notification reason 1`] = ` +{ + "description": "You were requested to review and approve a deployment.", + "type": "Deployment Review", +} +`; + +exports[`./utils/github-api.ts should format the notification reason 2`] = ` { "description": "You were assigned to the issue.", "type": "Assign", } `; -exports[`./utils/github-api.ts should format the notification reason 2`] = ` +exports[`./utils/github-api.ts should format the notification reason 3`] = ` { "description": "You created the thread.", "type": "Author", } `; -exports[`./utils/github-api.ts should format the notification reason 3`] = ` +exports[`./utils/github-api.ts should format the notification reason 4`] = ` +{ + "description": "A GitHub workflow run that you triggered was completed.", + "type": "Workflow Run", +} +`; + +exports[`./utils/github-api.ts should format the notification reason 5`] = ` { "description": "You commented on the thread.", "type": "Comment", } `; -exports[`./utils/github-api.ts should format the notification reason 4`] = ` +exports[`./utils/github-api.ts should format the notification reason 6`] = ` { "description": "You accepted an invitation to contribute to the repository.", "type": "Invitation", } `; -exports[`./utils/github-api.ts should format the notification reason 5`] = ` +exports[`./utils/github-api.ts should format the notification reason 7`] = ` { "description": "You subscribed to the thread (via an issue or pull request).", "type": "Manual", } `; -exports[`./utils/github-api.ts should format the notification reason 6`] = ` +exports[`./utils/github-api.ts should format the notification reason 8`] = ` { "description": "You were specifically @mentioned in the content.", "type": "Mention", } `; -exports[`./utils/github-api.ts should format the notification reason 7`] = ` +exports[`./utils/github-api.ts should format the notification reason 9`] = ` { "description": "You, or a team you're a member of, were requested to review a pull request.", "type": "Review Requested", } `; -exports[`./utils/github-api.ts should format the notification reason 8`] = ` +exports[`./utils/github-api.ts should format the notification reason 10`] = ` { "description": "GitHub discovered a security vulnerability in your repository.", "type": "Security Alert", } `; -exports[`./utils/github-api.ts should format the notification reason 9`] = ` +exports[`./utils/github-api.ts should format the notification reason 11`] = ` { "description": "You changed the thread state (for example, closing an issue or merging a pull request).", "type": "State Change", } `; -exports[`./utils/github-api.ts should format the notification reason 10`] = ` +exports[`./utils/github-api.ts should format the notification reason 12`] = ` { "description": "You're watching the repository.", "type": "Subscribed", } `; -exports[`./utils/github-api.ts should format the notification reason 11`] = ` +exports[`./utils/github-api.ts should format the notification reason 13`] = ` { "description": "You were on a team that was mentioned.", "type": "Team Mention", } `; -exports[`./utils/github-api.ts should format the notification reason 12`] = ` -{ - "description": "A GitHub Actions workflow run was triggered for your repository", - "type": "Workflow Run", -} -`; - -exports[`./utils/github-api.ts should format the notification reason 13`] = ` +exports[`./utils/github-api.ts should format the notification reason 14`] = ` { "description": "The reason for this notification is not supported by the app.", "type": "Unknown", diff --git a/src/utils/github-api.test.ts b/src/utils/github-api.test.ts index fec6cca3b..0579ef089 100644 --- a/src/utils/github-api.test.ts +++ b/src/utils/github-api.test.ts @@ -1,10 +1,16 @@ -import { formatReason, getNotificationTypeIcon } from './github-api'; +import { + formatReason, + getNotificationTypeIcon, + getWorkflowTypeFromTitle, +} from './github-api'; import { Reason, SubjectType } from '../typesGithub'; describe('./utils/github-api.ts', () => { it('should format the notification reason', () => { + expect(formatReason('approval_requested')).toMatchSnapshot(); expect(formatReason('assign')).toMatchSnapshot(); expect(formatReason('author')).toMatchSnapshot(); + expect(formatReason('ci_activity')).toMatchSnapshot(); expect(formatReason('comment')).toMatchSnapshot(); expect(formatReason('invitation')).toMatchSnapshot(); expect(formatReason('manual')).toMatchSnapshot(); @@ -14,7 +20,6 @@ describe('./utils/github-api.ts', () => { expect(formatReason('state_change')).toMatchSnapshot(); expect(formatReason('subscribed')).toMatchSnapshot(); expect(formatReason('team_mention')).toMatchSnapshot(); - expect(formatReason('ci_activity')).toMatchSnapshot(); expect(formatReason('something_else_unknown' as Reason)).toMatchSnapshot(); }); @@ -58,8 +63,47 @@ describe('./utils/github-api.ts', () => { expect( getNotificationTypeIcon('RepositoryVulnerabilityAlert').displayName, ).toBe('AlertIcon'); + expect(getNotificationTypeIcon('WorkflowRun').displayName).toBe( + 'RocketIcon', + ); expect(getNotificationTypeIcon('Unknown' as SubjectType).displayName).toBe( 'QuestionIcon', ); }); + + describe('should get the workflow status type from title', () => { + it('should infer failed workflow status from the title', () => { + expect( + getWorkflowTypeFromTitle('Demo workflow run failed for main branch'), + ).toBe('failure'); + }); + + it('should infer success workflow status from the title', () => { + expect( + getWorkflowTypeFromTitle('Demo workflow run succeeded for main branch'), + ).toBe('success'); + }); + + it('should infer cancelled workflow status from the title', () => { + expect( + getWorkflowTypeFromTitle('Demo workflow run cancelled for main branch'), + ).toBe('cancelled'); + }); + + it('should infer approval waiting status from the title', () => { + expect( + getWorkflowTypeFromTitle( + 'user requested your review to deploy to an environment', + ), + ).toBe('waiting'); + }); + + it('should return null for known workflow status', () => { + expect( + getWorkflowTypeFromTitle( + 'Demo workflow run has not status for main branch', + ), + ).toBeNull(); + }); + }); }); diff --git a/src/utils/github-api.ts b/src/utils/github-api.ts index 462f39e78..c5fbfed61 100644 --- a/src/utils/github-api.ts +++ b/src/utils/github-api.ts @@ -13,26 +13,28 @@ import { MailIcon, OcticonProps, QuestionIcon, + RocketIcon, SyncIcon, TagIcon, } from '@primer/octicons-react'; -import { Reason, StateType, SubjectType } from '../typesGithub'; +import { Reason, StateType, SubjectType, WorkflowType } from '../typesGithub'; // prettier-ignore const DESCRIPTIONS = { - ASSIGN: 'You were assigned to the issue.', - AUTHOR: 'You created the thread.', - COMMENT: 'You commented on the thread.', - INVITATION: 'You accepted an invitation to contribute to the repository.', - MANUAL: 'You subscribed to the thread (via an issue or pull request).', - MENTION: 'You were specifically @mentioned in the content.', - REVIEW_REQUESTED: "You, or a team you're a member of, were requested to review a pull request.", - SECURITY_ALERT: 'GitHub discovered a security vulnerability in your repository.', - STATE_CHANGE: 'You changed the thread state (for example, closing an issue or merging a pull request).', - SUBSCRIBED: "You're watching the repository.", - TEAM_MENTION: 'You were on a team that was mentioned.', - CI_ACTIVITY: 'A GitHub Actions workflow run was triggered for your repository', - UNKNOWN: 'The reason for this notification is not supported by the app.', + APPROVAL_REQUESTED: 'You were requested to review and approve a deployment.', + ASSIGN: 'You were assigned to the issue.', + AUTHOR: 'You created the thread.', + CI_ACTIVITY: 'A GitHub workflow run that you triggered was completed.', + COMMENT: 'You commented on the thread.', + INVITATION: 'You accepted an invitation to contribute to the repository.', + MANUAL: 'You subscribed to the thread (via an issue or pull request).', + MENTION: 'You were specifically @mentioned in the content.', + REVIEW_REQUESTED: "You, or a team you're a member of, were requested to review a pull request.", + SECURITY_ALERT: 'GitHub discovered a security vulnerability in your repository.', + STATE_CHANGE: 'You changed the thread state (for example, closing an issue or merging a pull request).', + SUBSCRIBED: "You're watching the repository.", + TEAM_MENTION: 'You were on a team that was mentioned.', + UNKNOWN: 'The reason for this notification is not supported by the app.', }; export function formatReason(reason: Reason): { @@ -41,10 +43,14 @@ export function formatReason(reason: Reason): { } { // prettier-ignore switch (reason) { + case 'approval_requested': + return { type: 'Deployment Review', description: DESCRIPTIONS['APPROVAL_REQUESTED'] }; case 'assign': return { type: 'Assign', description: DESCRIPTIONS['ASSIGN'] }; case 'author': return { type: 'Author', description: DESCRIPTIONS['AUTHOR'] }; + case 'ci_activity': + return { type: 'Workflow Run', description: DESCRIPTIONS['CI_ACTIVITY'] }; case 'comment': return { type: 'Comment', description: DESCRIPTIONS['COMMENT'] }; case 'invitation': @@ -63,8 +69,6 @@ export function formatReason(reason: Reason): { return { type: 'Subscribed', description: DESCRIPTIONS['SUBSCRIBED'] }; case 'team_mention': return { type: 'Team Mention', description: DESCRIPTIONS['TEAM_MENTION'] }; - case 'ci_activity': - return { type: 'Workflow Run', description: DESCRIPTIONS['CI_ACTIVITY'] }; default: return { type: 'Unknown', description: DESCRIPTIONS['UNKNOWN'] }; } @@ -110,27 +114,43 @@ export function getNotificationTypeIcon( return MailIcon; case 'RepositoryVulnerabilityAlert': return AlertIcon; + case 'WorkflowRun': + return RocketIcon; default: return QuestionIcon; } } +export function getWorkflowTypeFromTitle(title: string): WorkflowType | null { + if (title.includes('succeeded')) { + return 'success'; + } else if (title.includes('failed')) { + return 'failure'; + } else if (title.includes('cancelled')) { + return 'cancelled'; + } else if (title.includes('requested your review')) { + return 'waiting'; + } + + return null; +} + export function getNotificationTypeIconColor(state: StateType): string { switch (state) { case 'closed': return 'text-red-500'; - case 'open': - return 'text-green-500'; - case 'merged': - return 'text-purple-500'; - case 'reopened': - return 'text-green-500'; - case 'not_planned': - return 'text-gray-300'; case 'completed': return 'text-purple-500'; case 'draft': return 'text-gray-600'; + case 'merged': + return 'text-purple-500'; + case 'not_planned': + return 'text-gray-300'; + case 'open': + return 'text-green-500'; + case 'reopened': + return 'text-green-500'; default: return 'text-gray-300'; } diff --git a/src/utils/helpers.test.ts b/src/utils/helpers.test.ts index f9a60b455..526f76a1a 100644 --- a/src/utils/helpers.test.ts +++ b/src/utils/helpers.test.ts @@ -4,6 +4,9 @@ import { generateNotificationReferrerId, getCommentId, getLatestDiscussionCommentId, + inferWorkflowRunBranchFromTitle, + getWorkflowRunsUrl, + getDeploymentReviewUrl, } from './helpers'; import { mockedSingleNotification, @@ -30,19 +33,14 @@ describe('utils/helpers.ts', () => { mockedUser.id, ); expect(referrerId).toBe( - 'notification_referrer_id=MDE4Ok5vdGlmaWNhdGlvblRocmVhZDEzODY2MTA5NjoxMjM0NTY3ODk=', + 'MDE4Ok5vdGlmaWNhdGlvblRocmVhZDEzODY2MTA5NjoxMjM0NTY3ODk=', ); }); }); describe('generateGitHubWebUrl', () => { - let notificationReferrerId; - beforeAll(() => { - notificationReferrerId = generateNotificationReferrerId( - mockedSingleNotification.id, - mockedUser.id, - ); - }); + let notificationReferrerId = + 'notification_referrer_id=MDE4Ok5vdGlmaWNhdGlvblRocmVhZDEzODY2MTA5NjoxMjM0NTY3ODk%3D'; it('should generate the GitHub url - non enterprise - (issue)', () => testGenerateUrl( @@ -110,6 +108,12 @@ describe('utils/helpers.ts', () => { ), )); + it('should generate the GitHub workflow url with encoded workflow name', () => + testGenerateUrl( + `${URL.normal.api}/actions?workflow=Some Workflow`, + `${URL.normal.default}/actions?workflow=Some+Workflow&${notificationReferrerId}`, + )); + function testGenerateUrl(apiUrl, ExpectedResult, comment?) { const notif = { ...mockedSingleNotification, subject: { url: apiUrl } }; expect( @@ -134,4 +138,88 @@ describe('utils/helpers.ts', () => { expect(result).toBe('https://github.manos.im/api/v3/'); }); }); + + describe('inferWorkflowBranchFromTitle', () => { + let notification; + + beforeEach(() => { + notification = mockedSingleNotification; + }); + + it('should infer branch name from the title', () => { + notification.subject.title = + 'Demo workflow run succeeded for main branch'; + expect(inferWorkflowRunBranchFromTitle(notification)).toBe('main'); + }); + + it('should return null for known branch name', () => { + notification.subject.title = + 'Demo workflow run does not have branch name'; + expect(inferWorkflowRunBranchFromTitle(notification)).toBeNull(); + }); + }); + + describe('getCheckSuiteUrl', () => { + let notification; + + beforeEach(() => { + notification = mockedSingleNotification; + }); + + it('should generate a Github Action (CheckSuite) url', () => { + notification.subject.title = 'Demo workflow run'; + + const result = getWorkflowRunsUrl(notification); + expect(result).toBe( + 'https://github.com/manosim/notifications-test/actions', + ); + }); + + it('should generate a Github Action (CheckSuite) url with workflow and branch filters', () => { + notification.subject.title = + 'Demo workflow run unhandled for main branch'; + + const result = getWorkflowRunsUrl(notification); + expect(result).toBe( + 'https://github.com/manosim/notifications-test/actions?query=workflow:"Demo"+branch:main', + ); + }); + + it('should generate a Github Action (CheckSuite) url with all filters', () => { + notification.subject.title = + 'Demo workflow run succeeded for main branch'; + + const result = getWorkflowRunsUrl(notification); + expect(result).toBe( + 'https://github.com/manosim/notifications-test/actions?query=workflow:"Demo"+is:success+branch:main', + ); + }); + }); + + describe('getWorkflowRunUrl', () => { + let notification; + + beforeEach(() => { + notification = mockedSingleNotification; + }); + + it('should generate a Github Action (WorkflowRun) url', () => { + notification.subject.title = 'some title'; + + const result = getDeploymentReviewUrl(notification); + expect(result).toBe( + 'https://github.com/manosim/notifications-test/actions', + ); + }); + + it('should generate a Github Action (WorkflowRun) url for deployment review', () => { + notification.subject.title = + 'userA requested your review to deploy to an environment'; + + const result = getDeploymentReviewUrl(notification); + expect(result).toBe( + 'https://github.com/manosim/notifications-test/actions?query=is:waiting', + ); + }); + }); }); diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 708aac3af..f5adbfa6a 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -7,6 +7,7 @@ import { import { apiRequestAuth } from '../utils/api-requests'; import { openExternalLink } from '../utils/comms'; import { Constants } from './constants'; +import { getWorkflowTypeFromTitle } from './github-api'; export function getEnterpriseAccountToken( hostname: string, @@ -29,7 +30,7 @@ export function generateNotificationReferrerId( const buffer = Buffer.from( `018:NotificationThread${notificationId}:${userId}`, ); - return `notification_referrer_id=${buffer.toString('base64')}`; + return buffer.toString('base64'); } export function generateGitHubWebUrl( @@ -38,28 +39,32 @@ export function generateGitHubWebUrl( userId?: number, comment: string = '', ) { - const { hostname } = new URL(url); + const newURL = new URL(url); + const hostname = newURL.hostname; + let params = new URLSearchParams(newURL.search); + const isEnterprise = hostname !== `api.${Constants.DEFAULT_AUTH_OPTIONS.hostname}`; - let newUrl: string = isEnterprise - ? url.replace(`${hostname}/api/v3/repos`, hostname) - : url.replace('api.github.com/repos', 'github.com'); - - if (newUrl.indexOf('/pulls/') !== -1) { - newUrl = newUrl.replace('/pulls/', '/pull/'); + if (isEnterprise) { + newURL.href = newURL.href.replace(`${hostname}/api/v3/repos`, hostname); + } else { + newURL.href = newURL.href.replace('api.github.com/repos', 'github.com'); } + newURL.href = newURL.href.replace('/pulls/', '/pull/'); + if (userId) { const notificationReferrerId = generateNotificationReferrerId( notificationId, userId, ); - return `${newUrl}?${notificationReferrerId}${comment}`; + params.append('notification_referrer_id', notificationReferrerId); + newURL.search = params.toString(); } - return newUrl + comment; + return newURL.href + comment; } const addHours = (date: string, hours: number) => @@ -142,6 +147,69 @@ async function getDiscussionUrl( }; } +export function inferWorkflowRunBranchFromTitle( + notification: Notification, +): string { + const title = notification.subject.title; + + const titleParts = title.split('for'); + + if (titleParts[1]) { + return titleParts[1].replace('branch', '').trim(); + } + + return null; +} + +export function getWorkflowRunsUrl(notification: Notification) { + let url = `${notification.repository.html_url}/actions`; + let filters = []; + + const regexPattern = + /^(?.*?) workflow run (?.*?) 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 = getWorkflowTypeFromTitle( + 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 getDeploymentReviewUrl(notification: Notification) { + const workflowStatus = getWorkflowTypeFromTitle(notification.subject.title); + + let url = `${notification.repository.html_url}/actions`; + + if (workflowStatus) { + url += `?query=is:${workflowStatus}`; + } + + return url; +} + export const getLatestDiscussionCommentId = ( comments: DiscussionCommentEdge[], ) => @@ -158,15 +226,11 @@ export async function openInBrowser( notification: Notification, accounts: AuthState, ) { + console.log('ADAM : ' + notification.subject.type); if (notification.subject.type === 'Release') { getReleaseTagWebUrl(notification, accounts.token).then(({ url }) => openExternalLink( - generateGitHubWebUrl( - url, - notification.id, - accounts.user?.id, - undefined, - ), + generateGitHubWebUrl(url, notification.id, accounts.user?.id), ), ); } else if (notification.subject.type === 'Discussion') { @@ -183,6 +247,18 @@ export async function openInBrowser( ), ), ); + } else if (notification.subject.type === 'CheckSuite') { + const url = getWorkflowRunsUrl(notification); + + openExternalLink( + generateGitHubWebUrl(url, notification.id, accounts.user?.id), + ); + } else if (notification.subject.type === 'WorkflowRun') { + const url = getDeploymentReviewUrl(notification); + + openExternalLink( + generateGitHubWebUrl(url, notification.id, accounts.user?.id), + ); } else if (notification.subject.url) { const latestCommentId = getCommentId( notification.subject.latest_comment_url, @@ -195,5 +271,13 @@ export async function openInBrowser( latestCommentId ? '#issuecomment-' + latestCommentId : undefined, ), ); + } else { + openExternalLink( + generateGitHubWebUrl( + notification.repository.html_url, + notification.id, + accounts.user?.id, + ), + ); } }