Skip to content

Commit bb67807

Browse files
authored
Merge pull request desktop#16097 from desktop/pr-comment-notifications-support
Basic support for Pull Request comment notifications
2 parents ec166fc + 2fe76be commit bb67807

11 files changed

+419
-34
lines changed

app/src/lib/api.ts

+93-1
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,79 @@ export class API {
742742
}
743743
}
744744

745+
/**
746+
* Fetch an issue comment (i.e. a comment on an issue or pull request).
747+
*
748+
* @param owner The owner of the repository
749+
* @param name The name of the repository
750+
* @param commentId The ID of the comment
751+
*
752+
* @returns The comment if it was found, null if it wasn't, or an error
753+
* occurred.
754+
*/
755+
public async fetchIssueComment(
756+
owner: string,
757+
name: string,
758+
commentId: string
759+
): Promise<IAPIComment | null> {
760+
try {
761+
const response = await this.request(
762+
'GET',
763+
`repos/${owner}/${name}/issues/comments/${commentId}`
764+
)
765+
if (response.status === HttpStatusCode.NotFound) {
766+
log.warn(
767+
`fetchIssueComment: '${owner}/${name}/issues/comments/${commentId}' returned a 404`
768+
)
769+
return null
770+
}
771+
return await parsedResponse<IAPIComment>(response)
772+
} catch (e) {
773+
log.warn(
774+
`fetchIssueComment: an error occurred for '${owner}/${name}/issues/comments/${commentId}'`,
775+
e
776+
)
777+
return null
778+
}
779+
}
780+
781+
/**
782+
* Fetch a pull request review comment (i.e. a comment that was posted as part
783+
* of a review of a pull request).
784+
*
785+
* @param owner The owner of the repository
786+
* @param name The name of the repository
787+
* @param commentId The ID of the comment
788+
*
789+
* @returns The comment if it was found, null if it wasn't, or an error
790+
* occurred.
791+
*/
792+
public async fetchPullRequestReviewComment(
793+
owner: string,
794+
name: string,
795+
commentId: string
796+
): Promise<IAPIComment | null> {
797+
try {
798+
const response = await this.request(
799+
'GET',
800+
`repos/${owner}/${name}/pulls/comments/${commentId}`
801+
)
802+
if (response.status === HttpStatusCode.NotFound) {
803+
log.warn(
804+
`fetchPullRequestReviewComment: '${owner}/${name}/pulls/comments/${commentId}' returned a 404`
805+
)
806+
return null
807+
}
808+
return await parsedResponse<IAPIComment>(response)
809+
} catch (e) {
810+
log.warn(
811+
`fetchPullRequestReviewComment: an error occurred for '${owner}/${name}/pulls/comments/${commentId}'`,
812+
e
813+
)
814+
return null
815+
}
816+
}
817+
745818
/** Fetch a repo by its owner and name. */
746819
public async fetchRepository(
747820
owner: string,
@@ -1095,7 +1168,7 @@ export class API {
10951168
}
10961169
}
10971170

1098-
/** Fetches all comments from a given pull request. */
1171+
/** Fetches all review comments from a given pull request. */
10991172
public async fetchPullRequestComments(
11001173
owner: string,
11011174
name: string,
@@ -1114,6 +1187,25 @@ export class API {
11141187
}
11151188
}
11161189

1190+
/** Fetches all comments from a given issue. */
1191+
public async fetchIssueComments(
1192+
owner: string,
1193+
name: string,
1194+
issueNumber: string
1195+
) {
1196+
try {
1197+
const path = `/repos/${owner}/${name}/issues/${issueNumber}/comments`
1198+
const response = await this.request('GET', path)
1199+
return await parsedResponse<IAPIComment[]>(response)
1200+
} catch (e) {
1201+
log.debug(
1202+
`failed fetching issue comments for ${owner}/${name}/issues/${issueNumber}`,
1203+
e
1204+
)
1205+
return []
1206+
}
1207+
}
1208+
11171209
/**
11181210
* Get the combined status for the given ref.
11191211
*/

app/src/lib/feature-flag.ts

+4
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,7 @@ export function enablePreventClosingWhileUpdating(): boolean {
127127
export function enablePushPullFetchDropdown(): boolean {
128128
return enableBetaFeatures()
129129
}
130+
131+
export function enablePullRequestCommentNotifications(): boolean {
132+
return enableDevelopmentFeatures()
133+
}

app/src/lib/stores/app-store.ts

+32
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import {
9595
IAPIOrganization,
9696
getEndpointForRepository,
9797
IAPIFullRepository,
98+
IAPIComment,
9899
} from '../api'
99100
import { shell } from '../app-shell'
100101
import {
@@ -602,6 +603,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
602603
this.onPullRequestReviewSubmitNotification
603604
)
604605

606+
this.notificationsStore.onPullRequestCommentNotification(
607+
this.onPullRequestCommentNotification
608+
)
609+
605610
onShowInstallingUpdate(this.onShowInstallingUpdate)
606611
}
607612

@@ -7324,6 +7329,33 @@ export class AppStore extends TypedBaseStore<IAppState> {
73247329
})
73257330
}
73267331

7332+
private onPullRequestCommentNotification = async (
7333+
repository: RepositoryWithGitHubRepository,
7334+
pullRequest: PullRequest,
7335+
comment: IAPIComment
7336+
) => {
7337+
const selectedRepository =
7338+
this.selectedRepository ?? (await this._selectRepository(repository))
7339+
7340+
const state = this.repositoryStateCache.get(repository)
7341+
7342+
const { branchesState } = state
7343+
const { tip } = branchesState
7344+
const currentBranch = tip.kind === TipState.Valid ? tip.branch : null
7345+
7346+
return this._showPopup({
7347+
type: PopupType.PullRequestComment,
7348+
shouldCheckoutBranch:
7349+
currentBranch !== null && currentBranch.name !== pullRequest.head.ref,
7350+
shouldChangeRepository:
7351+
selectedRepository === null ||
7352+
selectedRepository.hash !== repository.hash,
7353+
comment,
7354+
pullRequest,
7355+
repository,
7356+
})
7357+
}
7358+
73277359
public async _startPullRequest(repository: Repository) {
73287360
const { tip, defaultBranch } =
73297361
this.repositoryStateCache.get(repository).branchesState

app/src/lib/stores/notifications-debug-store.ts

+3-26
Original file line numberDiff line numberDiff line change
@@ -76,42 +76,19 @@ export class NotificationsDebugStore {
7676

7777
const ghRepository = repository.gitHubRepository
7878

79-
const issueComments = await api.fetchPullRequestComments(
79+
const issueComments = await api.fetchIssueComments(
8080
ghRepository.owner.login,
8181
ghRepository.name,
8282
pullRequestNumber.toString()
8383
)
8484

85-
const issueCommentIds = new Set(issueComments.map(c => c.id))
86-
87-
// Fetch review comments of type COMMENTED and with no body
88-
const allReviews = await api.fetchPullRequestReviews(
85+
const reviewComments = await api.fetchPullRequestComments(
8986
ghRepository.owner.login,
9087
ghRepository.name,
9188
pullRequestNumber.toString()
9289
)
9390

94-
const commentedReviewsWithNoBody = allReviews.filter(
95-
review => review.state === 'COMMENTED' && !review.body
96-
)
97-
98-
const allReviewComments = await Promise.all(
99-
commentedReviewsWithNoBody.map(review =>
100-
api.fetchPullRequestReviewComments(
101-
ghRepository.owner.login,
102-
ghRepository.name,
103-
pullRequestNumber.toString(),
104-
review.id.toString()
105-
)
106-
)
107-
)
108-
109-
// Only reviews with one comment, and that comment is not an issue comment
110-
const singleReviewComments = allReviewComments
111-
.flatMap(comments => (comments.length === 1 ? comments : []))
112-
.filter(comment => !issueCommentIds.has(comment.id))
113-
114-
return [...issueComments, ...singleReviewComments]
91+
return [...issueComments, ...reviewComments]
11592
}
11693

11794
/** Simulate a notification for the given pull request review. */

app/src/lib/stores/notifications-store.ts

+99-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from '../../models/repository'
88
import { ForkContributionTarget } from '../../models/workflow-preferences'
99
import { getPullRequestCommitRef, PullRequest } from '../../models/pull-request'
10-
import { API, APICheckConclusion } from '../api'
10+
import { API, APICheckConclusion, IAPIComment } from '../api'
1111
import {
1212
createCombinedCheckFromChecks,
1313
getLatestCheckRunsByName,
@@ -24,6 +24,7 @@ import {
2424
AliveStore,
2525
DesktopAliveEvent,
2626
IDesktopChecksFailedAliveEvent,
27+
IDesktopPullRequestCommentAliveEvent,
2728
IDesktopPullRequestReviewSubmitAliveEvent,
2829
} from './alive-store'
2930
import { setBoolean, getBoolean } from '../local-storage'
@@ -36,6 +37,7 @@ import {
3637
ValidNotificationPullRequestReview,
3738
} from '../valid-notification-pull-request-review'
3839
import { NotificationCallback } from 'desktop-notifications/dist/notification-callback'
40+
import { enablePullRequestCommentNotifications } from '../feature-flag'
3941

4042
type OnChecksFailedCallback = (
4143
repository: RepositoryWithGitHubRepository,
@@ -51,6 +53,12 @@ type OnPullRequestReviewSubmitCallback = (
5153
review: ValidNotificationPullRequestReview
5254
) => void
5355

56+
type OnPullRequestCommentCallback = (
57+
repository: RepositoryWithGitHubRepository,
58+
pullRequest: PullRequest,
59+
comment: IAPIComment
60+
) => void
61+
5462
/**
5563
* The localStorage key for whether the user has enabled high-signal
5664
* notifications.
@@ -72,6 +80,8 @@ export class NotificationsStore {
7280
private onChecksFailedCallback: OnChecksFailedCallback | null = null
7381
private onPullRequestReviewSubmitCallback: OnPullRequestReviewSubmitCallback | null =
7482
null
83+
private onPullRequestCommentCallback: OnPullRequestCommentCallback | null =
84+
null
7585
private cachedCommits: Map<string, Commit> = new Map()
7686
private skipCommitShas: Set<string> = new Set()
7787
private skipCheckRuns: Set<number> = new Set()
@@ -119,9 +129,90 @@ export class NotificationsStore {
119129
return this.handleChecksFailedEvent(e, skipNotification)
120130
case 'pr-review-submit':
121131
return this.handlePullRequestReviewSubmitEvent(e, skipNotification)
132+
case 'pr-comment':
133+
return this.handlePullRequestCommentEvent(e, skipNotification)
122134
}
123135
}
124136

137+
private async handlePullRequestCommentEvent(
138+
event: IDesktopPullRequestCommentAliveEvent,
139+
skipNotification: boolean
140+
) {
141+
if (!enablePullRequestCommentNotifications()) {
142+
return
143+
}
144+
145+
const repository = this.repository
146+
if (repository === null) {
147+
return
148+
}
149+
150+
if (!this.isValidRepositoryForEvent(repository, event)) {
151+
if (this.isRecentRepositoryEvent(event)) {
152+
this.statsStore.recordPullRequestReviewNotiificationFromRecentRepo()
153+
} else {
154+
this.statsStore.recordPullRequestReviewNotiificationFromNonRecentRepo()
155+
}
156+
return
157+
}
158+
159+
const pullRequests = await this.pullRequestCoordinator.getAllPullRequests(
160+
repository
161+
)
162+
const pullRequest = pullRequests.find(
163+
pr => pr.pullRequestNumber === event.pull_request_number
164+
)
165+
166+
// If the PR is not in cache, it probably means the user didn't work on it
167+
// recently, so we don't want to show a notification.
168+
if (pullRequest === undefined) {
169+
return
170+
}
171+
172+
// Fetch comment from API depending on event subtype
173+
const api = await this.getAPIForRepository(repository.gitHubRepository)
174+
if (api === null) {
175+
return
176+
}
177+
178+
const comment =
179+
event.subtype === 'issue-comment'
180+
? await api.fetchIssueComment(event.owner, event.repo, event.comment_id)
181+
: await api.fetchPullRequestReviewComment(
182+
event.owner,
183+
event.repo,
184+
event.comment_id
185+
)
186+
187+
if (comment === null) {
188+
return
189+
}
190+
191+
const title = `@${comment.user.login} commented your pull request`
192+
const body = `${pullRequest.title} #${
193+
pullRequest.pullRequestNumber
194+
}\n${truncateWithEllipsis(comment.body, 50)}`
195+
const onClick = () => {
196+
// TODO: this.statsStore.recordPullRequestReviewNotificationClicked(review.state)
197+
198+
this.onPullRequestCommentCallback?.(repository, pullRequest, comment)
199+
}
200+
201+
if (skipNotification) {
202+
onClick()
203+
return
204+
}
205+
206+
showNotification({
207+
title,
208+
body,
209+
userInfo: event,
210+
onClick,
211+
})
212+
213+
// TODO: this.statsStore.recordPullRequestReviewNotificationShown(review.state)
214+
}
215+
125216
private async handlePullRequestReviewSubmitEvent(
126217
event: IDesktopPullRequestReviewSubmitAliveEvent,
127218
skipNotification: boolean
@@ -475,4 +566,11 @@ export class NotificationsStore {
475566
) {
476567
this.onPullRequestReviewSubmitCallback = callback
477568
}
569+
570+
/** Observe when the user reacted to a "PR comment" notification. */
571+
public onPullRequestCommentNotification(
572+
callback: OnPullRequestCommentCallback
573+
) {
574+
this.onPullRequestCommentCallback = callback
575+
}
478576
}

0 commit comments

Comments
 (0)