diff --git a/README.md b/README.md index cd48eb1..f8a90d2 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ See [CHANGELOG.md](CHANGELOG.md) for the full release history. ### ๐Ÿ—๏ธ **Build & Integration** - **Build Summaries** โ€” Lightweight build status cards in PR and work item detail panels - **Pipelines View** โ€” Browse recent Azure Pipelines runs across selected scopes; filter/group runs, inspect timeline details, open step logs from the tree or details timeline in VS Code, and open artifacts +- **Deployments View** โ€” Read-only visibility into classic Releases environments plus recent pipeline runs across selected scopes - **MCP Server** โ€” Foundry integration for AI-powered workflows and automations - **Azure Boards Integration** โ€” Full WIQL query support for advanced filtering and bulk operations @@ -124,7 +125,8 @@ code --install-extension ./adoext-.vsix - **Backlog** โ€” Hierarchical view of all work - **Sprints** โ€” Current and future sprint planning - **Boards** โ€” Kanban-style board view - - **Pipelines** โ€” Recent CI/CD runs across your selected scopes + - **Pipelines** โ€” Recent CI/CD runs across your selected scopes + - **Deployments** โ€” Classic release environment status plus recent pipeline runs (read-only) --- diff --git a/package.json b/package.json index 0e0acc9..1cdbe3c 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,12 @@ "name": "Pipelines", "icon": "media/icons/ado.svg", "contextualTitle": "Azure DevOps Pipelines" + }, + { + "id": "adoext.deployments", + "name": "Deployments", + "icon": "media/icons/ado.svg", + "contextualTitle": "Azure DevOps Deployments" } ] }, @@ -232,6 +238,12 @@ "category": "ADOExt", "icon": "$(refresh)" }, + { + "command": "adoext.refreshDeployments", + "title": "Refresh Deployments", + "category": "ADOExt", + "icon": "$(refresh)" + }, { "command": "adoext.setPipelineRunsFilter", "title": "Filter Pipeline Runs", @@ -250,6 +262,24 @@ "category": "ADOExt", "icon": "$(eye)" }, + { + "command": "adoext.viewReleaseDetails", + "title": "View Release Details", + "category": "ADOExt", + "icon": "$(eye)" + }, + { + "command": "adoext.openClassicRelease", + "title": "Open Release in Browser", + "category": "ADOExt", + "icon": "$(link-external)" + }, + { + "command": "adoext.openClassicReleaseEnvironment", + "title": "Open Release Environment in Browser", + "category": "ADOExt", + "icon": "$(link-external)" + }, { "command": "adoext.openPipelineRun", "title": "Open Pipeline Run in Browser", @@ -579,12 +609,12 @@ }, { "command": "adoext.detectRepoContext", - "when": "view == adoext.workItems || view == adoext.backlog || view == adoext.sprints || view == adoext.boards || view == adoext.pullRequests || view == adoext.pipelines", + "when": "view == adoext.workItems || view == adoext.backlog || view == adoext.sprints || view == adoext.boards || view == adoext.pullRequests || view == adoext.pipelines || view == adoext.deployments", "group": "navigation" }, { "command": "adoext.signIn", - "when": "(view == adoext.workItems || view == adoext.backlog || view == adoext.sprints || view == adoext.boards || view == adoext.pullRequests || view == adoext.pipelines) && !adoext.isSignedIn", + "when": "(view == adoext.workItems || view == adoext.backlog || view == adoext.sprints || view == adoext.boards || view == adoext.pullRequests || view == adoext.pipelines || view == adoext.deployments) && !adoext.isSignedIn", "group": "navigation" }, { @@ -617,6 +647,11 @@ "when": "view == adoext.pipelines", "group": "navigation" }, + { + "command": "adoext.refreshDeployments", + "when": "view == adoext.deployments", + "group": "navigation" + }, { "command": "adoext.setPipelineRunsFilter", "when": "view == adoext.pipelines", @@ -729,11 +764,26 @@ "when": "viewItem == pipelineRun || viewItem == pipelineRunRunning", "group": "inline" }, + { + "command": "adoext.viewReleaseDetails", + "when": "viewItem == classicRelease || viewItem == classicReleaseEnvironment", + "group": "inline" + }, { "command": "adoext.openPipelineRun", "when": "viewItem == pipelineRun || viewItem == pipelineRunRunning", "group": "navigation" }, + { + "command": "adoext.openClassicRelease", + "when": "viewItem == classicRelease || viewItem == classicReleaseEnvironment", + "group": "navigation" + }, + { + "command": "adoext.openClassicReleaseEnvironment", + "when": "viewItem == classicReleaseEnvironment", + "group": "navigation" + }, { "command": "adoext.openPipelineRunLogs", "when": "viewItem == pipelineRun || viewItem == pipelineRunRunning", @@ -1087,6 +1137,13 @@ "maximum": 100, "description": "Maximum number of pipeline runs fetched per project scope for the ADOExt Pipelines view." }, + "adoext.classicReleasesTop": { + "type": "number", + "default": 10, + "minimum": 1, + "maximum": 50, + "description": "Maximum number of classic releases fetched per project scope for the ADOExt Deployments view." + }, "adoext.showResolvedPullRequestThreads": { "type": "boolean", "default": true, diff --git a/src/api/adoClient.ts b/src/api/adoClient.ts index 814e347..4adfee2 100644 --- a/src/api/adoClient.ts +++ b/src/api/adoClient.ts @@ -4,10 +4,12 @@ import type { IGitApi } from 'azure-devops-node-api/GitApi'; import type { ICoreApi } from 'azure-devops-node-api/CoreApi'; import type { IPolicyApi } from 'azure-devops-node-api/PolicyApi'; import type { IBuildApi } from 'azure-devops-node-api/BuildApi'; +import type { IReleaseApi } from 'azure-devops-node-api/ReleaseApi'; import { CommentExpandOptions, QueryExpand, TreeStructureGroup, WorkItemExpand } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; import { GitVersionType, VersionControlChangeType, GitStatusState, PullRequestAsyncStatus, PullRequestMergeFailureType, PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { BuildReason, BuildResult, BuildStatus } from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { Operation } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; +import { ReleaseExpands, ReleaseQueryOrder } from 'azure-devops-node-api/interfaces/ReleaseInterfaces'; import { normalizeWorkItemTypeName, workItemTypeScopeKey } from '../utils/workItemTypeIcons'; import type { WorkItem, @@ -35,6 +37,7 @@ import type { TeamProject } from 'azure-devops-node-api/interfaces/CoreInterface import type { IdentityRef, JsonPatchDocument, JsonPatchOperation, ResourceRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; import type { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; import { PolicyEvaluationStatus } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; +import type { Release, ReleaseEnvironment } from 'azure-devops-node-api/interfaces/ReleaseInterfaces'; export type { WorkItem, @@ -54,6 +57,8 @@ export type { BuildArtifact, BuildLog, Timeline, + Release, + ReleaseEnvironment, PolicyEvaluationRecord }; export { GitStatusState, PolicyEvaluationStatus, PullRequestAsyncStatus, PullRequestMergeFailureType, PullRequestStatus, BuildReason, BuildResult, BuildStatus }; @@ -1297,6 +1302,81 @@ export class AdoClient { ); } + /** + * List classic Releases (Release Management / "Releases" UI) for a project. + * + * This API can be unavailable or permission-gated; callers should catch + * errors and degrade gracefully. + */ + async listClassicReleases( + project: string, + organization?: string, + options?: { + top?: number; + } + ): Promise { + const releaseApi: IReleaseApi = await this.getConnectionFor(organization).getReleaseApi(); + const top = options?.top ?? 10; + const expand = ReleaseExpands.Environments | ReleaseExpands.Artifacts | ReleaseExpands.Approvals; + + return releaseApi.getReleases( + project, + undefined, // definitionId + undefined, // definitionEnvironmentId + undefined, // searchText + undefined, // createdBy + undefined, // statusFilter + undefined, // environmentStatusFilter + undefined, // minCreatedTime + undefined, // maxCreatedTime + ReleaseQueryOrder.Descending, + top, + undefined, // continuationToken + expand + ); + } + + /** + * Fetch a single classic release with expanded environments/artifacts. + * + * `getRelease()` does not support expanding environments/artifacts, so we + * query `getReleases()` with a releaseId filter. + */ + async getClassicRelease( + project: string, + releaseId: number, + organization?: string + ): Promise { + const releaseApi: IReleaseApi = await this.getConnectionFor(organization).getReleaseApi(); + const expand = ReleaseExpands.Environments | ReleaseExpands.Artifacts | ReleaseExpands.Approvals; + + const releases = await releaseApi.getReleases( + project, + undefined, // definitionId + undefined, // definitionEnvironmentId + undefined, // searchText + undefined, // createdBy + undefined, // statusFilter + undefined, // environmentStatusFilter + undefined, // minCreatedTime + undefined, // maxCreatedTime + ReleaseQueryOrder.Descending, + 1, + undefined, // continuationToken + expand, + undefined, // artifactTypeId + undefined, // sourceId + undefined, // artifactVersionId + undefined, // sourceBranchFilter + undefined, // isDeleted + undefined, // tagFilter + undefined, // propertyFilters + [releaseId] // releaseIdFilter + ); + + return releases?.[0]; + } + get organization(): string | undefined { return this._organization; } diff --git a/src/commands/deploymentsCommands.ts b/src/commands/deploymentsCommands.ts new file mode 100644 index 0000000..039c7d6 --- /dev/null +++ b/src/commands/deploymentsCommands.ts @@ -0,0 +1,74 @@ +import * as vscode from 'vscode'; +import type { AdoClient } from '../api/adoClient'; +import type { ConfigManager } from '../config/configManager'; +import { ClassicReleaseEnvironmentNode, type ClassicReleaseNode } from '../providers/deploymentsProvider'; +import { ReleaseDetailsPanel } from '../views/releaseDetailsPanel'; +import { showErrorMessage, showInformationMessage } from '../utils/notifications'; +import { classicReleaseUrl } from '../utils/releaseUrls'; + +export async function viewReleaseDetails( + context: vscode.ExtensionContext, + node: ClassicReleaseNode | ClassicReleaseEnvironmentNode | undefined, + client: AdoClient, + config: ConfigManager +): Promise { + const releaseId = node instanceof ClassicReleaseEnvironmentNode ? node.releaseId : node?.releaseId; + if (!releaseId) { + showInformationMessage('Select a release first.'); + return; + } + if (!node) { + return; + } + + await ReleaseDetailsPanel.show(context, client, config, releaseId, { + organization: node.organization, + project: node.project + }); +} + +export async function openClassicReleaseInBrowser( + node: ClassicReleaseNode | ClassicReleaseEnvironmentNode | undefined, + client: AdoClient, + config: ConfigManager +): Promise { + const organization = node?.organization ?? client.organization ?? config.organization; + const project = node?.project ?? config.project; + const releaseId = node instanceof ClassicReleaseEnvironmentNode ? node.releaseId : node?.releaseId; + if (!organization || !project || !releaseId) { + showInformationMessage('Select an organization, project, and release first.'); + return; + } + + try { + await vscode.env.openExternal(vscode.Uri.parse(classicReleaseUrl(organization, project, releaseId))); + } catch (err) { + showErrorMessage(`Failed to open release: ${err}`); + } +} + +export async function openClassicReleaseEnvironmentInBrowser( + node: ClassicReleaseEnvironmentNode | undefined, + client: AdoClient, + config: ConfigManager +): Promise { + if (!node) { + showInformationMessage('Select a release environment first.'); + return; + } + + const organization = node.organization ?? client.organization ?? config.organization; + const project = node.project ?? config.project; + const releaseId = node.releaseId; + const environmentId = node.environmentId; + if (!organization || !project || !releaseId || !environmentId) { + showInformationMessage('Select an organization and project first.'); + return; + } + + try { + await vscode.env.openExternal(vscode.Uri.parse(classicReleaseUrl(organization, project, releaseId, { environmentId }))); + } catch (err) { + showErrorMessage(`Failed to open release environment: ${err}`); + } +} diff --git a/src/config/configManager.ts b/src/config/configManager.ts index 301c73c..dfcd3f9 100644 --- a/src/config/configManager.ts +++ b/src/config/configManager.ts @@ -411,6 +411,13 @@ export class ConfigManager { return Math.max(1, Math.min(100, top)); } + /** Max classic releases fetched per scope for the Deployments view (1-50). */ + get classicReleasesTop(): number { + const raw = this.config.get('classicReleasesTop', 10); + const top = Math.floor(raw); + return Math.max(1, Math.min(50, top)); + } + /** Returns true if both organization and project are configured. */ get isConfigured(): boolean { const organizations = this.selectedOrganizations; diff --git a/src/extension.ts b/src/extension.ts index 6f9128a..cce0579 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,6 +11,7 @@ import { PullRequestThreadNode } from './providers/pullRequestProvider'; import { PipelinesProvider, type PipelineRunNode, type PipelineStepLogNode } from './providers/pipelinesProvider'; +import { DeploymentsProvider, type ClassicReleaseNode, type ClassicReleaseEnvironmentNode } from './providers/deploymentsProvider'; import { BacklogProvider, SprintProvider, BoardProvider } from './providers/planningProviders'; import { WorkItemIconResolver } from './providers/workItemIconResolver'; import { PlanningPanel } from './views/planningPanel'; @@ -66,6 +67,11 @@ import { rerunPipelineRun, viewPipelineRunDetails } from './commands/pipelineCommands'; +import { + openClassicReleaseEnvironmentInBrowser, + openClassicReleaseInBrowser, + viewReleaseDetails +} from './commands/deploymentsCommands'; import { McpServerManager } from './mcp/mcpServerManager'; import { TodoCodeActionProvider } from './views/todoCodeActionProvider'; import { PipelineRunDetailsPanel } from './views/pipelineRunDetailsPanel'; @@ -124,6 +130,7 @@ export async function activate(context: vscode.ExtensionContext): Promise workItemProvider.refresh(); pullRequestProvider.refresh(); pipelinesProvider.refresh(); + deploymentsProvider.refresh(); pipelineLogContentProvider.clear(); backlogProvider.refresh(); sprintProvider.refresh(); @@ -216,6 +223,7 @@ export async function activate(context: vscode.ExtensionContext): Promise const workItemProvider = new WorkItemProvider(client, config, workItemIconResolver, recoverAuthAfterAdoError); const pullRequestProvider = new PullRequestProvider(client, config, recoverAuthAfterAdoError); const pipelinesProvider = new PipelinesProvider(client, config); + const deploymentsProvider = new DeploymentsProvider(client, config); const pipelineLogContentProvider = new PipelineLogContentProvider(client); const backlogProvider = new BacklogProvider(client, config, workItemIconResolver, recoverAuthAfterAdoError); const sprintProvider = new SprintProvider(client, config, workItemIconResolver, recoverAuthAfterAdoError); @@ -225,6 +233,7 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.window.registerTreeDataProvider('adoext.workItems', workItemProvider), vscode.window.registerTreeDataProvider('adoext.pullRequests', pullRequestProvider), vscode.window.registerTreeDataProvider('adoext.pipelines', pipelinesProvider), + vscode.window.registerTreeDataProvider('adoext.deployments', deploymentsProvider), vscode.window.registerTreeDataProvider('adoext.backlog', backlogProvider), vscode.window.registerTreeDataProvider('adoext.sprints', sprintProvider), vscode.window.registerTreeDataProvider('adoext.boards', boardProvider) @@ -839,6 +848,13 @@ export async function activate(context: vscode.ExtensionContext): Promise }) ); + context.subscriptions.push( + vscode.commands.registerCommand('adoext.refreshDeployments', async () => { + await ensureSignedIn(); + deploymentsProvider.refresh(); + }) + ); + context.subscriptions.push( vscode.commands.registerCommand( 'adoext.viewPipelineRunDetails', @@ -849,6 +865,36 @@ export async function activate(context: vscode.ExtensionContext): Promise ) ); + context.subscriptions.push( + vscode.commands.registerCommand( + 'adoext.viewReleaseDetails', + async (node?: ClassicReleaseNode | ClassicReleaseEnvironmentNode) => { + if (!(await ensureSignedIn())) { return; } + await viewReleaseDetails(context, node, client, config); + } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'adoext.openClassicRelease', + async (node?: ClassicReleaseNode | ClassicReleaseEnvironmentNode) => { + if (!(await ensureSignedIn())) { return; } + await openClassicReleaseInBrowser(node, client, config); + } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'adoext.openClassicReleaseEnvironment', + async (node?: ClassicReleaseEnvironmentNode) => { + if (!(await ensureSignedIn())) { return; } + await openClassicReleaseEnvironmentInBrowser(node, client, config); + } + ) + ); + context.subscriptions.push( vscode.commands.registerCommand( 'adoext.openPipelineRun', diff --git a/src/providers/deploymentsProvider.ts b/src/providers/deploymentsProvider.ts new file mode 100644 index 0000000..21d06f0 --- /dev/null +++ b/src/providers/deploymentsProvider.ts @@ -0,0 +1,388 @@ +import * as vscode from 'vscode'; +import type { AdoClient, Release } from '../api/adoClient'; +import type { ConfigManager } from '../config/configManager'; +import { + resolveProjectScopes, + scopeKey, + scopeLabel, + type ProjectScope +} from './projectScopes'; +import { mapWithConcurrencyLimit } from '../utils/async'; +import { PipelineRunNode } from './pipelinesProvider'; +import { classicReleaseUrl } from '../utils/releaseUrls'; + +const MAX_CONCURRENT_SCOPE_REQUESTS = 4; + +interface ScopedDeployments { + scope: ProjectScope; + pipelineRuns: PipelineRunNode[]; + releases: Release[]; + releaseError?: string; +} + +export class DeploymentsScopeGroup extends vscode.TreeItem { + constructor(public readonly data: ScopedDeployments) { + super(scopeLabel(data.scope), vscode.TreeItemCollapsibleState.Expanded); + this.description = ''; + this.iconPath = new vscode.ThemeIcon('project'); + this.contextValue = 'deploymentsScopeGroup'; + } +} + +type DeploymentsCategory = 'pipelines' | 'releases'; + +export class DeploymentsCategoryGroup extends vscode.TreeItem { + constructor( + public readonly category: DeploymentsCategory, + public readonly data: ScopedDeployments + ) { + const label = category === 'pipelines' ? 'Pipeline Runs' : 'Releases'; + super(label, vscode.TreeItemCollapsibleState.Expanded); + + const count = category === 'pipelines' ? data.pipelineRuns.length : data.releases.length; + this.description = `${count} item${count !== 1 ? 's' : ''}`; + this.iconPath = new vscode.ThemeIcon(category === 'pipelines' ? 'rocket' : 'package'); + this.contextValue = category === 'pipelines' ? 'deploymentsPipelinesGroup' : 'deploymentsReleasesGroup'; + } +} + +export class ClassicReleaseNode extends vscode.TreeItem { + public readonly organization: string; + public readonly project: string; + public readonly releaseId: number; + + constructor( + public readonly release: Release, + public readonly scope: ProjectScope + ) { + const definitionName = release.releaseDefinition?.name ?? 'Release'; + const releaseName = release.name ?? String(release.id ?? ''); + super(`${definitionName} ${releaseName}`, vscode.TreeItemCollapsibleState.Collapsed); + + this.organization = scope.organization; + this.project = scope.project; + this.releaseId = release.id ?? 0; + + const status = release.status !== undefined ? String(release.status) : ''; + const createdBy = release.createdBy?.displayName ?? ''; + const createdOn = release.createdOn ? new Date(release.createdOn).toLocaleString() : ''; + const artifactVersion = firstArtifactVersion(release); + this.description = [status, artifactVersion, createdBy, createdOn].filter(Boolean).join(' ยท '); + + this.tooltip = [ + `${definitionName} ${releaseName}`, + status ? `Status: ${status}` : undefined, + artifactVersion ? `Artifact: ${artifactVersion}` : undefined, + createdBy ? `Created by: ${createdBy}` : undefined, + createdOn ? `Created: ${createdOn}` : undefined, + `Project: ${scopeLabel(scope)}` + ].filter(Boolean).join('\n'); + + this.iconPath = new vscode.ThemeIcon('package'); + this.contextValue = 'classicRelease'; + this.command = { + command: 'adoext.viewReleaseDetails', + title: 'View Release Details', + arguments: [this] + }; + } +} + +export class ClassicReleaseEnvironmentNode extends vscode.TreeItem { + public readonly organization: string; + public readonly project: string; + public readonly releaseId: number; + public readonly environmentId: number; + + constructor( + public readonly release: Release, + public readonly scope: ProjectScope, + public readonly environment: NonNullable[number] + ) { + super(environment.name ?? '(unnamed)', vscode.TreeItemCollapsibleState.None); + + this.organization = scope.organization; + this.project = scope.project; + this.releaseId = release.id ?? 0; + this.environmentId = environment.id ?? 0; + + const status = environment.status !== undefined ? String(environment.status) : ''; + const approvals = approvalSummaryLabel(environment); + const modifiedOn = environment.modifiedOn ? new Date(environment.modifiedOn).toLocaleString() : ''; + + this.description = [status, approvals, modifiedOn].filter(Boolean).join(' ยท '); + this.tooltip = [ + environment.name ?? '(unnamed)', + status ? `Status: ${status}` : undefined, + approvals ? `Approvals: ${approvals}` : undefined, + modifiedOn ? `Updated: ${modifiedOn}` : undefined, + this.environmentId ? `Open: ${classicReleaseUrl(this.organization, this.project, this.releaseId, { environmentId: this.environmentId })}` : undefined + ].filter(Boolean).join('\n'); + + this.iconPath = new vscode.ThemeIcon('server-environment'); + this.contextValue = 'classicReleaseEnvironment'; + this.command = { + command: 'adoext.viewReleaseDetails', + title: 'View Release Details', + arguments: [new ClassicReleaseNode(release, scope)] + }; + } +} + +type DeploymentsTreeNode = + | DeploymentsScopeGroup + | DeploymentsCategoryGroup + | PipelineRunNode + | ClassicReleaseNode + | ClassicReleaseEnvironmentNode + | vscode.TreeItem; + +export class DeploymentsProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private _loading = false; + + constructor( + private readonly client: AdoClient, + private readonly config: ConfigManager + ) {} + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: DeploymentsTreeNode): vscode.TreeItem { + return element; + } + + async getChildren(element?: DeploymentsTreeNode): Promise { + if (element instanceof DeploymentsScopeGroup) { + return this.categoriesFor(element.data); + } + + if (element instanceof DeploymentsCategoryGroup) { + return this.childrenForCategory(element.category, element.data); + } + + if (element instanceof ClassicReleaseNode) { + const environments = element.release.environments ?? []; + if (environments.length === 0) { + const node = new vscode.TreeItem('No environments found', vscode.TreeItemCollapsibleState.None); + node.iconPath = new vscode.ThemeIcon('info'); + return [node]; + } + return environments.map(env => new ClassicReleaseEnvironmentNode(element.release, element.scope, env)); + } + + if (element instanceof ClassicReleaseEnvironmentNode) { + return []; + } + + if (element instanceof PipelineRunNode) { + // Pipeline runs already have their own tree under the Pipelines view; + // keep Deployments read-only and shallow. + return []; + } + + if (this._loading) { + return []; + } + this._loading = true; + + try { + const setupNode = this.getSetupNode(); + if (setupNode) { + return [setupNode]; + } + + const scopes = await resolveProjectScopes(this.client, this.config); + if (scopes.length === 0) { + return [this.createConfigureNode()]; + } + + const scoped = await this.loadDeployments(scopes); + if (scoped.length === 0) { + const node = new vscode.TreeItem('No deployments found', vscode.TreeItemCollapsibleState.None); + node.iconPath = new vscode.ThemeIcon('info'); + return [node]; + } + + if (scopes.length === 1) { + return this.categoriesFor(scoped[0]); + } + + const byScope = new Map(); + for (const item of scoped) { + byScope.set(scopeKey(item.scope), item); + } + + return [...byScope.values()] + .map(item => new DeploymentsScopeGroup(item)) + .sort((left, right) => `${left.label}`.localeCompare(`${right.label}`)); + } catch (err) { + const node = new vscode.TreeItem(`Error: ${err}`, vscode.TreeItemCollapsibleState.None); + node.iconPath = new vscode.ThemeIcon('error'); + return [node]; + } finally { + this._loading = false; + } + } + + private categoriesFor(data: ScopedDeployments): DeploymentsTreeNode[] { + return [ + new DeploymentsCategoryGroup('pipelines', data), + new DeploymentsCategoryGroup('releases', data) + ]; + } + + private childrenForCategory(category: DeploymentsCategory, data: ScopedDeployments): DeploymentsTreeNode[] { + if (category === 'pipelines') { + if (data.pipelineRuns.length === 0) { + const node = new vscode.TreeItem('No pipeline runs found', vscode.TreeItemCollapsibleState.None); + node.iconPath = new vscode.ThemeIcon('info'); + return [node]; + } + return data.pipelineRuns; + } + + if (data.releaseError) { + const node = new vscode.TreeItem(`Classic releases unavailable: ${data.releaseError}`, vscode.TreeItemCollapsibleState.None); + node.iconPath = new vscode.ThemeIcon('warning'); + return [node]; + } + + if (data.releases.length === 0) { + const node = new vscode.TreeItem('No releases found', vscode.TreeItemCollapsibleState.None); + node.iconPath = new vscode.ThemeIcon('info'); + return [node]; + } + + return data.releases.map(release => new ClassicReleaseNode(release, data.scope)); + } + + private async loadDeployments(scopes: ProjectScope[]): Promise { + const pipelineTop = this.config.pipelineRunsTop; + const pipelineFilter = this.config.pipelineRunsFilter; + const releasesTop = this.config.classicReleasesTop; + + return mapWithConcurrencyLimit(scopes, MAX_CONCURRENT_SCOPE_REQUESTS, async scope => { + const [runsResult, releasesResult] = await Promise.allSettled([ + this.client.listPipelineRuns(scope.project, scope.organization, { top: pipelineTop, filter: pipelineFilter }), + this.client.listClassicReleases(scope.project, scope.organization, { top: releasesTop }) + ]); + + const runs = runsResult.status === 'fulfilled' + ? runsResult.value.map(build => { + const node = new PipelineRunNode(build, scope); + node.collapsibleState = vscode.TreeItemCollapsibleState.None; + return node; + }) + : []; + + let releases: Release[] = []; + let releaseError: string | undefined; + if (releasesResult.status === 'fulfilled') { + releases = releasesResult.value ?? []; + } else { + releaseError = stringifyError(releasesResult.reason); + } + + return { + scope, + pipelineRuns: runs, + releases, + ...(releaseError ? { releaseError } : {}) + }; + }); + } + + private getSetupNode(): vscode.TreeItem | undefined { + if (!this.client.isConnected) { + const node = new vscode.TreeItem('Sign in to Azure DevOps...', vscode.TreeItemCollapsibleState.None); + node.command = { command: 'adoext.signIn', title: 'Sign In' }; + node.iconPath = new vscode.ThemeIcon('sign-in'); + return node; + } + + if (!this.config.isConfigured) { + return this.createConfigureNode(); + } + + return undefined; + } + + private createConfigureNode(): vscode.TreeItem { + const node = new vscode.TreeItem('Configure organizations and projects...', vscode.TreeItemCollapsibleState.None); + node.command = { command: 'adoext.selectOrganization', title: 'Select Organizations' }; + node.iconPath = new vscode.ThemeIcon('settings-gear'); + return node; + } +} + +function firstArtifactVersion(release: Release): string { + const artifact = release.artifacts?.[0]; + const version = artifact?.definitionReference?.version?.name ?? artifact?.definitionReference?.version?.id ?? ''; + return version ? String(version) : ''; +} + +function approvalSummaryLabel(environment: { preDeployApprovals?: Array<{ status?: unknown }>; postDeployApprovals?: Array<{ status?: unknown }> }): string { + const pre = summarizeApprovals(environment.preDeployApprovals ?? []); + const post = summarizeApprovals(environment.postDeployApprovals ?? []); + const parts = [ + pre ? `Pre: ${pre}` : '', + post ? `Post: ${post}` : '' + ].filter(Boolean); + return parts.join(' ยท '); +} + +function summarizeApprovals(approvals: Array<{ status?: unknown }>): string { + if (approvals.length === 0) { + return ''; + } + const byStatus = new Map(); + for (const approval of approvals) { + const key = approvalStatusLabel(approval.status); + if (!key) { continue; } + byStatus.set(key, (byStatus.get(key) ?? 0) + 1); + } + if (byStatus.size === 0) { + return ''; + } + return [...byStatus.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([status, count]) => count > 1 ? `${status} (${count})` : status) + .join(', '); +} + +function approvalStatusLabel(status: unknown): string { + if (typeof status === 'string') { + return status; + } + if (typeof status !== 'number') { + return ''; + } + switch (status) { + case 1: + return 'Pending'; + case 2: + return 'Approved'; + case 4: + return 'Rejected'; + case 6: + return 'Reassigned'; + case 7: + return 'Canceled'; + case 8: + return 'Skipped'; + default: + return String(status); + } +} + +function stringifyError(err: unknown): string { + if (err instanceof Error) { + return err.message || String(err); + } + return String(err); +} diff --git a/src/utils/releaseUrls.ts b/src/utils/releaseUrls.ts new file mode 100644 index 0000000..77e49c6 --- /dev/null +++ b/src/utils/releaseUrls.ts @@ -0,0 +1,17 @@ +export function classicReleaseUrl( + organization: string, + project: string, + releaseId: number, + options?: { environmentId?: number } +): string { + const org = encodeURIComponent(organization); + const proj = encodeURIComponent(project); + const id = encodeURIComponent(String(releaseId)); + const base = `https://dev.azure.com/${org}/${proj}/_releaseProgress?_a=release-pipeline-progress&releaseId=${id}`; + const environmentId = options?.environmentId; + if (typeof environmentId === 'number' && environmentId > 0) { + return `${base}&environmentId=${encodeURIComponent(String(environmentId))}`; + } + return base; +} + diff --git a/src/views/releaseDetailsPanel.ts b/src/views/releaseDetailsPanel.ts new file mode 100644 index 0000000..08a9145 --- /dev/null +++ b/src/views/releaseDetailsPanel.ts @@ -0,0 +1,401 @@ +import * as crypto from 'crypto'; +import * as vscode from 'vscode'; +import type { AdoClient, Release, ReleaseEnvironment } from '../api/adoClient'; +import type { ConfigManager } from '../config/configManager'; +import { classicReleaseUrl } from '../utils/releaseUrls'; +import { buildMessageDocument, webviewAssetRoots } from './webviewHtml'; + +interface ReleasePanelScope { + organization?: string; + project?: string; +} + +type ReleaseDetailsMessage = + | { type: 'openInBrowser' } + | { type: 'openEnvironment'; environmentId: number } + | { type: 'refresh' }; + +export class ReleaseDetailsPanel { + private static _panels = new Map(); + + private readonly _panel: vscode.WebviewPanel; + private readonly _panelKey: string; + private readonly _organization?: string; + private readonly _project?: string; + private _releaseId: number; + private _disposables: vscode.Disposable[] = []; + + static async show( + context: vscode.ExtensionContext, + client: AdoClient, + config: ConfigManager, + releaseId: number, + scope: ReleasePanelScope = {} + ): Promise { + const key = ReleaseDetailsPanel.panelKey( + releaseId, + scope.organization ?? client.organization ?? config.organization, + scope.project ?? config.project + ); + const existing = ReleaseDetailsPanel._panels.get(key); + if (existing) { + existing._panel.reveal(vscode.ViewColumn.One); + await existing._refresh(client, config); + return; + } + new ReleaseDetailsPanel(context, client, config, releaseId, key, scope); + } + + private constructor( + private readonly _context: vscode.ExtensionContext, + private readonly _client: AdoClient, + private readonly _config: ConfigManager, + releaseId: number, + panelKey: string, + scope: ReleasePanelScope + ) { + this._releaseId = releaseId; + this._panelKey = panelKey; + this._organization = scope.organization; + this._project = scope.project; + this._panel = vscode.window.createWebviewPanel( + 'adoext.releaseDetails', + `Release #${releaseId}`, + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: webviewAssetRoots(_context) + } + ); + + this._panel.onDidDispose(() => this._dispose(), null, this._disposables); + this._panel.webview.onDidReceiveMessage( + async (msg) => this._handleMessage(msg), + null, + this._disposables + ); + + ReleaseDetailsPanel._panels.set(panelKey, this); + void this._refresh(_client, _config); + } + + private async _refresh(client: AdoClient, config: ConfigManager): Promise { + const project = this._project ?? config.project; + const organization = this._organization ?? client.organization ?? config.organization; + if (!project || !organization) { + this._panel.webview.html = buildMessageDocument(this._panel.webview, 'Select an organization and project to view releases.'); + return; + } + + let release: Release | undefined; + try { + release = await client.getClassicRelease(project, this._releaseId, organization); + } catch { + this._panel.webview.html = buildMessageDocument(this._panel.webview, `Failed to load release #${this._releaseId}.`); + return; + } + + if (!release) { + this._panel.webview.html = buildMessageDocument(this._panel.webview, `Release #${this._releaseId} not found.`); + return; + } + + const definitionName = release.releaseDefinition?.name ?? 'Release'; + const releaseName = release.name ?? `#${this._releaseId}`; + this._panel.title = `${definitionName} ${releaseName}`; + this._panel.webview.html = buildReleaseDetailsHtml(this._panel.webview, release, organization, project); + } + + private async _handleMessage(msg: ReleaseDetailsMessage): Promise { + const project = this._project ?? this._config.project; + const organization = this._organization ?? this._client.organization ?? this._config.organization; + if (!project || !organization) { + return; + } + + switch (msg.type) { + case 'openInBrowser': + await vscode.env.openExternal(vscode.Uri.parse(classicReleaseUrl(organization, project, this._releaseId))); + return; + case 'openEnvironment': + if (msg.environmentId > 0) { + await vscode.env.openExternal( + vscode.Uri.parse(classicReleaseUrl(organization, project, this._releaseId, { environmentId: msg.environmentId })) + ); + } + return; + case 'refresh': + await this._refresh(this._client, this._config); + return; + } + } + + private _dispose(): void { + ReleaseDetailsPanel._panels.delete(this._panelKey); + this._disposables.forEach(d => d.dispose()); + this._disposables = []; + } + + private static panelKey(releaseId: number, organization?: string, project?: string): string { + return `${organization ?? ''}\u0000${project ?? ''}\u0000${releaseId}`; + } +} + +function buildReleaseDetailsHtml(webview: vscode.Webview, release: Release, organization: string, project: string): string { + const nonce = crypto.randomBytes(16).toString('hex'); + const csp = [ + "default-src 'none'", + `img-src ${webview.cspSource} https: data:`, + `style-src ${webview.cspSource} 'unsafe-inline'`, + `script-src 'nonce-${nonce}' ${webview.cspSource}` + ].join('; '); + + const definitionName = release.releaseDefinition?.name ?? 'Release'; + const releaseName = release.name ?? `#${release.id ?? ''}`; + const createdBy = release.createdBy?.displayName ?? ''; + const createdOn = release.createdOn ? new Date(release.createdOn).toLocaleString() : ''; + const status = releaseStatusLabel(release.status); + const artifacts = (release.artifacts ?? []).map(formatArtifact).filter(Boolean); + + const environments = release.environments ?? []; + const environmentsHtml = environments.length === 0 + ? `

No environments found.

` + : ` + + + ${environments.map(env => environmentRowHtml(env)).join('')} + +
EnvironmentStatusApprovalsUpdated
`; + + return /* html */` + + + + + +${escapeHtml(`${definitionName} ${releaseName}`)} + + + +
+ + +
+ +

${escapeHtml(`${definitionName} ${releaseName}`)}

+
+ ${status ? `Status: ${escapeHtml(status)}` : ''}${status && createdOn ? ' ยท ' : ''}${createdOn ? `Created: ${escapeHtml(createdOn)}` : ''} + ${createdBy ? `
Created by: ${escapeHtml(createdBy)}
` : ''} +
Project: ${escapeHtml(project)} ยท Org: ${escapeHtml(organization)}
+
+ +
+

Environments

+ ${environmentsHtml} +
+ +
+

Artifacts

+ ${artifacts.length === 0 + ? `

No artifacts found.

` + : `
${artifacts.map(a => `
${a}
`).join('')}
`} +
+ + + +`; +} + +function environmentRowHtml(env: ReleaseEnvironment): string { + const name = env.name ?? '(unnamed)'; + const statusLabel = environmentStatusLabel(env.status); + const statusKind = environmentStatusKind(env.status); + const approvals = approvalSummaryLabel(env); + const updatedOn = env.modifiedOn ? new Date(env.modifiedOn).toLocaleString() : ''; + const envId = env.id ?? 0; + + return ` + ${escapeHtml(name)} + ${statusLabel ? `${escapeHtml(statusLabel)}` : ''} + ${escapeHtml(approvals)} + ${escapeHtml(updatedOn)} + ${envId > 0 ? `` : ''} + `; +} + +function formatArtifact(artifact: NonNullable[number] | undefined): string { + if (!artifact) { + return ''; + } + const alias = artifact.alias ?? ''; + const type = artifact.type ?? ''; + const version = artifact.definitionReference?.version?.name ?? artifact.definitionReference?.version?.id ?? ''; + const defName = artifact.definitionReference?.definition?.name ?? ''; + const parts = [ + alias ? `${escapeHtml(alias)}` : '', + defName ? `${escapeHtml(defName)}` : '', + type ? `${escapeHtml(type)}` : '', + version ? `${escapeHtml(String(version))}` : '' + ].filter(Boolean); + return parts.join(' '); +} + +function approvalSummaryLabel(env: ReleaseEnvironment): string { + const pre = summarizeApprovals(env.preDeployApprovals ?? []); + const post = summarizeApprovals(env.postDeployApprovals ?? []); + const parts = [ + pre ? `Pre: ${pre}` : '', + post ? `Post: ${post}` : '' + ].filter(Boolean); + return parts.length > 0 ? parts.join(' ยท ') : ''; +} + +function summarizeApprovals(approvals: Array<{ status?: unknown }>): string { + if (approvals.length === 0) { + return ''; + } + const byStatus = new Map(); + for (const approval of approvals) { + const key = approvalStatusLabel(approval.status) || ''; + if (!key) { continue; } + byStatus.set(key, (byStatus.get(key) ?? 0) + 1); + } + if (byStatus.size === 0) { + return ''; + } + return [...byStatus.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([status, count]) => count > 1 ? `${status} (${count})` : status) + .join(', '); +} + +function releaseStatusLabel(status: unknown): string { + if (typeof status === 'string') { + return status; + } + if (typeof status === 'number') { + // Keep it simple; status enums can vary by API version. + return String(status); + } + return ''; +} + +function environmentStatusLabel(status: unknown): string { + if (typeof status === 'string') { + return status; + } + if (typeof status !== 'number') { + return ''; + } + switch (status) { + case 1: + return 'Not started'; + case 2: + return 'In progress'; + case 4: + return 'Succeeded'; + case 8: + return 'Canceled'; + case 16: + return 'Rejected'; + case 32: + return 'Queued'; + case 64: + return 'Scheduled'; + case 128: + return 'Partially succeeded'; + default: + return String(status); + } +} + +function approvalStatusLabel(status: unknown): string { + if (typeof status === 'string') { + return status; + } + if (typeof status !== 'number') { + return ''; + } + switch (status) { + case 1: + return 'Pending'; + case 2: + return 'Approved'; + case 4: + return 'Rejected'; + case 6: + return 'Reassigned'; + case 7: + return 'Canceled'; + case 8: + return 'Skipped'; + default: + return String(status); + } +} + +function environmentStatusKind(status: unknown): string { + if (typeof status !== 'number') { + return ''; + } + switch (status) { + case 4: + return 'badge-succeeded'; + case 128: + return 'badge-running'; + case 2: + case 1: + case 32: + case 64: + return 'badge-running'; + case 8: + case 16: + return 'badge-canceled'; + default: + return 'badge-failed'; + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +}