diff --git a/src/extension.ts b/src/extension.ts index 6f9128a..c6fbd76 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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'; @@ -214,7 +215,8 @@ export async function activate(context: vscode.ExtensionContext): Promise // ------------------------------------------------------------------------- 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); @@ -249,7 +251,7 @@ export async function activate(context: vscode.ExtensionContext): Promise // 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); diff --git a/src/notifications/handlers/prCommentHandler.ts b/src/notifications/handlers/prCommentHandler.ts index 93ce9f1..4bfb0e5 100644 --- a/src/notifications/handlers/prCommentHandler.ts +++ b/src/notifications/handlers/prCommentHandler.ts @@ -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'; @@ -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>(STATE_KEY, {}) }; } @@ -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; } diff --git a/src/providers/prThreadCache.ts b/src/providers/prThreadCache.ts new file mode 100644 index 0000000..18c12c1 --- /dev/null +++ b/src/providers/prThreadCache.ts @@ -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; +} + +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(); + private readonly inflight = new Map>(); + 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 }); + } + + 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 { + 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(); + } +} diff --git a/src/providers/pullRequestProvider.ts b/src/providers/pullRequestProvider.ts index d91b855..e2f6a58 100644 --- a/src/providers/pullRequestProvider.ts +++ b/src/providers/pullRequestProvider.ts @@ -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; @@ -555,7 +556,8 @@ export class PullRequestProvider implements vscode.TreeDataProvider + 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 );