Skip to content
Draft
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
866 changes: 866 additions & 0 deletions media/webviews/workItemSchemaInspector.js

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@
"category": "ADOExt",
"icon": "$(search)"
},
{
"command": "adoext.openWorkItemSchemaInspector",
"title": "Open Work Item Process Inspector",
"category": "ADOExt",
"icon": "$(beaker)"
},
{
"command": "adoext.createWorkItem",
"title": "Create Work Item",
Expand Down
3 changes: 2 additions & 1 deletion scripts/build-webviews.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const buildOptions = {
path.join(root, 'src', 'views', 'webview', 'pipelineRunDetails.ts'),
path.join(root, 'src', 'views', 'webview', 'prDetails.ts'),
path.join(root, 'src', 'views', 'webview', 'workItemDetails.ts'),
path.join(root, 'src', 'views', 'webview', 'workItemSchemaInspector.ts'),
path.join(root, 'src', 'views', 'webview', 'planning.ts')
],
outdir,
Expand Down Expand Up @@ -45,4 +46,4 @@ async function main() {
main().catch(error => {
console.error(error);
process.exit(1);
});
});
124 changes: 124 additions & 0 deletions src/api/adoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { GitVersionType, VersionControlChangeType, GitStatusState, PullRequestAs
import { BuildReason, BuildResult, BuildStatus } from 'azure-devops-node-api/interfaces/BuildInterfaces';
import { Operation } from 'azure-devops-node-api/interfaces/common/VSSInterfaces';
import { normalizeWorkItemTypeName, workItemTypeScopeKey } from '../utils/workItemTypeIcons';
import { mapWithConcurrencyLimit } from '../utils/async';
import type {
WorkItem,
WorkItemType,
Expand Down Expand Up @@ -103,6 +104,40 @@ export const PullRequestReviewVotes = {
approved: 10
} as const satisfies Record<string, PullRequestReviewVote>;

export interface WorkItemProcessTemplateInfo {
templateName?: string;
templateTypeId?: string;
templateVersion?: string;
}

export interface WorkItemSchemaStateInfo {
name: string;
category?: string;
color?: string;
}

export interface WorkItemSchemaFieldInfo {
name: string;
referenceName: string;
alwaysRequired: boolean;
helpText?: string;
}

export interface WorkItemTypeSchemaInfo {
name: string;
referenceName?: string;
color?: string;
iconUrl?: string;
states: WorkItemSchemaStateInfo[];
fields: WorkItemSchemaFieldInfo[];
}

export interface WorkItemProcessSchemaInfo {
processTemplate?: WorkItemProcessTemplateInfo;
types: WorkItemTypeSchemaInfo[];
warnings: string[];
}

const WORK_ITEM_QUERY_LIMIT = 200;
const PLANNING_WORK_ITEM_QUERY_LIMIT = 500;
const BUILDS_PER_QUERY = 10;
Expand Down Expand Up @@ -591,6 +626,95 @@ export class AdoClient {
return icons;
}

/**
* Fetch a read-only schema snapshot for work item types/states/fields in the project.
*
* This is intended for diagnostics and should not require admin permissions beyond
* reading work item metadata.
*/
async getWorkItemProcessSchema(
project: string,
organization?: string
): Promise<WorkItemProcessSchemaInfo> {
const warnings: string[] = [];
const coreApi: ICoreApi = await this.getConnectionFor(organization).getCoreApi();
const witApi: IWorkItemTrackingApi = await this.getConnectionFor(organization).getWorkItemTrackingApi();

let processTemplate: WorkItemProcessTemplateInfo | undefined;
try {
const projectInfo = await coreApi.getProject(project, true, false);
const template = projectInfo?.capabilities?.processTemplate;
if (template) {
processTemplate = {
templateName: template.templateName,
templateTypeId: template.templateTypeId,
templateVersion: template.templateVersion
};
}
} catch (err) {
warnings.push(`Failed to fetch project process template: ${this.formatError(err)}`);
}

const typeRefs = await this.getWorkItemTypes(project, organization);
const types = await mapWithConcurrencyLimit<WorkItemType, WorkItemTypeSchemaInfo | undefined>(typeRefs, 4, async (typeRef) => {
const typeName = typeRef.name?.trim();
if (!typeName) {
return undefined;
}

try {
const full = await witApi.getWorkItemType(project, typeName);
const states = (full.states ?? [])
.map(state => ({
name: state.name?.trim() ?? '',
category: state.category?.trim() || undefined,
color: state.color?.trim() || undefined
}))
.filter(state => state.name.length > 0);

const fields = (full.fields ?? full.fieldInstances ?? [])
.map(field => ({
name: field.name?.trim() ?? '',
referenceName: field.referenceName?.trim() ?? '',
alwaysRequired: Boolean(field.alwaysRequired),
helpText: field.helpText?.trim() || undefined
}))
.filter(field => field.referenceName.length > 0 && field.name.length > 0);

return {
name: full.name?.trim() ?? typeName,
referenceName: full.referenceName?.trim() || undefined,
color: full.color?.trim() || undefined,
iconUrl: full.icon?.url?.trim() || undefined,
states,
fields
} satisfies WorkItemTypeSchemaInfo;
} catch (err) {
warnings.push(`Failed to fetch work item type "${typeName}": ${this.formatError(err)}`);
return {
name: typeName,
referenceName: typeRef.referenceName?.trim() || undefined,
color: typeRef.color?.trim() || undefined,
iconUrl: typeRef.icon?.url?.trim() || undefined,
states: [],
fields: []
} satisfies WorkItemTypeSchemaInfo;
}
});

return {
processTemplate,
types: types
.filter((type): type is WorkItemTypeSchemaInfo => Boolean(type))
.sort((a, b) => a.name.localeCompare(b.name)),
warnings
};
}

private formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

// -------------------------------------------------------------------------
// Pull Requests
// -------------------------------------------------------------------------
Expand Down
41 changes: 41 additions & 0 deletions src/commands/schemaInspectorCommands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as vscode from 'vscode';
import type { AdoClient } from '../api/adoClient';
import type { ConfigManager } from '../config/configManager';
import { resolveProjectScopes, scopeLabel, type ProjectScope } from '../providers/projectScopes';
import { showInformationMessage } from '../utils/notifications';
import { WorkItemSchemaInspectorPanel } from '../views/workItemSchemaInspectorPanel';

export async function openWorkItemSchemaInspector(
context: vscode.ExtensionContext,
client: AdoClient,
config: ConfigManager
): Promise<void> {
const scopes = await resolveProjectScopes(client, config);
if (scopes.length === 0) {
showInformationMessage('Select an organization and project first (ADOExt: Select Organization / Select Project).');
return;
}

const scope = await pickScope(scopes);
if (!scope) {
return;
}

await WorkItemSchemaInspectorPanel.show(context, client, config, scope);
}

async function pickScope(scopes: ProjectScope[]): Promise<ProjectScope | undefined> {
if (scopes.length === 1) {
return scopes[0];
}

const choice = await vscode.window.showQuickPick(
scopes.map(scope => ({
label: scopeLabel(scope),
scope
})),
{ placeHolder: 'Select a project scope to inspect' }
);
return choice?.scope;
}

8 changes: 8 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
saveWorkItemQuery,
savePullRequestQuery
} from './commands/queryCommands';
import { openWorkItemSchemaInspector } from './commands/schemaInspectorCommands';
import {
cancelPipelineRun,
openPipelineRunInBrowser,
Expand Down Expand Up @@ -539,6 +540,13 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
})
);

context.subscriptions.push(
vscode.commands.registerCommand('adoext.openWorkItemSchemaInspector', async () => {
if (!(await ensureSignedIn())) { return; }
await openWorkItemSchemaInspector(context, client, config);
})
);

context.subscriptions.push(
vscode.commands.registerCommand('adoext.createWorkItem', async () => {
if (!(await ensureSignedIn())) { return; }
Expand Down
Loading