mcp-consultant-tools 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,394 @@
1
+ import axios from 'axios';
2
+ export class AzureDevOpsService {
3
+ config;
4
+ baseUrl;
5
+ searchUrl;
6
+ authHeader;
7
+ apiVersion;
8
+ constructor(config) {
9
+ this.config = {
10
+ ...config,
11
+ apiVersion: config.apiVersion || '7.1',
12
+ enableWorkItemWrite: config.enableWorkItemWrite ?? false,
13
+ enableWorkItemDelete: config.enableWorkItemDelete ?? false,
14
+ enableWikiWrite: config.enableWikiWrite ?? false
15
+ };
16
+ this.baseUrl = `https://dev.azure.com/${this.config.organization}`;
17
+ this.searchUrl = `https://almsearch.dev.azure.com/${this.config.organization}`;
18
+ this.apiVersion = this.config.apiVersion;
19
+ // Encode PAT for Basic Auth (format is :PAT encoded in base64)
20
+ this.authHeader = `Basic ${Buffer.from(`:${this.config.pat}`).toString('base64')}`;
21
+ }
22
+ /**
23
+ * Validate that a project is in the allowed list
24
+ */
25
+ validateProject(project) {
26
+ if (!this.config.projects.includes(project)) {
27
+ throw new Error(`Project '${project}' is not in the allowed projects list. Allowed projects: ${this.config.projects.join(', ')}`);
28
+ }
29
+ }
30
+ /**
31
+ * Make an authenticated request to the Azure DevOps API
32
+ */
33
+ async makeRequest(endpoint, method = 'GET', data, useSearchUrl = false) {
34
+ try {
35
+ const baseUrl = useSearchUrl ? this.searchUrl : this.baseUrl;
36
+ const url = `${baseUrl}/${endpoint}`;
37
+ const response = await axios({
38
+ method,
39
+ url,
40
+ headers: {
41
+ 'Authorization': this.authHeader,
42
+ 'Content-Type': method === 'PATCH' ? 'application/json-patch+json' : 'application/json',
43
+ 'Accept': 'application/json'
44
+ },
45
+ data
46
+ });
47
+ return response.data;
48
+ }
49
+ catch (error) {
50
+ const errorDetails = error.response?.data?.message || error.response?.data || error.message;
51
+ console.error('Azure DevOps API request failed:', {
52
+ endpoint,
53
+ method,
54
+ status: error.response?.status,
55
+ statusText: error.response?.statusText,
56
+ error: errorDetails
57
+ });
58
+ // Provide user-friendly error messages
59
+ if (error.response?.status === 401) {
60
+ throw new Error('Azure DevOps authentication failed. Please check your PAT token and permissions.');
61
+ }
62
+ if (error.response?.status === 403) {
63
+ throw new Error('Azure DevOps access denied. Please check your PAT scopes and project permissions.');
64
+ }
65
+ if (error.response?.status === 404) {
66
+ throw new Error(`Azure DevOps resource not found: ${endpoint}`);
67
+ }
68
+ throw new Error(`Azure DevOps API request failed: ${error.message} - ${JSON.stringify(errorDetails)}`);
69
+ }
70
+ }
71
+ // ==================== WIKI OPERATIONS ====================
72
+ /**
73
+ * Convert a git path (returned by search) to a wiki path (used by get-page API)
74
+ * Git paths use dashes and .md extensions: /Release-Notes/Page-Name.md
75
+ * Wiki paths use spaces and no extensions: /Release Notes/Page Name
76
+ * @param gitPath The git path from search results
77
+ * @returns The wiki path for use with get-page API
78
+ */
79
+ convertGitPathToWikiPath(gitPath) {
80
+ return gitPath
81
+ .replace(/\.md$/, '') // Remove .md extension
82
+ .replace(/-/g, ' ') // Replace ALL dashes with spaces
83
+ .replace(/%2D/gi, '-'); // Decode %2D back to - (actual dashes in page names)
84
+ }
85
+ /**
86
+ * Get all wikis in a project
87
+ * @param project The project name
88
+ * @returns List of wikis in the project
89
+ */
90
+ async getWikis(project) {
91
+ this.validateProject(project);
92
+ const response = await this.makeRequest(`${project}/_apis/wiki/wikis?api-version=${this.apiVersion}`);
93
+ return {
94
+ project,
95
+ totalCount: response.value.length,
96
+ wikis: response.value.map((wiki) => ({
97
+ id: wiki.id,
98
+ name: wiki.name,
99
+ type: wiki.type,
100
+ url: wiki.url,
101
+ projectId: wiki.projectId,
102
+ repositoryId: wiki.repositoryId,
103
+ mappedPath: wiki.mappedPath
104
+ }))
105
+ };
106
+ }
107
+ /**
108
+ * Search wiki pages across projects
109
+ * @param searchText The text to search for
110
+ * @param project Optional project filter
111
+ * @param maxResults Maximum number of results (default: 25)
112
+ * @returns Search results with highlighted content
113
+ */
114
+ async searchWikiPages(searchText, project, maxResults = 25) {
115
+ if (project) {
116
+ this.validateProject(project);
117
+ }
118
+ const searchBody = {
119
+ searchText,
120
+ $top: maxResults,
121
+ $skip: 0
122
+ };
123
+ // Add project filter if specified
124
+ if (project) {
125
+ searchBody.filters = {
126
+ Project: [project]
127
+ };
128
+ }
129
+ const response = await this.makeRequest(`_apis/search/wikisearchresults?api-version=${this.apiVersion}`, 'POST', searchBody, true // Use search URL
130
+ );
131
+ return {
132
+ searchText,
133
+ project: project || 'all',
134
+ totalCount: response.count || 0,
135
+ results: (response.results || []).map((result) => {
136
+ const gitPath = result.path;
137
+ const wikiPath = this.convertGitPathToWikiPath(gitPath);
138
+ return {
139
+ fileName: result.fileName,
140
+ gitPath: gitPath, // Original git path (for reference)
141
+ path: wikiPath, // Wiki path (for get-page API) - kept as 'path' for backward compatibility
142
+ wikiName: result.wiki?.name,
143
+ wikiId: result.wiki?.id,
144
+ project: result.project?.name,
145
+ highlights: result.hits?.map((hit) => hit.highlights).flat() || []
146
+ };
147
+ })
148
+ };
149
+ }
150
+ /**
151
+ * Get a specific wiki page with content
152
+ * @param project The project name
153
+ * @param wikiId The wiki identifier (ID or name)
154
+ * @param pagePath The path to the page (e.g., "/Setup/Authentication")
155
+ * Accepts both wiki paths (with spaces) and git paths (with dashes and .md)
156
+ * @param includeContent Include page content (default: true)
157
+ * @returns Wiki page with content and metadata
158
+ */
159
+ async getWikiPage(project, wikiId, pagePath, includeContent = true) {
160
+ this.validateProject(project);
161
+ // Auto-convert git paths to wiki paths for better compatibility
162
+ // If the path ends with .md, it's likely a git path from search results
163
+ let wikiPath = pagePath;
164
+ if (pagePath.endsWith('.md')) {
165
+ wikiPath = this.convertGitPathToWikiPath(pagePath);
166
+ console.log(`Auto-converted git path to wiki path: ${pagePath} -> ${wikiPath}`);
167
+ }
168
+ const response = await this.makeRequest(`${project}/_apis/wiki/wikis/${wikiId}/pages?path=${encodeURIComponent(wikiPath)}&includeContent=${includeContent}&api-version=${this.apiVersion}`);
169
+ // The API returns the page data directly (not wrapped in a 'page' property)
170
+ return {
171
+ id: response.id,
172
+ path: response.path,
173
+ content: response.content,
174
+ gitItemPath: response.gitItemPath,
175
+ subPages: response.subPages || [],
176
+ url: response.url,
177
+ remoteUrl: response.remoteUrl,
178
+ project,
179
+ wikiId
180
+ };
181
+ }
182
+ /**
183
+ * Create a new wiki page
184
+ * @param project The project name
185
+ * @param wikiId The wiki identifier
186
+ * @param pagePath The path for the new page
187
+ * @param content The markdown content
188
+ * @returns Created page information
189
+ */
190
+ async createWikiPage(project, wikiId, pagePath, content) {
191
+ this.validateProject(project);
192
+ if (!this.config.enableWikiWrite) {
193
+ throw new Error('Wiki write operations are disabled. Set AZUREDEVOPS_ENABLE_WIKI_WRITE=true to enable.');
194
+ }
195
+ const response = await this.makeRequest(`${project}/_apis/wiki/wikis/${wikiId}/pages?path=${encodeURIComponent(pagePath)}&api-version=${this.apiVersion}`, 'PUT', { content });
196
+ return {
197
+ id: response.page?.id,
198
+ path: response.page?.path,
199
+ gitItemPath: response.page?.gitItemPath,
200
+ project,
201
+ wikiId
202
+ };
203
+ }
204
+ /**
205
+ * Update an existing wiki page
206
+ * @param project The project name
207
+ * @param wikiId The wiki identifier
208
+ * @param pagePath The path to the page
209
+ * @param content The updated markdown content
210
+ * @param version The ETag/version for optimistic concurrency
211
+ * @returns Updated page information
212
+ */
213
+ async updateWikiPage(project, wikiId, pagePath, content, version) {
214
+ this.validateProject(project);
215
+ if (!this.config.enableWikiWrite) {
216
+ throw new Error('Wiki write operations are disabled. Set AZUREDEVOPS_ENABLE_WIKI_WRITE=true to enable.');
217
+ }
218
+ const response = await this.makeRequest(`${project}/_apis/wiki/wikis/${wikiId}/pages?path=${encodeURIComponent(pagePath)}&api-version=${this.apiVersion}`, 'PUT', { content });
219
+ return {
220
+ id: response.page?.id,
221
+ path: response.page?.path,
222
+ gitItemPath: response.page?.gitItemPath,
223
+ project,
224
+ wikiId
225
+ };
226
+ }
227
+ // ==================== WORK ITEM OPERATIONS ====================
228
+ /**
229
+ * Get a work item by ID with full details
230
+ * @param project The project name
231
+ * @param workItemId The work item ID
232
+ * @returns Complete work item details
233
+ */
234
+ async getWorkItem(project, workItemId) {
235
+ this.validateProject(project);
236
+ const response = await this.makeRequest(`${project}/_apis/wit/workitems/${workItemId}?$expand=all&api-version=${this.apiVersion}`);
237
+ return {
238
+ id: response.id,
239
+ rev: response.rev,
240
+ url: response.url,
241
+ fields: response.fields,
242
+ relations: response.relations || [],
243
+ _links: response._links,
244
+ commentVersionRef: response.commentVersionRef,
245
+ project
246
+ };
247
+ }
248
+ /**
249
+ * Query work items using WIQL (Work Item Query Language)
250
+ * @param project The project name
251
+ * @param wiql The WIQL query string
252
+ * @param maxResults Maximum number of results (default: 200)
253
+ * @returns Work items matching the query
254
+ */
255
+ async queryWorkItems(project, wiql, maxResults = 200) {
256
+ this.validateProject(project);
257
+ // Execute WIQL query
258
+ const queryResult = await this.makeRequest(`${project}/_apis/wit/wiql?api-version=${this.apiVersion}`, 'POST', { query: wiql });
259
+ if (!queryResult.workItems || queryResult.workItems.length === 0) {
260
+ return {
261
+ query: wiql,
262
+ project,
263
+ totalCount: 0,
264
+ workItems: []
265
+ };
266
+ }
267
+ // Get work item IDs (limit to maxResults)
268
+ const workItemIds = queryResult.workItems
269
+ .slice(0, maxResults)
270
+ .map((wi) => wi.id);
271
+ // Batch get full work item details
272
+ const workItems = await this.makeRequest(`${project}/_apis/wit/workitemsbatch?api-version=${this.apiVersion}`, 'POST', {
273
+ ids: workItemIds,
274
+ $expand: 'all'
275
+ });
276
+ return {
277
+ query: wiql,
278
+ project,
279
+ totalCount: workItems.value.length,
280
+ workItems: workItems.value
281
+ };
282
+ }
283
+ /**
284
+ * Get comments/discussion for a work item
285
+ * @param project The project name
286
+ * @param workItemId The work item ID
287
+ * @returns List of comments
288
+ */
289
+ async getWorkItemComments(project, workItemId) {
290
+ this.validateProject(project);
291
+ const response = await this.makeRequest(`${project}/_apis/wit/workItems/${workItemId}/comments?api-version=${this.apiVersion}`);
292
+ return {
293
+ workItemId,
294
+ project,
295
+ totalCount: response.totalCount || response.value.length,
296
+ comments: response.value.map((comment) => ({
297
+ id: comment.id,
298
+ text: comment.text,
299
+ createdBy: comment.createdBy?.displayName,
300
+ createdDate: comment.createdDate,
301
+ modifiedBy: comment.modifiedBy?.displayName,
302
+ modifiedDate: comment.modifiedDate,
303
+ url: comment.url
304
+ }))
305
+ };
306
+ }
307
+ /**
308
+ * Add a comment to a work item
309
+ * @param project The project name
310
+ * @param workItemId The work item ID
311
+ * @param commentText The comment text (supports markdown)
312
+ * @returns Created comment information
313
+ */
314
+ async addWorkItemComment(project, workItemId, commentText) {
315
+ this.validateProject(project);
316
+ if (!this.config.enableWorkItemWrite) {
317
+ throw new Error('Work item write operations are disabled. Set AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true to enable.');
318
+ }
319
+ const response = await this.makeRequest(`${project}/_apis/wit/workItems/${workItemId}/comments?api-version=${this.apiVersion}`, 'POST', { text: commentText });
320
+ return {
321
+ id: response.id,
322
+ workItemId,
323
+ project,
324
+ text: response.text,
325
+ createdBy: response.createdBy?.displayName,
326
+ createdDate: response.createdDate
327
+ };
328
+ }
329
+ /**
330
+ * Update a work item using JSON Patch operations
331
+ * @param project The project name
332
+ * @param workItemId The work item ID
333
+ * @param patchOperations Array of JSON Patch operations
334
+ * @returns Updated work item
335
+ */
336
+ async updateWorkItem(project, workItemId, patchOperations) {
337
+ this.validateProject(project);
338
+ if (!this.config.enableWorkItemWrite) {
339
+ throw new Error('Work item write operations are disabled. Set AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true to enable.');
340
+ }
341
+ const response = await this.makeRequest(`${project}/_apis/wit/workitems/${workItemId}?api-version=${this.apiVersion}`, 'PATCH', patchOperations);
342
+ return {
343
+ id: response.id,
344
+ rev: response.rev,
345
+ fields: response.fields,
346
+ project
347
+ };
348
+ }
349
+ /**
350
+ * Create a new work item
351
+ * @param project The project name
352
+ * @param workItemType The work item type (e.g., "Bug", "Task", "User Story")
353
+ * @param fields Object with field values (e.g., { "System.Title": "Bug title" })
354
+ * @returns Created work item
355
+ */
356
+ async createWorkItem(project, workItemType, fields) {
357
+ this.validateProject(project);
358
+ if (!this.config.enableWorkItemWrite) {
359
+ throw new Error('Work item write operations are disabled. Set AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true to enable.');
360
+ }
361
+ // Convert fields object to JSON Patch operations
362
+ const patchOperations = Object.keys(fields).map(field => ({
363
+ op: 'add',
364
+ path: `/fields/${field}`,
365
+ value: fields[field]
366
+ }));
367
+ const response = await this.makeRequest(`${project}/_apis/wit/workitems/$${workItemType}?api-version=${this.apiVersion}`, 'PATCH', patchOperations);
368
+ return {
369
+ id: response.id,
370
+ rev: response.rev,
371
+ fields: response.fields,
372
+ url: response._links?.html?.href,
373
+ project
374
+ };
375
+ }
376
+ /**
377
+ * Delete a work item
378
+ * @param project The project name
379
+ * @param workItemId The work item ID
380
+ * @returns Deletion confirmation
381
+ */
382
+ async deleteWorkItem(project, workItemId) {
383
+ this.validateProject(project);
384
+ if (!this.config.enableWorkItemDelete) {
385
+ throw new Error('Work item delete operations are disabled. Set AZUREDEVOPS_ENABLE_WORK_ITEM_DELETE=true to enable.');
386
+ }
387
+ await this.makeRequest(`${project}/_apis/wit/workitems/${workItemId}?api-version=${this.apiVersion}`, 'DELETE');
388
+ return {
389
+ workItemId,
390
+ project,
391
+ deleted: true
392
+ };
393
+ }
394
+ }