@mcp-consultant-tools/azure-devops 27.0.0 → 28.0.0-beta.1
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.
- package/build/azure-devops-client.d.ts +42 -0
- package/build/azure-devops-client.d.ts.map +1 -0
- package/build/azure-devops-client.js +144 -0
- package/build/azure-devops-client.js.map +1 -0
- package/build/index.d.ts +8 -11
- package/build/index.d.ts.map +1 -1
- package/build/index.js +40 -1878
- package/build/index.js.map +1 -1
- package/build/models/api-types.d.ts +159 -0
- package/build/models/api-types.d.ts.map +1 -0
- package/build/models/api-types.js +5 -0
- package/build/models/api-types.js.map +1 -0
- package/build/models/index.d.ts +5 -0
- package/build/models/index.d.ts.map +1 -0
- package/build/models/index.js +5 -0
- package/build/models/index.js.map +1 -0
- package/build/prompts/index.d.ts +3 -0
- package/build/prompts/index.d.ts.map +1 -0
- package/build/prompts/index.js +83 -0
- package/build/prompts/index.js.map +1 -0
- package/build/prompts/templates.d.ts +8 -0
- package/build/prompts/templates.d.ts.map +1 -0
- package/build/prompts/templates.js +126 -0
- package/build/prompts/templates.js.map +1 -0
- package/build/services/build-service.d.ts +16 -0
- package/build/services/build-service.d.ts.map +1 -0
- package/build/services/build-service.js +195 -0
- package/build/services/build-service.js.map +1 -0
- package/build/services/configuration-service.d.ts +7 -0
- package/build/services/configuration-service.d.ts.map +1 -0
- package/build/services/configuration-service.js +24 -0
- package/build/services/configuration-service.js.map +1 -0
- package/build/services/index.d.ts +11 -0
- package/build/services/index.d.ts.map +1 -0
- package/build/services/index.js +11 -0
- package/build/services/index.js.map +1 -0
- package/build/services/pull-request-service.d.ts +29 -0
- package/build/services/pull-request-service.d.ts.map +1 -0
- package/build/services/pull-request-service.js +390 -0
- package/build/services/pull-request-service.js.map +1 -0
- package/build/services/sync-service.d.ts +19 -0
- package/build/services/sync-service.d.ts.map +1 -0
- package/build/services/sync-service.js +439 -0
- package/build/services/sync-service.js.map +1 -0
- package/build/services/variable-group-service.d.ts +11 -0
- package/build/services/variable-group-service.d.ts.map +1 -0
- package/build/services/variable-group-service.js +62 -0
- package/build/services/variable-group-service.js.map +1 -0
- package/build/services/wiki-service.d.ts +41 -0
- package/build/services/wiki-service.d.ts.map +1 -0
- package/build/services/wiki-service.js +346 -0
- package/build/services/wiki-service.js.map +1 -0
- package/build/services/work-item-service.d.ts +22 -0
- package/build/services/work-item-service.d.ts.map +1 -0
- package/build/services/work-item-service.js +284 -0
- package/build/services/work-item-service.js.map +1 -0
- package/build/sync/file-utils.d.ts +7 -5
- package/build/sync/file-utils.d.ts.map +1 -1
- package/build/sync/file-utils.js +17 -8
- package/build/sync/file-utils.js.map +1 -1
- package/build/sync/markdown-serializer.d.ts +5 -4
- package/build/sync/markdown-serializer.d.ts.map +1 -1
- package/build/sync/markdown-serializer.js +21 -9
- package/build/sync/markdown-serializer.js.map +1 -1
- package/build/tool-examples.d.ts +8 -0
- package/build/tool-examples.d.ts.map +1 -1
- package/build/tool-examples.js +28 -0
- package/build/tool-examples.js.map +1 -1
- package/build/tools/build-tools.d.ts +3 -0
- package/build/tools/build-tools.d.ts.map +1 -0
- package/build/tools/build-tools.js +56 -0
- package/build/tools/build-tools.js.map +1 -0
- package/build/tools/configuration-tools.d.ts +6 -0
- package/build/tools/configuration-tools.d.ts.map +1 -0
- package/build/tools/configuration-tools.js +30 -0
- package/build/tools/configuration-tools.js.map +1 -0
- package/build/tools/index.d.ts +13 -0
- package/build/tools/index.d.ts.map +1 -0
- package/build/tools/index.js +30 -0
- package/build/tools/index.js.map +1 -0
- package/build/tools/pull-request-tools.d.ts +5 -0
- package/build/tools/pull-request-tools.d.ts.map +1 -0
- package/build/tools/pull-request-tools.js +227 -0
- package/build/tools/pull-request-tools.js.map +1 -0
- package/build/tools/sync-tools.d.ts +3 -0
- package/build/tools/sync-tools.d.ts.map +1 -0
- package/build/tools/sync-tools.js +146 -0
- package/build/tools/sync-tools.js.map +1 -0
- package/build/tools/variable-group-tools.d.ts +3 -0
- package/build/tools/variable-group-tools.d.ts.map +1 -0
- package/build/tools/variable-group-tools.js +32 -0
- package/build/tools/variable-group-tools.js.map +1 -0
- package/build/tools/wiki-tools.d.ts +3 -0
- package/build/tools/wiki-tools.d.ts.map +1 -0
- package/build/tools/wiki-tools.js +151 -0
- package/build/tools/wiki-tools.js.map +1 -0
- package/build/tools/work-item-tools.d.ts +3 -0
- package/build/tools/work-item-tools.d.ts.map +1 -0
- package/build/tools/work-item-tools.js +188 -0
- package/build/tools/work-item-tools.js.map +1 -0
- package/build/types.d.ts +23 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/package.json +2 -2
- package/build/AzureDevOpsService.d.ts +0 -489
- package/build/AzureDevOpsService.d.ts.map +0 -1
- package/build/AzureDevOpsService.js +0 -1558
- package/build/AzureDevOpsService.js.map +0 -1
|
@@ -1,1558 +0,0 @@
|
|
|
1
|
-
import axios from 'axios';
|
|
2
|
-
import { marked } from 'marked';
|
|
3
|
-
import { getAllLargeTextFields } from './sync/html-detection.js';
|
|
4
|
-
export class AzureDevOpsService {
|
|
5
|
-
config;
|
|
6
|
-
baseUrl;
|
|
7
|
-
searchUrl;
|
|
8
|
-
authHeader;
|
|
9
|
-
apiVersion;
|
|
10
|
-
constructor(config) {
|
|
11
|
-
this.config = {
|
|
12
|
-
...config,
|
|
13
|
-
apiVersion: config.apiVersion || '7.1',
|
|
14
|
-
enableWorkItemWrite: config.enableWorkItemWrite ?? false,
|
|
15
|
-
enableWorkItemDelete: config.enableWorkItemDelete ?? false,
|
|
16
|
-
enableWikiWrite: config.enableWikiWrite ?? false,
|
|
17
|
-
enableWikiDelete: config.enableWikiDelete ?? false,
|
|
18
|
-
enablePullRequestWrite: config.enablePullRequestWrite ?? false,
|
|
19
|
-
};
|
|
20
|
-
this.baseUrl = `https://dev.azure.com/${this.config.organization}`;
|
|
21
|
-
this.searchUrl = `https://almsearch.dev.azure.com/${this.config.organization}`;
|
|
22
|
-
this.apiVersion = this.config.apiVersion;
|
|
23
|
-
// Encode PAT for Basic Auth (format is :PAT encoded in base64)
|
|
24
|
-
this.authHeader = `Basic ${Buffer.from(`:${this.config.pat}`).toString('base64')}`;
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Validate that a project is in the allowed list
|
|
28
|
-
*/
|
|
29
|
-
validateProject(project) {
|
|
30
|
-
if (!this.config.projects.includes(project)) {
|
|
31
|
-
throw new Error(`Project '${project}' is not in the allowed projects list. Allowed projects: ${this.config.projects.join(', ')}`);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Make an authenticated request to the Azure DevOps API
|
|
36
|
-
*/
|
|
37
|
-
async makeRequest(endpoint, method = 'GET', data, useSearchUrl = false, customHeaders) {
|
|
38
|
-
try {
|
|
39
|
-
const baseUrl = useSearchUrl ? this.searchUrl : this.baseUrl;
|
|
40
|
-
const url = `${baseUrl}/${endpoint}`;
|
|
41
|
-
const response = await axios({
|
|
42
|
-
method,
|
|
43
|
-
url,
|
|
44
|
-
headers: {
|
|
45
|
-
'Authorization': this.authHeader,
|
|
46
|
-
'Content-Type': method === 'PATCH' ? 'application/json-patch+json' : 'application/json',
|
|
47
|
-
'Accept': 'application/json',
|
|
48
|
-
...customHeaders // Merge custom headers (can override defaults)
|
|
49
|
-
},
|
|
50
|
-
data
|
|
51
|
-
});
|
|
52
|
-
return response.data;
|
|
53
|
-
}
|
|
54
|
-
catch (error) {
|
|
55
|
-
const errorDetails = error.response?.data?.message || error.response?.data || error.message;
|
|
56
|
-
console.error('Azure DevOps API request failed:', {
|
|
57
|
-
endpoint,
|
|
58
|
-
method,
|
|
59
|
-
status: error.response?.status,
|
|
60
|
-
statusText: error.response?.statusText,
|
|
61
|
-
error: errorDetails
|
|
62
|
-
});
|
|
63
|
-
// Provide user-friendly error messages
|
|
64
|
-
if (error.response?.status === 401) {
|
|
65
|
-
throw new Error('Azure DevOps authentication failed. Please check your PAT token and permissions.');
|
|
66
|
-
}
|
|
67
|
-
if (error.response?.status === 403) {
|
|
68
|
-
throw new Error('Azure DevOps access denied. Please check your PAT scopes and project permissions.');
|
|
69
|
-
}
|
|
70
|
-
if (error.response?.status === 404) {
|
|
71
|
-
throw new Error(`Azure DevOps resource not found: ${endpoint}`);
|
|
72
|
-
}
|
|
73
|
-
throw new Error(`Azure DevOps API request failed: ${error.message} - ${JSON.stringify(errorDetails)}`);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
// ==================== WIKI OPERATIONS ====================
|
|
77
|
-
/**
|
|
78
|
-
* Convert a git path (returned by search) to a wiki path (used by get-page API)
|
|
79
|
-
* Git paths use dashes and .md extensions: /Release-Notes/Page-Name.md
|
|
80
|
-
* Wiki paths use spaces and no extensions: /Release Notes/Page Name
|
|
81
|
-
* @param gitPath The git path from search results
|
|
82
|
-
* @returns The wiki path for use with get-page API
|
|
83
|
-
*/
|
|
84
|
-
convertGitPathToWikiPath(gitPath) {
|
|
85
|
-
return gitPath
|
|
86
|
-
.replace(/\.md$/, '') // Remove .md extension
|
|
87
|
-
.replace(/-/g, ' ') // Replace ALL dashes with spaces
|
|
88
|
-
.replace(/%2D/gi, '-'); // Decode %2D back to - (actual dashes in page names)
|
|
89
|
-
}
|
|
90
|
-
/**
|
|
91
|
-
* Count occurrences of a string in content
|
|
92
|
-
* @param content The content to search in
|
|
93
|
-
* @param searchStr The string to search for
|
|
94
|
-
* @returns Number of occurrences
|
|
95
|
-
*/
|
|
96
|
-
countOccurrences(content, searchStr) {
|
|
97
|
-
const regex = new RegExp(this.escapeRegExp(searchStr), 'g');
|
|
98
|
-
const matches = content.match(regex);
|
|
99
|
-
return matches ? matches.length : 0;
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* Get locations where a string appears in content
|
|
103
|
-
* @param content The content to search in
|
|
104
|
-
* @param searchStr The string to search for
|
|
105
|
-
* @returns Formatted string showing line numbers and context
|
|
106
|
-
*/
|
|
107
|
-
getMatchLocations(content, searchStr) {
|
|
108
|
-
const lines = content.split('\n');
|
|
109
|
-
const matches = [];
|
|
110
|
-
lines.forEach((line, index) => {
|
|
111
|
-
if (line.includes(searchStr)) {
|
|
112
|
-
matches.push(`Line ${index + 1}: ${this.truncate(line.trim(), 100)}`);
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
const maxDisplay = 10;
|
|
116
|
-
const result = matches.slice(0, maxDisplay).join('\n');
|
|
117
|
-
if (matches.length > maxDisplay) {
|
|
118
|
-
return result + `\n... and ${matches.length - maxDisplay} more`;
|
|
119
|
-
}
|
|
120
|
-
return result;
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* Generate a unified diff showing changes
|
|
124
|
-
* @param oldContent Original content
|
|
125
|
-
* @param newContent Updated content
|
|
126
|
-
* @param oldStr The string that was replaced
|
|
127
|
-
* @param newStr The replacement string
|
|
128
|
-
* @returns Formatted diff output
|
|
129
|
-
*/
|
|
130
|
-
generateUnifiedDiff(oldContent, newContent, oldStr, newStr) {
|
|
131
|
-
const oldLines = oldContent.split('\n');
|
|
132
|
-
const newLines = newContent.split('\n');
|
|
133
|
-
// Find changed lines
|
|
134
|
-
const changedLineNumbers = [];
|
|
135
|
-
oldLines.forEach((line, index) => {
|
|
136
|
-
if (line.includes(oldStr)) {
|
|
137
|
-
changedLineNumbers.push(index);
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
// Build diff output
|
|
141
|
-
const diffLines = [];
|
|
142
|
-
changedLineNumbers.forEach(lineNum => {
|
|
143
|
-
diffLines.push(`@@ Line ${lineNum + 1} @@`);
|
|
144
|
-
diffLines.push(`- ${oldLines[lineNum]}`);
|
|
145
|
-
diffLines.push(`+ ${newLines[lineNum]}`);
|
|
146
|
-
diffLines.push('');
|
|
147
|
-
});
|
|
148
|
-
return diffLines.join('\n');
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* Escape special regex characters
|
|
152
|
-
* @param str String to escape
|
|
153
|
-
* @returns Escaped string safe for use in regex
|
|
154
|
-
*/
|
|
155
|
-
escapeRegExp(str) {
|
|
156
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* Truncate a string for display
|
|
160
|
-
* @param str String to truncate
|
|
161
|
-
* @param maxLen Maximum length
|
|
162
|
-
* @returns Truncated string with ellipsis if needed
|
|
163
|
-
*/
|
|
164
|
-
truncate(str, maxLen) {
|
|
165
|
-
return str.length > maxLen ? str.substring(0, maxLen) + '...' : str;
|
|
166
|
-
}
|
|
167
|
-
/**
|
|
168
|
-
* Get all wikis in a project
|
|
169
|
-
* @param project The project name
|
|
170
|
-
* @returns List of wikis in the project
|
|
171
|
-
*/
|
|
172
|
-
async getWikis(project) {
|
|
173
|
-
this.validateProject(project);
|
|
174
|
-
const response = await this.makeRequest(`${project}/_apis/wiki/wikis?api-version=${this.apiVersion}`);
|
|
175
|
-
return {
|
|
176
|
-
project,
|
|
177
|
-
totalCount: response.value.length,
|
|
178
|
-
wikis: response.value.map((wiki) => ({
|
|
179
|
-
id: wiki.id,
|
|
180
|
-
name: wiki.name,
|
|
181
|
-
type: wiki.type,
|
|
182
|
-
url: wiki.url,
|
|
183
|
-
projectId: wiki.projectId,
|
|
184
|
-
repositoryId: wiki.repositoryId,
|
|
185
|
-
mappedPath: wiki.mappedPath
|
|
186
|
-
}))
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
/**
|
|
190
|
-
* Search wiki pages across projects
|
|
191
|
-
* @param searchText The text to search for
|
|
192
|
-
* @param project Optional project filter
|
|
193
|
-
* @param maxResults Maximum number of results (default: 25)
|
|
194
|
-
* @returns Search results with highlighted content
|
|
195
|
-
*/
|
|
196
|
-
async searchWikiPages(searchText, project, maxResults = 25) {
|
|
197
|
-
if (project) {
|
|
198
|
-
this.validateProject(project);
|
|
199
|
-
}
|
|
200
|
-
const searchBody = {
|
|
201
|
-
searchText,
|
|
202
|
-
$top: maxResults,
|
|
203
|
-
$skip: 0
|
|
204
|
-
};
|
|
205
|
-
// Add project filter if specified
|
|
206
|
-
if (project) {
|
|
207
|
-
searchBody.filters = {
|
|
208
|
-
Project: [project]
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
const response = await this.makeRequest(`_apis/search/wikisearchresults?api-version=${this.apiVersion}`, 'POST', searchBody, true // Use search URL
|
|
212
|
-
);
|
|
213
|
-
return {
|
|
214
|
-
searchText,
|
|
215
|
-
project: project || 'all',
|
|
216
|
-
totalCount: response.count || 0,
|
|
217
|
-
results: (response.results || []).map((result) => {
|
|
218
|
-
const gitPath = result.path;
|
|
219
|
-
const wikiPath = this.convertGitPathToWikiPath(gitPath);
|
|
220
|
-
return {
|
|
221
|
-
fileName: result.fileName,
|
|
222
|
-
gitPath: gitPath, // Original git path (for reference)
|
|
223
|
-
path: wikiPath, // Wiki path (for get-page API) - kept as 'path' for backward compatibility
|
|
224
|
-
wikiName: result.wiki?.name,
|
|
225
|
-
wikiId: result.wiki?.id,
|
|
226
|
-
project: result.project?.name,
|
|
227
|
-
highlights: result.hits?.map((hit) => hit.highlights).flat() || []
|
|
228
|
-
};
|
|
229
|
-
})
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Get a specific wiki page with content
|
|
234
|
-
* @param project The project name
|
|
235
|
-
* @param wikiId The wiki identifier (ID or name)
|
|
236
|
-
* @param pagePath The path to the page (e.g., "/Setup/Authentication")
|
|
237
|
-
* Accepts both wiki paths (with spaces) and git paths (with dashes and .md)
|
|
238
|
-
* @param includeContent Include page content (default: true)
|
|
239
|
-
* @returns Wiki page with content and metadata
|
|
240
|
-
*/
|
|
241
|
-
async getWikiPage(project, wikiId, pagePath, includeContent = true) {
|
|
242
|
-
this.validateProject(project);
|
|
243
|
-
// Always normalize paths to wiki format (removes .md, converts dashes to spaces)
|
|
244
|
-
// This ensures consistent behavior regardless of input format
|
|
245
|
-
const wikiPath = this.convertGitPathToWikiPath(pagePath);
|
|
246
|
-
// Log conversion if the path was changed (for debugging)
|
|
247
|
-
if (wikiPath !== pagePath) {
|
|
248
|
-
console.error(`Normalized wiki path: ${pagePath} -> ${wikiPath}`);
|
|
249
|
-
}
|
|
250
|
-
// Use axios directly to access response headers (for ETag)
|
|
251
|
-
const url = `${this.baseUrl}/${project}/_apis/wiki/wikis/${wikiId}/pages?path=${encodeURIComponent(wikiPath)}&includeContent=${includeContent}&api-version=${this.apiVersion}`;
|
|
252
|
-
try {
|
|
253
|
-
const axiosResponse = await axios({
|
|
254
|
-
method: 'GET',
|
|
255
|
-
url,
|
|
256
|
-
headers: {
|
|
257
|
-
'Authorization': this.authHeader,
|
|
258
|
-
'Content-Type': 'application/json',
|
|
259
|
-
'Accept': 'application/json'
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
const response = axiosResponse.data;
|
|
263
|
-
// Extract ETag from response headers (needed for updates)
|
|
264
|
-
const etag = axiosResponse.headers['etag'] || axiosResponse.headers['ETag'];
|
|
265
|
-
// The API returns the page data directly (not wrapped in a 'page' property)
|
|
266
|
-
return {
|
|
267
|
-
id: response.id,
|
|
268
|
-
path: response.path,
|
|
269
|
-
content: response.content,
|
|
270
|
-
gitItemPath: response.gitItemPath,
|
|
271
|
-
subPages: response.subPages || [],
|
|
272
|
-
url: response.url,
|
|
273
|
-
remoteUrl: response.remoteUrl,
|
|
274
|
-
version: etag, // Include ETag for use with updateWikiPage
|
|
275
|
-
project,
|
|
276
|
-
wikiId
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
catch (error) {
|
|
280
|
-
// Handle errors similar to makeRequest
|
|
281
|
-
const errorDetails = error.response?.data?.message || error.response?.data || error.message;
|
|
282
|
-
console.error('Azure DevOps API request failed:', {
|
|
283
|
-
url,
|
|
284
|
-
status: error.response?.status,
|
|
285
|
-
error: errorDetails
|
|
286
|
-
});
|
|
287
|
-
if (error.response?.status === 401) {
|
|
288
|
-
throw new Error('Azure DevOps authentication failed. Please check your PAT token and permissions.');
|
|
289
|
-
}
|
|
290
|
-
if (error.response?.status === 403) {
|
|
291
|
-
throw new Error('Azure DevOps access denied. Please check your PAT scopes and project permissions.');
|
|
292
|
-
}
|
|
293
|
-
if (error.response?.status === 404) {
|
|
294
|
-
throw new Error(`Wiki page not found: ${wikiPath} (original input: ${pagePath})`);
|
|
295
|
-
}
|
|
296
|
-
throw new Error(`Azure DevOps API request failed: ${error.message} - ${JSON.stringify(errorDetails)}`);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
/**
|
|
300
|
-
* Create a new wiki page
|
|
301
|
-
* @param project The project name
|
|
302
|
-
* @param wikiId The wiki identifier
|
|
303
|
-
* @param pagePath The path for the new page (will be normalized to wiki format)
|
|
304
|
-
* @param content The markdown content
|
|
305
|
-
* @returns Created page information
|
|
306
|
-
*/
|
|
307
|
-
async createWikiPage(project, wikiId, pagePath, content) {
|
|
308
|
-
this.validateProject(project);
|
|
309
|
-
if (!this.config.enableWikiWrite) {
|
|
310
|
-
throw new Error('Wiki write operations are disabled. Set AZUREDEVOPS_ENABLE_WIKI_WRITE=true to enable.');
|
|
311
|
-
}
|
|
312
|
-
// Always normalize paths to wiki format (removes .md, converts dashes to spaces)
|
|
313
|
-
const wikiPath = this.convertGitPathToWikiPath(pagePath);
|
|
314
|
-
// Log conversion if the path was changed (for debugging)
|
|
315
|
-
if (wikiPath !== pagePath) {
|
|
316
|
-
console.error(`Normalized wiki path for creation: ${pagePath} -> ${wikiPath}`);
|
|
317
|
-
}
|
|
318
|
-
const response = await this.makeRequest(`${project}/_apis/wiki/wikis/${wikiId}/pages?path=${encodeURIComponent(wikiPath)}&api-version=${this.apiVersion}`, 'PUT', { content });
|
|
319
|
-
return {
|
|
320
|
-
id: response.page?.id,
|
|
321
|
-
path: response.page?.path,
|
|
322
|
-
gitItemPath: response.page?.gitItemPath,
|
|
323
|
-
project,
|
|
324
|
-
wikiId
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
/**
|
|
328
|
-
* Update an existing wiki page
|
|
329
|
-
* @param project The project name
|
|
330
|
-
* @param wikiId The wiki identifier
|
|
331
|
-
* @param pagePath The path to the page (will be normalized to wiki format)
|
|
332
|
-
* @param content The updated markdown content
|
|
333
|
-
* @param version The ETag/version for optimistic concurrency. If not provided, will be auto-fetched from current page.
|
|
334
|
-
* @returns Updated page information
|
|
335
|
-
*/
|
|
336
|
-
async updateWikiPage(project, wikiId, pagePath, content, version) {
|
|
337
|
-
this.validateProject(project);
|
|
338
|
-
if (!this.config.enableWikiWrite) {
|
|
339
|
-
throw new Error('Wiki write operations are disabled. Set AZUREDEVOPS_ENABLE_WIKI_WRITE=true to enable.');
|
|
340
|
-
}
|
|
341
|
-
// Always normalize paths to wiki format (removes .md, converts dashes to spaces)
|
|
342
|
-
const wikiPath = this.convertGitPathToWikiPath(pagePath);
|
|
343
|
-
// Log conversion if the path was changed (for debugging)
|
|
344
|
-
if (wikiPath !== pagePath) {
|
|
345
|
-
console.error(`Normalized wiki path for update: ${pagePath} -> ${wikiPath}`);
|
|
346
|
-
}
|
|
347
|
-
// Auto-fetch version if not provided (required for updating existing pages)
|
|
348
|
-
if (!version) {
|
|
349
|
-
try {
|
|
350
|
-
const currentPage = await this.getWikiPage(project, wikiId, wikiPath, false);
|
|
351
|
-
version = currentPage.version;
|
|
352
|
-
if (version) {
|
|
353
|
-
console.error(`Auto-fetched version for wiki update: ${version}`);
|
|
354
|
-
}
|
|
355
|
-
else {
|
|
356
|
-
console.error(`Warning: getWikiPage returned no version/etag for existing page. Response: ${JSON.stringify(currentPage)}`);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
catch (error) {
|
|
360
|
-
// If page doesn't exist, proceed without version (will create new page)
|
|
361
|
-
const errorMsg = error.message?.toLowerCase() || '';
|
|
362
|
-
if (!errorMsg.includes('not found') && !errorMsg.includes('404') && !errorMsg.includes('does not exist')) {
|
|
363
|
-
throw error;
|
|
364
|
-
}
|
|
365
|
-
console.error(`Page not found, will create new page: ${wikiPath}`);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
// Add If-Match header if version is available (for optimistic concurrency control)
|
|
369
|
-
const customHeaders = version ? { 'If-Match': version } : undefined;
|
|
370
|
-
const response = await this.makeRequest(`${project}/_apis/wiki/wikis/${wikiId}/pages?path=${encodeURIComponent(wikiPath)}&api-version=${this.apiVersion}`, 'PUT', { content }, false, // useSearchUrl
|
|
371
|
-
customHeaders);
|
|
372
|
-
return {
|
|
373
|
-
id: response.page?.id,
|
|
374
|
-
path: response.page?.path,
|
|
375
|
-
gitItemPath: response.page?.gitItemPath,
|
|
376
|
-
project,
|
|
377
|
-
wikiId
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
/**
|
|
381
|
-
* Replace a specific string in a wiki page without rewriting entire content
|
|
382
|
-
* @param project The project name
|
|
383
|
-
* @param wikiId The wiki identifier
|
|
384
|
-
* @param pagePath The path to the page (will be normalized to wiki format)
|
|
385
|
-
* @param oldStr The exact string to replace
|
|
386
|
-
* @param newStr The replacement string
|
|
387
|
-
* @param replaceAll If true, replace all occurrences; if false, old_str must be unique
|
|
388
|
-
* @param description Optional description of the change for audit logging
|
|
389
|
-
* @returns Result with diff, occurrence count, version, and message
|
|
390
|
-
*/
|
|
391
|
-
async strReplaceWikiPage(project, wikiId, pagePath, oldStr, newStr, replaceAll = false, description) {
|
|
392
|
-
this.validateProject(project);
|
|
393
|
-
// 1. Validate write permission
|
|
394
|
-
if (!this.config.enableWikiWrite) {
|
|
395
|
-
throw new Error('Wiki write operations are disabled. Set AZUREDEVOPS_ENABLE_WIKI_WRITE=true to enable.');
|
|
396
|
-
}
|
|
397
|
-
// 2. Fetch current page content and version (auto-fetch latest)
|
|
398
|
-
const currentPage = await this.getWikiPage(project, wikiId, pagePath, true);
|
|
399
|
-
const currentContent = currentPage.content;
|
|
400
|
-
const currentVersion = currentPage.version;
|
|
401
|
-
// 3. Count occurrences of old_str
|
|
402
|
-
const occurrences = this.countOccurrences(currentContent, oldStr);
|
|
403
|
-
if (occurrences === 0) {
|
|
404
|
-
throw new Error(`String not found in page.\n\n` +
|
|
405
|
-
`Looking for: "${this.truncate(oldStr, 200)}"\n\n` +
|
|
406
|
-
`Page excerpt:\n${this.truncate(currentContent, 500)}`);
|
|
407
|
-
}
|
|
408
|
-
if (occurrences > 1 && !replaceAll) {
|
|
409
|
-
throw new Error(`String appears ${occurrences} times in the page. ` +
|
|
410
|
-
`Either provide more context to make old_str unique, or set replace_all=true.\n\n` +
|
|
411
|
-
`Matching locations:\n${this.getMatchLocations(currentContent, oldStr)}`);
|
|
412
|
-
}
|
|
413
|
-
// 4. Perform replacement
|
|
414
|
-
const regex = new RegExp(this.escapeRegExp(oldStr), replaceAll ? 'g' : '');
|
|
415
|
-
const newContent = currentContent.replace(regex, newStr);
|
|
416
|
-
// 5. Validate replacement succeeded
|
|
417
|
-
if (newContent === currentContent) {
|
|
418
|
-
throw new Error('Replacement failed - content unchanged');
|
|
419
|
-
}
|
|
420
|
-
// 6. Update wiki page with version conflict retry
|
|
421
|
-
let updateResult;
|
|
422
|
-
try {
|
|
423
|
-
updateResult = await this.updateWikiPage(project, wikiId, pagePath, newContent, currentVersion);
|
|
424
|
-
}
|
|
425
|
-
catch (error) {
|
|
426
|
-
// Version conflict - retry once with fresh version
|
|
427
|
-
if (error.message.includes('412') || error.message.includes('version') || error.message.includes('conflict')) {
|
|
428
|
-
console.error('Version conflict detected, retrying with fresh version...');
|
|
429
|
-
const freshPage = await this.getWikiPage(project, wikiId, pagePath, true);
|
|
430
|
-
const freshContent = freshPage.content;
|
|
431
|
-
const freshVersion = freshPage.version;
|
|
432
|
-
// Re-apply replacement to fresh content
|
|
433
|
-
const freshRegex = new RegExp(this.escapeRegExp(oldStr), replaceAll ? 'g' : '');
|
|
434
|
-
const freshNewContent = freshContent.replace(freshRegex, newStr);
|
|
435
|
-
updateResult = await this.updateWikiPage(project, wikiId, pagePath, freshNewContent, freshVersion);
|
|
436
|
-
}
|
|
437
|
-
else {
|
|
438
|
-
throw error;
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
// 7. Generate diff output
|
|
442
|
-
const diff = this.generateUnifiedDiff(currentContent, newContent, oldStr, newStr);
|
|
443
|
-
// 8. Return result with diff
|
|
444
|
-
return {
|
|
445
|
-
success: true,
|
|
446
|
-
diff,
|
|
447
|
-
occurrences: replaceAll ? occurrences : 1,
|
|
448
|
-
version: currentVersion,
|
|
449
|
-
message: `Successfully replaced ${replaceAll ? occurrences : 1} occurrence(s)`,
|
|
450
|
-
...updateResult
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
|
-
/**
|
|
454
|
-
* Delete a wiki page permanently
|
|
455
|
-
* @param project The project name
|
|
456
|
-
* @param wikiId The wiki identifier
|
|
457
|
-
* @param pagePath The path to the page (used as-is, no conversion applied)
|
|
458
|
-
* @returns Deletion confirmation
|
|
459
|
-
*/
|
|
460
|
-
async deleteWikiPage(project, wikiId, pagePath) {
|
|
461
|
-
this.validateProject(project);
|
|
462
|
-
if (!this.config.enableWikiDelete) {
|
|
463
|
-
throw new Error('Wiki delete operations are disabled. Set AZUREDEVOPS_ENABLE_WIKI_DELETE=true to enable.');
|
|
464
|
-
}
|
|
465
|
-
// NOTE: Unlike other wiki methods, we do NOT convert the path here.
|
|
466
|
-
// The user provides the actual wiki path (with hyphens, spaces, etc.) directly.
|
|
467
|
-
// Converting hyphens to spaces would break paths with dates (e.g., "2026-02-03")
|
|
468
|
-
// or other legitimate hyphens in page names.
|
|
469
|
-
await this.makeRequest(`${project}/_apis/wiki/wikis/${wikiId}/pages?path=${encodeURIComponent(pagePath)}&api-version=${this.apiVersion}`, 'DELETE');
|
|
470
|
-
return {
|
|
471
|
-
project,
|
|
472
|
-
wikiId,
|
|
473
|
-
pagePath: pagePath,
|
|
474
|
-
deleted: true
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
|
-
// ==================== WORK ITEM OPERATIONS ====================
|
|
478
|
-
/**
|
|
479
|
-
* Get a work item by ID with full details
|
|
480
|
-
* @param project The project name
|
|
481
|
-
* @param workItemId The work item ID
|
|
482
|
-
* @returns Complete work item details
|
|
483
|
-
*/
|
|
484
|
-
async getWorkItem(project, workItemId) {
|
|
485
|
-
this.validateProject(project);
|
|
486
|
-
const response = await this.makeRequest(`${project}/_apis/wit/workitems/${workItemId}?$expand=all&api-version=${this.apiVersion}`);
|
|
487
|
-
return {
|
|
488
|
-
id: response.id,
|
|
489
|
-
rev: response.rev,
|
|
490
|
-
url: response.url,
|
|
491
|
-
fields: response.fields,
|
|
492
|
-
relations: response.relations || [],
|
|
493
|
-
_links: response._links,
|
|
494
|
-
commentVersionRef: response.commentVersionRef,
|
|
495
|
-
project
|
|
496
|
-
};
|
|
497
|
-
}
|
|
498
|
-
/**
|
|
499
|
-
* Query work items using WIQL (Work Item Query Language)
|
|
500
|
-
* @param project The project name
|
|
501
|
-
* @param wiql The WIQL query string
|
|
502
|
-
* @param maxResults Maximum number of results (default: 200)
|
|
503
|
-
* @returns Work items matching the query
|
|
504
|
-
*/
|
|
505
|
-
async queryWorkItems(project, wiql, maxResults = 200) {
|
|
506
|
-
this.validateProject(project);
|
|
507
|
-
// Execute WIQL query
|
|
508
|
-
const queryResult = await this.makeRequest(`${project}/_apis/wit/wiql?api-version=${this.apiVersion}`, 'POST', { query: wiql });
|
|
509
|
-
if (!queryResult.workItems || queryResult.workItems.length === 0) {
|
|
510
|
-
return {
|
|
511
|
-
query: wiql,
|
|
512
|
-
project,
|
|
513
|
-
totalCount: 0,
|
|
514
|
-
workItems: []
|
|
515
|
-
};
|
|
516
|
-
}
|
|
517
|
-
// Get work item IDs (limit to maxResults)
|
|
518
|
-
const workItemIds = queryResult.workItems
|
|
519
|
-
.slice(0, maxResults)
|
|
520
|
-
.map((wi) => wi.id);
|
|
521
|
-
// Batch get full work item details
|
|
522
|
-
const workItems = await this.makeRequest(`${project}/_apis/wit/workitemsbatch?api-version=${this.apiVersion}`, 'POST', {
|
|
523
|
-
ids: workItemIds,
|
|
524
|
-
$expand: 'all'
|
|
525
|
-
});
|
|
526
|
-
return {
|
|
527
|
-
query: wiql,
|
|
528
|
-
project,
|
|
529
|
-
totalCount: workItems.value.length,
|
|
530
|
-
workItems: workItems.value
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
static SUMMARY_FIELDS = [
|
|
534
|
-
'System.Id',
|
|
535
|
-
'System.Title',
|
|
536
|
-
'System.AssignedTo',
|
|
537
|
-
'System.State',
|
|
538
|
-
'Microsoft.VSTS.Common.Severity',
|
|
539
|
-
'Microsoft.VSTS.Common.Priority',
|
|
540
|
-
'System.Tags',
|
|
541
|
-
'Microsoft.VSTS.Scheduling.StoryPoints',
|
|
542
|
-
'Microsoft.VSTS.Common.ResolvedReason',
|
|
543
|
-
'System.WorkItemType',
|
|
544
|
-
];
|
|
545
|
-
async runSavedQuery(project, queryId, maxResults = 50, detail = 'summary', fields, groupBy) {
|
|
546
|
-
this.validateProject(project);
|
|
547
|
-
const queryResult = await this.makeRequest(`${project}/_apis/wit/wiql/${queryId}?api-version=${this.apiVersion}`, 'GET');
|
|
548
|
-
if (!queryResult.workItems || queryResult.workItems.length === 0) {
|
|
549
|
-
return {
|
|
550
|
-
queryId,
|
|
551
|
-
project,
|
|
552
|
-
totalCount: 0,
|
|
553
|
-
queriedCount: queryResult.workItems?.length ?? 0,
|
|
554
|
-
workItems: [],
|
|
555
|
-
};
|
|
556
|
-
}
|
|
557
|
-
const totalAvailable = queryResult.workItems.length;
|
|
558
|
-
const workItemIds = queryResult.workItems
|
|
559
|
-
.slice(0, maxResults)
|
|
560
|
-
.map((wi) => wi.id);
|
|
561
|
-
const isFull = detail === 'full';
|
|
562
|
-
const requestedFields = fields ?? (isFull ? undefined : AzureDevOpsService.SUMMARY_FIELDS);
|
|
563
|
-
const body = { ids: workItemIds };
|
|
564
|
-
if (requestedFields) {
|
|
565
|
-
body.fields = requestedFields;
|
|
566
|
-
}
|
|
567
|
-
else {
|
|
568
|
-
body.$expand = 'all';
|
|
569
|
-
}
|
|
570
|
-
const workItems = await this.makeRequest(`${project}/_apis/wit/workitemsbatch?api-version=${this.apiVersion}`, 'POST', body);
|
|
571
|
-
const items = workItems.value.map((wi) => {
|
|
572
|
-
if (isFull && !fields)
|
|
573
|
-
return wi;
|
|
574
|
-
const f = wi.fields || {};
|
|
575
|
-
return {
|
|
576
|
-
id: wi.id,
|
|
577
|
-
type: f['System.WorkItemType'],
|
|
578
|
-
title: f['System.Title'],
|
|
579
|
-
assignedTo: f['System.AssignedTo']?.displayName ?? f['System.AssignedTo'] ?? null,
|
|
580
|
-
state: f['System.State'],
|
|
581
|
-
severity: f['Microsoft.VSTS.Common.Severity'] ?? null,
|
|
582
|
-
priority: f['Microsoft.VSTS.Common.Priority'] ?? null,
|
|
583
|
-
tags: f['System.Tags'] ?? null,
|
|
584
|
-
storyPoints: f['Microsoft.VSTS.Scheduling.StoryPoints'] ?? null,
|
|
585
|
-
resolvedReason: f['Microsoft.VSTS.Common.ResolvedReason'] ?? null,
|
|
586
|
-
...(fields ? Object.fromEntries(Object.entries(f).filter(([k]) => !AzureDevOpsService.SUMMARY_FIELDS.includes(k))) : {}),
|
|
587
|
-
};
|
|
588
|
-
});
|
|
589
|
-
const result = {
|
|
590
|
-
queryId,
|
|
591
|
-
project,
|
|
592
|
-
totalAvailable,
|
|
593
|
-
returnedCount: items.length,
|
|
594
|
-
detail,
|
|
595
|
-
};
|
|
596
|
-
if (groupBy) {
|
|
597
|
-
const groups = {};
|
|
598
|
-
for (const item of items) {
|
|
599
|
-
const key = item[groupBy] ?? item.state ?? 'Unknown';
|
|
600
|
-
if (!groups[key])
|
|
601
|
-
groups[key] = [];
|
|
602
|
-
groups[key].push(item);
|
|
603
|
-
}
|
|
604
|
-
result.groupedBy = groupBy;
|
|
605
|
-
result.groups = Object.fromEntries(Object.entries(groups).map(([k, v]) => [k, { count: v.length, items: v }]));
|
|
606
|
-
}
|
|
607
|
-
else {
|
|
608
|
-
result.workItems = items;
|
|
609
|
-
}
|
|
610
|
-
return result;
|
|
611
|
-
}
|
|
612
|
-
async getSavedQuery(project, queryId) {
|
|
613
|
-
this.validateProject(project);
|
|
614
|
-
return this.makeRequest(`${project}/_apis/wit/queries/${queryId}?api-version=${this.apiVersion}`, 'GET');
|
|
615
|
-
}
|
|
616
|
-
/**
|
|
617
|
-
* Get comments/discussion for a work item
|
|
618
|
-
* @param project The project name
|
|
619
|
-
* @param workItemId The work item ID
|
|
620
|
-
* @returns List of comments
|
|
621
|
-
*/
|
|
622
|
-
async getWorkItemComments(project, workItemId) {
|
|
623
|
-
this.validateProject(project);
|
|
624
|
-
// Comments API requires -preview suffix (not GA in 7.1)
|
|
625
|
-
const response = await this.makeRequest(`${project}/_apis/wit/workItems/${workItemId}/comments?api-version=7.1-preview`);
|
|
626
|
-
// v7.1 Comments API returns array in response.comments, not response.value
|
|
627
|
-
const comments = response.comments || response.value || [];
|
|
628
|
-
return {
|
|
629
|
-
workItemId,
|
|
630
|
-
project,
|
|
631
|
-
totalCount: response.totalCount ?? comments.length,
|
|
632
|
-
comments: comments.map((comment) => ({
|
|
633
|
-
id: comment.id,
|
|
634
|
-
text: comment.text,
|
|
635
|
-
createdBy: comment.createdBy?.displayName,
|
|
636
|
-
createdDate: comment.createdDate,
|
|
637
|
-
modifiedBy: comment.modifiedBy?.displayName,
|
|
638
|
-
modifiedDate: comment.modifiedDate,
|
|
639
|
-
url: comment.url
|
|
640
|
-
}))
|
|
641
|
-
};
|
|
642
|
-
}
|
|
643
|
-
/**
|
|
644
|
-
* Add a comment to a work item
|
|
645
|
-
* @param project The project name
|
|
646
|
-
* @param workItemId The work item ID
|
|
647
|
-
* @param commentText The comment text (supports markdown)
|
|
648
|
-
* @returns Created comment information
|
|
649
|
-
*/
|
|
650
|
-
async addWorkItemComment(project, workItemId, commentText) {
|
|
651
|
-
this.validateProject(project);
|
|
652
|
-
if (!this.config.enableWorkItemWrite) {
|
|
653
|
-
throw new Error('Work item write operations are disabled. Set AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true to enable.');
|
|
654
|
-
}
|
|
655
|
-
// Convert Markdown to HTML if configured for legacy orgs
|
|
656
|
-
let finalText = commentText;
|
|
657
|
-
const format = this.config.commentFormat || 'markdown';
|
|
658
|
-
if (format === 'html') {
|
|
659
|
-
// Convert Markdown to HTML for organizations without Markdown preview
|
|
660
|
-
finalText = await marked.parse(commentText);
|
|
661
|
-
}
|
|
662
|
-
// Comments API requires -preview suffix (not GA in 7.1)
|
|
663
|
-
const response = await this.makeRequest(`${project}/_apis/wit/workItems/${workItemId}/comments?api-version=7.1-preview`, 'POST', { text: finalText });
|
|
664
|
-
return {
|
|
665
|
-
id: response.id,
|
|
666
|
-
workItemId,
|
|
667
|
-
project,
|
|
668
|
-
text: response.text,
|
|
669
|
-
format: format,
|
|
670
|
-
createdBy: response.createdBy?.displayName,
|
|
671
|
-
createdDate: response.createdDate
|
|
672
|
-
};
|
|
673
|
-
}
|
|
674
|
-
/**
|
|
675
|
-
* Update a work item using JSON Patch operations
|
|
676
|
-
* @param project The project name
|
|
677
|
-
* @param workItemId The work item ID
|
|
678
|
-
* @param patchOperations Array of JSON Patch operations
|
|
679
|
-
* @returns Updated work item
|
|
680
|
-
*/
|
|
681
|
-
async updateWorkItem(project, workItemId, patchOperations) {
|
|
682
|
-
this.validateProject(project);
|
|
683
|
-
if (!this.config.enableWorkItemWrite) {
|
|
684
|
-
throw new Error('Work item write operations are disabled. Set AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true to enable.');
|
|
685
|
-
}
|
|
686
|
-
const response = await this.makeRequest(`${project}/_apis/wit/workitems/${workItemId}?api-version=${this.apiVersion}`, 'PATCH', patchOperations);
|
|
687
|
-
return {
|
|
688
|
-
id: response.id,
|
|
689
|
-
rev: response.rev,
|
|
690
|
-
fields: response.fields,
|
|
691
|
-
project
|
|
692
|
-
};
|
|
693
|
-
}
|
|
694
|
-
/**
|
|
695
|
-
* Set work item field(s) to markdown format.
|
|
696
|
-
* This is IRREVERSIBLE - once set to markdown, cannot revert to HTML.
|
|
697
|
-
*
|
|
698
|
-
* @param project - Project name
|
|
699
|
-
* @param workItemId - Work item ID
|
|
700
|
-
* @param fields - Array of field names to set to markdown format (e.g., ['System.Description'])
|
|
701
|
-
*/
|
|
702
|
-
async setFieldsToMarkdownFormat(project, workItemId, fields) {
|
|
703
|
-
if (!this.config.enableWorkItemWrite) {
|
|
704
|
-
throw new Error('Work item write operations are disabled. Set AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true to enable.');
|
|
705
|
-
}
|
|
706
|
-
const patchOperations = fields.map(field => ({
|
|
707
|
-
op: 'add',
|
|
708
|
-
path: `/multilineFieldsFormat/${field}`,
|
|
709
|
-
value: 'Markdown'
|
|
710
|
-
}));
|
|
711
|
-
await this.updateWorkItem(project, workItemId, patchOperations);
|
|
712
|
-
}
|
|
713
|
-
/**
|
|
714
|
-
* Create a new work item
|
|
715
|
-
* @param project The project name
|
|
716
|
-
* @param workItemType The work item type (e.g., "Bug", "Task", "User Story")
|
|
717
|
-
* @param fields Object with field values (e.g., { "System.Title": "Bug title" })
|
|
718
|
-
* @param parentId Optional parent work item ID (for creating child items)
|
|
719
|
-
* @param relations Optional array of work item relationships
|
|
720
|
-
* @returns Created work item
|
|
721
|
-
*/
|
|
722
|
-
async createWorkItem(project, workItemType, fields, parentId, relations) {
|
|
723
|
-
this.validateProject(project);
|
|
724
|
-
if (!this.config.enableWorkItemWrite) {
|
|
725
|
-
throw new Error('Work item write operations are disabled. Set AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true to enable.');
|
|
726
|
-
}
|
|
727
|
-
// Build patch operations array
|
|
728
|
-
const patchOperations = [];
|
|
729
|
-
// Add field operations
|
|
730
|
-
Object.keys(fields).forEach(field => {
|
|
731
|
-
patchOperations.push({
|
|
732
|
-
op: 'add',
|
|
733
|
-
path: `/fields/${field}`,
|
|
734
|
-
value: fields[field]
|
|
735
|
-
});
|
|
736
|
-
});
|
|
737
|
-
// Auto-set markdown format for large text fields that are being set
|
|
738
|
-
// This ensures all new work items use markdown format (irreversible)
|
|
739
|
-
// Uses getAllLargeTextFields() to include custom fields like Custom.Howtotest
|
|
740
|
-
const allLargeTextFields = getAllLargeTextFields();
|
|
741
|
-
for (const field of allLargeTextFields) {
|
|
742
|
-
if (fields[field] !== undefined) {
|
|
743
|
-
patchOperations.push({
|
|
744
|
-
op: 'add',
|
|
745
|
-
path: `/multilineFieldsFormat/${field}`,
|
|
746
|
-
value: 'Markdown'
|
|
747
|
-
});
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
// Handle parentId parameter (simplified parent relationship)
|
|
751
|
-
if (parentId !== undefined) {
|
|
752
|
-
const parentUrl = `${this.baseUrl}/${encodeURIComponent(project)}/_apis/wit/workItems/${parentId}`;
|
|
753
|
-
patchOperations.push({
|
|
754
|
-
op: 'add',
|
|
755
|
-
path: '/relations/-',
|
|
756
|
-
value: {
|
|
757
|
-
rel: 'System.LinkTypes.Hierarchy-Reverse',
|
|
758
|
-
url: parentUrl
|
|
759
|
-
}
|
|
760
|
-
});
|
|
761
|
-
}
|
|
762
|
-
// Handle relations array (advanced relationships)
|
|
763
|
-
if (relations && relations.length > 0) {
|
|
764
|
-
relations.forEach(relation => {
|
|
765
|
-
patchOperations.push({
|
|
766
|
-
op: 'add',
|
|
767
|
-
path: '/relations/-',
|
|
768
|
-
value: relation
|
|
769
|
-
});
|
|
770
|
-
});
|
|
771
|
-
}
|
|
772
|
-
const response = await this.makeRequest(`${project}/_apis/wit/workitems/$${workItemType}?api-version=${this.apiVersion}`, 'PATCH', patchOperations);
|
|
773
|
-
return {
|
|
774
|
-
id: response.id,
|
|
775
|
-
rev: response.rev,
|
|
776
|
-
fields: response.fields,
|
|
777
|
-
relations: response.relations || [], // Include relations in response
|
|
778
|
-
url: response._links?.html?.href,
|
|
779
|
-
project
|
|
780
|
-
};
|
|
781
|
-
}
|
|
782
|
-
/**
|
|
783
|
-
* Delete a work item
|
|
784
|
-
* @param project The project name
|
|
785
|
-
* @param workItemId The work item ID
|
|
786
|
-
* @returns Deletion confirmation
|
|
787
|
-
*/
|
|
788
|
-
async deleteWorkItem(project, workItemId) {
|
|
789
|
-
this.validateProject(project);
|
|
790
|
-
if (!this.config.enableWorkItemDelete) {
|
|
791
|
-
throw new Error('Work item delete operations are disabled. Set AZUREDEVOPS_ENABLE_WORK_ITEM_DELETE=true to enable.');
|
|
792
|
-
}
|
|
793
|
-
await this.makeRequest(`${project}/_apis/wit/workitems/${workItemId}?api-version=${this.apiVersion}`, 'DELETE');
|
|
794
|
-
return {
|
|
795
|
-
workItemId,
|
|
796
|
-
project,
|
|
797
|
-
deleted: true
|
|
798
|
-
};
|
|
799
|
-
}
|
|
800
|
-
// ==================== VARIABLE GROUP OPERATIONS ====================
|
|
801
|
-
/**
|
|
802
|
-
* Get all variable groups in a project
|
|
803
|
-
* @param project The project name
|
|
804
|
-
* @returns List of variable groups with their variables
|
|
805
|
-
*/
|
|
806
|
-
async getVariableGroups(project) {
|
|
807
|
-
this.validateProject(project);
|
|
808
|
-
const response = await this.makeRequest(`${project}/_apis/distributedtask/variablegroups?api-version=${this.apiVersion}`);
|
|
809
|
-
return {
|
|
810
|
-
project,
|
|
811
|
-
totalCount: response.value.length,
|
|
812
|
-
variableGroups: response.value.map((group) => ({
|
|
813
|
-
id: group.id,
|
|
814
|
-
name: group.name,
|
|
815
|
-
description: group.description,
|
|
816
|
-
type: group.type,
|
|
817
|
-
createdBy: group.createdBy?.displayName,
|
|
818
|
-
createdOn: group.createdOn,
|
|
819
|
-
modifiedBy: group.modifiedBy?.displayName,
|
|
820
|
-
modifiedOn: group.modifiedOn,
|
|
821
|
-
isShared: group.isShared,
|
|
822
|
-
variableGroupProjectReferences: group.variableGroupProjectReferences,
|
|
823
|
-
// Include variable names but mask secret values
|
|
824
|
-
variables: Object.keys(group.variables || {}).reduce((acc, key) => {
|
|
825
|
-
const variable = group.variables[key];
|
|
826
|
-
acc[key] = {
|
|
827
|
-
value: variable.isSecret ? '***SECRET***' : variable.value,
|
|
828
|
-
isSecret: variable.isSecret || false,
|
|
829
|
-
isReadOnly: variable.isReadOnly || false
|
|
830
|
-
};
|
|
831
|
-
return acc;
|
|
832
|
-
}, {})
|
|
833
|
-
}))
|
|
834
|
-
};
|
|
835
|
-
}
|
|
836
|
-
/**
|
|
837
|
-
* Get a specific variable group by ID
|
|
838
|
-
* @param project The project name
|
|
839
|
-
* @param groupId The variable group ID
|
|
840
|
-
* @returns Variable group details with variables
|
|
841
|
-
*/
|
|
842
|
-
async getVariableGroup(project, groupId) {
|
|
843
|
-
this.validateProject(project);
|
|
844
|
-
const response = await this.makeRequest(`${project}/_apis/distributedtask/variablegroups/${groupId}?api-version=${this.apiVersion}`);
|
|
845
|
-
return {
|
|
846
|
-
id: response.id,
|
|
847
|
-
name: response.name,
|
|
848
|
-
description: response.description,
|
|
849
|
-
type: response.type,
|
|
850
|
-
createdBy: response.createdBy?.displayName,
|
|
851
|
-
createdOn: response.createdOn,
|
|
852
|
-
modifiedBy: response.modifiedBy?.displayName,
|
|
853
|
-
modifiedOn: response.modifiedOn,
|
|
854
|
-
isShared: response.isShared,
|
|
855
|
-
variableGroupProjectReferences: response.variableGroupProjectReferences,
|
|
856
|
-
project,
|
|
857
|
-
// Include variable names but mask secret values
|
|
858
|
-
variables: Object.keys(response.variables || {}).reduce((acc, key) => {
|
|
859
|
-
const variable = response.variables[key];
|
|
860
|
-
acc[key] = {
|
|
861
|
-
value: variable.isSecret ? '***SECRET***' : variable.value,
|
|
862
|
-
isSecret: variable.isSecret || false,
|
|
863
|
-
isReadOnly: variable.isReadOnly || false
|
|
864
|
-
};
|
|
865
|
-
return acc;
|
|
866
|
-
}, {})
|
|
867
|
-
};
|
|
868
|
-
}
|
|
869
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
870
|
-
// BUILD TROUBLESHOOTING OPERATIONS (Read-only)
|
|
871
|
-
// NOTE: These methods are duplicated in azure-devops-admin package.
|
|
872
|
-
// If you update these, also update packages/azure-devops-admin/src/AzureDevOpsAdminService.ts
|
|
873
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
874
|
-
/**
|
|
875
|
-
* Get build status with optional timeline and logs
|
|
876
|
-
* @param project The project name
|
|
877
|
-
* @param buildId The build ID
|
|
878
|
-
* @param detail Level of detail: 'summary', 'timeline', or 'full'
|
|
879
|
-
* @param timelineScope Scope for timeline: 'stages', 'jobs', 'all', 'problems'
|
|
880
|
-
* @param maxIssues Maximum issues per record
|
|
881
|
-
*/
|
|
882
|
-
async getBuildStatus(project, buildId, detail = 'summary', timelineScope = 'problems', maxIssues = 5) {
|
|
883
|
-
this.validateProject(project);
|
|
884
|
-
const response = await this.makeRequest(`${project}/_apis/build/builds/${buildId}?api-version=${this.apiVersion}`);
|
|
885
|
-
const result = {
|
|
886
|
-
id: response.id,
|
|
887
|
-
buildNumber: response.buildNumber,
|
|
888
|
-
status: response.status,
|
|
889
|
-
result: response.result,
|
|
890
|
-
queueTime: response.queueTime,
|
|
891
|
-
startTime: response.startTime,
|
|
892
|
-
finishTime: response.finishTime,
|
|
893
|
-
sourceBranch: response.sourceBranch,
|
|
894
|
-
sourceVersion: response.sourceVersion,
|
|
895
|
-
definition: response.definition ? {
|
|
896
|
-
id: response.definition.id,
|
|
897
|
-
name: response.definition.name
|
|
898
|
-
} : null,
|
|
899
|
-
requestedBy: response.requestedBy?.displayName,
|
|
900
|
-
requestedFor: response.requestedFor?.displayName,
|
|
901
|
-
reason: response.reason,
|
|
902
|
-
priority: response.priority,
|
|
903
|
-
project: response.project?.name,
|
|
904
|
-
url: response._links?.web?.href
|
|
905
|
-
};
|
|
906
|
-
if (detail === 'timeline' || detail === 'full') {
|
|
907
|
-
const timeline = await this.getBuildTimeline(project, buildId, timelineScope, maxIssues);
|
|
908
|
-
result.timeline = timeline;
|
|
909
|
-
}
|
|
910
|
-
if (detail === 'full') {
|
|
911
|
-
const logs = await this.getBuildLogs(project, buildId);
|
|
912
|
-
result.logs = logs;
|
|
913
|
-
}
|
|
914
|
-
return result;
|
|
915
|
-
}
|
|
916
|
-
/**
|
|
917
|
-
* Get build timeline with step-by-step breakdown
|
|
918
|
-
* @param project The project name
|
|
919
|
-
* @param buildId The build ID
|
|
920
|
-
* @param scope Filter scope: 'stages', 'jobs', 'all', 'problems'
|
|
921
|
-
* @param maxIssues Maximum issues per record
|
|
922
|
-
*/
|
|
923
|
-
async getBuildTimeline(project, buildId, scope = 'problems', maxIssues = 5) {
|
|
924
|
-
this.validateProject(project);
|
|
925
|
-
const response = await this.makeRequest(`${project}/_apis/build/builds/${buildId}/timeline?api-version=${this.apiVersion}`);
|
|
926
|
-
const allRecords = response.records || [];
|
|
927
|
-
const summary = {
|
|
928
|
-
total: allRecords.length,
|
|
929
|
-
byType: {},
|
|
930
|
-
byResult: {},
|
|
931
|
-
totalErrors: 0,
|
|
932
|
-
totalWarnings: 0,
|
|
933
|
-
failed: [],
|
|
934
|
-
};
|
|
935
|
-
for (const record of allRecords) {
|
|
936
|
-
summary.byType[record.type] = (summary.byType[record.type] || 0) + 1;
|
|
937
|
-
if (record.result) {
|
|
938
|
-
summary.byResult[record.result] = (summary.byResult[record.result] || 0) + 1;
|
|
939
|
-
}
|
|
940
|
-
summary.totalErrors += record.errorCount || 0;
|
|
941
|
-
summary.totalWarnings += record.warningCount || 0;
|
|
942
|
-
if (record.result === 'failed' || record.result === 'canceled') {
|
|
943
|
-
summary.failed.push(`${record.type}: ${record.name}`);
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
let filteredRecords = allRecords;
|
|
947
|
-
switch (scope) {
|
|
948
|
-
case 'stages':
|
|
949
|
-
filteredRecords = allRecords.filter((r) => r.type === 'Stage');
|
|
950
|
-
break;
|
|
951
|
-
case 'jobs':
|
|
952
|
-
filteredRecords = allRecords.filter((r) => r.type === 'Stage' || r.type === 'Job');
|
|
953
|
-
break;
|
|
954
|
-
case 'problems':
|
|
955
|
-
filteredRecords = allRecords.filter((r) => (r.errorCount && r.errorCount > 0) ||
|
|
956
|
-
(r.warningCount && r.warningCount > 0) ||
|
|
957
|
-
r.result === 'failed' ||
|
|
958
|
-
r.result === 'canceled');
|
|
959
|
-
break;
|
|
960
|
-
case 'all':
|
|
961
|
-
default:
|
|
962
|
-
break;
|
|
963
|
-
}
|
|
964
|
-
const truncateIssues = (issues, max) => {
|
|
965
|
-
if (!issues || issues.length === 0)
|
|
966
|
-
return { items: [], totalCount: 0, truncated: false };
|
|
967
|
-
const sorted = [...issues].sort((a, b) => {
|
|
968
|
-
const priority = (issue) => {
|
|
969
|
-
if (issue.type === 'error')
|
|
970
|
-
return 0;
|
|
971
|
-
if (issue.type === 'warning')
|
|
972
|
-
return 1;
|
|
973
|
-
return 2;
|
|
974
|
-
};
|
|
975
|
-
return priority(a) - priority(b);
|
|
976
|
-
});
|
|
977
|
-
return {
|
|
978
|
-
items: sorted.slice(0, max),
|
|
979
|
-
totalCount: issues.length,
|
|
980
|
-
truncated: issues.length > max
|
|
981
|
-
};
|
|
982
|
-
};
|
|
983
|
-
const mappedRecords = filteredRecords.map((record) => {
|
|
984
|
-
const truncatedIssues = truncateIssues(record.issues, maxIssues);
|
|
985
|
-
return {
|
|
986
|
-
id: record.id,
|
|
987
|
-
parentId: record.parentId,
|
|
988
|
-
type: record.type,
|
|
989
|
-
name: record.name,
|
|
990
|
-
state: record.state,
|
|
991
|
-
result: record.result,
|
|
992
|
-
startTime: record.startTime,
|
|
993
|
-
finishTime: record.finishTime,
|
|
994
|
-
order: record.order,
|
|
995
|
-
errorCount: record.errorCount,
|
|
996
|
-
warningCount: record.warningCount,
|
|
997
|
-
log: record.log ? { id: record.log.id } : null,
|
|
998
|
-
issues: truncatedIssues.items,
|
|
999
|
-
issuesTruncated: truncatedIssues.truncated,
|
|
1000
|
-
totalIssueCount: truncatedIssues.totalCount
|
|
1001
|
-
};
|
|
1002
|
-
});
|
|
1003
|
-
return {
|
|
1004
|
-
buildId,
|
|
1005
|
-
project,
|
|
1006
|
-
scope,
|
|
1007
|
-
summary,
|
|
1008
|
-
recordCount: mappedRecords.length,
|
|
1009
|
-
records: mappedRecords
|
|
1010
|
-
};
|
|
1011
|
-
}
|
|
1012
|
-
/**
|
|
1013
|
-
* Filter log content to reduce noise from progress indicators
|
|
1014
|
-
* @param content Raw log content string
|
|
1015
|
-
* @param mode Filter mode: 'summary' removes progress, 'errors' shows only errors, 'full' returns everything
|
|
1016
|
-
*/
|
|
1017
|
-
filterLogContent(content, mode) {
|
|
1018
|
-
const lines = content.split('\n');
|
|
1019
|
-
const originalLineCount = lines.length;
|
|
1020
|
-
if (mode === 'full') {
|
|
1021
|
-
return { filtered: content, originalLineCount, filteredLineCount: originalLineCount };
|
|
1022
|
-
}
|
|
1023
|
-
// Progress indicator patterns to remove in summary mode
|
|
1024
|
-
const PROGRESS_PATTERNS = [
|
|
1025
|
-
/remote: Counting objects:\s+\d+%/,
|
|
1026
|
-
/remote: Compressing objects:\s+\d+%/,
|
|
1027
|
-
/Receiving objects:\s+\d+%/,
|
|
1028
|
-
/Resolving deltas:\s+\d+%/,
|
|
1029
|
-
/Unpacking objects:\s+\d+%/,
|
|
1030
|
-
/Updating files:\s+\d+%/,
|
|
1031
|
-
];
|
|
1032
|
-
// Error/warning patterns for errors mode
|
|
1033
|
-
const ERROR_PATTERNS = [
|
|
1034
|
-
/##\[error\]/i,
|
|
1035
|
-
/##\[warning\]/i,
|
|
1036
|
-
/\berror\b.*:/i,
|
|
1037
|
-
/\bfailed\b/i,
|
|
1038
|
-
/\bexception\b/i,
|
|
1039
|
-
/\bfatal\b/i,
|
|
1040
|
-
];
|
|
1041
|
-
const filteredLines = lines.filter(line => {
|
|
1042
|
-
// Remove timestamp prefix for pattern matching
|
|
1043
|
-
const trimmedLine = line.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*/, '');
|
|
1044
|
-
if (mode === 'errors') {
|
|
1045
|
-
return ERROR_PATTERNS.some(p => p.test(trimmedLine));
|
|
1046
|
-
}
|
|
1047
|
-
// summary mode: exclude progress indicators, keep everything else
|
|
1048
|
-
return !PROGRESS_PATTERNS.some(p => p.test(trimmedLine));
|
|
1049
|
-
});
|
|
1050
|
-
return {
|
|
1051
|
-
filtered: filteredLines.join('\n'),
|
|
1052
|
-
originalLineCount,
|
|
1053
|
-
filteredLineCount: filteredLines.length
|
|
1054
|
-
};
|
|
1055
|
-
}
|
|
1056
|
-
/**
|
|
1057
|
-
* Get build logs (list or specific log content)
|
|
1058
|
-
* @param project The project name
|
|
1059
|
-
* @param buildId The build ID
|
|
1060
|
-
* @param logId Optional specific log ID to retrieve content
|
|
1061
|
-
* @param mode Filter mode: 'summary' (default) removes progress indicators, 'full' returns everything, 'errors' shows only errors/warnings
|
|
1062
|
-
*/
|
|
1063
|
-
async getBuildLogs(project, buildId, logId, mode = 'summary') {
|
|
1064
|
-
this.validateProject(project);
|
|
1065
|
-
if (logId !== undefined) {
|
|
1066
|
-
const response = await this.makeRequest(`${project}/_apis/build/builds/${buildId}/logs/${logId}?api-version=${this.apiVersion}`);
|
|
1067
|
-
const { filtered, originalLineCount, filteredLineCount } = this.filterLogContent(response, mode);
|
|
1068
|
-
return {
|
|
1069
|
-
buildId,
|
|
1070
|
-
logId,
|
|
1071
|
-
project,
|
|
1072
|
-
mode,
|
|
1073
|
-
originalLineCount,
|
|
1074
|
-
filteredLineCount,
|
|
1075
|
-
content: filtered
|
|
1076
|
-
};
|
|
1077
|
-
}
|
|
1078
|
-
const response = await this.makeRequest(`${project}/_apis/build/builds/${buildId}/logs?api-version=${this.apiVersion}`);
|
|
1079
|
-
return {
|
|
1080
|
-
buildId,
|
|
1081
|
-
project,
|
|
1082
|
-
totalCount: response.value.length,
|
|
1083
|
-
logs: response.value.map((log) => ({
|
|
1084
|
-
id: log.id,
|
|
1085
|
-
type: log.type,
|
|
1086
|
-
lineCount: log.lineCount,
|
|
1087
|
-
createdOn: log.createdOn,
|
|
1088
|
-
lastChangedOn: log.lastChangedOn,
|
|
1089
|
-
url: log.url
|
|
1090
|
-
}))
|
|
1091
|
-
};
|
|
1092
|
-
}
|
|
1093
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1094
|
-
// PULL REQUEST OPERATIONS
|
|
1095
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1096
|
-
/**
|
|
1097
|
-
* List all Git repositories in a project
|
|
1098
|
-
* @param project The project name
|
|
1099
|
-
* @returns List of repositories with their IDs
|
|
1100
|
-
*/
|
|
1101
|
-
async listRepositories(project) {
|
|
1102
|
-
this.validateProject(project);
|
|
1103
|
-
const response = await this.makeRequest(`${project}/_apis/git/repositories?api-version=${this.apiVersion}`);
|
|
1104
|
-
return {
|
|
1105
|
-
project,
|
|
1106
|
-
totalCount: response.value.length,
|
|
1107
|
-
repositories: response.value.map((repo) => ({
|
|
1108
|
-
id: repo.id,
|
|
1109
|
-
name: repo.name,
|
|
1110
|
-
url: repo.url,
|
|
1111
|
-
defaultBranch: repo.defaultBranch,
|
|
1112
|
-
size: repo.size,
|
|
1113
|
-
remoteUrl: repo.remoteUrl,
|
|
1114
|
-
webUrl: repo.webUrl
|
|
1115
|
-
}))
|
|
1116
|
-
};
|
|
1117
|
-
}
|
|
1118
|
-
/**
|
|
1119
|
-
* List pull requests in a repository
|
|
1120
|
-
* @param project The project name
|
|
1121
|
-
* @param repositoryId Repository ID (GUID) or name
|
|
1122
|
-
* @param status Filter by status: active, completed, abandoned, all (default: active)
|
|
1123
|
-
* @param top Maximum results (default: 25)
|
|
1124
|
-
* @param creatorId Filter by creator ID
|
|
1125
|
-
* @param reviewerId Filter by reviewer ID
|
|
1126
|
-
* @returns List of pull requests
|
|
1127
|
-
*/
|
|
1128
|
-
async listPullRequests(project, repositoryId, status = 'active', top = 25, creatorId, reviewerId) {
|
|
1129
|
-
this.validateProject(project);
|
|
1130
|
-
let url = `${project}/_apis/git/repositories/${repositoryId}/pullrequests?searchCriteria.status=${status}&$top=${top}&api-version=${this.apiVersion}`;
|
|
1131
|
-
if (creatorId)
|
|
1132
|
-
url += `&searchCriteria.creatorId=${creatorId}`;
|
|
1133
|
-
if (reviewerId)
|
|
1134
|
-
url += `&searchCriteria.reviewerId=${reviewerId}`;
|
|
1135
|
-
const response = await this.makeRequest(url);
|
|
1136
|
-
return {
|
|
1137
|
-
project,
|
|
1138
|
-
repositoryId,
|
|
1139
|
-
status,
|
|
1140
|
-
totalCount: response.value.length,
|
|
1141
|
-
pullRequests: response.value.map((pr) => ({
|
|
1142
|
-
pullRequestId: pr.pullRequestId,
|
|
1143
|
-
title: pr.title,
|
|
1144
|
-
description: pr.description ? this.truncate(pr.description, 200) : null,
|
|
1145
|
-
status: pr.status,
|
|
1146
|
-
createdBy: pr.createdBy?.displayName,
|
|
1147
|
-
creationDate: pr.creationDate,
|
|
1148
|
-
closedDate: pr.closedDate,
|
|
1149
|
-
sourceBranch: pr.sourceRefName?.replace('refs/heads/', ''),
|
|
1150
|
-
targetBranch: pr.targetRefName?.replace('refs/heads/', ''),
|
|
1151
|
-
mergeStatus: pr.mergeStatus,
|
|
1152
|
-
isDraft: pr.isDraft,
|
|
1153
|
-
reviewerCount: pr.reviewers?.length || 0,
|
|
1154
|
-
url: pr._links?.web?.href
|
|
1155
|
-
}))
|
|
1156
|
-
};
|
|
1157
|
-
}
|
|
1158
|
-
/**
|
|
1159
|
-
* Get details of a specific pull request
|
|
1160
|
-
* @param project The project name
|
|
1161
|
-
* @param repositoryId Repository ID (GUID) or name
|
|
1162
|
-
* @param pullRequestId The PR ID
|
|
1163
|
-
* @returns Pull request details with reviewers
|
|
1164
|
-
*/
|
|
1165
|
-
async getPullRequest(project, repositoryId, pullRequestId) {
|
|
1166
|
-
this.validateProject(project);
|
|
1167
|
-
const response = await this.makeRequest(`${project}/_apis/git/repositories/${repositoryId}/pullrequests/${pullRequestId}?api-version=${this.apiVersion}`);
|
|
1168
|
-
return {
|
|
1169
|
-
pullRequestId: response.pullRequestId,
|
|
1170
|
-
title: response.title,
|
|
1171
|
-
description: response.description,
|
|
1172
|
-
status: response.status,
|
|
1173
|
-
createdBy: {
|
|
1174
|
-
displayName: response.createdBy?.displayName,
|
|
1175
|
-
id: response.createdBy?.id,
|
|
1176
|
-
uniqueName: response.createdBy?.uniqueName
|
|
1177
|
-
},
|
|
1178
|
-
creationDate: response.creationDate,
|
|
1179
|
-
closedDate: response.closedDate,
|
|
1180
|
-
sourceBranch: response.sourceRefName?.replace('refs/heads/', ''),
|
|
1181
|
-
targetBranch: response.targetRefName?.replace('refs/heads/', ''),
|
|
1182
|
-
mergeStatus: response.mergeStatus,
|
|
1183
|
-
isDraft: response.isDraft,
|
|
1184
|
-
mergeId: response.lastMergeCommit?.commitId,
|
|
1185
|
-
sourceCommitId: response.lastMergeSourceCommit?.commitId,
|
|
1186
|
-
targetCommitId: response.lastMergeTargetCommit?.commitId,
|
|
1187
|
-
supportsIterations: response.supportsIterations,
|
|
1188
|
-
reviewers: (response.reviewers || []).map((r) => ({
|
|
1189
|
-
displayName: r.displayName,
|
|
1190
|
-
id: r.id,
|
|
1191
|
-
vote: r.vote,
|
|
1192
|
-
voteLabel: this.getVoteLabel(r.vote),
|
|
1193
|
-
isRequired: r.isRequired,
|
|
1194
|
-
hasDeclined: r.hasDeclined
|
|
1195
|
-
})),
|
|
1196
|
-
labels: response.labels?.map((l) => l.name) || [],
|
|
1197
|
-
autoComplete: response.autoCompleteSetBy ? {
|
|
1198
|
-
setBy: response.autoCompleteSetBy.displayName,
|
|
1199
|
-
mergeStrategy: response.completionOptions?.mergeStrategy
|
|
1200
|
-
} : null,
|
|
1201
|
-
url: response._links?.web?.href,
|
|
1202
|
-
project
|
|
1203
|
-
};
|
|
1204
|
-
}
|
|
1205
|
-
/**
|
|
1206
|
-
* Convert numeric vote to label
|
|
1207
|
-
*/
|
|
1208
|
-
getVoteLabel(vote) {
|
|
1209
|
-
switch (vote) {
|
|
1210
|
-
case -10: return 'Rejected';
|
|
1211
|
-
case -5: return 'Waiting for author';
|
|
1212
|
-
case 0: return 'No response';
|
|
1213
|
-
case 5: return 'Approved with suggestions';
|
|
1214
|
-
case 10: return 'Approved';
|
|
1215
|
-
default: return `Unknown (${vote})`;
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
/**
|
|
1219
|
-
* Get commits in a pull request
|
|
1220
|
-
* @param project The project name
|
|
1221
|
-
* @param repositoryId Repository ID (GUID) or name
|
|
1222
|
-
* @param pullRequestId The PR ID
|
|
1223
|
-
* @returns List of commits
|
|
1224
|
-
*/
|
|
1225
|
-
async getPullRequestCommits(project, repositoryId, pullRequestId) {
|
|
1226
|
-
this.validateProject(project);
|
|
1227
|
-
const response = await this.makeRequest(`${project}/_apis/git/repositories/${repositoryId}/pullrequests/${pullRequestId}/commits?api-version=${this.apiVersion}`);
|
|
1228
|
-
return {
|
|
1229
|
-
pullRequestId,
|
|
1230
|
-
project,
|
|
1231
|
-
totalCount: response.value.length,
|
|
1232
|
-
commits: response.value.map((c) => ({
|
|
1233
|
-
commitId: c.commitId,
|
|
1234
|
-
comment: c.comment,
|
|
1235
|
-
author: {
|
|
1236
|
-
name: c.author?.name,
|
|
1237
|
-
email: c.author?.email,
|
|
1238
|
-
date: c.author?.date
|
|
1239
|
-
},
|
|
1240
|
-
committer: {
|
|
1241
|
-
name: c.committer?.name,
|
|
1242
|
-
date: c.committer?.date
|
|
1243
|
-
},
|
|
1244
|
-
url: c.url
|
|
1245
|
-
}))
|
|
1246
|
-
};
|
|
1247
|
-
}
|
|
1248
|
-
/**
|
|
1249
|
-
* Get threads/comments on a pull request
|
|
1250
|
-
* @param project The project name
|
|
1251
|
-
* @param repositoryId Repository ID (GUID) or name
|
|
1252
|
-
* @param pullRequestId The PR ID
|
|
1253
|
-
* @returns List of discussion threads
|
|
1254
|
-
*/
|
|
1255
|
-
async getPullRequestThreads(project, repositoryId, pullRequestId) {
|
|
1256
|
-
this.validateProject(project);
|
|
1257
|
-
const response = await this.makeRequest(`${project}/_apis/git/repositories/${repositoryId}/pullrequests/${pullRequestId}/threads?api-version=${this.apiVersion}`);
|
|
1258
|
-
return {
|
|
1259
|
-
pullRequestId,
|
|
1260
|
-
project,
|
|
1261
|
-
totalCount: response.value.length,
|
|
1262
|
-
threads: response.value.map((t) => ({
|
|
1263
|
-
id: t.id,
|
|
1264
|
-
status: t.status,
|
|
1265
|
-
publishedDate: t.publishedDate,
|
|
1266
|
-
lastUpdatedDate: t.lastUpdatedDate,
|
|
1267
|
-
isDeleted: t.isDeleted,
|
|
1268
|
-
threadContext: t.threadContext ? {
|
|
1269
|
-
filePath: t.threadContext.filePath,
|
|
1270
|
-
rightFileStart: t.threadContext.rightFileStart,
|
|
1271
|
-
rightFileEnd: t.threadContext.rightFileEnd
|
|
1272
|
-
} : null,
|
|
1273
|
-
comments: (t.comments || []).filter((c) => !c.isDeleted).map((c) => ({
|
|
1274
|
-
id: c.id,
|
|
1275
|
-
author: c.author?.displayName,
|
|
1276
|
-
content: c.content,
|
|
1277
|
-
publishedDate: c.publishedDate,
|
|
1278
|
-
commentType: c.commentType,
|
|
1279
|
-
parentCommentId: c.parentCommentId
|
|
1280
|
-
}))
|
|
1281
|
-
}))
|
|
1282
|
-
};
|
|
1283
|
-
}
|
|
1284
|
-
/**
|
|
1285
|
-
* Get file changes in a pull request
|
|
1286
|
-
* @param project The project name
|
|
1287
|
-
* @param repositoryId Repository ID (GUID) or name
|
|
1288
|
-
* @param pullRequestId The PR ID
|
|
1289
|
-
* @param iterationId Iteration ID (default: latest)
|
|
1290
|
-
* @returns List of changed files
|
|
1291
|
-
*/
|
|
1292
|
-
async getPullRequestChanges(project, repositoryId, pullRequestId, iterationId) {
|
|
1293
|
-
this.validateProject(project);
|
|
1294
|
-
// If no iteration specified, get the latest
|
|
1295
|
-
let targetIteration = iterationId;
|
|
1296
|
-
if (!targetIteration) {
|
|
1297
|
-
const iterations = await this.makeRequest(`${project}/_apis/git/repositories/${repositoryId}/pullrequests/${pullRequestId}/iterations?api-version=${this.apiVersion}`);
|
|
1298
|
-
if (iterations.value.length > 0) {
|
|
1299
|
-
targetIteration = iterations.value[iterations.value.length - 1].id;
|
|
1300
|
-
}
|
|
1301
|
-
else {
|
|
1302
|
-
throw new Error('No iterations found for this pull request');
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
const response = await this.makeRequest(`${project}/_apis/git/repositories/${repositoryId}/pullrequests/${pullRequestId}/iterations/${targetIteration}/changes?api-version=${this.apiVersion}`);
|
|
1306
|
-
return {
|
|
1307
|
-
pullRequestId,
|
|
1308
|
-
iterationId: targetIteration,
|
|
1309
|
-
project,
|
|
1310
|
-
totalCount: response.changeEntries?.length || 0,
|
|
1311
|
-
changes: (response.changeEntries || []).map((c) => ({
|
|
1312
|
-
changeType: c.changeType,
|
|
1313
|
-
path: c.item?.path,
|
|
1314
|
-
originalPath: c.originalPath,
|
|
1315
|
-
objectId: c.item?.objectId,
|
|
1316
|
-
originalObjectId: c.item?.originalObjectId
|
|
1317
|
-
}))
|
|
1318
|
-
};
|
|
1319
|
-
}
|
|
1320
|
-
/**
|
|
1321
|
-
* Add a comment thread to a pull request
|
|
1322
|
-
* @param project The project name
|
|
1323
|
-
* @param repositoryId Repository ID (GUID) or name
|
|
1324
|
-
* @param pullRequestId The PR ID
|
|
1325
|
-
* @param content Comment content (markdown supported)
|
|
1326
|
-
* @param filePath Optional file path for inline comment
|
|
1327
|
-
* @param lineNumber Optional line number (right side) for inline comment
|
|
1328
|
-
* @param status Thread status: active, fixed, wontFix, closed, byDesign, pending (default: active)
|
|
1329
|
-
* @returns Created thread
|
|
1330
|
-
*/
|
|
1331
|
-
async addPullRequestThread(project, repositoryId, pullRequestId, content, filePath, lineNumber, status = 'active') {
|
|
1332
|
-
this.validateProject(project);
|
|
1333
|
-
if (!this.config.enablePullRequestWrite) {
|
|
1334
|
-
throw new Error('Pull request write operations are disabled. Set AZUREDEVOPS_ENABLE_PR_WRITE=true to enable.');
|
|
1335
|
-
}
|
|
1336
|
-
const threadData = {
|
|
1337
|
-
comments: [
|
|
1338
|
-
{
|
|
1339
|
-
parentCommentId: 0,
|
|
1340
|
-
content: content,
|
|
1341
|
-
commentType: 1 // 1 = text
|
|
1342
|
-
}
|
|
1343
|
-
],
|
|
1344
|
-
status: status
|
|
1345
|
-
};
|
|
1346
|
-
// Add file context for inline comments
|
|
1347
|
-
if (filePath && lineNumber) {
|
|
1348
|
-
threadData.threadContext = {
|
|
1349
|
-
filePath: filePath.startsWith('/') ? filePath : `/${filePath}`,
|
|
1350
|
-
rightFileStart: { line: lineNumber, offset: 1 },
|
|
1351
|
-
rightFileEnd: { line: lineNumber, offset: 1 }
|
|
1352
|
-
};
|
|
1353
|
-
}
|
|
1354
|
-
const response = await this.makeRequest(`${project}/_apis/git/repositories/${repositoryId}/pullrequests/${pullRequestId}/threads?api-version=${this.apiVersion}`, 'POST', threadData);
|
|
1355
|
-
return {
|
|
1356
|
-
threadId: response.id,
|
|
1357
|
-
status: response.status,
|
|
1358
|
-
publishedDate: response.publishedDate,
|
|
1359
|
-
filePath: response.threadContext?.filePath,
|
|
1360
|
-
comments: response.comments?.map((c) => ({
|
|
1361
|
-
id: c.id,
|
|
1362
|
-
content: c.content,
|
|
1363
|
-
author: c.author?.displayName
|
|
1364
|
-
})),
|
|
1365
|
-
message: filePath
|
|
1366
|
-
? `Inline comment added to ${filePath} at line ${lineNumber}`
|
|
1367
|
-
: 'Comment thread created',
|
|
1368
|
-
pullRequestId,
|
|
1369
|
-
project
|
|
1370
|
-
};
|
|
1371
|
-
}
|
|
1372
|
-
/**
|
|
1373
|
-
* Create a new pull request
|
|
1374
|
-
*/
|
|
1375
|
-
async createPullRequest(project, repositoryId, sourceRefName, targetRefName, title, description, reviewerIds, isDraft) {
|
|
1376
|
-
this.validateProject(project);
|
|
1377
|
-
if (!this.config.enablePullRequestWrite) {
|
|
1378
|
-
throw new Error('Pull request write operations are disabled. Set AZUREDEVOPS_ENABLE_PR_WRITE=true to enable.');
|
|
1379
|
-
}
|
|
1380
|
-
const body = {
|
|
1381
|
-
sourceRefName,
|
|
1382
|
-
targetRefName,
|
|
1383
|
-
title,
|
|
1384
|
-
description: description || '',
|
|
1385
|
-
isDraft: isDraft || false,
|
|
1386
|
-
};
|
|
1387
|
-
if (reviewerIds && reviewerIds.length > 0) {
|
|
1388
|
-
body.reviewers = reviewerIds.map(id => ({ id }));
|
|
1389
|
-
}
|
|
1390
|
-
const response = await this.makeRequest(`${project}/_apis/git/repositories/${repositoryId}/pullrequests?api-version=${this.apiVersion}`, 'POST', body, false, { 'Content-Type': 'application/json' });
|
|
1391
|
-
return {
|
|
1392
|
-
pullRequestId: response.pullRequestId,
|
|
1393
|
-
title: response.title,
|
|
1394
|
-
status: response.status,
|
|
1395
|
-
isDraft: response.isDraft,
|
|
1396
|
-
sourceBranch: response.sourceRefName?.replace('refs/heads/', ''),
|
|
1397
|
-
targetBranch: response.targetRefName?.replace('refs/heads/', ''),
|
|
1398
|
-
createdBy: response.createdBy?.displayName,
|
|
1399
|
-
creationDate: response.creationDate,
|
|
1400
|
-
url: response.url,
|
|
1401
|
-
project
|
|
1402
|
-
};
|
|
1403
|
-
}
|
|
1404
|
-
/**
|
|
1405
|
-
* Update an existing pull request (title, description, status, draft)
|
|
1406
|
-
*/
|
|
1407
|
-
async updatePullRequest(project, repositoryId, pullRequestId, updates) {
|
|
1408
|
-
this.validateProject(project);
|
|
1409
|
-
if (!this.config.enablePullRequestWrite) {
|
|
1410
|
-
throw new Error('Pull request write operations are disabled. Set AZUREDEVOPS_ENABLE_PR_WRITE=true to enable.');
|
|
1411
|
-
}
|
|
1412
|
-
const body = {};
|
|
1413
|
-
if (updates.title !== undefined)
|
|
1414
|
-
body.title = updates.title;
|
|
1415
|
-
if (updates.description !== undefined)
|
|
1416
|
-
body.description = updates.description;
|
|
1417
|
-
if (updates.status !== undefined)
|
|
1418
|
-
body.status = updates.status;
|
|
1419
|
-
if (updates.isDraft !== undefined)
|
|
1420
|
-
body.isDraft = updates.isDraft;
|
|
1421
|
-
const response = await this.makeRequest(`${project}/_apis/git/repositories/${repositoryId}/pullrequests/${pullRequestId}?api-version=${this.apiVersion}`, 'PATCH', body, false, { 'Content-Type': 'application/json' });
|
|
1422
|
-
return {
|
|
1423
|
-
pullRequestId: response.pullRequestId,
|
|
1424
|
-
title: response.title,
|
|
1425
|
-
description: response.description ? this.truncate(response.description, 200) : null,
|
|
1426
|
-
status: response.status,
|
|
1427
|
-
isDraft: response.isDraft,
|
|
1428
|
-
project
|
|
1429
|
-
};
|
|
1430
|
-
}
|
|
1431
|
-
/**
|
|
1432
|
-
* Complete (merge) a pull request
|
|
1433
|
-
*/
|
|
1434
|
-
async completePullRequest(project, repositoryId, pullRequestId, mergeStrategy = 'squash', deleteSourceBranch = true, transitionWorkItems = true, mergeCommitMessage) {
|
|
1435
|
-
this.validateProject(project);
|
|
1436
|
-
if (!this.config.enablePullRequestWrite) {
|
|
1437
|
-
throw new Error('Pull request write operations are disabled. Set AZUREDEVOPS_ENABLE_PR_WRITE=true to enable.');
|
|
1438
|
-
}
|
|
1439
|
-
// Fetch current PR to get lastMergeSourceCommit
|
|
1440
|
-
const currentPr = await this.makeRequest(`${project}/_apis/git/repositories/${repositoryId}/pullrequests/${pullRequestId}?api-version=${this.apiVersion}`);
|
|
1441
|
-
const mergeStrategyMap = {
|
|
1442
|
-
noFastForward: 1,
|
|
1443
|
-
squash: 2,
|
|
1444
|
-
rebase: 3,
|
|
1445
|
-
rebaseMerge: 4,
|
|
1446
|
-
};
|
|
1447
|
-
const body = {
|
|
1448
|
-
status: 'completed',
|
|
1449
|
-
lastMergeSourceCommit: currentPr.lastMergeSourceCommit,
|
|
1450
|
-
completionOptions: {
|
|
1451
|
-
mergeStrategy: mergeStrategyMap[mergeStrategy],
|
|
1452
|
-
deleteSourceBranch,
|
|
1453
|
-
transitionWorkItems,
|
|
1454
|
-
},
|
|
1455
|
-
};
|
|
1456
|
-
if (mergeCommitMessage) {
|
|
1457
|
-
body.completionOptions.mergeCommitMessage = mergeCommitMessage;
|
|
1458
|
-
}
|
|
1459
|
-
const response = await this.makeRequest(`${project}/_apis/git/repositories/${repositoryId}/pullrequests/${pullRequestId}?api-version=${this.apiVersion}`, 'PATCH', body, false, { 'Content-Type': 'application/json' });
|
|
1460
|
-
return {
|
|
1461
|
-
pullRequestId: response.pullRequestId,
|
|
1462
|
-
title: response.title,
|
|
1463
|
-
status: response.status,
|
|
1464
|
-
mergeStrategy,
|
|
1465
|
-
closedDate: response.closedDate,
|
|
1466
|
-
project
|
|
1467
|
-
};
|
|
1468
|
-
}
|
|
1469
|
-
/**
|
|
1470
|
-
* Add or remove a reviewer from a pull request
|
|
1471
|
-
*/
|
|
1472
|
-
async addOrRemovePrReviewer(project, repositoryId, pullRequestId, reviewerId, isRequired, remove) {
|
|
1473
|
-
this.validateProject(project);
|
|
1474
|
-
if (!this.config.enablePullRequestWrite) {
|
|
1475
|
-
throw new Error('Pull request write operations are disabled. Set AZUREDEVOPS_ENABLE_PR_WRITE=true to enable.');
|
|
1476
|
-
}
|
|
1477
|
-
const url = `${project}/_apis/git/repositories/${repositoryId}/pullrequests/${pullRequestId}/reviewers/${reviewerId}?api-version=${this.apiVersion}`;
|
|
1478
|
-
if (remove) {
|
|
1479
|
-
await this.makeRequest(url, 'DELETE');
|
|
1480
|
-
return { pullRequestId, reviewerId, action: 'removed', project };
|
|
1481
|
-
}
|
|
1482
|
-
const body = { id: reviewerId };
|
|
1483
|
-
if (isRequired !== undefined) {
|
|
1484
|
-
body.isRequired = isRequired;
|
|
1485
|
-
}
|
|
1486
|
-
const response = await this.makeRequest(url, 'PUT', body, false, { 'Content-Type': 'application/json' });
|
|
1487
|
-
return {
|
|
1488
|
-
pullRequestId,
|
|
1489
|
-
reviewerId: response.id,
|
|
1490
|
-
displayName: response.displayName,
|
|
1491
|
-
isRequired: response.isRequired,
|
|
1492
|
-
vote: response.vote,
|
|
1493
|
-
action: 'added',
|
|
1494
|
-
project
|
|
1495
|
-
};
|
|
1496
|
-
}
|
|
1497
|
-
/**
|
|
1498
|
-
* Submit a vote on a pull request
|
|
1499
|
-
*/
|
|
1500
|
-
async votePullRequest(project, repositoryId, pullRequestId, vote, reviewerId) {
|
|
1501
|
-
this.validateProject(project);
|
|
1502
|
-
if (!this.config.enablePullRequestWrite) {
|
|
1503
|
-
throw new Error('Pull request write operations are disabled. Set AZUREDEVOPS_ENABLE_PR_WRITE=true to enable.');
|
|
1504
|
-
}
|
|
1505
|
-
const voteMap = {
|
|
1506
|
-
approve: 10,
|
|
1507
|
-
approveWithSuggestions: 5,
|
|
1508
|
-
noResponse: 0,
|
|
1509
|
-
waitForAuthor: -5,
|
|
1510
|
-
reject: -10,
|
|
1511
|
-
};
|
|
1512
|
-
// If no reviewerId, resolve authenticated user
|
|
1513
|
-
let userId = reviewerId;
|
|
1514
|
-
if (!userId) {
|
|
1515
|
-
const connectionData = await this.makeRequest('_apis/connectionData');
|
|
1516
|
-
userId = connectionData.authenticatedUser?.id;
|
|
1517
|
-
if (!userId) {
|
|
1518
|
-
throw new Error('Could not resolve authenticated user ID. Provide reviewerId explicitly.');
|
|
1519
|
-
}
|
|
1520
|
-
}
|
|
1521
|
-
const body = { vote: voteMap[vote] };
|
|
1522
|
-
const response = await this.makeRequest(`${project}/_apis/git/repositories/${repositoryId}/pullrequests/${pullRequestId}/reviewers/${userId}?api-version=${this.apiVersion}`, 'PUT', body, false, { 'Content-Type': 'application/json' });
|
|
1523
|
-
return {
|
|
1524
|
-
pullRequestId,
|
|
1525
|
-
reviewerId: response.id,
|
|
1526
|
-
displayName: response.displayName,
|
|
1527
|
-
vote: response.vote,
|
|
1528
|
-
voteLabel: vote,
|
|
1529
|
-
project
|
|
1530
|
-
};
|
|
1531
|
-
}
|
|
1532
|
-
/**
|
|
1533
|
-
* Reply to a PR thread and optionally update thread status
|
|
1534
|
-
*/
|
|
1535
|
-
async replyToPrThread(project, repositoryId, pullRequestId, threadId, content, status) {
|
|
1536
|
-
this.validateProject(project);
|
|
1537
|
-
if (!this.config.enablePullRequestWrite) {
|
|
1538
|
-
throw new Error('Pull request write operations are disabled. Set AZUREDEVOPS_ENABLE_PR_WRITE=true to enable.');
|
|
1539
|
-
}
|
|
1540
|
-
const results = { pullRequestId, threadId, project };
|
|
1541
|
-
// Add reply comment if content provided
|
|
1542
|
-
if (content) {
|
|
1543
|
-
const commentResponse = await this.makeRequest(`${project}/_apis/git/repositories/${repositoryId}/pullrequests/${pullRequestId}/threads/${threadId}/comments?api-version=${this.apiVersion}`, 'POST', { content, parentCommentId: 0, commentType: 1 }, false, { 'Content-Type': 'application/json' });
|
|
1544
|
-
results.comment = {
|
|
1545
|
-
id: commentResponse.id,
|
|
1546
|
-
content: commentResponse.content,
|
|
1547
|
-
author: commentResponse.author?.displayName,
|
|
1548
|
-
};
|
|
1549
|
-
}
|
|
1550
|
-
// Update thread status if provided
|
|
1551
|
-
if (status) {
|
|
1552
|
-
await this.makeRequest(`${project}/_apis/git/repositories/${repositoryId}/pullrequests/${pullRequestId}/threads/${threadId}?api-version=${this.apiVersion}`, 'PATCH', { status }, false, { 'Content-Type': 'application/json' });
|
|
1553
|
-
results.status = status;
|
|
1554
|
-
}
|
|
1555
|
-
return results;
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
//# sourceMappingURL=AzureDevOpsService.js.map
|