diff --git a/src/api/adoClient.ts b/src/api/adoClient.ts index 075846d..8351055 100644 --- a/src/api/adoClient.ts +++ b/src/api/adoClient.ts @@ -12,6 +12,7 @@ import { BuildReason, BuildResult, BuildStatus } from 'azure-devops-node-api/int import { ResultDetails, TestOutcome } from 'azure-devops-node-api/interfaces/TestInterfaces'; import { Operation } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; import { normalizeWorkItemTypeName, workItemTypeScopeKey } from '../utils/workItemTypeIcons'; +import { TtlCache } from '../utils/ttlCache'; import { formatAdoError } from '../utils/adoErrors'; import type { WorkItem, @@ -154,6 +155,7 @@ export class AdoClient { private _currentUserIds = new Map(); private _workItemStatesByType = new Map(); private _workItemTypeIconsByScope = new Map }>(); + private _workItemTypesCache = new TtlCache(300_000); constructor(private _accessToken: string) {} @@ -166,6 +168,7 @@ export class AdoClient { this._connectionsByOrganization.clear(); this._workItemStatesByType.clear(); this._workItemTypeIconsByScope.clear(); + this._workItemTypesCache.clear(); if (!token.trim()) { this.disconnect(); @@ -200,6 +203,7 @@ export class AdoClient { this._currentUserIds.clear(); this._workItemStatesByType.clear(); this._workItemTypeIconsByScope.clear(); + this._workItemTypesCache.clear(); } private get connection(): azdev.WebApi { @@ -573,9 +577,16 @@ export class AdoClient { project: string, organization?: string ): Promise { + const cacheKey = JSON.stringify([organization ?? this._organization ?? null, project]); + const cached = this._workItemTypesCache.get(cacheKey); + if (cached) { + return cached; + } const witApi: IWorkItemTrackingApi = await this.getConnectionFor(organization).getWorkItemTrackingApi(); const types = await witApi.getWorkItemTypes(project); - return (types ?? []).filter((type): type is WorkItemType => type !== null && !type.isDisabled); + const filtered = (types ?? []).filter((type): type is WorkItemType => type !== null && !type.isDisabled); + this._workItemTypesCache.set(cacheKey, filtered); + return filtered; } async getWorkItemTypeStates( diff --git a/src/providers/pipelinesProvider.ts b/src/providers/pipelinesProvider.ts index 3a439f0..2c8f484 100644 --- a/src/providers/pipelinesProvider.ts +++ b/src/providers/pipelinesProvider.ts @@ -3,14 +3,11 @@ import type { AdoClient, Build, Timeline } from '../api/adoClient'; import { BuildResult, BuildStatus } from '../api/adoClient'; import type { ConfigManager, PipelineRunsGroupBy } from '../config/configManager'; import { - resolveProjectScopes, scopeKey, scopeLabel, type ProjectScope } from './projectScopes'; -import { mapWithConcurrencyLimit } from '../utils/async'; - -const MAX_CONCURRENT_SCOPE_REQUESTS = 4; +import { forEachScope } from './projectScopes'; interface ScopedPipelineRun { build: Build; @@ -239,12 +236,19 @@ export class PipelinesProvider implements vscode.TreeDataProvider { + const builds = await this.client.listPipelineRuns( + scope.project, + scope.organization, + { top, filter } + ); + return builds.map(build => ({ build, scope })); + }); if (scopes.length === 0) { return [this.createConfigureNode()]; } - - const scopedRuns = await this.loadRuns(scopes); if (scopedRuns.length === 0) { const node = new vscode.TreeItem('No pipeline runs found', vscode.TreeItemCollapsibleState.None); node.iconPath = new vscode.ThemeIcon('info'); @@ -280,16 +284,6 @@ export class PipelinesProvider implements vscode.TreeDataProvider { - const filter = this.config.pipelineRunsFilter; - const top = this.config.pipelineRunsTop; - const results = await mapWithConcurrencyLimit(scopes, MAX_CONCURRENT_SCOPE_REQUESTS, async scope => { - const builds = await this.client.listPipelineRuns(scope.project, scope.organization, { top, filter }); - return builds.map(build => ({ build, scope })); - }); - return results.flat(); - } - private async getTimelineChildren(node: PipelineRunNode): Promise { const buildId = node.build.id; if (!buildId) { diff --git a/src/providers/planningProviders.ts b/src/providers/planningProviders.ts index 3cbdc31..0aaeac6 100644 --- a/src/providers/planningProviders.ts +++ b/src/providers/planningProviders.ts @@ -4,18 +4,15 @@ import type { AdoClient } from '../api/adoClient'; import type { ConfigManager } from '../config/configManager'; import { WorkItemNode, stateIcon } from './workItemProvider'; import { - resolveProjectScopes, scopeKey, scopeLabel, type ProjectScope } from './projectScopes'; -import { mapWithConcurrencyLimit } from '../utils/async'; +import { forEachScope } from './projectScopes'; import { WorkItemIconResolver } from './workItemIconResolver'; import type { AuthRecoveryHandler } from '../utils/authRecovery'; import { handleProviderError } from './providerErrors'; -const MAX_CONCURRENT_SCOPE_REQUESTS = 4; - interface ScopedWorkItem { workItem: WorkItem; scope: ProjectScope; @@ -350,13 +347,13 @@ async function loadPlanningItems( client: AdoClient, config: ConfigManager ): Promise<{ scopes: ProjectScope[]; items: ScopedWorkItem[] }> { - const scopes = await resolveProjectScopes(client, config); const assignedToMe = config.planningAssignedFilter === 'mine'; - const results = await mapWithConcurrencyLimit(scopes, MAX_CONCURRENT_SCOPE_REQUESTS, async scope => { + const { scopes, items: rawItems } = await forEachScope(client, config, async scope => { const workItems = await client.getPlanningWorkItems(scope.project, scope.organization, assignedToMe); return workItems.map(workItem => ({ workItem, scope })); }); - let items = results.flat(); + let items = rawItems; + const hideStates = new Set(config.workItemHideStates.map(s => s.toLowerCase())); if (hideStates.size > 0) { diff --git a/src/providers/projectScopes.ts b/src/providers/projectScopes.ts index 9a48834..fadf951 100644 --- a/src/providers/projectScopes.ts +++ b/src/providers/projectScopes.ts @@ -1,5 +1,6 @@ import type { AdoClient } from '../api/adoClient'; import { ALL_PROJECTS, type ConfigManager } from '../config/configManager'; +import { mapWithConcurrencyLimit } from '../utils/async'; export interface ProjectScope { organization: string; @@ -38,4 +39,17 @@ export async function resolveProjectScopes( } return scopes; -} \ No newline at end of file +} +export async function forEachScope( + client: AdoClient, + config: ConfigManager, + fetcher: (scope: ProjectScope) => Promise, + concurrency = 4 +): Promise<{ scopes: ProjectScope[]; items: T[] }> { + const scopes = await resolveProjectScopes(client, config); + if (scopes.length === 0) { + return { scopes, items: [] }; + } + const nested = await mapWithConcurrencyLimit(scopes, concurrency, fetcher); + return { scopes, items: nested.flat() }; +} diff --git a/src/providers/pullRequestProvider.ts b/src/providers/pullRequestProvider.ts index d91b855..587f666 100644 --- a/src/providers/pullRequestProvider.ts +++ b/src/providers/pullRequestProvider.ts @@ -5,17 +5,14 @@ import type { ConfigManager } from '../config/configManager'; import { isToolIdentity, isSystemThread } from '../utils/prCommentIdentity'; import { isResolvedPullRequestThread } from '../utils/prThreadStatus'; import { - resolveProjectScopes, scopeKey, scopeLabel, type ProjectScope } from './projectScopes'; -import { mapWithConcurrencyLimit } from '../utils/async'; +import { forEachScope } from './projectScopes'; import type { AuthRecoveryHandler } from '../utils/authRecovery'; import { handleProviderError } from './providerErrors'; -const MAX_CONCURRENT_SCOPE_REQUESTS = 4; - interface ScopedPullRequest { pr: GitPullRequest; scope: ProjectScope; @@ -656,12 +653,20 @@ export class PullRequestProvider implements vscode.TreeDataProvider { try { - const scopes = await resolveProjectScopes(this.client, this.config); + const filter = bucket.filter; + const { scopes, items: prs } = await forEachScope(this.client, this.config, async scope => { + const pulls = await this.client.getPullRequests( + scope.project, + filter, + undefined, + scope.organization + ); + return pulls.map(pr => ({ pr, scope })); + }); if (scopes.length === 0) { return [this.createConfigureNode()]; } - const prs = await this.loadPullRequests(scopes, bucket.filter); this._prCache.set(bucket.bucketId, prs); const forceScopeGrouping = scopes.length > 1; this._bucketScopeGrouping.set(bucket.bucketId, forceScopeGrouping); @@ -740,22 +745,6 @@ export class PullRequestProvider implements vscode.TreeDataProvider { - const results = await mapWithConcurrencyLimit(scopes, MAX_CONCURRENT_SCOPE_REQUESTS, async scope => { - const prs = await this.client.getPullRequests( - scope.project, - filter, - undefined, - scope.organization - ); - return prs.map(pr => ({ pr, scope })); - }); - return results.flat(); - } - private async loadPrChildren( pr: GitPullRequest, scope?: ProjectScope diff --git a/src/providers/workItemProvider.ts b/src/providers/workItemProvider.ts index 635c745..4717795 100644 --- a/src/providers/workItemProvider.ts +++ b/src/providers/workItemProvider.ts @@ -3,19 +3,16 @@ import type { WorkItem } from '../api/adoClient'; import type { AdoClient } from '../api/adoClient'; import type { ConfigManager } from '../config/configManager'; import { - resolveProjectScopes, scopeKey, scopeLabel, type ProjectScope } from './projectScopes'; -import { mapWithConcurrencyLimit } from '../utils/async'; +import { forEachScope } from './projectScopes'; import { bundledWorkItemTypeIconFile } from '../utils/workItemTypeIcons'; import { WorkItemIconResolver } from './workItemIconResolver'; import type { AuthRecoveryHandler } from '../utils/authRecovery'; import { handleProviderError } from './providerErrors'; -const MAX_CONCURRENT_SCOPE_REQUESTS = 4; - interface ScopedWorkItem { workItem: WorkItem; scope: ProjectScope; @@ -178,13 +175,20 @@ export class WorkItemProvider implements vscode.TreeDataProvider { + const workItems = await this.client.getWorkItems( + scope.project, + filter, + scope.organization + ); + return workItems.map(workItem => ({ workItem, scope })); + }); if (scopes.length === 0) { return [this.createConfigureNode()]; } await this._iconResolver.loadForScopes(scopes); - const scopedItems = await this.loadWorkItems(scopes); if (scopedItems.length === 0) { const node = new vscode.TreeItem('No work items found', vscode.TreeItemCollapsibleState.None); node.iconPath = new vscode.ThemeIcon('info'); @@ -216,19 +220,6 @@ export class WorkItemProvider implements vscode.TreeDataProvider { - const query = this.config.activeWorkItemQuery; - const results = await mapWithConcurrencyLimit(scopes, MAX_CONCURRENT_SCOPE_REQUESTS, async scope => { - const workItems = await this.client.getWorkItems( - scope.project, - query.filter, - scope.organization - ); - return workItems.map(workItem => ({ workItem, scope })); - }); - return results.flat(); - } - private buildStateGroups(items: ScopedWorkItem[]): WorkItemStateGroup[] { // Apply regex filtering const filtered = items.filter(item => this.matchesFilter(item)); diff --git a/src/utils/ttlCache.ts b/src/utils/ttlCache.ts new file mode 100644 index 0000000..a957a3e --- /dev/null +++ b/src/utils/ttlCache.ts @@ -0,0 +1,33 @@ +interface CacheEntry { + value: T; + expiresAt: number; +} + +export class TtlCache { + private _map = new Map>(); + + constructor(private _ttlMs: number) {} + + get(key: string): T | undefined { + const entry = this._map.get(key); + if (entry && entry.expiresAt > Date.now()) { + return entry.value; + } + if (entry) { + this._map.delete(key); + } + return undefined; + } + + set(key: string, value: T): void { + this._map.set(key, { value, expiresAt: Date.now() + this._ttlMs }); + } + + clear(): void { + this._map.clear(); + } + + has(key: string): boolean { + return this.get(key) !== undefined; + } +}