10.5 KB308 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {
9 CodeReviewSystem,
10 DiffComment,
11 DiffId,
12 DiffSignalSummary,
13 Disposable,
14 Hash,
15 Result,
16} from 'isl/src/types';
17import type {CodeReviewProvider} from '../CodeReviewProvider';
18import type {Logger} from '../logger';
19import type {
20 MergeQueueSupportQueryData,
21 MergeQueueSupportQueryVariables,
22 PullRequestCommentsQueryData,
23 PullRequestCommentsQueryVariables,
24 PullRequestReviewComment,
25 PullRequestReviewDecision,
26 ReactionContent,
27 YourPullRequestsQueryData,
28 YourPullRequestsQueryVariables,
29 YourPullRequestsWithoutMergeQueueQueryData,
30 YourPullRequestsWithoutMergeQueueQueryVariables,
31} from './generated/graphql';
32
33import {TypedEventEmitter} from 'shared/TypedEventEmitter';
34import {debounce} from 'shared/debounce';
35import {notEmpty} from 'shared/utils';
36import {
37 MergeQueueSupportQuery,
38 PullRequestCommentsQuery,
39 PullRequestState,
40 StatusState,
41 YourPullRequestsQuery,
42 YourPullRequestsWithoutMergeQueueQuery,
43} from './generated/graphql';
44import queryGraphQL from './queryGraphQL';
45
46export type GitHubDiffSummary = {
47 type: 'github';
48 title: string;
49 commitMessage: string;
50 state: PullRequestState | 'DRAFT' | 'MERGE_QUEUED';
51 number: DiffId;
52 url: string;
53 commentCount: number;
54 anyUnresolvedComments: false;
55 signalSummary?: DiffSignalSummary;
56 reviewDecision?: PullRequestReviewDecision;
57 /** Base of the Pull Request (public parent), as it is on GitHub (may be out of date) */
58 base: Hash;
59 /** Head of the Pull Request (topmost commit), as it is on GitHub (may be out of date) */
60 head: Hash;
61 /** Name of the branch on GitHub, which should match the local bookmark */
62 branchName?: string;
63};
64
65const DEFAULT_GH_FETCH_TIMEOUT = 60_000; // 1 minute
66
67type GitHubCodeReviewSystem = CodeReviewSystem & {type: 'github'};
68export class GitHubCodeReviewProvider implements CodeReviewProvider {
69 constructor(
70 private codeReviewSystem: GitHubCodeReviewSystem,
71 private logger: Logger,
72 ) {}
73 private diffSummaries = new TypedEventEmitter<'data', Map<DiffId, GitHubDiffSummary>>();
74 private hasMergeQueueSupport: Promise<boolean> | null = null;
75
76 onChangeDiffSummaries(
77 callback: (result: Result<Map<DiffId, GitHubDiffSummary>>) => unknown,
78 ): Disposable {
79 const handleData = (data: Map<DiffId, GitHubDiffSummary>) => callback({value: data});
80 const handleError = (error: Error) => callback({error});
81 this.diffSummaries.on('data', handleData);
82 this.diffSummaries.on('error', handleError);
83 return {
84 dispose: () => {
85 this.diffSummaries.off('data', handleData);
86 this.diffSummaries.off('error', handleError);
87 },
88 };
89 }
90
91 private detectMergeQueueSupport(): Promise<boolean> {
92 if (this.hasMergeQueueSupport == null) {
93 this.hasMergeQueueSupport = (async (): Promise<boolean> => {
94 this.logger.info('detecting if merge queue is supported');
95 const data = await this.query<MergeQueueSupportQueryData, MergeQueueSupportQueryVariables>(
96 MergeQueueSupportQuery,
97 {},
98 10_000,
99 ).catch(err => {
100 this.logger.info('failed to detect merge queue support', err);
101 return undefined;
102 });
103 const hasMergeQueueSupport = data?.__type != null;
104 this.logger.info('set merge queue support to ' + hasMergeQueueSupport);
105 return hasMergeQueueSupport;
106 })();
107 }
108 return this.hasMergeQueueSupport;
109 }
110
111 private fetchYourPullRequestsGraphQL(
112 includeMergeQueue: boolean,
113 ): Promise<YourPullRequestsQueryData | undefined> {
114 const variables = {
115 // TODO: somehow base this query on the list of DiffIds
116 // This is not very easy with github's graphql API, which doesn't allow more than 5 "OR"s in a search query.
117 // But if we used one-query-per-diff we would reach rate limiting too quickly.
118 searchQuery: `repo:${this.codeReviewSystem.owner}/${this.codeReviewSystem.repo} is:pr author:@me`,
119 numToFetch: 50,
120 };
121 if (includeMergeQueue) {
122 return this.query<YourPullRequestsQueryData, YourPullRequestsQueryVariables>(
123 YourPullRequestsQuery,
124 variables,
125 );
126 } else {
127 return this.query<
128 YourPullRequestsWithoutMergeQueueQueryData,
129 YourPullRequestsWithoutMergeQueueQueryVariables
130 >(YourPullRequestsWithoutMergeQueueQuery, variables);
131 }
132 }
133
134 triggerDiffSummariesFetch = debounce(
135 async () => {
136 try {
137 const hasMergeQueueSupport = await this.detectMergeQueueSupport();
138 this.logger.info('fetching github PR summaries');
139 const allSummaries = await this.fetchYourPullRequestsGraphQL(hasMergeQueueSupport);
140 if (allSummaries?.search.nodes == null) {
141 this.diffSummaries.emit('data', new Map());
142 return;
143 }
144
145 const map = new Map<DiffId, GitHubDiffSummary>();
146 for (const summary of allSummaries.search.nodes) {
147 if (summary != null && summary.__typename === 'PullRequest') {
148 const id = String(summary.number);
149 const commitMessage = summary.body.slice(summary.title.length + 1);
150 if (summary.baseRef?.target == null || summary.headRef?.target == null) {
151 this.logger.warn(`PR #${id} is missing base or head ref, skipping.`);
152 continue;
153 }
154 map.set(id, {
155 type: 'github',
156 title: summary.title,
157 commitMessage,
158 // For some reason, `isDraft` is a separate boolean and not a state,
159 // but we generally treat it as its own state in the UI.
160 state:
161 summary.isDraft && summary.state === PullRequestState.Open
162 ? 'DRAFT'
163 : summary.mergeQueueEntry != null
164 ? 'MERGE_QUEUED'
165 : summary.state,
166 number: id,
167 url: summary.url,
168 commentCount: summary.comments.totalCount,
169 anyUnresolvedComments: false,
170 signalSummary: githubStatusRollupStateToCIStatus(
171 summary.commits.nodes?.[0]?.commit.statusCheckRollup?.state,
172 ),
173 reviewDecision: summary.reviewDecision ?? undefined,
174 base: summary.baseRef.target.oid,
175 head: summary.headRef.target.oid,
176 branchName: summary.headRef.name,
177 });
178 }
179 }
180 this.logger.info(`fetched ${map.size} github PR summaries`);
181 this.diffSummaries.emit('data', map);
182 } catch (error) {
183 this.logger.info('error fetching github PR summaries: ', error);
184 this.diffSummaries.emit('error', error as Error);
185 }
186 },
187 2000,
188 undefined,
189 /* leading */ true,
190 );
191
192 public async fetchComments(diffId: string): Promise<DiffComment[]> {
193 const response = await this.query<
194 PullRequestCommentsQueryData,
195 PullRequestCommentsQueryVariables
196 >(PullRequestCommentsQuery, {
197 url: this.getPrUrl(diffId),
198 numToFetch: 50,
199 });
200
201 if (response == null) {
202 throw new Error(`Failed to fetch comments for ${diffId}`);
203 }
204
205 const pr = response?.resource as
206 | (PullRequestCommentsQueryData['resource'] & {__typename: 'PullRequest'})
207 | undefined;
208
209 const comments = pr?.comments.nodes ?? [];
210
211 const inline =
212 pr?.reviews?.nodes?.filter(notEmpty).flatMap(review => review.comments.nodes) ?? [];
213
214 this.logger.info(`fetched ${comments?.length} comments for github PR ${diffId}}`);
215
216 return (
217 [...comments, ...inline]?.filter(notEmpty).map(comment => {
218 return {
219 author: comment.author?.login ?? '',
220 authorAvatarUri: comment.author?.avatarUrl,
221 html: comment.bodyHTML,
222 created: new Date(comment.createdAt),
223 filename: (comment as PullRequestReviewComment).path ?? undefined,
224 line: (comment as PullRequestReviewComment).line ?? undefined,
225 reactions:
226 comment.reactions?.nodes
227 ?.filter(
228 (reaction): reaction is {user: {login: string}; content: ReactionContent} =>
229 reaction?.user?.login != null,
230 )
231 .map(reaction => ({
232 name: reaction.user.login,
233 reaction: reaction.content,
234 })) ?? [],
235 replies: [], // PR top level doesn't have nested replies, you just reply to their name
236 };
237 }) ?? []
238 );
239 }
240
241 private query<D, V>(query: string, variables: V, timeoutMs?: number): Promise<D | undefined> {
242 return queryGraphQL<D, V>(
243 query,
244 variables,
245 this.codeReviewSystem.hostname,
246 timeoutMs ?? DEFAULT_GH_FETCH_TIMEOUT,
247 );
248 }
249
250 public dispose() {
251 this.diffSummaries.removeAllListeners();
252 this.triggerDiffSummariesFetch.dispose();
253 }
254
255 public getSummaryName(): string {
256 return `github:${this.codeReviewSystem.hostname}/${this.codeReviewSystem.owner}/${this.codeReviewSystem.repo}`;
257 }
258
259 public getPrUrl(diffId: DiffId): string {
260 return `https://${this.codeReviewSystem.hostname}/${this.codeReviewSystem.owner}/${this.codeReviewSystem.repo}/pull/${diffId}`;
261 }
262
263 public getDiffUrlMarkdown(diffId: DiffId): string {
264 return `[#${diffId}](${this.getPrUrl(diffId)})`;
265 }
266
267 public getCommitHashUrlMarkdown(hash: string): string {
268 return `[\`${hash.slice(0, 12)}\`](https://${this.codeReviewSystem.hostname}/${
269 this.codeReviewSystem.owner
270 }/${this.codeReviewSystem.repo}/commit/${hash})`;
271 }
272
273 getRemoteFileURL(
274 path: string,
275 publicCommitHash: string | null,
276 selectionStart?: {line: number; char: number},
277 selectionEnd?: {line: number; char: number},
278 ): string {
279 const {hostname, owner, repo} = this.codeReviewSystem;
280 let url = `https://${hostname}/${owner}/${repo}/blob/${publicCommitHash ?? 'HEAD'}/${path}`;
281 if (selectionStart != null) {
282 url += `#L${selectionStart.line + 1}`;
283 if (
284 selectionEnd &&
285 (selectionEnd.line !== selectionStart.line || selectionEnd.char !== selectionStart.char)
286 ) {
287 url += `C${selectionStart.char + 1}-L${selectionEnd.line + 1}C${selectionEnd.char + 1}`;
288 }
289 }
290 return url;
291 }
292}
293
294function githubStatusRollupStateToCIStatus(state: StatusState | undefined): DiffSignalSummary {
295 switch (state) {
296 case undefined:
297 case StatusState.Expected:
298 return 'no-signal';
299 case StatusState.Pending:
300 return 'running';
301 case StatusState.Error:
302 case StatusState.Failure:
303 return 'failed';
304 case StatusState.Success:
305 return 'pass';
306 }
307}
308