Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/prompts/node/test/fixtures/pullRequestModel.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as buffer from 'buffer';
7
import equals from 'fast-deep-equal';
8
import * as path from 'path';
9
import * as vscode from 'vscode';
10
import { DiffSide, IComment, IReviewThread, ViewedState } from '../common/comment';
11
import { parseDiff } from '../common/diffHunk';
12
import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file';
13
import { GitHubRef } from '../common/githubRef';
14
import Logger from '../common/logger';
15
import { Remote } from '../common/remote';
16
import { ITelemetry } from '../common/telemetry';
17
import { ReviewEvent as CommonReviewEvent, EventType, TimelineEvent } from '../common/timelineEvent';
18
import { resolvePath, toPRUri, toReviewUri } from '../common/uri';
19
import { formatError } from '../common/utils';
20
import { OctokitCommon } from './common';
21
import { FolderRepositoryManager } from './folderRepositoryManager';
22
import { GitHubRepository } from './githubRepository';
23
import {
24
AddCommentResponse,
25
AddReactionResponse,
26
AddReviewThreadResponse,
27
DeleteReactionResponse,
28
DeleteReviewResponse,
29
EditCommentResponse,
30
LatestReviewCommitResponse,
31
LatestReviewsResponse,
32
MarkPullRequestReadyForReviewResponse,
33
PendingReviewIdResponse,
34
PullRequestCommentsResponse,
35
PullRequestFilesResponse,
36
PullRequestMergabilityResponse,
37
ReactionGroup,
38
ResolveReviewThreadResponse,
39
StartReviewResponse,
40
SubmitReviewResponse,
41
TimelineEventsResponse,
42
UnresolveReviewThreadResponse,
43
UpdatePullRequestResponse,
44
} from './graphql';
45
import {
46
CheckState,
47
GithubItemStateEnum,
48
IAccount,
49
IRawFileChange,
50
ISuggestedReviewer,
51
MergeMethod,
52
PullRequest,
53
PullRequestChecks,
54
PullRequestMergeability,
55
ReviewEvent,
56
} from './interface';
57
import { IssueModel } from './issueModel';
58
import {
59
convertRESTPullRequestToRawPullRequest,
60
convertRESTReviewEvent,
61
convertRESTUserToAccount,
62
getReactionGroup,
63
insertNewCommitsSinceReview,
64
parseGraphQLComment,
65
parseGraphQLReaction,
66
parseGraphQLReviewEvent,
67
parseGraphQLReviewThread,
68
parseGraphQLTimelineEvents,
69
parseMergeability,
70
restPaginate,
71
} from './utils';
72
73
interface IPullRequestModel {
74
head: GitHubRef | null;
75
}
76
77
export interface IResolvedPullRequestModel extends IPullRequestModel {
78
head: GitHubRef;
79
}
80
81
export interface ReviewThreadChangeEvent {
82
added: IReviewThread[];
83
changed: IReviewThread[];
84
removed: IReviewThread[];
85
}
86
87
export interface FileViewedStateChangeEvent {
88
changed: {
89
fileName: string;
90
viewed: ViewedState;
91
}[];
92
}
93
94
export const REVIEW_REQUIRED_CHECK_ID = 'reviewRequired';
95
96
export type FileViewedState = { [key: string]: ViewedState };
97
98
export class PullRequestModel extends IssueModel<PullRequest> implements IPullRequestModel {
99
static ID = 'PullRequestModel';
100
101
public isDraft?: boolean;
102
public localBranchName?: string;
103
public mergeBase?: string;
104
public suggestedReviewers?: ISuggestedReviewer[];
105
public hasChangesSinceLastReview?: boolean;
106
private _showChangesSinceReview: boolean;
107
private _hasPendingReview: boolean = false;
108
private _onDidChangePendingReviewState: vscode.EventEmitter<boolean> = new vscode.EventEmitter<boolean>();
109
public onDidChangePendingReviewState = this._onDidChangePendingReviewState.event;
110
111
private _reviewThreadsCache: IReviewThread[] = [];
112
private _reviewThreadsCacheInitialized = false;
113
private _onDidChangeReviewThreads = new vscode.EventEmitter<ReviewThreadChangeEvent>();
114
public onDidChangeReviewThreads = this._onDidChangeReviewThreads.event;
115
116
private _fileChangeViewedState: FileViewedState = {};
117
private _viewedFiles: Set<string> = new Set();
118
private _unviewedFiles: Set<string> = new Set();
119
private _onDidChangeFileViewedState = new vscode.EventEmitter<FileViewedStateChangeEvent>();
120
public onDidChangeFileViewedState = this._onDidChangeFileViewedState.event;
121
122
private _onDidChangeChangesSinceReview = new vscode.EventEmitter<void>();
123
public onDidChangeChangesSinceReview = this._onDidChangeChangesSinceReview.event;
124
125
private _comments: IComment[] | undefined;
126
private _onDidChangeComments: vscode.EventEmitter<void> = new vscode.EventEmitter();
127
public readonly onDidChangeComments: vscode.Event<void> = this._onDidChangeComments.event;
128
129
// Whether the pull request is currently checked out locally
130
private _isActive: boolean;
131
public get isActive(): boolean {
132
return this._isActive;
133
}
134
public set isActive(isActive: boolean) {
135
this._isActive = isActive;
136
}
137
138
_telemetry: ITelemetry;
139
140
constructor(
141
telemetry: ITelemetry,
142
githubRepository: GitHubRepository,
143
remote: Remote,
144
item: PullRequest,
145
isActive?: boolean,
146
) {
147
super(githubRepository, remote, item, true);
148
149
this._telemetry = telemetry;
150
this.isActive = !!isActive;
151
152
this._showChangesSinceReview = false;
153
154
this.update(item);
155
}
156
157
public clear() {
158
this.comments = [];
159
this._reviewThreadsCacheInitialized = false;
160
this._reviewThreadsCache = [];
161
}
162
163
public async initializeReviewThreadCache(): Promise<void> {
164
await this.getReviewThreads();
165
this._reviewThreadsCacheInitialized = true;
166
}
167
168
public get reviewThreadsCache(): IReviewThread[] {
169
return this._reviewThreadsCache;
170
}
171
172
public get reviewThreadsCacheReady(): boolean {
173
return this._reviewThreadsCacheInitialized;
174
}
175
176
public get isMerged(): boolean {
177
return this.state === GithubItemStateEnum.Merged;
178
}
179
180
public get hasPendingReview(): boolean {
181
return this._hasPendingReview;
182
}
183
184
public set hasPendingReview(hasPendingReview: boolean) {
185
if (this._hasPendingReview !== hasPendingReview) {
186
this._hasPendingReview = hasPendingReview;
187
this._onDidChangePendingReviewState.fire(this._hasPendingReview);
188
}
189
}
190
191
public get showChangesSinceReview() {
192
return this._showChangesSinceReview;
193
}
194
195
public set showChangesSinceReview(isChangesSinceReview: boolean) {
196
this._showChangesSinceReview = isChangesSinceReview;
197
this._onDidChangeChangesSinceReview.fire();
198
}
199
200
get comments(): IComment[] {
201
return this._comments ?? [];
202
}
203
204
set comments(comments: IComment[]) {
205
this._comments = comments;
206
this._onDidChangeComments.fire();
207
}
208
209
get fileChangeViewedState(): FileViewedState {
210
return this._fileChangeViewedState;
211
}
212
213
public isRemoteHeadDeleted?: boolean;
214
public head: GitHubRef | null;
215
public isRemoteBaseDeleted?: boolean;
216
public base: GitHubRef;
217
218
protected updateState(state: string) {
219
if (state.toLowerCase() === 'open') {
220
this.state = GithubItemStateEnum.Open;
221
} else {
222
this.state = this.item.merged ? GithubItemStateEnum.Merged : GithubItemStateEnum.Closed;
223
}
224
}
225
226
update(item: PullRequest): void {
227
super.update(item);
228
this.isDraft = item.isDraft;
229
this.suggestedReviewers = item.suggestedReviewers;
230
231
if (item.isRemoteHeadDeleted != null) {
232
this.isRemoteHeadDeleted = item.isRemoteHeadDeleted;
233
}
234
if (item.head) {
235
this.head = new GitHubRef(item.head.ref, item.head.label, item.head.sha, item.head.repo.cloneUrl, item.head.repo.owner, item.head.repo.name);
236
}
237
238
if (item.isRemoteBaseDeleted != null) {
239
this.isRemoteBaseDeleted = item.isRemoteBaseDeleted;
240
}
241
if (item.base) {
242
this.base = new GitHubRef(item.base.ref, item.base!.label, item.base!.sha, item.base!.repo.cloneUrl, item.base.repo.owner, item.base.repo.name);
243
}
244
}
245
246
/**
247
* Validate if the pull request has a valid HEAD.
248
* Use only when the method can fail silently, otherwise use `validatePullRequestModel`
249
*/
250
isResolved(): this is IResolvedPullRequestModel {
251
return !!this.head;
252
}
253
254
/**
255
* Validate if the pull request has a valid HEAD. Show a warning message to users when the pull request is invalid.
256
* @param message Human readable action execution failure message.
257
*/
258
validatePullRequestModel(message?: string): this is IResolvedPullRequestModel {
259
if (!!this.head) {
260
return true;
261
}
262
263
const reason = vscode.l10n.t('There is no upstream branch for Pull Request #{0}. View it on GitHub for more details', this.number);
264
265
if (message) {
266
message += `: ${reason}`;
267
} else {
268
message = reason;
269
}
270
271
const openString = vscode.l10n.t('Open on GitHub');
272
vscode.window.showWarningMessage(message, openString).then(action => {
273
if (action && action === openString) {
274
vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(this.html_url));
275
}
276
});
277
278
return false;
279
}
280
281
/**
282
* Approve the pull request.
283
* @param message Optional approval comment text.
284
*/
285
async approve(message?: string): Promise<CommonReviewEvent> {
286
const action: Promise<CommonReviewEvent> = (await this.getPendingReviewId())
287
? this.submitReview(ReviewEvent.Approve, message)
288
: this.createReview(ReviewEvent.Approve, message);
289
290
return action.then(x => {
291
this._onDidChangeComments.fire();
292
return x;
293
});
294
}
295
296
/**
297
* Request changes on the pull request.
298
* @param message Optional comment text to leave with the review.
299
*/
300
async requestChanges(message?: string): Promise<CommonReviewEvent> {
301
const action: Promise<CommonReviewEvent> = (await this.getPendingReviewId())
302
? this.submitReview(ReviewEvent.RequestChanges, message)
303
: this.createReview(ReviewEvent.RequestChanges, message);
304
305
return action.then(x => {
306
this._onDidChangeComments.fire();
307
return x;
308
});
309
}
310
311
/**
312
* Close the pull request.
313
*/
314
async close(): Promise<PullRequest> {
315
const { octokit, remote } = await this.githubRepository.ensure();
316
const ret = await octokit.call(octokit.api.pulls.update, {
317
owner: remote.owner,
318
repo: remote.repositoryName,
319
pull_number: this.number,
320
state: 'closed',
321
});
322
323
return convertRESTPullRequestToRawPullRequest(ret.data, this.githubRepository);
324
}
325
326
/**
327
* Create a new review.
328
* @param event The type of review to create, an approval, request for changes, or comment.
329
* @param message The summary comment text.
330
*/
331
private async createReview(event: ReviewEvent, message?: string): Promise<CommonReviewEvent> {
332
const { octokit, remote } = await this.githubRepository.ensure();
333
334
const { data } = await octokit.call(octokit.api.pulls.createReview, {
335
owner: remote.owner,
336
repo: remote.repositoryName,
337
pull_number: this.number,
338
event: event,
339
body: message,
340
});
341
342
return convertRESTReviewEvent(data, this.githubRepository);
343
}
344
345
/**
346
* Submit an existing review.
347
* @param event The type of review to create, an approval, request for changes, or comment.
348
* @param body The summary comment text.
349
*/
350
async submitReview(event?: ReviewEvent, body?: string): Promise<CommonReviewEvent> {
351
let pendingReviewId = await this.getPendingReviewId();
352
const { mutate, schema } = await this.githubRepository.ensure();
353
354
if (!pendingReviewId && (event === ReviewEvent.Comment)) {
355
// Create a new review so that we can comment on it.
356
pendingReviewId = await this.startReview();
357
}
358
359
if (pendingReviewId) {
360
const { data } = await mutate<SubmitReviewResponse>({
361
mutation: schema.SubmitReview,
362
variables: {
363
id: pendingReviewId,
364
event: event || ReviewEvent.Comment,
365
body,
366
},
367
});
368
369
this.hasPendingReview = false;
370
await this.updateDraftModeContext();
371
const reviewEvent = parseGraphQLReviewEvent(data!.submitPullRequestReview.pullRequestReview, this.githubRepository);
372
373
const threadWithComment = this._reviewThreadsCache.find(thread =>
374
thread.comments.length ? (thread.comments[0].pullRequestReviewId === reviewEvent.id) : undefined,
375
);
376
if (threadWithComment) {
377
threadWithComment.comments = reviewEvent.comments;
378
threadWithComment.viewerCanResolve = true;
379
this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] });
380
}
381
return reviewEvent;
382
} else {
383
throw new Error(`Submitting review failed, no pending review for current pull request: ${this.number}.`);
384
}
385
}
386
387
async updateMilestone(id: string): Promise<void> {
388
const { mutate, schema } = await this.githubRepository.ensure();
389
const finalId = id === 'null' ? null : id;
390
391
try {
392
await mutate<UpdatePullRequestResponse>({
393
mutation: schema.UpdatePullRequest,
394
variables: {
395
input: {
396
pullRequestId: this.item.graphNodeId,
397
milestoneId: finalId,
398
},
399
},
400
});
401
} catch (err) {
402
Logger.error(err, PullRequestModel.ID);
403
}
404
}
405
406
async addAssignees(assignees: string[]): Promise<void> {
407
const { octokit, remote } = await this.githubRepository.ensure();
408
await octokit.call(octokit.api.issues.addAssignees, {
409
owner: remote.owner,
410
repo: remote.repositoryName,
411
issue_number: this.number,
412
assignees,
413
});
414
}
415
416
/**
417
* Query to see if there is an existing review.
418
*/
419
async getPendingReviewId(): Promise<string | undefined> {
420
const { query, schema } = await this.githubRepository.ensure();
421
const currentUser = await this.githubRepository.getAuthenticatedUser();
422
try {
423
const { data } = await query<PendingReviewIdResponse>({
424
query: schema.GetPendingReviewId,
425
variables: {
426
pullRequestId: this.item.graphNodeId,
427
author: currentUser,
428
},
429
});
430
return data.node.reviews.nodes.length > 0 ? data.node.reviews.nodes[0].id : undefined;
431
} catch (error) {
432
return;
433
}
434
}
435
436
async getViewerLatestReviewCommit(): Promise<{ sha: string } | undefined> {
437
Logger.debug(`Fetch viewers latest review commit`, IssueModel.ID);
438
const { query, remote, schema } = await this.githubRepository.ensure();
439
440
try {
441
const { data } = await query<LatestReviewCommitResponse>({
442
query: schema.LatestReviewCommit,
443
variables: {
444
owner: remote.owner,
445
name: remote.repositoryName,
446
number: this.number,
447
},
448
});
449
450
return data.repository.pullRequest.viewerLatestReview ? {
451
sha: data.repository.pullRequest.viewerLatestReview.commit.oid,
452
} : undefined;
453
}
454
catch (e) {
455
return undefined;
456
}
457
}
458
459
/**
460
* Delete an existing in progress review.
461
*/
462
async deleteReview(): Promise<{ deletedReviewId: number; deletedReviewComments: IComment[] }> {
463
const pendingReviewId = await this.getPendingReviewId();
464
const { mutate, schema } = await this.githubRepository.ensure();
465
const { data } = await mutate<DeleteReviewResponse>({
466
mutation: schema.DeleteReview,
467
variables: {
468
input: { pullRequestReviewId: pendingReviewId },
469
},
470
});
471
472
const { comments, databaseId } = data!.deletePullRequestReview.pullRequestReview;
473
474
this.hasPendingReview = false;
475
await this.updateDraftModeContext();
476
477
this.getReviewThreads();
478
479
return {
480
deletedReviewId: databaseId,
481
deletedReviewComments: comments.nodes.map(comment => parseGraphQLComment(comment, false, this.githubRepository)),
482
};
483
}
484
485
/**
486
* Start a new review.
487
* @param initialComment The comment text and position information to begin the review with
488
* @param commitId The optional commit id to start the review on. Defaults to using the current head commit.
489
*/
490
async startReview(commitId?: string): Promise<string> {
491
const { mutate, schema } = await this.githubRepository.ensure();
492
const { data } = await mutate<StartReviewResponse>({
493
mutation: schema.StartReview,
494
variables: {
495
input: {
496
body: '',
497
pullRequestId: this.item.graphNodeId,
498
commitOID: commitId || this.head?.sha,
499
},
500
},
501
});
502
503
if (!data) {
504
throw new Error('Failed to start review');
505
}
506
this.hasPendingReview = true;
507
this._onDidChangeComments.fire();
508
return data.addPullRequestReview.pullRequestReview.id;
509
}
510
511
/**
512
* Creates a new review thread, either adding it to an existing pending review, or creating
513
* a new review.
514
* @param body The body of the thread's first comment.
515
* @param commentPath The path to the file being commented on.
516
* @param startLine The start line on which to add the comment.
517
* @param endLine The end line on which to add the comment.
518
* @param side The side the comment should be deleted on, i.e. the original or modified file.
519
* @param suppressDraftModeUpdate If a draft mode change should event should be suppressed. In the
520
* case of a single comment add, the review is created and then immediately submitted, so this prevents
521
* a "Pending" label from flashing on the comment.
522
* @returns The new review thread object.
523
*/
524
async createReviewThread(
525
body: string,
526
commentPath: string,
527
startLine: number,
528
endLine: number,
529
side: DiffSide,
530
suppressDraftModeUpdate?: boolean,
531
): Promise<IReviewThread | undefined> {
532
if (!this.validatePullRequestModel('Creating comment failed')) {
533
return;
534
}
535
const pendingReviewId = await this.getPendingReviewId();
536
537
const { mutate, schema } = await this.githubRepository.ensure();
538
const { data } = await mutate<AddReviewThreadResponse>({
539
mutation: schema.AddReviewThread,
540
variables: {
541
input: {
542
path: commentPath,
543
body,
544
pullRequestId: this.graphNodeId,
545
pullRequestReviewId: pendingReviewId,
546
startLine: startLine === endLine ? undefined : startLine,
547
line: endLine,
548
side,
549
},
550
},
551
});
552
553
if (!data) {
554
throw new Error('Creating review thread failed.');
555
}
556
557
if (!data.addPullRequestReviewThread.thread) {
558
throw new Error('File has been deleted.');
559
}
560
561
if (!suppressDraftModeUpdate) {
562
this.hasPendingReview = true;
563
await this.updateDraftModeContext();
564
}
565
566
const thread = data.addPullRequestReviewThread.thread;
567
const newThread = parseGraphQLReviewThread(thread, this.githubRepository);
568
this._reviewThreadsCache.push(newThread);
569
this._onDidChangeReviewThreads.fire({ added: [newThread], changed: [], removed: [] });
570
return newThread;
571
}
572
573
/**
574
* Creates a new comment in reply to an existing comment
575
* @param body The text of the comment to be created
576
* @param inReplyTo The id of the comment this is in reply to
577
* @param isSingleComment Whether this is a single comment, i.e. one that
578
* will be immediately submitted and so should not show a pending label
579
* @param commitId The commit id the comment was made on
580
* @returns The new comment
581
*/
582
async createCommentReply(
583
body: string,
584
inReplyTo: string,
585
isSingleComment: boolean,
586
commitId?: string,
587
): Promise<IComment | undefined> {
588
if (!this.validatePullRequestModel('Creating comment failed')) {
589
return;
590
}
591
592
let pendingReviewId = await this.getPendingReviewId();
593
if (!pendingReviewId) {
594
pendingReviewId = await this.startReview(commitId);
595
}
596
597
const { mutate, schema } = await this.githubRepository.ensure();
598
const { data } = await mutate<AddCommentResponse>({
599
mutation: schema.AddComment,
600
variables: {
601
input: {
602
pullRequestReviewId: pendingReviewId,
603
body,
604
inReplyTo,
605
commitOID: commitId || this.head?.sha,
606
},
607
},
608
});
609
610
if (!data) {
611
throw new Error('Creating comment reply failed.');
612
}
613
614
const { comment } = data.addPullRequestReviewComment;
615
const newComment = parseGraphQLComment(comment, false, this.githubRepository);
616
617
if (isSingleComment) {
618
newComment.isDraft = false;
619
}
620
621
const threadWithComment = this._reviewThreadsCache.find(thread =>
622
thread.comments.some(comment => comment.graphNodeId === inReplyTo),
623
);
624
if (threadWithComment) {
625
threadWithComment.comments.push(newComment);
626
this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] });
627
}
628
629
return newComment;
630
}
631
632
/**
633
* Check whether there is an existing pending review and update the context key to control what comment actions are shown.
634
*/
635
async validateDraftMode(): Promise<boolean> {
636
const inDraftMode = !!(await this.getPendingReviewId());
637
if (inDraftMode !== this.hasPendingReview) {
638
this.hasPendingReview = inDraftMode;
639
}
640
641
await this.updateDraftModeContext();
642
643
return inDraftMode;
644
}
645
646
private async updateDraftModeContext() {
647
if (this.isActive) {
648
await vscode.commands.executeCommand('setContext', 'reviewInDraftMode', this.hasPendingReview);
649
}
650
}
651
652
/**
653
* Edit an existing review comment.
654
* @param comment The comment to edit
655
* @param text The new comment text
656
*/
657
async editReviewComment(comment: IComment, text: string): Promise<IComment> {
658
const { mutate, schema } = await this.githubRepository.ensure();
659
let threadWithComment = this._reviewThreadsCache.find(thread =>
660
thread.comments.some(c => c.graphNodeId === comment.graphNodeId),
661
);
662
663
if (!threadWithComment) {
664
return this.editIssueComment(comment, text);
665
}
666
667
const { data } = await mutate<EditCommentResponse>({
668
mutation: schema.EditComment,
669
variables: {
670
input: {
671
pullRequestReviewCommentId: comment.graphNodeId,
672
body: text,
673
},
674
},
675
});
676
677
if (!data) {
678
throw new Error('Editing review comment failed.');
679
}
680
681
const newComment = parseGraphQLComment(
682
data.updatePullRequestReviewComment.pullRequestReviewComment,
683
!!comment.isResolved,
684
this.githubRepository
685
);
686
if (threadWithComment) {
687
const index = threadWithComment.comments.findIndex(c => c.graphNodeId === comment.graphNodeId);
688
threadWithComment.comments.splice(index, 1, newComment);
689
this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] });
690
}
691
692
return newComment;
693
}
694
695
/**
696
* Deletes a review comment.
697
* @param commentId The comment id to delete
698
*/
699
async deleteReviewComment(commentId: string): Promise<void> {
700
try {
701
const { octokit, remote } = await this.githubRepository.ensure();
702
const id = Number(commentId);
703
const threadIndex = this._reviewThreadsCache.findIndex(thread => thread.comments.some(c => c.id === id));
704
705
if (threadIndex === -1) {
706
this.deleteIssueComment(commentId);
707
} else {
708
await octokit.call(octokit.api.pulls.deleteReviewComment, {
709
owner: remote.owner,
710
repo: remote.repositoryName,
711
comment_id: id,
712
});
713
714
if (threadIndex > -1) {
715
const threadWithComment = this._reviewThreadsCache[threadIndex];
716
const index = threadWithComment.comments.findIndex(c => c.id === id);
717
threadWithComment.comments.splice(index, 1);
718
if (threadWithComment.comments.length === 0) {
719
this._reviewThreadsCache.splice(threadIndex, 1);
720
this._onDidChangeReviewThreads.fire({ added: [], changed: [], removed: [threadWithComment] });
721
} else {
722
this._onDidChangeReviewThreads.fire({ added: [], changed: [threadWithComment], removed: [] });
723
}
724
}
725
}
726
} catch (e) {
727
throw new Error(formatError(e));
728
}
729
}
730
731
/**
732
* Get existing requests to review.
733
*/
734
async getReviewRequests(): Promise<IAccount[]> {
735
const githubRepository = this.githubRepository;
736
const { remote, octokit } = await githubRepository.ensure();
737
const result = await octokit.call(octokit.api.pulls.listRequestedReviewers, {
738
owner: remote.owner,
739
repo: remote.repositoryName,
740
pull_number: this.number,
741
});
742
743
return result.data.users.map((user: any) => convertRESTUserToAccount(user, githubRepository));
744
}
745
746
/**
747
* Add reviewers to a pull request
748
* @param reviewers A list of GitHub logins
749
*/
750
async requestReview(reviewers: string[]): Promise<void> {
751
const { octokit, remote } = await this.githubRepository.ensure();
752
await octokit.call(octokit.api.pulls.requestReviewers, {
753
owner: remote.owner,
754
repo: remote.repositoryName,
755
pull_number: this.number,
756
reviewers,
757
});
758
}
759
760
/**
761
* Remove a review request that has not yet been completed
762
* @param reviewer A GitHub Login
763
*/
764
async deleteReviewRequest(reviewers: string[]): Promise<void> {
765
const { octokit, remote } = await this.githubRepository.ensure();
766
await octokit.call(octokit.api.pulls.removeRequestedReviewers, {
767
owner: remote.owner,
768
repo: remote.repositoryName,
769
pull_number: this.number,
770
reviewers,
771
});
772
}
773
774
async deleteAssignees(assignees: string[]): Promise<void> {
775
const { octokit, remote } = await this.githubRepository.ensure();
776
await octokit.call(octokit.api.issues.removeAssignees, {
777
owner: remote.owner,
778
repo: remote.repositoryName,
779
issue_number: this.number,
780
assignees,
781
});
782
}
783
784
private diffThreads(oldReviewThreads: IReviewThread[], newReviewThreads: IReviewThread[]): void {
785
const added: IReviewThread[] = [];
786
const changed: IReviewThread[] = [];
787
const removed: IReviewThread[] = [];
788
789
newReviewThreads.forEach(thread => {
790
const existingThread = oldReviewThreads.find(t => t.id === thread.id);
791
if (existingThread) {
792
if (!equals(thread, existingThread)) {
793
changed.push(thread);
794
}
795
} else {
796
added.push(thread);
797
}
798
});
799
800
oldReviewThreads.forEach(thread => {
801
if (!newReviewThreads.find(t => t.id === thread.id)) {
802
removed.push(thread);
803
}
804
});
805
806
this._onDidChangeReviewThreads.fire({
807
added,
808
changed,
809
removed,
810
});
811
}
812
813
async getReviewThreads(): Promise<IReviewThread[]> {
814
const { remote, query, schema } = await this.githubRepository.ensure();
815
try {
816
const { data } = await query<PullRequestCommentsResponse>({
817
query: schema.PullRequestComments,
818
variables: {
819
owner: remote.owner,
820
name: remote.repositoryName,
821
number: this.number,
822
},
823
});
824
825
const reviewThreads = data.repository.pullRequest.reviewThreads.nodes.map(node => {
826
return parseGraphQLReviewThread(node, this.githubRepository);
827
});
828
829
const oldReviewThreads = this._reviewThreadsCache;
830
this._reviewThreadsCache = reviewThreads;
831
this.diffThreads(oldReviewThreads, reviewThreads);
832
return reviewThreads;
833
} catch (e) {
834
Logger.error(`Failed to get pull request review comments: ${e}`, PullRequestModel.ID);
835
return [];
836
}
837
}
838
839
/**
840
* Get all review comments.
841
*/
842
async initializeReviewComments(): Promise<void> {
843
const { remote, query, schema } = await this.githubRepository.ensure();
844
try {
845
const { data } = await query<PullRequestCommentsResponse>({
846
query: schema.PullRequestComments,
847
variables: {
848
owner: remote.owner,
849
name: remote.repositoryName,
850
number: this.number,
851
},
852
});
853
854
const comments = data.repository.pullRequest.reviewThreads.nodes
855
.map(node => node.comments.nodes.map(comment => parseGraphQLComment(comment, node.isResolved, this.githubRepository), remote))
856
.reduce((prev, curr) => prev.concat(curr), [])
857
.sort((a: IComment, b: IComment) => {
858
return a.createdAt > b.createdAt ? 1 : -1;
859
});
860
861
this.comments = comments;
862
} catch (e) {
863
Logger.error(`Failed to get pull request review comments: ${e}`, PullRequestModel.ID);
864
}
865
}
866
867
/**
868
* Get a list of the commits within a pull request.
869
*/
870
async getCommits(): Promise<OctokitCommon.PullsListCommitsResponseData> {
871
try {
872
Logger.debug(`Fetch commits of PR #${this.number} - enter`, PullRequestModel.ID);
873
const { remote, octokit } = await this.githubRepository.ensure();
874
const commitData = await octokit.call(octokit.api.pulls.listCommits, {
875
pull_number: this.number,
876
owner: remote.owner,
877
repo: remote.repositoryName,
878
});
879
Logger.debug(`Fetch commits of PR #${this.number} - done`, PullRequestModel.ID);
880
881
return commitData.data;
882
} catch (e) {
883
vscode.window.showErrorMessage(`Fetching commits failed: ${formatError(e)}`);
884
return [];
885
}
886
}
887
888
/**
889
* Get all changed files within a commit
890
* @param commit The commit
891
*/
892
async getCommitChangedFiles(
893
commit: OctokitCommon.PullsListCommitsResponseData[0],
894
): Promise<OctokitCommon.ReposGetCommitResponseFiles> {
895
try {
896
Logger.debug(
897
`Fetch file changes of commit ${commit.sha} in PR #${this.number} - enter`,
898
PullRequestModel.ID,
899
);
900
const { octokit, remote } = await this.githubRepository.ensure();
901
const fullCommit = await octokit.call(octokit.api.repos.getCommit, {
902
owner: remote.owner,
903
repo: remote.repositoryName,
904
ref: commit.sha,
905
});
906
Logger.debug(
907
`Fetch file changes of commit ${commit.sha} in PR #${this.number} - done`,
908
PullRequestModel.ID,
909
);
910
911
return fullCommit.data.files ?? [];
912
} catch (e) {
913
vscode.window.showErrorMessage(`Fetching commit file changes failed: ${formatError(e)}`);
914
return [];
915
}
916
}
917
918
/**
919
* Gets file content for a file at the specified commit
920
* @param filePath The file path
921
* @param commit The commit
922
*/
923
async getFile(filePath: string, commit: string) {
924
const { octokit, remote } = await this.githubRepository.ensure();
925
const fileContent = await octokit.call(octokit.api.repos.getContent, {
926
owner: remote.owner,
927
repo: remote.repositoryName,
928
path: filePath,
929
ref: commit,
930
});
931
932
if (Array.isArray(fileContent.data)) {
933
throw new Error(`Unexpected array response when getting file ${filePath}`);
934
}
935
936
const contents = (fileContent.data as any).content ?? '';
937
const buff = buffer.Buffer.from(contents, (fileContent.data as any).encoding);
938
return buff.toString();
939
}
940
941
/**
942
* Get the timeline events of a pull request, including comments, reviews, commits, merges, deletes, and assigns.
943
*/
944
async getTimelineEvents(): Promise<TimelineEvent[]> {
945
Logger.debug(`Fetch timeline events of PR #${this.number} - enter`, PullRequestModel.ID);
946
const { query, remote, schema } = await this.githubRepository.ensure();
947
948
try {
949
const [{ data }, latestReviewCommitInfo, currentUser, reviewThreads] = await Promise.all([
950
query<TimelineEventsResponse>({
951
query: schema.TimelineEvents,
952
variables: {
953
owner: remote.owner,
954
name: remote.repositoryName,
955
number: this.number,
956
},
957
}),
958
this.getViewerLatestReviewCommit(),
959
this.githubRepository.getAuthenticatedUser(),
960
this.getReviewThreads()
961
]);
962
963
const ret = data.repository.pullRequest.timelineItems.nodes;
964
const events = parseGraphQLTimelineEvents(ret, this.githubRepository);
965
966
this.addReviewTimelineEventComments(events, reviewThreads);
967
insertNewCommitsSinceReview(events, latestReviewCommitInfo?.sha, currentUser, this.head);
968
969
return events;
970
} catch (e) {
971
console.log(e);
972
return [];
973
}
974
}
975
976
private addReviewTimelineEventComments(events: TimelineEvent[], reviewThreads: IReviewThread[]): void {
977
interface CommentNode extends IComment {
978
childComments?: CommentNode[];
979
}
980
981
const reviewEvents = events.filter((e): e is CommonReviewEvent => e.event === EventType.Reviewed);
982
const reviewComments = reviewThreads.reduce((previous, current) => (previous as IComment[]).concat(current.comments), []);
983
984
const reviewEventsById = reviewEvents.reduce((index, evt) => {
985
index[evt.id] = evt;
986
evt.comments = [];
987
return index;
988
}, {} as { [key: number]: CommonReviewEvent });
989
990
const commentsById = reviewComments.reduce((index, evt) => {
991
index[evt.id] = evt;
992
return index;
993
}, {} as { [key: number]: CommentNode });
994
995
const roots: CommentNode[] = [];
996
let i = reviewComments.length;
997
while (i-- > 0) {
998
const c: CommentNode = reviewComments[i];
999
if (!c.inReplyToId) {
1000
roots.unshift(c);
1001
continue;
1002
}
1003
const parent = commentsById[c.inReplyToId];
1004
parent.childComments = parent.childComments || [];
1005
parent.childComments = [c, ...(c.childComments || []), ...parent.childComments];
1006
}
1007
1008
roots.forEach(c => {
1009
const review = reviewEventsById[c.pullRequestReviewId!];
1010
if (review) {
1011
review.comments = review.comments.concat(c).concat(c.childComments || []);
1012
}
1013
});
1014
1015
reviewThreads.forEach(thread => {
1016
if (!thread.prReviewDatabaseId || !reviewEventsById[thread.prReviewDatabaseId]) {
1017
return;
1018
}
1019
const prReviewThreadEvent = reviewEventsById[thread.prReviewDatabaseId];
1020
prReviewThreadEvent.reviewThread = {
1021
threadId: thread.id,
1022
canResolve: thread.viewerCanResolve,
1023
canUnresolve: thread.viewerCanUnresolve,
1024
isResolved: thread.isResolved
1025
};
1026
1027
});
1028
1029
const pendingReview = reviewEvents.filter(r => r.state.toLowerCase() === 'pending')[0];
1030
if (pendingReview) {
1031
// Ensures that pending comments made in reply to other reviews are included for the pending review
1032
pendingReview.comments = reviewComments.filter(c => c.isDraft);
1033
}
1034
}
1035
1036
private async _getReviewRequiredCheck() {
1037
const { query, remote, octokit, schema } = await this.githubRepository.ensure();
1038
1039
const [branch, reviewStates] = await Promise.all([
1040
octokit.call(octokit.api.repos.getBranch, { branch: this.base.ref, owner: remote.owner, repo: remote.repositoryName }),
1041
query<LatestReviewsResponse>({
1042
query: schema.LatestReviews,
1043
variables: {
1044
owner: remote.owner,
1045
name: remote.repositoryName,
1046
number: this.number,
1047
}
1048
})
1049
]);
1050
if (branch.data.protected && branch.data.protection.required_status_checks && branch.data.protection.required_status_checks.enforcement_level !== 'off') {
1051
// We need to add the "review required" check manually.
1052
return {
1053
id: REVIEW_REQUIRED_CHECK_ID,
1054
context: 'Branch Protection',
1055
description: vscode.l10n.t('Other requirements have not been met.'),
1056
state: (reviewStates.data as LatestReviewsResponse).repository.pullRequest.latestReviews.nodes.every(node => node.state !== 'CHANGES_REQUESTED') ? CheckState.Neutral : CheckState.Failure,
1057
target_url: this.html_url
1058
};
1059
}
1060
return undefined;
1061
}
1062
1063
/**
1064
* Get the status checks of the pull request, those for the last commit.
1065
*/
1066
async getStatusChecks(): Promise<PullRequestChecks | undefined> {
1067
let checks = await this.githubRepository.getStatusChecks(this.number);
1068
1069
// Fun info: The checks don't include whether a review is required.
1070
// Also, unless you're an admin on the repo, you can't just do octokit.repos.getBranchProtection
1071
if ((this.item.mergeable === PullRequestMergeability.NotMergeable) && (!checks || checks.statuses.every(status => status.state === CheckState.Success))) {
1072
const reviewRequiredCheck = await this._getReviewRequiredCheck();
1073
if (reviewRequiredCheck) {
1074
if (!checks) {
1075
checks = {
1076
state: CheckState.Failure,
1077
statuses: []
1078
};
1079
}
1080
checks.statuses.push(reviewRequiredCheck);
1081
checks.state = CheckState.Failure;
1082
}
1083
}
1084
1085
return checks;
1086
}
1087
1088
static async openDiffFromComment(
1089
folderManager: FolderRepositoryManager,
1090
pullRequestModel: PullRequestModel,
1091
comment: IComment,
1092
): Promise<void> {
1093
const contentChanges = await pullRequestModel.getFileChangesInfo();
1094
const change = contentChanges.find(
1095
fileChange => fileChange.fileName === comment.path || fileChange.previousFileName === comment.path,
1096
);
1097
if (!change) {
1098
throw new Error(`Can't find matching file`);
1099
}
1100
1101
const pathSegments = comment.path!.split('/');
1102
this.openDiff(folderManager, pullRequestModel, change, pathSegments[pathSegments.length - 1]);
1103
}
1104
1105
static async openFirstDiff(
1106
folderManager: FolderRepositoryManager,
1107
pullRequestModel: PullRequestModel,
1108
) {
1109
const contentChanges = await pullRequestModel.getFileChangesInfo();
1110
if (!contentChanges.length) {
1111
return;
1112
}
1113
1114
const firstChange = contentChanges[0];
1115
this.openDiff(folderManager, pullRequestModel, firstChange, firstChange.fileName);
1116
}
1117
1118
static async openDiff(
1119
folderManager: FolderRepositoryManager,
1120
pullRequestModel: PullRequestModel,
1121
change: SlimFileChange | InMemFileChange,
1122
diffTitle: string
1123
): Promise<void> {
1124
1125
1126
let headUri, baseUri: vscode.Uri;
1127
if (!pullRequestModel.equals(folderManager.activePullRequest)) {
1128
const headCommit = pullRequestModel.head!.sha;
1129
const parentFileName = change.status === GitChangeType.RENAME ? change.previousFileName! : change.fileName;
1130
headUri = toPRUri(
1131
vscode.Uri.file(resolvePath(folderManager.repository.rootUri, change.fileName)),
1132
pullRequestModel,
1133
change.baseCommit,
1134
headCommit,
1135
change.fileName,
1136
false,
1137
change.status,
1138
change.previousFileName
1139
);
1140
baseUri = toPRUri(
1141
vscode.Uri.file(resolvePath(folderManager.repository.rootUri, parentFileName)),
1142
pullRequestModel,
1143
change.baseCommit,
1144
headCommit,
1145
change.fileName,
1146
true,
1147
change.status,
1148
change.previousFileName
1149
);
1150
} else {
1151
const uri = vscode.Uri.file(path.resolve(folderManager.repository.rootUri.fsPath, change.fileName));
1152
1153
headUri =
1154
change.status === GitChangeType.DELETE
1155
? toReviewUri(
1156
uri,
1157
undefined,
1158
undefined,
1159
'',
1160
false,
1161
{ base: false },
1162
folderManager.repository.rootUri,
1163
)
1164
: uri;
1165
1166
const mergeBase = pullRequestModel.mergeBase || pullRequestModel.base.sha;
1167
baseUri = toReviewUri(
1168
uri,
1169
change.status === GitChangeType.RENAME ? change.previousFileName : change.fileName,
1170
undefined,
1171
change.status === GitChangeType.ADD ? '' : mergeBase,
1172
false,
1173
{ base: true },
1174
folderManager.repository.rootUri,
1175
);
1176
}
1177
1178
vscode.commands.executeCommand(
1179
'vscode.diff',
1180
baseUri,
1181
headUri,
1182
`${diffTitle} (Pull Request)`,
1183
{},
1184
);
1185
}
1186
1187
private _fileChanges: Map<string, SlimFileChange | InMemFileChange> = new Map();
1188
get fileChanges(): Map<string, SlimFileChange | InMemFileChange> {
1189
return this._fileChanges;
1190
}
1191
1192
async getFileChangesInfo() {
1193
this._fileChanges.clear();
1194
const data = await this.getRawFileChangesInfo();
1195
const mergebase = this.mergeBase || this.base.sha;
1196
const parsed = await parseDiff(data, mergebase);
1197
parsed.forEach(fileChange => {
1198
this._fileChanges.set(fileChange.fileName, fileChange);
1199
});
1200
return parsed;
1201
}
1202
1203
/**
1204
* List the changed files in a pull request.
1205
*/
1206
private async getRawFileChangesInfo(): Promise<IRawFileChange[]> {
1207
Logger.debug(
1208
`Fetch file changes, base, head and merge base of PR #${this.number} - enter`,
1209
PullRequestModel.ID,
1210
);
1211
const githubRepository = this.githubRepository;
1212
const { octokit, remote } = await githubRepository.ensure();
1213
1214
if (!this.base) {
1215
const info = await octokit.call(octokit.api.pulls.get, {
1216
owner: remote.owner,
1217
repo: remote.repositoryName,
1218
pull_number: this.number,
1219
});
1220
this.update(convertRESTPullRequestToRawPullRequest(info.data, githubRepository));
1221
}
1222
1223
let compareWithBaseRef = this.base.sha;
1224
const latestReview = await this.getViewerLatestReviewCommit();
1225
const oldHasChangesSinceReview = this.hasChangesSinceLastReview;
1226
this.hasChangesSinceLastReview = latestReview !== undefined && this.head?.sha !== latestReview.sha;
1227
1228
if (this._showChangesSinceReview && this.hasChangesSinceLastReview && latestReview != undefined) {
1229
compareWithBaseRef = latestReview.sha;
1230
}
1231
1232
if (this.item.merged) {
1233
const response = await restPaginate<typeof octokit.api.pulls.listFiles, IRawFileChange>(octokit.api.pulls.listFiles, {
1234
repo: remote.repositoryName,
1235
owner: remote.owner,
1236
pull_number: this.number,
1237
});
1238
1239
// Use the original base to compare against for merged PRs
1240
this.mergeBase = this.base.sha;
1241
1242
return response;
1243
}
1244
1245
const { data } = await octokit.call(octokit.api.repos.compareCommits, {
1246
repo: remote.repositoryName,
1247
owner: remote.owner,
1248
base: `${this.base.repositoryCloneUrl.owner}:${compareWithBaseRef}`,
1249
head: `${this.head!.repositoryCloneUrl.owner}:${this.head!.sha}`,
1250
});
1251
1252
this.mergeBase = data.merge_base_commit.sha;
1253
1254
const MAX_FILE_CHANGES_IN_COMPARE_COMMITS = 100;
1255
let files: IRawFileChange[] = [];
1256
1257
if (data.files && data.files.length >= MAX_FILE_CHANGES_IN_COMPARE_COMMITS) {
1258
// compareCommits will return a maximum of 100 changed files
1259
// If we have (maybe) more than that, we'll need to fetch them with listFiles API call
1260
Logger.debug(
1261
`More than ${MAX_FILE_CHANGES_IN_COMPARE_COMMITS} files changed, fetching all file changes of PR #${this.number}`,
1262
PullRequestModel.ID,
1263
);
1264
files = await restPaginate<typeof octokit.api.pulls.listFiles, IRawFileChange>(octokit.api.pulls.listFiles, {
1265
owner: this.base.repositoryCloneUrl.owner,
1266
pull_number: this.number,
1267
repo: remote.repositoryName,
1268
});
1269
} else {
1270
// if we're under the limit, just use the result from compareCommits, don't make additional API calls.
1271
files = data.files ? data.files as IRawFileChange[] : [];
1272
}
1273
1274
if (oldHasChangesSinceReview !== undefined && oldHasChangesSinceReview !== this.hasChangesSinceLastReview && this.hasChangesSinceLastReview && this._showChangesSinceReview) {
1275
this._onDidChangeChangesSinceReview.fire();
1276
}
1277
1278
Logger.debug(
1279
`Fetch file changes and merge base of PR #${this.number} - done, total files ${files.length} `,
1280
PullRequestModel.ID,
1281
);
1282
return files;
1283
}
1284
1285
get autoMerge(): boolean {
1286
return !!this.item.autoMerge;
1287
}
1288
1289
get autoMergeMethod(): MergeMethod | undefined {
1290
return this.item.autoMergeMethod;
1291
}
1292
1293
get allowAutoMerge(): boolean {
1294
return !!this.item.allowAutoMerge;
1295
}
1296
1297
/**
1298
* Get the current mergeability of the pull request.
1299
*/
1300
async getMergeability(): Promise<PullRequestMergeability> {
1301
try {
1302
Logger.debug(`Fetch pull request mergeability ${this.number} - enter`, PullRequestModel.ID);
1303
const { query, remote, schema } = await this.githubRepository.ensure();
1304
1305
const { data } = await query<PullRequestMergabilityResponse>({
1306
query: schema.PullRequestMergeability,
1307
variables: {
1308
owner: remote.owner,
1309
name: remote.repositoryName,
1310
number: this.number,
1311
},
1312
});
1313
Logger.debug(`Fetch pull request mergeability ${this.number} - done`, PullRequestModel.ID);
1314
const mergeability = parseMergeability(data.repository.pullRequest.mergeable, data.repository.pullRequest.mergeStateStatus);
1315
this.item.mergeable = mergeability;
1316
return mergeability;
1317
} catch (e) {
1318
Logger.error(`Unable to fetch PR Mergeability: ${e}`, PullRequestModel.ID);
1319
return PullRequestMergeability.Unknown;
1320
}
1321
}
1322
1323
/**
1324
* Set a draft pull request as ready to be reviewed.
1325
*/
1326
async setReadyForReview(): Promise<any> {
1327
try {
1328
const { mutate, schema } = await this.githubRepository.ensure();
1329
1330
const { data } = await mutate<MarkPullRequestReadyForReviewResponse>({
1331
mutation: schema.ReadyForReview,
1332
variables: {
1333
input: {
1334
pullRequestId: this.graphNodeId,
1335
},
1336
},
1337
});
1338
1339
return data!.markPullRequestReadyForReview.pullRequest.isDraft;
1340
} catch (e) {
1341
throw e;
1342
}
1343
}
1344
1345
private updateCommentReactions(graphNodeId: string, reactionGroups: ReactionGroup[]) {
1346
const reviewThread = this._reviewThreadsCache.find(thread =>
1347
thread.comments.some(c => c.graphNodeId === graphNodeId),
1348
);
1349
if (reviewThread) {
1350
const updatedComment = reviewThread.comments.find(c => c.graphNodeId === graphNodeId);
1351
if (updatedComment) {
1352
updatedComment.reactions = parseGraphQLReaction(reactionGroups);
1353
this._onDidChangeReviewThreads.fire({ added: [], changed: [reviewThread], removed: [] });
1354
}
1355
}
1356
}
1357
1358
async addCommentReaction(graphNodeId: string, reaction: vscode.CommentReaction): Promise<AddReactionResponse | undefined> {
1359
const reactionEmojiToContent = getReactionGroup().reduce((prev, curr) => {
1360
prev[curr.label] = curr.title;
1361
return prev;
1362
}, {} as { [key: string]: string });
1363
const { mutate, schema } = await this.githubRepository.ensure();
1364
const { data } = await mutate<AddReactionResponse>({
1365
mutation: schema.AddReaction,
1366
variables: {
1367
input: {
1368
subjectId: graphNodeId,
1369
content: reactionEmojiToContent[reaction.label!],
1370
},
1371
},
1372
});
1373
1374
if (!data) {
1375
throw new Error('Add comment reaction failed.');
1376
}
1377
1378
const reactionGroups = data.addReaction.subject.reactionGroups;
1379
this.updateCommentReactions(graphNodeId, reactionGroups);
1380
1381
return data;
1382
}
1383
1384
async deleteCommentReaction(
1385
graphNodeId: string,
1386
reaction: vscode.CommentReaction,
1387
): Promise<DeleteReactionResponse | undefined> {
1388
const reactionEmojiToContent = getReactionGroup().reduce((prev, curr) => {
1389
prev[curr.label] = curr.title;
1390
return prev;
1391
}, {} as { [key: string]: string });
1392
const { mutate, schema } = await this.githubRepository.ensure();
1393
const { data } = await mutate<DeleteReactionResponse>({
1394
mutation: schema.DeleteReaction,
1395
variables: {
1396
input: {
1397
subjectId: graphNodeId,
1398
content: reactionEmojiToContent[reaction.label!],
1399
},
1400
},
1401
});
1402
1403
if (!data) {
1404
throw new Error('Delete comment reaction failed.');
1405
}
1406
1407
const reactionGroups = data.removeReaction.subject.reactionGroups;
1408
this.updateCommentReactions(graphNodeId, reactionGroups);
1409
1410
return data;
1411
}
1412
1413
async resolveReviewThread(threadId: string): Promise<void> {
1414
const { mutate, schema } = await this.githubRepository.ensure();
1415
1416
// optimistically update
1417
const oldThread = this._reviewThreadsCache.find(thread => thread.id === threadId);
1418
if (oldThread && oldThread.viewerCanResolve) {
1419
oldThread.isResolved = true;
1420
oldThread.viewerCanResolve = false;
1421
oldThread.viewerCanUnresolve = true;
1422
this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] });
1423
}
1424
1425
const { data } = await mutate<ResolveReviewThreadResponse>({
1426
mutation: schema.ResolveReviewThread,
1427
variables: {
1428
input: {
1429
threadId,
1430
},
1431
},
1432
});
1433
1434
if (!data) {
1435
// Undo optimistic update
1436
if (oldThread && oldThread.viewerCanUnresolve) {
1437
oldThread.isResolved = false;
1438
oldThread.viewerCanResolve = true;
1439
oldThread.viewerCanUnresolve = false;
1440
this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] });
1441
}
1442
throw new Error('Resolve review thread failed.');
1443
}
1444
1445
const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId);
1446
if (index > -1) {
1447
const thread = parseGraphQLReviewThread(data.resolveReviewThread.thread, this.githubRepository);
1448
this._reviewThreadsCache.splice(index, 1, thread);
1449
this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] });
1450
}
1451
}
1452
1453
async unresolveReviewThread(threadId: string): Promise<void> {
1454
const { mutate, schema } = await this.githubRepository.ensure();
1455
1456
// optimistically update
1457
const oldThread = this._reviewThreadsCache.find(thread => thread.id === threadId);
1458
if (oldThread && oldThread.viewerCanUnresolve) {
1459
oldThread.isResolved = false;
1460
oldThread.viewerCanUnresolve = false;
1461
oldThread.viewerCanResolve = true;
1462
this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] });
1463
}
1464
1465
const { data } = await mutate<UnresolveReviewThreadResponse>({
1466
mutation: schema.UnresolveReviewThread,
1467
variables: {
1468
input: {
1469
threadId,
1470
},
1471
},
1472
});
1473
1474
if (!data) {
1475
// Undo optimistic update
1476
if (oldThread && oldThread.viewerCanResolve) {
1477
oldThread.isResolved = true;
1478
oldThread.viewerCanUnresolve = true;
1479
oldThread.viewerCanResolve = false;
1480
this._onDidChangeReviewThreads.fire({ added: [], changed: [oldThread], removed: [] });
1481
}
1482
throw new Error('Unresolve review thread failed.');
1483
}
1484
1485
const index = this._reviewThreadsCache.findIndex(thread => thread.id === threadId);
1486
if (index > -1) {
1487
const thread = parseGraphQLReviewThread(data.unresolveReviewThread.thread, this.githubRepository);
1488
this._reviewThreadsCache.splice(index, 1, thread);
1489
this._onDidChangeReviewThreads.fire({ added: [], changed: [thread], removed: [] });
1490
}
1491
}
1492
1493
async enableAutoMerge(mergeMethod: MergeMethod): Promise<void> {
1494
try {
1495
const { mutate, schema } = await this.githubRepository.ensure();
1496
const { data } = await mutate({
1497
mutation: schema.EnablePullRequestAutoMerge,
1498
variables: {
1499
input: {
1500
mergeMethod: mergeMethod.toUpperCase(),
1501
pullRequestId: this.graphNodeId
1502
}
1503
}
1504
});
1505
1506
if (!data) {
1507
throw new Error('Enable auto-merge failed.');
1508
}
1509
this.item.autoMerge = true;
1510
this.item.autoMergeMethod = mergeMethod;
1511
} catch (e) {
1512
if (e.message === 'GraphQL error: ["Pull request Pull request is in clean status"]') {
1513
vscode.window.showWarningMessage(vscode.l10n.t('Unable to enable auto-merge. Pull request status checks are already green.'));
1514
} else {
1515
throw e;
1516
}
1517
}
1518
}
1519
1520
async disableAutoMerge(): Promise<void> {
1521
try {
1522
const { mutate, schema } = await this.githubRepository.ensure();
1523
const { data } = await mutate({
1524
mutation: schema.DisablePullRequestAutoMerge,
1525
variables: {
1526
input: {
1527
pullRequestId: this.graphNodeId
1528
}
1529
}
1530
});
1531
1532
if (!data) {
1533
throw new Error('Disable auto-merge failed.');
1534
}
1535
this.item.autoMerge = false;
1536
} catch (e) {
1537
if (e.message === 'GraphQL error: ["Pull request Pull request is in clean status"]') {
1538
vscode.window.showWarningMessage(vscode.l10n.t('Unable to enable auto-merge. Pull request status checks are already green.'));
1539
} else {
1540
throw e;
1541
}
1542
}
1543
}
1544
1545
async initializePullRequestFileViewState(): Promise<void> {
1546
const { query, schema, remote } = await this.githubRepository.ensure();
1547
1548
const changed: { fileName: string, viewed: ViewedState }[] = [];
1549
let after: string | null = null;
1550
let hasNextPage = false;
1551
1552
do {
1553
const { data } = await query<PullRequestFilesResponse>({
1554
query: schema.PullRequestFiles,
1555
variables: {
1556
owner: remote.owner,
1557
name: remote.repositoryName,
1558
number: this.number,
1559
after: after,
1560
},
1561
});
1562
1563
data.repository.pullRequest.files.nodes.forEach(n => {
1564
if (this._fileChangeViewedState[n.path] !== n.viewerViewedState) {
1565
changed.push({ fileName: n.path, viewed: n.viewerViewedState });
1566
}
1567
// No event for setting the file viewed state here.
1568
// Instead, wait until all the changes have been made and set the context at the end.
1569
this.setFileViewedState(n.path, n.viewerViewedState, false);
1570
});
1571
1572
hasNextPage = data.repository.pullRequest.files.pageInfo.hasNextPage;
1573
after = data.repository.pullRequest.files.pageInfo.endCursor;
1574
} while (hasNextPage);
1575
1576
if (changed.length) {
1577
this._onDidChangeFileViewedState.fire({ changed });
1578
}
1579
}
1580
1581
async markFileAsViewed(filePathOrSubpath: string): Promise<void> {
1582
const { mutate, schema } = await this.githubRepository.ensure();
1583
const fileName = filePathOrSubpath.startsWith(this.githubRepository.rootUri.path) ?
1584
filePathOrSubpath.substring(this.githubRepository.rootUri.path.length + 1) : filePathOrSubpath;
1585
await mutate<void>({
1586
mutation: schema.MarkFileAsViewed,
1587
variables: {
1588
input: {
1589
path: fileName,
1590
pullRequestId: this.graphNodeId,
1591
},
1592
},
1593
});
1594
1595
this.setFileViewedState(fileName, ViewedState.VIEWED, true);
1596
}
1597
1598
async unmarkFileAsViewed(filePathOrSubpath: string): Promise<void> {
1599
const { mutate, schema } = await this.githubRepository.ensure();
1600
const fileName = filePathOrSubpath.startsWith(this.githubRepository.rootUri.path) ?
1601
filePathOrSubpath.substring(this.githubRepository.rootUri.path.length + 1) : filePathOrSubpath;
1602
await mutate<void>({
1603
mutation: schema.UnmarkFileAsViewed,
1604
variables: {
1605
input: {
1606
path: fileName,
1607
pullRequestId: this.graphNodeId,
1608
},
1609
},
1610
});
1611
1612
this.setFileViewedState(fileName, ViewedState.UNVIEWED, true);
1613
}
1614
1615
async unmarkAllFilesAsViewed(): Promise<void[]> {
1616
return Promise.all(Array.from(this.fileChanges.keys()).map(change => this.unmarkFileAsViewed(change)));
1617
}
1618
1619
private setFileViewedState(fileSubpath: string, viewedState: ViewedState, event: boolean) {
1620
const filePath = vscode.Uri.joinPath(this.githubRepository.rootUri, fileSubpath).fsPath;
1621
switch (viewedState) {
1622
case ViewedState.DISMISSED: {
1623
this._viewedFiles.delete(filePath);
1624
this._unviewedFiles.delete(filePath);
1625
break;
1626
}
1627
case ViewedState.UNVIEWED: {
1628
this._viewedFiles.delete(filePath);
1629
this._unviewedFiles.add(filePath);
1630
break;
1631
}
1632
case ViewedState.VIEWED: {
1633
this._viewedFiles.add(filePath);
1634
this._unviewedFiles.delete(filePath);
1635
}
1636
}
1637
this._fileChangeViewedState[fileSubpath] = viewedState;
1638
if (event) {
1639
this._onDidChangeFileViewedState.fire({ changed: [{ fileName: fileSubpath, viewed: viewedState }] });
1640
}
1641
}
1642
1643
public getViewedFileStates() {
1644
return {
1645
viewed: this._viewedFiles,
1646
unviewed: this._unviewedFiles
1647
};
1648
}
1649
}
1650
1651