From 3243d4013ef0cf53a97d23dabc233917ba1ed229 Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Wed, 20 May 2026 22:18:21 +0200 Subject: [PATCH 1/3] perf: add TTL cache for getWorkItemTypes Extract reusable TtlCache class to src/utils/ttlCache.ts. Add 5-minute TTL cache to AdoClient.getWorkItemTypes to avoid redundant API calls when querying work item types for the same project within the cache window. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/api/adoClient.ts | 13 ++++++++++++- src/utils/ttlCache.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 src/utils/ttlCache.ts 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/utils/ttlCache.ts b/src/utils/ttlCache.ts new file mode 100644 index 0000000..5c0a40f --- /dev/null +++ b/src/utils/ttlCache.ts @@ -0,0 +1,34 @@ +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 { + const entry = this._map.get(key); + return entry !== undefined && entry.expiresAt > Date.now(); + } +} From 9442c508d6a4adc751d575d6029b5ceb1d023b09 Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Wed, 20 May 2026 22:18:26 +0200 Subject: [PATCH 2/3] perf: add forEachScope helper and migrate scope-fetch callsites Create forEachScope() in async.ts that combines resolveProjectScopes + mapWithConcurrencyLimit into a single reusable pattern returning { scopes, items }. Migrate workItemProvider, pipelinesProvider, pullRequestProvider, and planningProviders to use the helper, removing the duplicate resolveProjectScopes + mapParallelLimit boilerplate and the MAX_CONCURRENT_SCOPE_REQUESTS constants from each file. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/providers/pipelinesProvider.ts | 26 ++++++++-------------- src/providers/planningProviders.ts | 11 ++++------ src/providers/pullRequestProvider.ts | 33 ++++++++++------------------ src/providers/workItemProvider.ts | 28 ++++++++--------------- src/utils/async.ts | 18 +++++++++++++++ 5 files changed, 51 insertions(+), 65 deletions(-) diff --git a/src/providers/pipelinesProvider.ts b/src/providers/pipelinesProvider.ts index 3a439f0..c3c44e4 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 '../utils/async'; interface ScopedPipelineRun { build: Build; @@ -239,12 +236,17 @@ export class PipelinesProvider implements vscode.TreeDataProvider { + const builds = await this.client.listPipelineRuns( + scope.project, + scope.organization, + { top: this.config.pipelineRunsTop, filter: this.config.pipelineRunsFilter } + ); + 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 +282,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..c73dba1 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 '../utils/async'; 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/pullRequestProvider.ts b/src/providers/pullRequestProvider.ts index d91b855..185b068 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 '../utils/async'; 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..222b2fe 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 '../utils/async'; 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,19 @@ export class WorkItemProvider implements vscode.TreeDataProvider { + const workItems = await this.client.getWorkItems( + scope.project, + this.config.activeWorkItemQuery.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 +219,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/async.ts b/src/utils/async.ts index 98ff6fa..2daf477 100644 --- a/src/utils/async.ts +++ b/src/utils/async.ts @@ -1,3 +1,21 @@ +import type { AdoClient } from '../api/adoClient'; +import type { ConfigManager } from '../config/configManager'; +import { resolveProjectScopes, type ProjectScope } from '../providers/projectScopes'; + +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() }; +} + export async function mapWithConcurrencyLimit( items: readonly TInput[], concurrencyLimit: number, From 6414848c57ce0831f08983896244d0702a40e871 Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Thu, 21 May 2026 00:30:19 +0200 Subject: [PATCH 3/3] review: address PR 63 feedback - TtlCache.has() now evicts expired entries (delegates to get()) - Move forEachScope from utils/async.ts to providers/projectScopes.ts so unrelated utils/async consumers don't transitively pull in the provider layer - Capture activeWorkItemQuery.filter / pipelineRunsTop / pipelineRunsFilter before the concurrent fetcher to guard against config changes mid-load --- src/providers/pipelinesProvider.ts | 6 ++++-- src/providers/planningProviders.ts | 2 +- src/providers/projectScopes.ts | 16 +++++++++++++++- src/providers/pullRequestProvider.ts | 2 +- src/providers/workItemProvider.ts | 5 +++-- src/utils/async.ts | 18 ------------------ src/utils/ttlCache.ts | 3 +-- 7 files changed, 25 insertions(+), 27 deletions(-) diff --git a/src/providers/pipelinesProvider.ts b/src/providers/pipelinesProvider.ts index c3c44e4..2c8f484 100644 --- a/src/providers/pipelinesProvider.ts +++ b/src/providers/pipelinesProvider.ts @@ -7,7 +7,7 @@ import { scopeLabel, type ProjectScope } from './projectScopes'; -import { forEachScope } from '../utils/async'; +import { forEachScope } from './projectScopes'; interface ScopedPipelineRun { build: Build; @@ -236,11 +236,13 @@ export class PipelinesProvider implements vscode.TreeDataProvider { const builds = await this.client.listPipelineRuns( scope.project, scope.organization, - { top: this.config.pipelineRunsTop, filter: this.config.pipelineRunsFilter } + { top, filter } ); return builds.map(build => ({ build, scope })); }); diff --git a/src/providers/planningProviders.ts b/src/providers/planningProviders.ts index c73dba1..0aaeac6 100644 --- a/src/providers/planningProviders.ts +++ b/src/providers/planningProviders.ts @@ -8,7 +8,7 @@ import { scopeLabel, type ProjectScope } from './projectScopes'; -import { forEachScope } from '../utils/async'; +import { forEachScope } from './projectScopes'; import { WorkItemIconResolver } from './workItemIconResolver'; import type { AuthRecoveryHandler } from '../utils/authRecovery'; import { handleProviderError } from './providerErrors'; 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 185b068..587f666 100644 --- a/src/providers/pullRequestProvider.ts +++ b/src/providers/pullRequestProvider.ts @@ -9,7 +9,7 @@ import { scopeLabel, type ProjectScope } from './projectScopes'; -import { forEachScope } from '../utils/async'; +import { forEachScope } from './projectScopes'; import type { AuthRecoveryHandler } from '../utils/authRecovery'; import { handleProviderError } from './providerErrors'; diff --git a/src/providers/workItemProvider.ts b/src/providers/workItemProvider.ts index 222b2fe..4717795 100644 --- a/src/providers/workItemProvider.ts +++ b/src/providers/workItemProvider.ts @@ -7,7 +7,7 @@ import { scopeLabel, type ProjectScope } from './projectScopes'; -import { forEachScope } from '../utils/async'; +import { forEachScope } from './projectScopes'; import { bundledWorkItemTypeIconFile } from '../utils/workItemTypeIcons'; import { WorkItemIconResolver } from './workItemIconResolver'; import type { AuthRecoveryHandler } from '../utils/authRecovery'; @@ -175,10 +175,11 @@ export class WorkItemProvider implements vscode.TreeDataProvider { const workItems = await this.client.getWorkItems( scope.project, - this.config.activeWorkItemQuery.filter, + filter, scope.organization ); return workItems.map(workItem => ({ workItem, scope })); diff --git a/src/utils/async.ts b/src/utils/async.ts index 2daf477..98ff6fa 100644 --- a/src/utils/async.ts +++ b/src/utils/async.ts @@ -1,21 +1,3 @@ -import type { AdoClient } from '../api/adoClient'; -import type { ConfigManager } from '../config/configManager'; -import { resolveProjectScopes, type ProjectScope } from '../providers/projectScopes'; - -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() }; -} - export async function mapWithConcurrencyLimit( items: readonly TInput[], concurrencyLimit: number, diff --git a/src/utils/ttlCache.ts b/src/utils/ttlCache.ts index 5c0a40f..a957a3e 100644 --- a/src/utils/ttlCache.ts +++ b/src/utils/ttlCache.ts @@ -28,7 +28,6 @@ export class TtlCache { } has(key: string): boolean { - const entry = this._map.get(key); - return entry !== undefined && entry.expiresAt > Date.now(); + return this.get(key) !== undefined; } }