Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts
13401 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 { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
7
import { autorun, derivedOpts, IObservable, observableValue, transaction } from '../../../../base/common/observable.js';
8
import { isEqual } from '../../../../base/common/resources.js';
9
import { URI, UriComponents } from '../../../../base/common/uri.js';
10
import { IRange, Range } from '../../../../editor/common/core/range.js';
11
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
12
import { ICommandService } from '../../../../platform/commands/common/commands.js';
13
import { ILogService } from '../../../../platform/log/common/log.js';
14
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
15
import { generateUuid } from '../../../../base/common/uuid.js';
16
import { hash } from '../../../../base/common/hash.js';
17
import { hasKey } from '../../../../base/common/types.js';
18
import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
19
import { IGitHubService } from '../../github/browser/githubService.js';
20
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
21
import { ISessionFileChange } from '../../../services/sessions/common/session.js';
22
import { structuralEquals } from '../../../../base/common/equals.js';
23
24
// --- Types -------------------------------------------------------------------
25
26
export interface ICodeReviewComment {
27
readonly id: string;
28
readonly uri: URI;
29
readonly range: IRange;
30
readonly body: string;
31
readonly kind: string;
32
readonly severity: string;
33
readonly suggestion?: ICodeReviewSuggestion;
34
}
35
36
export interface ICodeReviewSuggestion {
37
readonly edits: readonly ICodeReviewSuggestionChange[];
38
}
39
40
export interface ICodeReviewSuggestionChange {
41
readonly range: IRange;
42
readonly newText: string;
43
readonly oldText: string;
44
}
45
46
export interface ICodeReviewFile {
47
readonly currentUri: URI;
48
readonly baseUri?: URI;
49
}
50
51
export function getCodeReviewFilesFromSessionChanges(changes: readonly ISessionFileChange[]): readonly ICodeReviewFile[] {
52
return changes.map(change => {
53
if (isIChatSessionFileChange2(change)) {
54
return {
55
currentUri: change.modifiedUri ?? change.uri,
56
baseUri: change.originalUri,
57
};
58
}
59
60
return {
61
currentUri: change.modifiedUri,
62
baseUri: change.originalUri,
63
};
64
});
65
}
66
67
export function getCodeReviewVersion(files: readonly ICodeReviewFile[]): string {
68
const stableFileList = files
69
.map(file => `${file.currentUri.toString()}|${file.baseUri?.toString() ?? ''}`)
70
.sort();
71
72
return `v1:${stableFileList.length}:${hash(stableFileList)}`;
73
}
74
75
export const MAX_CODE_REVIEWS_PER_SESSION_VERSION = 5;
76
77
export const enum CodeReviewStateKind {
78
Idle = 'idle',
79
Loading = 'loading',
80
Result = 'result',
81
Error = 'error',
82
}
83
84
export type ICodeReviewState =
85
| { readonly kind: CodeReviewStateKind.Idle }
86
| { readonly kind: CodeReviewStateKind.Loading; readonly version: string; readonly reviewCount: number }
87
| { readonly kind: CodeReviewStateKind.Result; readonly version: string; readonly reviewCount: number; readonly comments: readonly ICodeReviewComment[]; readonly didProduceComments: boolean }
88
| { readonly kind: CodeReviewStateKind.Error; readonly version: string; readonly reviewCount: number; readonly reason: string };
89
90
// --- PR Review Types ---------------------------------------------------------
91
92
export const enum PRReviewStateKind {
93
None = 'none',
94
Loading = 'loading',
95
Loaded = 'loaded',
96
Error = 'error',
97
}
98
99
export type IPRReviewState =
100
| { readonly kind: PRReviewStateKind.None }
101
| { readonly kind: PRReviewStateKind.Loading }
102
| { readonly kind: PRReviewStateKind.Loaded; readonly comments: readonly IPRReviewComment[] }
103
| { readonly kind: PRReviewStateKind.Error; readonly reason: string };
104
105
export interface IPRReviewComment {
106
readonly id: string;
107
readonly uri: URI;
108
readonly range: IRange;
109
readonly body: string;
110
readonly author: string;
111
}
112
113
/** Shape of a single comment as returned by the code review command. */
114
interface IRawCodeReviewComment {
115
readonly uri: IRawCodeReviewUri;
116
readonly range: IRawCodeReviewRange;
117
readonly body?: string;
118
readonly kind?: string;
119
readonly severity?: string;
120
readonly suggestion?: IRawCodeReviewSuggestion;
121
}
122
123
type IRawCodeReviewUri = URI | UriComponents | string;
124
125
interface IRawCodeReviewPosition {
126
readonly line?: number;
127
readonly character?: number;
128
}
129
130
interface IRawCodeReviewRangeWithPositions {
131
readonly start?: IRawCodeReviewPosition;
132
readonly end?: IRawCodeReviewPosition;
133
}
134
135
interface IRawCodeReviewRangeWithLines {
136
readonly startLine?: number;
137
readonly startColumn?: number;
138
readonly endLine?: number;
139
readonly endColumn?: number;
140
}
141
142
type IRawCodeReviewRangeTuple = readonly [IRawCodeReviewPosition, IRawCodeReviewPosition];
143
144
type IRawCodeReviewRange = IRange | IRawCodeReviewRangeWithPositions | IRawCodeReviewRangeWithLines | IRawCodeReviewRangeTuple;
145
146
interface IRawCodeReviewSuggestion {
147
readonly edits: readonly IRawCodeReviewSuggestionChange[];
148
}
149
150
interface IRawCodeReviewSuggestionChange {
151
readonly range: IRawCodeReviewRange;
152
readonly newText: string;
153
readonly oldText: string;
154
}
155
156
// --- Service Interface -------------------------------------------------------
157
158
export const ICodeReviewService = createDecorator<ICodeReviewService>('codeReviewService');
159
160
export interface ICodeReviewService {
161
readonly _serviceBrand: undefined;
162
163
/**
164
* Get the observable review state for a session.
165
*/
166
getReviewState(sessionResource: URI): IObservable<ICodeReviewState>;
167
168
/**
169
* Synchronously check if a completed review exists for the given session+version.
170
*/
171
hasReview(sessionResource: URI, version: string): boolean;
172
173
/**
174
* Request a code review for the given session. The review is associated with
175
* a version string (fingerprint of changed files). If a review is already in
176
* progress or there are still unresolved review comments for this version,
177
* this is a no-op.
178
*/
179
requestReview(sessionResource: URI, version: string, files: readonly { readonly currentUri: URI; readonly baseUri?: URI }[]): void;
180
181
/**
182
* Remove a single comment from the review results.
183
*/
184
removeComment(sessionResource: URI, commentId: string): void;
185
186
/**
187
* Update the body text of a single code review comment.
188
*/
189
updateComment(sessionResource: URI, commentId: string, newBody: string): void;
190
191
/**
192
* Dismiss/clear the review for a session entirely.
193
*/
194
dismissReview(sessionResource: URI): void;
195
196
/**
197
* Get the observable PR review state for a session.
198
* Returns unresolved review comments from the PR associated with the session.
199
*/
200
getPRReviewState(sessionResource: URI): IObservable<IPRReviewState>;
201
202
/**
203
* Resolve a PR review thread on GitHub and remove it from local state.
204
*/
205
resolvePRReviewThread(sessionResource: URI, threadId: string): Promise<void>;
206
207
/**
208
* Mark a PR review comment as locally converted to agent feedback.
209
* The comment is hidden from the PR review state until the session is
210
* cleaned up.
211
*/
212
markPRReviewCommentConverted(sessionResource: URI, commentId: string): void;
213
}
214
215
// --- Storage Types -----------------------------------------------------------
216
217
interface IStoredCodeReview {
218
readonly version: string;
219
readonly reviewCount?: number;
220
readonly didProduceComments?: boolean;
221
readonly comments: readonly IStoredCodeReviewComment[];
222
}
223
224
interface IStoredCodeReviewComment {
225
readonly id: string;
226
readonly uri: UriComponents;
227
readonly range: IRange;
228
readonly body: string;
229
readonly kind: string;
230
readonly severity: string;
231
readonly suggestion?: ICodeReviewSuggestion;
232
}
233
234
// --- Implementation ----------------------------------------------------------
235
236
interface ISessionReviewData {
237
readonly state: ReturnType<typeof observableValue<ICodeReviewState>>;
238
}
239
240
type IPullRequestReviewThreadsModel = ReturnType<IGitHubService['getPullRequestReviewThreads']>;
241
242
interface IPRSessionReviewData {
243
readonly state: ReturnType<typeof observableValue<IPRReviewState>>;
244
readonly disposables: DisposableStore;
245
readonly pollingDisposable: DisposableStore;
246
reviewThreadsModel?: IPullRequestReviewThreadsModel;
247
initialized: boolean;
248
}
249
250
function isRawCodeReviewRangeWithPositions(range: IRawCodeReviewRange): range is IRawCodeReviewRangeWithPositions {
251
return typeof range === 'object' && range !== null && hasKey(range, { start: true, end: true });
252
}
253
254
function isRawCodeReviewRangeTuple(range: IRawCodeReviewRange): range is IRawCodeReviewRangeTuple {
255
return Array.isArray(range) && range.length >= 2;
256
}
257
258
function normalizeCodeReviewUri(uri: IRawCodeReviewUri): URI {
259
return typeof uri === 'string' ? URI.parse(uri) : URI.revive(uri);
260
}
261
262
function normalizeCodeReviewRange(range: IRawCodeReviewRange): IRange {
263
if (Range.isIRange(range)) {
264
return Range.lift(range);
265
}
266
267
if (isRawCodeReviewRangeTuple(range)) {
268
const [start, end] = range;
269
return new Range(
270
(start.line ?? 0) + 1,
271
(start.character ?? 0) + 1,
272
(end.line ?? start.line ?? 0) + 1,
273
(end.character ?? start.character ?? 0) + 1,
274
);
275
}
276
277
if (isRawCodeReviewRangeWithPositions(range) && range.start && range.end) {
278
return new Range(
279
(range.start.line ?? 0) + 1,
280
(range.start.character ?? 0) + 1,
281
(range.end.line ?? range.start.line ?? 0) + 1,
282
(range.end.character ?? range.start.character ?? 0) + 1,
283
);
284
}
285
286
const lineRange = range as IRawCodeReviewRangeWithLines;
287
return new Range(
288
(lineRange.startLine ?? 0) + 1,
289
(lineRange.startColumn ?? 0) + 1,
290
(lineRange.endLine ?? lineRange.startLine ?? 0) + 1,
291
(lineRange.endColumn ?? lineRange.startColumn ?? 0) + 1,
292
);
293
}
294
295
function normalizeCodeReviewSuggestion(suggestion: IRawCodeReviewSuggestion | undefined): ICodeReviewSuggestion | undefined {
296
if (!suggestion) {
297
return undefined;
298
}
299
300
return {
301
edits: suggestion.edits.map(edit => ({
302
range: normalizeCodeReviewRange(edit.range),
303
newText: edit.newText,
304
oldText: edit.oldText,
305
})),
306
};
307
}
308
309
export class CodeReviewService extends Disposable implements ICodeReviewService {
310
311
declare readonly _serviceBrand: undefined;
312
313
private static readonly _STORAGE_KEY = 'codeReview.reviews';
314
315
private readonly _reviewsBySession = new Map<string, ISessionReviewData>();
316
private readonly _prReviewBySession = new Map<string, IPRSessionReviewData>();
317
/** PR review comment IDs that have been converted to agent feedback (per session). */
318
private readonly _convertedPRCommentsBySession = new Map<string, Set<string>>();
319
320
constructor(
321
@ICommandService private readonly _commandService: ICommandService,
322
@ILogService private readonly _logService: ILogService,
323
@IStorageService private readonly _storageService: IStorageService,
324
@IGitHubService private readonly _gitHubService: IGitHubService,
325
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
326
) {
327
super();
328
this._loadFromStorage();
329
this._registerSessionListeners();
330
331
const activeSessionResourceObs = derivedOpts({ equalsFn: isEqual }, reader => {
332
return this._sessionsManagementService.activeSession.read(reader)?.resource;
333
});
334
335
const gitHubInfoObs = derivedOpts<{ owner: string; repo: string; pullRequestNumber: number } | undefined>({ equalsFn: structuralEquals }, reader => {
336
const gitHubInfo = this._sessionsManagementService.activeSession.read(reader)?.gitHubInfo.read(reader);
337
if (!gitHubInfo?.pullRequest) {
338
return undefined;
339
}
340
341
return {
342
owner: gitHubInfo.owner,
343
repo: gitHubInfo.repo,
344
pullRequestNumber: gitHubInfo.pullRequest.number,
345
};
346
});
347
348
this._register(autorun(reader => {
349
const activeSessionResource = activeSessionResourceObs.read(reader);
350
if (!activeSessionResource) {
351
return;
352
}
353
354
const gitHubInfo = gitHubInfoObs.read(reader);
355
const data = this._ensurePRReviewInitialized(activeSessionResource, gitHubInfo);
356
357
if (!data.reviewThreadsModel) {
358
return;
359
}
360
361
// Initial fetch of review threads
362
data.reviewThreadsModel.refresh().catch(err => {
363
this._logService.error('[CodeReviewService] Failed to fetch PR review threads:', err);
364
data.state.set({ kind: PRReviewStateKind.Error, reason: String(err) }, undefined);
365
});
366
367
// Start polling of review threads
368
data.pollingDisposable.add(data.reviewThreadsModel.startPolling());
369
370
reader.store.add(toDisposable(() => {
371
data.pollingDisposable.clear();
372
}));
373
}));
374
}
375
376
getReviewState(sessionResource: URI): IObservable<ICodeReviewState> {
377
return this._getOrCreateData(sessionResource).state;
378
}
379
380
hasReview(sessionResource: URI, version: string): boolean {
381
const data = this._reviewsBySession.get(sessionResource.toString());
382
if (!data) {
383
return false;
384
}
385
const state = data.state.get();
386
return state.kind === CodeReviewStateKind.Result && state.version === version;
387
}
388
389
requestReview(sessionResource: URI, version: string, files: readonly { readonly currentUri: URI; readonly baseUri?: URI }[]): void {
390
const data = this._getOrCreateData(sessionResource);
391
const currentState = data.state.get();
392
const currentReviewCount = currentState.kind !== CodeReviewStateKind.Idle && currentState.version === version ? currentState.reviewCount : 0;
393
394
// Don't re-request if already loading or unresolved comments remain for this version.
395
if (currentState.kind === CodeReviewStateKind.Loading && currentState.version === version) {
396
return;
397
}
398
if (currentReviewCount >= MAX_CODE_REVIEWS_PER_SESSION_VERSION) {
399
return;
400
}
401
if (currentState.kind === CodeReviewStateKind.Result && currentState.version === version && currentState.comments.length > 0) {
402
return;
403
}
404
405
data.state.set({ kind: CodeReviewStateKind.Loading, version, reviewCount: currentReviewCount + 1 }, undefined);
406
407
this._executeReview(sessionResource, version, files, data);
408
}
409
410
removeComment(sessionResource: URI, commentId: string): void {
411
const data = this._reviewsBySession.get(sessionResource.toString());
412
if (!data) {
413
return;
414
}
415
416
const state = data.state.get();
417
if (state.kind !== CodeReviewStateKind.Result) {
418
return;
419
}
420
421
const filtered = state.comments.filter(c => c.id !== commentId);
422
data.state.set({ kind: CodeReviewStateKind.Result, version: state.version, reviewCount: state.reviewCount, comments: filtered, didProduceComments: state.didProduceComments }, undefined);
423
this._saveToStorage();
424
}
425
426
updateComment(sessionResource: URI, commentId: string, newBody: string): void {
427
const data = this._reviewsBySession.get(sessionResource.toString());
428
if (!data) {
429
return;
430
}
431
432
const state = data.state.get();
433
if (state.kind !== CodeReviewStateKind.Result) {
434
return;
435
}
436
437
const updated = state.comments.map(c => c.id === commentId ? { ...c, body: newBody } : c);
438
data.state.set({ kind: CodeReviewStateKind.Result, version: state.version, reviewCount: state.reviewCount, comments: updated, didProduceComments: state.didProduceComments }, undefined);
439
this._saveToStorage();
440
}
441
442
dismissReview(sessionResource: URI): void {
443
const data = this._reviewsBySession.get(sessionResource.toString());
444
if (data) {
445
data.state.set({ kind: CodeReviewStateKind.Idle }, undefined);
446
this._saveToStorage();
447
}
448
}
449
450
private _getOrCreateData(sessionResource: URI): ISessionReviewData {
451
const key = sessionResource.toString();
452
let data = this._reviewsBySession.get(key);
453
if (!data) {
454
data = {
455
state: observableValue<ICodeReviewState>(`codeReview.state.${key}`, { kind: CodeReviewStateKind.Idle }),
456
};
457
this._reviewsBySession.set(key, data);
458
}
459
return data;
460
}
461
462
private async _executeReview(
463
sessionResource: URI,
464
version: string,
465
files: readonly { readonly currentUri: URI; readonly baseUri?: URI }[],
466
data: ISessionReviewData,
467
): Promise<void> {
468
try {
469
const result: { type: string; comments?: IRawCodeReviewComment[]; reason?: string } | undefined =
470
await this._commandService.executeCommand('chat.internal.codeReview.run', {
471
files: files.map(f => ({
472
currentUri: f.currentUri,
473
baseUri: f.baseUri,
474
})),
475
});
476
477
// Check if version is still current (hasn't been dismissed or replaced)
478
const currentState = data.state.get();
479
if (currentState.kind !== CodeReviewStateKind.Loading || currentState.version !== version) {
480
return;
481
}
482
483
if (!result || result.type === 'cancelled') {
484
data.state.set({ kind: CodeReviewStateKind.Idle }, undefined);
485
return;
486
}
487
488
if (result.type === 'error') {
489
data.state.set({ kind: CodeReviewStateKind.Error, version, reviewCount: currentState.reviewCount, reason: result.reason ?? 'Unknown error' }, undefined);
490
return;
491
}
492
493
if (result.type === 'success') {
494
const comments: ICodeReviewComment[] = (result.comments ?? []).map((raw) => ({
495
id: generateUuid(),
496
uri: normalizeCodeReviewUri(raw.uri),
497
range: normalizeCodeReviewRange(raw.range),
498
body: raw.body ?? '',
499
kind: raw.kind ?? '',
500
severity: raw.severity ?? '',
501
suggestion: normalizeCodeReviewSuggestion(raw.suggestion),
502
}));
503
504
transaction(tx => {
505
data.state.set({ kind: CodeReviewStateKind.Result, version, reviewCount: currentState.reviewCount, comments, didProduceComments: comments.length > 0 }, tx);
506
});
507
this._saveToStorage();
508
}
509
} catch (err) {
510
const currentState = data.state.get();
511
if (currentState.kind === CodeReviewStateKind.Loading && currentState.version === version) {
512
data.state.set({ kind: CodeReviewStateKind.Error, version, reviewCount: currentState.reviewCount, reason: String(err) }, undefined);
513
}
514
}
515
}
516
517
private _loadFromStorage(): void {
518
const raw = this._storageService.get(CodeReviewService._STORAGE_KEY, StorageScope.WORKSPACE);
519
if (!raw) {
520
return;
521
}
522
523
try {
524
const stored: Record<string, IStoredCodeReview> = JSON.parse(raw);
525
for (const [key, review] of Object.entries(stored)) {
526
const comments: ICodeReviewComment[] = review.comments.map(c => ({
527
id: c.id,
528
uri: URI.revive(c.uri),
529
range: c.range,
530
body: c.body,
531
kind: c.kind,
532
severity: c.severity,
533
suggestion: c.suggestion,
534
}));
535
const data = this._getOrCreateData(URI.parse(key));
536
data.state.set({ kind: CodeReviewStateKind.Result, version: review.version, reviewCount: review.reviewCount ?? 1, comments, didProduceComments: review.didProduceComments ?? comments.length > 0 }, undefined);
537
}
538
} catch {
539
// Corrupted storage data - ignore
540
}
541
}
542
543
private _saveToStorage(): void {
544
const stored: Record<string, IStoredCodeReview> = {};
545
for (const [key, data] of this._reviewsBySession) {
546
const state = data.state.get();
547
if (state.kind === CodeReviewStateKind.Result) {
548
stored[key] = {
549
version: state.version,
550
reviewCount: state.reviewCount,
551
didProduceComments: state.didProduceComments,
552
comments: state.comments.map(c => ({
553
id: c.id,
554
uri: c.uri.toJSON(),
555
range: c.range,
556
body: c.body,
557
kind: c.kind,
558
severity: c.severity,
559
suggestion: c.suggestion,
560
})),
561
};
562
}
563
}
564
565
if (Object.keys(stored).length === 0) {
566
this._storageService.remove(CodeReviewService._STORAGE_KEY, StorageScope.WORKSPACE);
567
} else {
568
this._storageService.store(CodeReviewService._STORAGE_KEY, JSON.stringify(stored), StorageScope.WORKSPACE, StorageTarget.MACHINE);
569
}
570
}
571
572
private _registerSessionListeners(): void {
573
this._register(this._sessionsManagementService.onDidChangeSessions(e => {
574
let changed = false;
575
576
// Clean up reviews for removed/archived sessions
577
for (const session of [...e.removed, ...e.changed.filter(s => s.isArchived.get())]) {
578
this._disposePRReview(session.resource);
579
580
const key = session.resource.toString();
581
const data = this._reviewsBySession.get(key);
582
if (data) {
583
data.state.set({ kind: CodeReviewStateKind.Idle }, undefined);
584
changed = true;
585
}
586
}
587
588
// Check for stale review versions when sessions change
589
for (const [key, data] of this._reviewsBySession) {
590
const state = data.state.get();
591
if (state.kind !== CodeReviewStateKind.Result) {
592
continue;
593
}
594
595
const session = this._sessionsManagementService.getSession(URI.parse(key));
596
if (!session) {
597
// Session no longer exists - clean up
598
data.state.set({ kind: CodeReviewStateKind.Idle }, undefined);
599
changed = true;
600
continue;
601
}
602
603
const changes = session.changes.get();
604
if (changes.length === 0) {
605
// Session has no file-level changes - clean up
606
data.state.set({ kind: CodeReviewStateKind.Idle }, undefined);
607
changed = true;
608
continue;
609
}
610
611
const files = getCodeReviewFilesFromSessionChanges(changes);
612
const currentVersion = getCodeReviewVersion(files);
613
if (state.version !== currentVersion) {
614
// Version mismatch - review is stale
615
data.state.set({ kind: CodeReviewStateKind.Idle }, undefined);
616
changed = true;
617
}
618
}
619
620
if (changed) {
621
this._saveToStorage();
622
}
623
}));
624
}
625
626
getPRReviewState(sessionResource: URI): IObservable<IPRReviewState> {
627
return this._getOrCreatePRReviewData(sessionResource).state;
628
}
629
630
async resolvePRReviewThread(sessionResource: URI, threadId: string): Promise<void> {
631
const session = this._sessionsManagementService.getSession(sessionResource);
632
const gitHubInfo = session?.gitHubInfo.get();
633
if (gitHubInfo?.pullRequest) {
634
const reviewThreadsModel = this._gitHubService.getPullRequestReviewThreads(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number);
635
try {
636
await reviewThreadsModel.resolveThread(threadId);
637
} catch (err) {
638
this._logService.warn('[CodeReviewService] Failed to resolve PR thread on GitHub:', err);
639
}
640
}
641
642
// Remove from local state regardless of GitHub success
643
const data = this._prReviewBySession.get(sessionResource.toString());
644
if (data) {
645
const currentState = data.state.get();
646
if (currentState.kind === PRReviewStateKind.Loaded) {
647
const filtered = currentState.comments.filter(c => c.id !== threadId);
648
data.state.set({ kind: PRReviewStateKind.Loaded, comments: filtered }, undefined);
649
}
650
}
651
}
652
653
markPRReviewCommentConverted(sessionResource: URI, commentId: string): void {
654
const key = sessionResource.toString();
655
let converted = this._convertedPRCommentsBySession.get(key);
656
if (!converted) {
657
converted = new Set();
658
this._convertedPRCommentsBySession.set(key, converted);
659
}
660
converted.add(commentId);
661
662
// Immediately filter the comment from the observable PR review state
663
const data = this._prReviewBySession.get(key);
664
if (data) {
665
const currentState = data.state.get();
666
if (currentState.kind === PRReviewStateKind.Loaded) {
667
const filtered = currentState.comments.filter(c => c.id !== commentId);
668
data.state.set({ kind: PRReviewStateKind.Loaded, comments: filtered }, undefined);
669
}
670
}
671
}
672
673
private _getOrCreatePRReviewData(sessionResource: URI): IPRSessionReviewData {
674
const key = sessionResource.toString();
675
let data = this._prReviewBySession.get(key);
676
if (!data) {
677
data = {
678
state: observableValue<IPRReviewState>(`prReview.state.${key}`, { kind: PRReviewStateKind.None }),
679
disposables: new DisposableStore(),
680
pollingDisposable: new DisposableStore(),
681
initialized: false,
682
};
683
data.disposables.add(data.pollingDisposable);
684
this._prReviewBySession.set(key, data);
685
}
686
return data;
687
}
688
689
private _ensurePRReviewInitialized(sessionResource: URI, gitHubInfo: { owner: string; repo: string; pullRequestNumber: number } | undefined): IPRSessionReviewData {
690
const data = this._getOrCreatePRReviewData(sessionResource);
691
if (data.initialized) {
692
return data;
693
}
694
695
const session = this._sessionsManagementService.getSession(sessionResource);
696
if (!session || !gitHubInfo) {
697
return data;
698
}
699
700
data.initialized = true;
701
data.state.set({ kind: PRReviewStateKind.Loading }, undefined);
702
703
const reviewThreadsModel = this._gitHubService.getPullRequestReviewThreads(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequestNumber);
704
const workspace = session.workspace.get();
705
data.reviewThreadsModel = reviewThreadsModel;
706
707
// Watch the PR review threads model and map to local state
708
data.disposables.add(autorun(reader => {
709
const threads = reviewThreadsModel.reviewThreads.read(reader);
710
const converted = this._convertedPRCommentsBySession.get(sessionResource.toString());
711
const comments: IPRReviewComment[] = [];
712
713
for (const thread of threads) {
714
if (thread.isResolved) {
715
continue;
716
}
717
const threadId = String(thread.id);
718
if (converted?.has(threadId)) {
719
continue;
720
}
721
const baseUri = workspace?.repositories[0]?.workingDirectory ?? workspace?.repositories[0]?.uri;
722
if (!baseUri) {
723
continue;
724
}
725
const fileUri = URI.joinPath(baseUri, thread.path);
726
const line = thread.line ?? 1;
727
const firstComment = thread.comments[0];
728
comments.push({
729
id: String(thread.id),
730
uri: fileUri,
731
range: new Range(line, 1, line, 1),
732
body: firstComment?.body ?? '',
733
author: firstComment?.author.login ?? '',
734
});
735
}
736
737
data.state.set({ kind: PRReviewStateKind.Loaded, comments }, undefined);
738
}));
739
740
return data;
741
}
742
743
private _disposePRReview(sessionResource: URI): void {
744
const key = sessionResource.toString();
745
this._convertedPRCommentsBySession.delete(key);
746
const data = this._prReviewBySession.get(key);
747
if (data) {
748
data.disposables.dispose();
749
750
this._prReviewBySession.delete(key);
751
}
752
}
753
754
override dispose(): void {
755
for (const data of this._prReviewBySession.values()) {
756
data.disposables.dispose();
757
}
758
this._prReviewBySession.clear();
759
760
super.dispose();
761
}
762
}
763
764