@elisra-devops/docgen-data-provider 1.69.0 → 1.69.2
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/bin/modules/PipelinesDataProvider.d.ts +104 -22
- package/bin/modules/PipelinesDataProvider.js +364 -58
- package/bin/modules/PipelinesDataProvider.js.map +1 -1
- package/bin/modules/ResultDataProvider.d.ts +3 -0
- package/bin/modules/ResultDataProvider.js +49 -6
- package/bin/modules/ResultDataProvider.js.map +1 -1
- package/bin/tests/modules/ResultDataProvider.test.js +40 -0
- package/bin/tests/modules/ResultDataProvider.test.js.map +1 -1
- package/bin/tests/modules/pipelineDataProvider.test.js +207 -8
- package/bin/tests/modules/pipelineDataProvider.test.js.map +1 -1
- package/package.json +1 -1
- package/src/modules/PipelinesDataProvider.ts +404 -60
- package/src/modules/ResultDataProvider.ts +52 -6
- package/src/tests/modules/ResultDataProvider.test.ts +52 -0
- package/src/tests/modules/pipelineDataProvider.test.ts +232 -8
|
@@ -7,6 +7,7 @@ import GitDataProvider from './GitDataProvider';
|
|
|
7
7
|
export default class PipelinesDataProvider {
|
|
8
8
|
orgUrl: string = '';
|
|
9
9
|
token: string = '';
|
|
10
|
+
private projectNameByIdCache: Map<string, string> = new Map();
|
|
10
11
|
|
|
11
12
|
constructor(orgUrl: string, token: string) {
|
|
12
13
|
this.orgUrl = orgUrl;
|
|
@@ -36,7 +37,7 @@ export default class PipelinesDataProvider {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
const fromPipeline = await this.getPipelineRunDetails(teamProject, Number(pipelineId), pipelineRun.id);
|
|
39
|
-
if (!fromPipeline
|
|
40
|
+
if (!fromPipeline?.resources?.repositories) {
|
|
40
41
|
continue;
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -96,11 +97,19 @@ export default class PipelinesDataProvider {
|
|
|
96
97
|
targetPipeline: PipelineRun,
|
|
97
98
|
searchPrevPipelineFromDifferentCommit: boolean
|
|
98
99
|
): boolean {
|
|
100
|
+
if (!fromPipeline?.resources?.repositories || !targetPipeline?.resources?.repositories) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
99
104
|
const fromRepo =
|
|
100
105
|
fromPipeline.resources.repositories[0]?.self || fromPipeline.resources.repositories.__designer_repo;
|
|
101
106
|
const targetRepo =
|
|
102
107
|
targetPipeline.resources.repositories[0]?.self || targetPipeline.resources.repositories.__designer_repo;
|
|
103
108
|
|
|
109
|
+
if (!fromRepo?.repository?.id || !targetRepo?.repository?.id) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
104
113
|
if (fromRepo.repository.id !== targetRepo.repository.id) {
|
|
105
114
|
return false;
|
|
106
115
|
}
|
|
@@ -112,75 +121,406 @@ export default class PipelinesDataProvider {
|
|
|
112
121
|
return fromRepo.refName === targetRepo.refName;
|
|
113
122
|
}
|
|
114
123
|
|
|
124
|
+
private tryGetTeamProjectFromAzureDevOpsUrl(url?: string): string | undefined {
|
|
125
|
+
if (!url) return undefined;
|
|
126
|
+
try {
|
|
127
|
+
const parsed = new URL(url);
|
|
128
|
+
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
129
|
+
const apiIndex = parts.findIndex((p) => p === '_apis');
|
|
130
|
+
if (apiIndex <= 0) return undefined;
|
|
131
|
+
return parts[apiIndex - 1];
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Returns `true` when the input looks like an Azure DevOps GUID identifier.
|
|
139
|
+
*/
|
|
140
|
+
private isGuidLike(value?: string): boolean {
|
|
141
|
+
if (!value) return false;
|
|
142
|
+
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
|
|
143
|
+
String(value).trim()
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
115
147
|
/**
|
|
116
|
-
*
|
|
148
|
+
* Normalizes a project identifier into a project name suitable for `{project}` URL path segments.
|
|
117
149
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
150
|
+
* Azure DevOps APIs often accept both project names and IDs, but some endpoints (especially on ADO Server)
|
|
151
|
+
* behave differently or return empty results when a GUID is used in the URL path. This method converts
|
|
152
|
+
* a GUID into its canonical project name via `/_apis/projects/{id}` and caches the mapping.
|
|
120
153
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
154
|
+
* @param projectNameOrId Project name (e.g. "Test CMMI") or project GUID.
|
|
155
|
+
* @returns The project name, or the original input when resolution fails.
|
|
156
|
+
*/
|
|
157
|
+
private async normalizeProjectName(projectNameOrId?: string): Promise<string | undefined> {
|
|
158
|
+
if (!projectNameOrId) return undefined;
|
|
159
|
+
const raw = String(projectNameOrId).trim();
|
|
160
|
+
if (!raw) return undefined;
|
|
161
|
+
if (!this.isGuidLike(raw)) return raw;
|
|
162
|
+
|
|
163
|
+
const cached = this.projectNameByIdCache.get(raw);
|
|
164
|
+
if (cached) return cached;
|
|
165
|
+
|
|
166
|
+
// ADO supports querying projects at the collection/org root.
|
|
167
|
+
const url = `${this.orgUrl}_apis/projects/${encodeURIComponent(raw)}?api-version=6.0`;
|
|
168
|
+
try {
|
|
169
|
+
const project = await TFSServices.getItemContent(url, this.token, 'get', null, null, false);
|
|
170
|
+
const resolvedName = String(project?.name || '').trim();
|
|
171
|
+
if (resolvedName) {
|
|
172
|
+
this.projectNameByIdCache.set(raw, resolvedName);
|
|
173
|
+
return resolvedName;
|
|
174
|
+
}
|
|
175
|
+
return raw;
|
|
176
|
+
} catch (err: any) {
|
|
177
|
+
return raw;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Attempts to extract a run/build id from an Azure DevOps URL.
|
|
129
183
|
*
|
|
130
|
-
*
|
|
131
|
-
* -
|
|
132
|
-
* - buildId
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
184
|
+
* Supports URLs shaped like:
|
|
185
|
+
* - `.../_apis/pipelines/{pipelineId}/runs/{runId}`
|
|
186
|
+
* - `.../_apis/build/builds/{buildId}`
|
|
187
|
+
*/
|
|
188
|
+
private tryParseRunIdFromUrl(url?: string): number | undefined {
|
|
189
|
+
if (!url) return undefined;
|
|
190
|
+
try {
|
|
191
|
+
const parsed = new URL(url);
|
|
192
|
+
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
193
|
+
const runsIndex = parts.findIndex((p) => p === 'runs');
|
|
194
|
+
if (runsIndex >= 0 && parts[runsIndex + 1]) {
|
|
195
|
+
const runId = Number(parts[runsIndex + 1]);
|
|
196
|
+
return Number.isFinite(runId) ? runId : undefined;
|
|
197
|
+
}
|
|
198
|
+
const buildsIndex = parts.findIndex((p) => p === 'builds');
|
|
199
|
+
if (buildsIndex >= 0 && parts[buildsIndex + 1]) {
|
|
200
|
+
const buildId = Number(parts[buildsIndex + 1]);
|
|
201
|
+
return Number.isFinite(buildId) ? buildId : undefined;
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
} catch {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Normalizes a branch name into a `refs/...` form accepted by the Builds API.
|
|
211
|
+
*/
|
|
212
|
+
private normalizeBranchName(branch?: string): string | undefined {
|
|
213
|
+
if (!branch) return undefined;
|
|
214
|
+
const b = String(branch).trim();
|
|
215
|
+
if (!b) return undefined;
|
|
216
|
+
if (b.startsWith('refs/')) return b;
|
|
217
|
+
if (b.startsWith('heads/')) return `refs/${b}`;
|
|
218
|
+
return `refs/heads/${b}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Searches for a build by build number without restricting by definition id.
|
|
137
223
|
*
|
|
138
|
-
*
|
|
224
|
+
* Used as a fallback when the `resources.pipelines[alias].pipeline.id` is not a stable build definition id
|
|
225
|
+
* (some ADO instances return a run/build id or a pipeline revision-related id instead).
|
|
226
|
+
*
|
|
227
|
+
* @param projectName Team project name.
|
|
228
|
+
* @param buildNumber Build number / run name (e.g. "20251225.2", "1.0.56").
|
|
229
|
+
* @param branch Optional branch filter.
|
|
230
|
+
* @param expectedDefinitionName Optional build definition name to disambiguate results (typically YAML `source`).
|
|
139
231
|
*/
|
|
140
|
-
|
|
141
|
-
|
|
232
|
+
private async findBuildByBuildNumber(
|
|
233
|
+
projectName: string,
|
|
234
|
+
buildNumber: string,
|
|
235
|
+
branch?: string,
|
|
236
|
+
expectedDefinitionName?: string
|
|
237
|
+
): Promise<any | undefined> {
|
|
238
|
+
const bn = String(buildNumber || '').trim();
|
|
239
|
+
if (!bn) return undefined;
|
|
240
|
+
const normalizedBranch = this.normalizeBranchName(branch);
|
|
241
|
+
let url = `${this.orgUrl}${projectName}/_apis/build/builds?buildNumber=${encodeURIComponent(
|
|
242
|
+
bn
|
|
243
|
+
)}&$top=20&queryOrder=finishTimeDescending&api-version=6.0`;
|
|
244
|
+
if (normalizedBranch) {
|
|
245
|
+
url += `&branchName=${encodeURIComponent(normalizedBranch)}`;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const res: any = await TFSServices.getItemContent(url, this.token, 'get', null, null);
|
|
249
|
+
const value: any[] = res?.value || [];
|
|
250
|
+
const filtered = expectedDefinitionName
|
|
251
|
+
? value.filter((b: any) => String(b?.definition?.name || '') === String(expectedDefinitionName))
|
|
252
|
+
: value;
|
|
253
|
+
return filtered[0] ?? value[0];
|
|
254
|
+
} catch (err: any) {
|
|
255
|
+
logger.error(
|
|
256
|
+
`Error resolving build by buildNumber (no definition) (${projectName}/${bn}): ${err?.message || err}`
|
|
257
|
+
);
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
142
261
|
|
|
143
|
-
|
|
144
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Retrieves a build by id, with an additional fallback for ADO instances that allow the non-project-scoped route.
|
|
264
|
+
*
|
|
265
|
+
* @param projectName Optional team project name.
|
|
266
|
+
* @param buildId Build id.
|
|
267
|
+
*/
|
|
268
|
+
private async tryGetBuildByIdWithFallback(
|
|
269
|
+
projectName: string | undefined,
|
|
270
|
+
buildId: number
|
|
271
|
+
): Promise<any | undefined> {
|
|
272
|
+
if (projectName) {
|
|
273
|
+
try {
|
|
274
|
+
return await this.getPipelineBuildByBuildId(projectName, buildId);
|
|
275
|
+
} catch (e1: any) {
|
|
276
|
+
}
|
|
145
277
|
}
|
|
146
|
-
const pipelines = inPipeline.resources.pipelines;
|
|
147
278
|
|
|
148
|
-
|
|
279
|
+
// Some ADO instances allow build lookup without the {project} path segment.
|
|
280
|
+
try {
|
|
281
|
+
const url = `${this.orgUrl}_apis/build/builds/${buildId}`;
|
|
282
|
+
return await TFSServices.getItemContent(url, this.token, 'get', null, null);
|
|
283
|
+
} catch (e2: any) {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Searches for a build by definition id + build number.
|
|
290
|
+
*
|
|
291
|
+
* @param projectName Team project name.
|
|
292
|
+
* @param definitionId Build definition id (classic build definition id).
|
|
293
|
+
* @param buildNumber Build number / run name.
|
|
294
|
+
* @param branch Optional branch filter.
|
|
295
|
+
*/
|
|
296
|
+
private async findBuildByDefinitionAndBuildNumber(
|
|
297
|
+
projectName: string,
|
|
298
|
+
definitionId: number,
|
|
299
|
+
buildNumber: string,
|
|
300
|
+
branch?: string
|
|
301
|
+
): Promise<any | undefined> {
|
|
302
|
+
const bn = String(buildNumber || '').trim();
|
|
303
|
+
if (!bn) return undefined;
|
|
304
|
+
const normalizedBranch = this.normalizeBranchName(branch);
|
|
305
|
+
let url = `${this.orgUrl}${projectName}/_apis/build/builds?definitions=${encodeURIComponent(
|
|
306
|
+
String(definitionId)
|
|
307
|
+
)}&buildNumber=${encodeURIComponent(bn)}&$top=1&queryOrder=finishTimeDescending&api-version=6.0`;
|
|
308
|
+
if (normalizedBranch) {
|
|
309
|
+
url += `&branchName=${encodeURIComponent(normalizedBranch)}`;
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const res: any = await TFSServices.getItemContent(url, this.token, 'get', null, null);
|
|
313
|
+
const value: any[] = res?.value || [];
|
|
314
|
+
return value[0];
|
|
315
|
+
} catch (err: any) {
|
|
316
|
+
logger.error(
|
|
317
|
+
`Error resolving build by buildNumber for definition ${definitionId} (${projectName}/${bn}): ${err.message}`
|
|
318
|
+
);
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Resolves a pipelines API run id by comparing a desired run name against the pipeline run history.
|
|
325
|
+
*
|
|
326
|
+
* Useful when Builds API lookups don't return results even though the upstream run exists.
|
|
327
|
+
*
|
|
328
|
+
* @param projectName Team project name.
|
|
329
|
+
* @param pipelineId Pipeline id.
|
|
330
|
+
* @param runName Run "name" from ADO UI (e.g. "20251225.2").
|
|
331
|
+
*/
|
|
332
|
+
private async findRunIdByPipelineRunName(
|
|
333
|
+
projectName: string,
|
|
334
|
+
pipelineId: number,
|
|
335
|
+
runName: string
|
|
336
|
+
): Promise<number | undefined> {
|
|
337
|
+
const desired = String(runName || '').trim();
|
|
338
|
+
if (!desired) return undefined;
|
|
339
|
+
try {
|
|
340
|
+
const history = await this.GetPipelineRunHistory(projectName, String(pipelineId));
|
|
341
|
+
const runs: any[] = history?.value || [];
|
|
342
|
+
const match = runs.find((r: any) => {
|
|
343
|
+
const name = String(r?.name || '').trim();
|
|
344
|
+
const id = String(r?.id || '').trim();
|
|
345
|
+
return name === desired || name === `#${desired}` || id === desired;
|
|
346
|
+
});
|
|
347
|
+
if (match?.id) {
|
|
348
|
+
return Number(match.id);
|
|
349
|
+
}
|
|
350
|
+
return undefined;
|
|
351
|
+
} catch (e: any) {
|
|
352
|
+
logger.error(
|
|
353
|
+
`Error resolving runId by runName for pipeline ${pipelineId} (${projectName}/${desired}): ${e?.message || e}`
|
|
354
|
+
);
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Attempts to infer a run/build id for a pipeline resource from the run payload fields.
|
|
361
|
+
*
|
|
362
|
+
* Resolution order:
|
|
363
|
+
* 1) `resource.runId` (preferred)
|
|
364
|
+
* 2) numeric `resource.version` (only if it's an integer string/number)
|
|
365
|
+
* 3) parse from `resource.pipeline.url` if it contains `/runs/{id}` or `/builds/{id}`
|
|
366
|
+
*/
|
|
367
|
+
private inferRunIdFromPipelineResource(resource: any, pipelineUrl?: string): number | undefined {
|
|
368
|
+
const explicit = Number(resource?.runId);
|
|
369
|
+
if (Number.isFinite(explicit)) return explicit;
|
|
370
|
+
|
|
371
|
+
const version = resource?.version;
|
|
372
|
+
if (typeof version === 'number' && Number.isFinite(version)) return version;
|
|
373
|
+
if (typeof version === 'string' && /^\d+$/.test(version)) return Number(version);
|
|
374
|
+
|
|
375
|
+
const parsed = this.tryParseRunIdFromUrl(pipelineUrl);
|
|
376
|
+
return Number.isFinite(parsed) ? Number(parsed) : undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Resolves a pipeline resource run id from a non-numeric `version` (run name/build number).
|
|
381
|
+
*
|
|
382
|
+
* Fall back order (when `runId` isn't present):
|
|
383
|
+
* 1) Builds API: definitionId + buildNumber
|
|
384
|
+
* 2) Builds API: buildNumber-only (optionally filtered by YAML `source`)
|
|
385
|
+
* 3) Pipelines API: run history match by `name` / `#name`
|
|
386
|
+
* 4) Heuristic: treat `pipelineIdCandidate` as buildId and fetch that build
|
|
387
|
+
*/
|
|
388
|
+
private async resolveRunIdFromVersion(params: {
|
|
389
|
+
projectName: string | undefined;
|
|
390
|
+
pipelineIdCandidate: number;
|
|
391
|
+
version: unknown;
|
|
392
|
+
branch?: string;
|
|
393
|
+
source?: unknown;
|
|
394
|
+
}): Promise<number | undefined> {
|
|
395
|
+
const { projectName, pipelineIdCandidate, version, branch, source } = params;
|
|
396
|
+
|
|
397
|
+
if (!Number.isFinite(pipelineIdCandidate)) return undefined;
|
|
398
|
+
|
|
399
|
+
if (!projectName) {
|
|
400
|
+
const buildById = await this.tryGetBuildByIdWithFallback(undefined, pipelineIdCandidate);
|
|
401
|
+
return buildById?.id ? Number(buildById.id) : undefined;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (typeof version !== 'string' || !String(version).trim()) return undefined;
|
|
405
|
+
const buildNumber = String(version).trim();
|
|
406
|
+
|
|
407
|
+
const buildByNumber = await this.findBuildByDefinitionAndBuildNumber(
|
|
408
|
+
projectName,
|
|
409
|
+
pipelineIdCandidate,
|
|
410
|
+
buildNumber,
|
|
411
|
+
branch
|
|
412
|
+
);
|
|
413
|
+
if (buildByNumber?.id) return Number(buildByNumber.id);
|
|
414
|
+
|
|
415
|
+
const buildByNumberAny = await this.findBuildByBuildNumber(
|
|
416
|
+
projectName,
|
|
417
|
+
buildNumber,
|
|
418
|
+
branch,
|
|
419
|
+
typeof source === 'string' ? source : undefined
|
|
420
|
+
);
|
|
421
|
+
if (buildByNumberAny?.id) return Number(buildByNumberAny.id);
|
|
422
|
+
|
|
423
|
+
const runIdByName = await this.findRunIdByPipelineRunName(projectName, pipelineIdCandidate, buildNumber);
|
|
424
|
+
if (runIdByName) return runIdByName;
|
|
425
|
+
|
|
426
|
+
const buildById = await this.tryGetBuildByIdWithFallback(projectName, pipelineIdCandidate);
|
|
427
|
+
return buildById?.id ? Number(buildById.id) : undefined;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private isSupportedResourcePipelineBuild(buildResponse: any): boolean {
|
|
431
|
+
const definitionType = buildResponse?.definition?.type;
|
|
432
|
+
const repoType = buildResponse?.repository?.type;
|
|
433
|
+
return (
|
|
434
|
+
!!buildResponse &&
|
|
435
|
+
definitionType === 'build' &&
|
|
436
|
+
(repoType === 'TfsGit' || repoType === 'azureReposGit')
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Extracts and resolves pipeline resources (`resources.pipelines`) from a pipeline run.
|
|
442
|
+
*
|
|
443
|
+
* Azure DevOps represents pipeline dependencies as "pipeline resources". Those resources do not always include
|
|
444
|
+
* a concrete `runId`, so this method resolves them into build-backed objects that can be used for recursion
|
|
445
|
+
* (e.g., release notes / SVD traversal).
|
|
446
|
+
*
|
|
447
|
+
* @param inPipeline Pipeline run payload that contains `resources.pipelines`.
|
|
448
|
+
* @returns A collection of normalized pipeline resource objects (array), or an empty Set when there are no resources.
|
|
449
|
+
*
|
|
450
|
+
* Complexity: O(p) for p pipeline resources (network I/O dominates).
|
|
451
|
+
*
|
|
452
|
+
* Returned object shape:
|
|
453
|
+
* - `name`: resource alias
|
|
454
|
+
* - `buildId`: resolved build/run id
|
|
455
|
+
* - `definitionId`: build definition id (classic build definition id)
|
|
456
|
+
* - `buildNumber`: build number / run name
|
|
457
|
+
* - `teamProject`: resolved project name
|
|
458
|
+
* - `provider`: repo provider type (e.g., `TfsGit`)
|
|
459
|
+
*/
|
|
460
|
+
public async getPipelineResourcePipelinesFromObject(inPipeline: PipelineRun) {
|
|
461
|
+
const resourcePipelinesByKey: Map<string, any> = new Map();
|
|
462
|
+
|
|
463
|
+
if (!inPipeline?.resources?.pipelines) {
|
|
464
|
+
return new Set();
|
|
465
|
+
}
|
|
466
|
+
const pipelineEntries = Object.entries(inPipeline.resources.pipelines);
|
|
149
467
|
|
|
150
468
|
await Promise.all(
|
|
151
469
|
pipelineEntries.map(async ([resourcePipelineAlias, resource]) => {
|
|
152
|
-
const resourcePipelineObj = (resource as any)
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
470
|
+
const resourcePipelineObj = (resource as any)?.pipeline;
|
|
471
|
+
const pipelineIdCandidate = Number(resourcePipelineObj?.id);
|
|
472
|
+
const source = (resource as any)?.source;
|
|
473
|
+
const version = (resource as any)?.version;
|
|
474
|
+
const branch = (resource as any)?.branch;
|
|
475
|
+
|
|
476
|
+
const rawProjectName =
|
|
477
|
+
(resource as any)?.project?.name ||
|
|
478
|
+
this.tryGetTeamProjectFromAzureDevOpsUrl(resourcePipelineObj?.url) ||
|
|
479
|
+
this.tryGetTeamProjectFromAzureDevOpsUrl(inPipeline?.url);
|
|
480
|
+
const projectName = await this.normalizeProjectName(rawProjectName);
|
|
481
|
+
|
|
482
|
+
if (!Number.isFinite(pipelineIdCandidate)) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
let runId: number | undefined = this.inferRunIdFromPipelineResource(resource, resourcePipelineObj?.url);
|
|
487
|
+
if (typeof runId !== 'number' || !Number.isFinite(runId)) {
|
|
488
|
+
runId = await this.resolveRunIdFromVersion({
|
|
489
|
+
projectName,
|
|
490
|
+
pipelineIdCandidate,
|
|
491
|
+
version,
|
|
492
|
+
branch,
|
|
493
|
+
source,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (typeof runId !== 'number' || !Number.isFinite(runId)) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
157
501
|
let buildResponse: any;
|
|
158
502
|
try {
|
|
159
|
-
buildResponse = await
|
|
503
|
+
buildResponse = await this.tryGetBuildByIdWithFallback(projectName, runId);
|
|
160
504
|
} catch (err: any) {
|
|
161
|
-
logger.error(`Error fetching pipeline ${
|
|
505
|
+
logger.error(`Error fetching pipeline ${resourcePipelineAlias} run ${runId} : ${err.message}`);
|
|
162
506
|
}
|
|
163
|
-
if (
|
|
164
|
-
buildResponse
|
|
165
|
-
buildResponse.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
teamProject: buildResponse.project.name,
|
|
174
|
-
provider: buildResponse.repository.type,
|
|
507
|
+
if (this.isSupportedResourcePipelineBuild(buildResponse)) {
|
|
508
|
+
const definitionId = buildResponse.definition?.id ?? pipelineIdCandidate;
|
|
509
|
+
const buildId = buildResponse.id ?? runId;
|
|
510
|
+
const resourcePipelineToAdd = {
|
|
511
|
+
name: resourcePipelineAlias,
|
|
512
|
+
buildId,
|
|
513
|
+
definitionId,
|
|
514
|
+
buildNumber: buildResponse.buildNumber ?? (resource as any)?.runName ?? (resource as any)?.version,
|
|
515
|
+
teamProject: buildResponse.project?.name ?? projectName,
|
|
516
|
+
provider: buildResponse.repository?.type,
|
|
175
517
|
};
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
518
|
+
const key = `${resourcePipelineToAdd.teamProject}:${resourcePipelineToAdd.definitionId}:${resourcePipelineToAdd.buildId}:${resourcePipelineToAdd.name}`;
|
|
519
|
+
if (!resourcePipelinesByKey.has(key)) resourcePipelinesByKey.set(key, resourcePipelineToAdd);
|
|
179
520
|
}
|
|
180
521
|
})
|
|
181
522
|
);
|
|
182
|
-
|
|
183
|
-
return [...resourcePipelines];
|
|
523
|
+
return [...resourcePipelinesByKey.values()];
|
|
184
524
|
}
|
|
185
525
|
|
|
186
526
|
/**
|
|
@@ -193,19 +533,21 @@ export default class PipelinesDataProvider {
|
|
|
193
533
|
public async getPipelineResourceRepositoriesFromObject(
|
|
194
534
|
inPipeline: PipelineRun,
|
|
195
535
|
gitDataProviderInstance: GitDataProvider
|
|
196
|
-
) {
|
|
197
|
-
const
|
|
536
|
+
): Promise<ResourceRepository[]> {
|
|
537
|
+
const resourceRepositoriesById: Map<string, ResourceRepository> = new Map();
|
|
198
538
|
|
|
199
|
-
|
|
200
|
-
|
|
539
|
+
const repositories = inPipeline?.resources?.repositories;
|
|
540
|
+
if (!repositories) {
|
|
541
|
+
return [];
|
|
201
542
|
}
|
|
202
|
-
|
|
543
|
+
|
|
203
544
|
for (const prop in repositories) {
|
|
204
545
|
const resourceRepo = repositories[prop];
|
|
205
|
-
if (resourceRepo
|
|
546
|
+
if (resourceRepo?.repository?.type !== 'azureReposGit') {
|
|
206
547
|
continue;
|
|
207
548
|
}
|
|
208
549
|
const repoId = resourceRepo.repository.id;
|
|
550
|
+
if (!repoId) continue;
|
|
209
551
|
|
|
210
552
|
const repo: Repository = await gitDataProviderInstance.GetGitRepoFromRepoId(repoId);
|
|
211
553
|
const resourceRepository: ResourceRepository = {
|
|
@@ -213,11 +555,13 @@ export default class PipelinesDataProvider {
|
|
|
213
555
|
repoSha1: resourceRepo.version,
|
|
214
556
|
url: repo.url,
|
|
215
557
|
};
|
|
216
|
-
|
|
217
|
-
|
|
558
|
+
const key = String(repoId);
|
|
559
|
+
if (!resourceRepositoriesById.has(key)) {
|
|
560
|
+
resourceRepositoriesById.set(key, resourceRepository);
|
|
218
561
|
}
|
|
219
562
|
}
|
|
220
|
-
|
|
563
|
+
|
|
564
|
+
return [...resourceRepositoriesById.values()];
|
|
221
565
|
}
|
|
222
566
|
|
|
223
567
|
/**
|
|
@@ -325,7 +669,7 @@ export default class PipelinesDataProvider {
|
|
|
325
669
|
let { value } = res;
|
|
326
670
|
if (value) {
|
|
327
671
|
const successfulRunHistory = value.filter(
|
|
328
|
-
(run: any) => run.result !== 'failed'
|
|
672
|
+
(run: any) => run.result !== 'failed' && run.result !== 'canceled'
|
|
329
673
|
);
|
|
330
674
|
return { count: successfulRunHistory.length, value: successfulRunHistory };
|
|
331
675
|
}
|
|
@@ -828,14 +828,58 @@ export default class ResultDataProvider {
|
|
|
828
828
|
const fromComments = await this.tryFetchDiscussionFromComments(projectName, id);
|
|
829
829
|
if (fromComments !== null) {
|
|
830
830
|
const sorted = this.sortDiscussionEntries(fromComments);
|
|
831
|
-
this.
|
|
832
|
-
|
|
831
|
+
const normalized = this.normalizeDiscussionEntries(sorted);
|
|
832
|
+
this.workItemDiscussionCache.set(id, normalized);
|
|
833
|
+
return includeAllHistory ? normalized : normalized.slice(0, 1);
|
|
833
834
|
}
|
|
834
835
|
|
|
835
836
|
const fromUpdates = await this.tryFetchDiscussionFromUpdates(projectName, id);
|
|
836
837
|
const sorted = this.sortDiscussionEntries(fromUpdates ?? []);
|
|
837
|
-
this.
|
|
838
|
-
|
|
838
|
+
const normalized = this.normalizeDiscussionEntries(sorted);
|
|
839
|
+
this.workItemDiscussionCache.set(id, normalized);
|
|
840
|
+
return includeAllHistory ? normalized : normalized.slice(0, 1);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
private normalizeDiscussionEntries(entries: any[]): any[] {
|
|
844
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
845
|
+
const seen = new Set<string>();
|
|
846
|
+
const out: any[] = [];
|
|
847
|
+
|
|
848
|
+
for (const e of list) {
|
|
849
|
+
const createdDate = String(e?.createdDate ?? '').trim();
|
|
850
|
+
const createdBy = String(e?.createdBy ?? '').trim();
|
|
851
|
+
const textRaw = e?.text;
|
|
852
|
+
const text = typeof textRaw === 'string' ? textRaw.trim() : '';
|
|
853
|
+
|
|
854
|
+
if (!text) continue;
|
|
855
|
+
if (this.isSystemIdentity(createdBy)) continue;
|
|
856
|
+
|
|
857
|
+
const textForKey = this.stripHtmlForEmptiness(text);
|
|
858
|
+
if (!textForKey) continue;
|
|
859
|
+
|
|
860
|
+
const key = `${createdDate}|${createdBy}|${textForKey}`;
|
|
861
|
+
if (seen.has(key)) continue;
|
|
862
|
+
seen.add(key);
|
|
863
|
+
|
|
864
|
+
out.push({ createdDate, createdBy, text });
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return out;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
private stripHtmlForEmptiness(html: string): string {
|
|
871
|
+
return String(html ?? '')
|
|
872
|
+
.replace(/<[^>]*>/g, ' ')
|
|
873
|
+
.replace(/ /gi, ' ')
|
|
874
|
+
.replace(/&/gi, '&')
|
|
875
|
+
.replace(/\s+/g, ' ')
|
|
876
|
+
.trim();
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private isSystemIdentity(displayName: string): boolean {
|
|
880
|
+
const s = String(displayName ?? '').toLowerCase().trim();
|
|
881
|
+
if (!s) return false;
|
|
882
|
+
return s === 'microsoft.teamfoundation.system' || s.includes('microsoft.teamfoundation.system');
|
|
839
883
|
}
|
|
840
884
|
|
|
841
885
|
private sortDiscussionEntries(entries: any[]): any[] {
|
|
@@ -861,7 +905,8 @@ export default class ResultDataProvider {
|
|
|
861
905
|
const entries = comments
|
|
862
906
|
.filter((c) => !c?.isDeleted)
|
|
863
907
|
.map((c, idx) => {
|
|
864
|
-
const
|
|
908
|
+
const raw = c?.text ?? c?.renderedText ?? '';
|
|
909
|
+
const text = typeof raw === 'string' ? raw.trim() : '';
|
|
865
910
|
if (!text) return null;
|
|
866
911
|
const createdBy = c?.createdBy?.displayName ?? c?.createdBy?.uniqueName ?? '';
|
|
867
912
|
const createdDate = c?.createdDate ?? '';
|
|
@@ -885,7 +930,8 @@ export default class ResultDataProvider {
|
|
|
885
930
|
const entries = updates
|
|
886
931
|
.map((u, idx) => {
|
|
887
932
|
const historyChange = u?.fields?.['System.History'];
|
|
888
|
-
const
|
|
933
|
+
const raw = historyChange?.newValue ?? historyChange?.value ?? '';
|
|
934
|
+
const text = typeof raw === 'string' ? raw.trim() : '';
|
|
889
935
|
if (!text) return null;
|
|
890
936
|
const createdBy = u?.revisedBy?.displayName ?? u?.revisedBy?.uniqueName ?? '';
|
|
891
937
|
const createdDate = u?.revisedDate ?? '';
|