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
13 changes: 12 additions & 1 deletion src/api/adoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -154,6 +155,7 @@ export class AdoClient {
private _currentUserIds = new Map<string, string>();
private _workItemStatesByType = new Map<string, string[]>();
private _workItemTypeIconsByScope = new Map<string, { expiresAt: number; icons: Map<string, string> }>();
private _workItemTypesCache = new TtlCache<WorkItemType[]>(300_000);

constructor(private _accessToken: string) {}

Expand All @@ -166,6 +168,7 @@ export class AdoClient {
this._connectionsByOrganization.clear();
this._workItemStatesByType.clear();
this._workItemTypeIconsByScope.clear();
this._workItemTypesCache.clear();

if (!token.trim()) {
this.disconnect();
Expand Down Expand Up @@ -200,6 +203,7 @@ export class AdoClient {
this._currentUserIds.clear();
this._workItemStatesByType.clear();
this._workItemTypeIconsByScope.clear();
this._workItemTypesCache.clear();
}

private get connection(): azdev.WebApi {
Expand Down Expand Up @@ -573,9 +577,16 @@ export class AdoClient {
project: string,
organization?: string
): Promise<WorkItemType[]> {
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(
Expand Down
28 changes: 11 additions & 17 deletions src/providers/pipelinesProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -239,12 +236,19 @@ export class PipelinesProvider implements vscode.TreeDataProvider<PipelinesTreeN
return [setupNode];
}

const scopes = await resolveProjectScopes(this.client, this.config);
const top = this.config.pipelineRunsTop;
const filter = this.config.pipelineRunsFilter;
const { scopes, items: scopedRuns } = await forEachScope(this.client, this.config, async scope => {
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');
Expand Down Expand Up @@ -280,16 +284,6 @@ export class PipelinesProvider implements vscode.TreeDataProvider<PipelinesTreeN
}
}

private async loadRuns(scopes: ProjectScope[]): Promise<ScopedPipelineRun[]> {
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<PipelinesTreeNode[]> {
const buildId = node.build.id;
if (!buildId) {
Expand Down
11 changes: 4 additions & 7 deletions src/providers/planningProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 15 additions & 1 deletion src/providers/projectScopes.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -38,4 +39,17 @@ export async function resolveProjectScopes(
}

return scopes;
}
}
export async function forEachScope<T>(
client: AdoClient,
config: ConfigManager,
fetcher: (scope: ProjectScope) => Promise<T[]>,
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() };
}
33 changes: 11 additions & 22 deletions src/providers/pullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -656,12 +653,20 @@ export class PullRequestProvider implements vscode.TreeDataProvider<PullRequestT

private async doLoadBucketChildren(bucket: PullRequestBucketNode): Promise<PullRequestTreeNode[]> {
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);
Expand Down Expand Up @@ -740,22 +745,6 @@ export class PullRequestProvider implements vscode.TreeDataProvider<PullRequestT
});
}

private async loadPullRequests(
scopes: ProjectScope[],
filter: 'mine' | 'created' | 'assigned' | 'all'
): Promise<ScopedPullRequest[]> {
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
Expand Down
29 changes: 10 additions & 19 deletions src/providers/workItemProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -178,13 +175,20 @@ export class WorkItemProvider implements vscode.TreeDataProvider<WorkItemTreeNod
return [setupNode];
}

const scopes = await resolveProjectScopes(this.client, this.config);
const filter = this.config.activeWorkItemQuery.filter;
const { scopes, items: scopedItems } = await forEachScope(this.client, this.config, async scope => {
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');
Expand Down Expand Up @@ -216,19 +220,6 @@ export class WorkItemProvider implements vscode.TreeDataProvider<WorkItemTreeNod
}
}

private async loadWorkItems(scopes: ProjectScope[]): Promise<ScopedWorkItem[]> {
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));
Expand Down
33 changes: 33 additions & 0 deletions src/utils/ttlCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
interface CacheEntry<T> {
value: T;
expiresAt: number;
}

export class TtlCache<T> {
private _map = new Map<string, CacheEntry<T>>();

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;
}
}
51 changes: 51 additions & 0 deletions src/views/panelBase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as vscode from 'vscode';
import type { AdoClient } from '../api/adoClient';
import type { ConfigManager } from '../config/configManager';
import { webviewAssetRoots } from './webviewHtml';

/**
* Shared lifecycle for webview panels. Handles webview creation,
* message listener registration, and disposal.
*
* Subclasses implement their own static show(), refresh(), and
* handleMessage().
*/
export abstract class PanelBase {
protected readonly _panel: vscode.WebviewPanel;
protected readonly _disposables: vscode.Disposable[] = [];

constructor(
protected readonly _context: vscode.ExtensionContext,
protected readonly _client: AdoClient,
protected readonly _config: ConfigManager,
viewType: string,
title: string,
) {
this._panel = vscode.window.createWebviewPanel(
viewType,
title,
vscode.ViewColumn.One,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: webviewAssetRoots(_context)
}
);
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
}

protected onMessage(handler: (msg: unknown) => Promise<void>): void {
this._panel.webview.onDidReceiveMessage(
async (msg) => handler(msg),
null,
this._disposables
);
}

dispose(): void {
for (const d of this._disposables) {
d.dispose();
}
this._disposables.length = 0;
}
}
Loading