Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
PullRequestCommentNode,
PullRequestThreadNode
} from './providers/pullRequestProvider';
import { PrThreadCache } from './providers/prThreadCache';
import { PipelinesProvider, type PipelineRunNode, type PipelineStepLogNode } from './providers/pipelinesProvider';
import { BacklogProvider, SprintProvider, BoardProvider } from './providers/planningProviders';
import { WorkItemIconResolver } from './providers/workItemIconResolver';
Expand Down Expand Up @@ -214,7 +215,8 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
// -------------------------------------------------------------------------
const workItemIconResolver = new WorkItemIconResolver(client, config);
const workItemProvider = new WorkItemProvider(client, config, workItemIconResolver, recoverAuthAfterAdoError);
const pullRequestProvider = new PullRequestProvider(client, config, recoverAuthAfterAdoError);
const prThreadCache = new PrThreadCache();
const pullRequestProvider = new PullRequestProvider(client, config, recoverAuthAfterAdoError, prThreadCache);
const pipelinesProvider = new PipelinesProvider(client, config);
const pipelineLogContentProvider = new PipelineLogContentProvider(client);
const backlogProvider = new BacklogProvider(client, config, workItemIconResolver, recoverAuthAfterAdoError);
Expand Down Expand Up @@ -249,7 +251,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
// requests, and vote/status changes. New event types can be added by
// registering additional INotificationHandler implementations below.
const notificationService = new NotificationService(client, config, [
new PrCommentHandler(client, config, context.globalState),
new PrCommentHandler(client, config, context.globalState, prThreadCache),
new PrReviewRequestHandler(client, config, context.globalState),
new PrStatusChangeHandler(client, config, context.globalState)
], recoverAuthAfterAdoError);
Expand Down
18 changes: 11 additions & 7 deletions src/notifications/handlers/prCommentHandler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as vscode from 'vscode';
import type { AdoClient, GitPullRequestCommentThread } from '../../api/adoClient';
import type { ConfigManager } from '../../config/configManager';
import type { PrThreadCache } from '../../providers/prThreadCache';
import { mapWithConcurrencyLimit } from '../../utils/async';
import { showErrorMessage, showInformationMessage, showWarningMessage } from '../../utils/notifications';
import type { INotificationHandler, PrWithScope } from '../iNotificationHandler';
Expand Down Expand Up @@ -34,7 +35,8 @@ export class PrCommentHandler implements INotificationHandler {
constructor(
private readonly _client: AdoClient,
private readonly _config: ConfigManager,
private readonly _state: vscode.Memento
private readonly _state: vscode.Memento,
private readonly _threadCache?: PrThreadCache
) {
this._lastSeen = { ..._state.get<Record<string, number>>(STATE_KEY, {}) };
}
Expand Down Expand Up @@ -82,12 +84,14 @@ export class PrCommentHandler implements INotificationHandler {

let threads: GitPullRequestCommentThread[];
try {
threads = await this._client.getPullRequestThreads(
scope.project,
repositoryId,
pullRequestId,
scope.organization
);
const fetcher = (p: string, r: string, id: number, org: string) =>
this._client.getPullRequestThreads(p, r, id, org);
threads = this._threadCache
? await this._threadCache.getOrFetch(
{ organization: scope.organization, project: scope.project, repositoryId, pullRequestId },
fetcher
)
: await fetcher(scope.project, repositoryId, pullRequestId, scope.organization);
} catch {
return;
}
Expand Down
106 changes: 106 additions & 0 deletions src/providers/prThreadCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { GitPullRequestCommentThread } from '../api/adoClient';

const DEFAULT_TTL_MS = 30_000;

type Key = string;

interface Entry {
threads: GitPullRequestCommentThread[];
expires: number;
}

export interface ThreadFetcher {
(
project: string,
repositoryId: string,
pullRequestId: number,
organization: string
): Promise<GitPullRequestCommentThread[]>;
}

export interface PrThreadKey {
organization: string;
project: string;
repositoryId: string;
pullRequestId: number;
}

function keyOf(k: PrThreadKey): Key {
return `${k.organization}\0${k.project}\0${k.repositoryId}\0${k.pullRequestId}`;
}

/**
* Shared cache + concurrency dedup for PR comment threads.
*
* Consumers (tree provider, notification poll) hit the same instance via
* `getOrFetch`. Within the TTL window, repeat calls return cached data;
* concurrent calls for the same PR share a single in-flight promise so we
* never issue duplicate ADO requests. A tree expand shortly after a poll
* tick is served from memory.
*/
export class PrThreadCache {
private readonly entries = new Map<Key, Entry>();
private readonly inflight = new Map<Key, Promise<GitPullRequestCommentThread[]>>();
private readonly ttlMs: number;

constructor(ttlMs: number = DEFAULT_TTL_MS) {
this.ttlMs = ttlMs;
}

get(k: PrThreadKey): GitPullRequestCommentThread[] | undefined {
const entry = this.entries.get(keyOf(k));
if (!entry) { return undefined; }
if (entry.expires <= Date.now()) {
this.entries.delete(keyOf(k));
return undefined;
}
return entry.threads;
}

set(k: PrThreadKey, threads: GitPullRequestCommentThread[]): void {
this.sweepExpired();
this.entries.set(keyOf(k), { threads, expires: Date.now() + this.ttlMs });
}
Comment on lines +50 to +63

private sweepExpired(): void {
const now = Date.now();
for (const [id, entry] of this.entries) {
if (entry.expires <= now) {
this.entries.delete(id);
}
}
}

async getOrFetch(
k: PrThreadKey,
fetch: ThreadFetcher
): Promise<GitPullRequestCommentThread[]> {
const cached = this.get(k);
if (cached) { return cached; }

const id = keyOf(k);
const pending = this.inflight.get(id);
if (pending) { return pending; }

const promise = (async () => {
try {
const threads = await fetch(k.project, k.repositoryId, k.pullRequestId, k.organization);
this.set(k, threads);
return threads;
} finally {
this.inflight.delete(id);
}
})();
this.inflight.set(id, promise);
return promise;
}

invalidate(k: PrThreadKey): void {
this.entries.delete(keyOf(k));
}

clear(): void {
this.entries.clear();
this.inflight.clear();
}
}
12 changes: 10 additions & 2 deletions src/providers/pullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { mapWithConcurrencyLimit } from '../utils/async';
import type { AuthRecoveryHandler } from '../utils/authRecovery';
import { handleProviderError } from './providerErrors';
import type { PrThreadCache } from './prThreadCache';

const MAX_CONCURRENT_SCOPE_REQUESTS = 4;

Expand Down Expand Up @@ -555,21 +556,24 @@ export class PullRequestProvider implements vscode.TreeDataProvider<PullRequestT
constructor(
private readonly client: AdoClient,
private readonly config: ConfigManager,
private readonly onAuthError?: AuthRecoveryHandler
private readonly onAuthError?: AuthRecoveryHandler,
private readonly threadCache?: PrThreadCache
) {}

refresh(): void {
this._prCache.clear();
this._bucketScopeGrouping.clear();
this._loadingPromises.clear();
this._buckets = [];
this.threadCache?.clear();
this._onDidChangeTreeData.fire();
}

refreshBucket(bucket: PullRequestBucketNode): void {
this._prCache.delete(bucket.bucketId);
this._bucketScopeGrouping.delete(bucket.bucketId);
this._loadingPromises.delete(bucket.bucketId);
this.threadCache?.clear();
this._onDidChangeTreeData.fire(bucket);
}

Expand Down Expand Up @@ -806,7 +810,11 @@ export class PullRequestProvider implements vscode.TreeDataProvider<PullRequestT
const prId = pr.pullRequestId ?? 0;
const project = scope?.project ?? this.config.project;
const organization = scope?.organization ?? this.config.organization;
const threads = await this.client.getPullRequestThreads(project, repoId, prId, organization);
const key = { organization, project, repositoryId: repoId, pullRequestId: prId };
const threads = this.threadCache
? await this.threadCache.getOrFetch(key, (p, r, id, org) =>
this.client.getPullRequestThreads(p, r, id, org))
: await this.client.getPullRequestThreads(project, repoId, prId, organization);
const meaningful = (threads ?? []).filter(
thread => (thread.comments ?? []).some(comment => !!comment.content) && !thread.isDeleted
);
Expand Down