@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.
@@ -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.resources.repositories) {
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
- * Retrieves a set of pipeline resources from a given pipeline run object.
148
+ * Normalizes a project identifier into a project name suitable for `{project}` URL path segments.
117
149
  *
118
- * @param inPipeline - The pipeline run object containing resources.
119
- * @returns A promise that resolves to an array of unique pipeline resource objects.
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
- * The function performs the following steps:
122
- * 1. Initializes an empty set to store unique pipeline resources.
123
- * 2. Checks if the input pipeline has any resources of type pipelines.
124
- * 3. Iterates over each pipeline resource and processes it.
125
- * 4. Fixes the URL of the pipeline resource to match the build API format.
126
- * 5. Fetches the build details using the fixed URL.
127
- * 6. If the build response is valid and matches the criteria, adds the pipeline resource to the set.
128
- * 7. Returns an array of unique pipeline resources.
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
- * The returned pipeline resource object contains the following properties:
131
- * - name: The alias name of the resource pipeline.
132
- * - buildId: The ID of the resource pipeline.
133
- * - definitionId: The ID of the build definition.
134
- * - buildNumber: The build number.
135
- * - teamProject: The name of the team project.
136
- * - provider: The type of repository provider.
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
- * @throws Will log an error message if there is an issue fetching the pipeline resource.
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
- public async getPipelineResourcePipelinesFromObject(inPipeline: PipelineRun) {
141
- const resourcePipelines: Set<any> = new Set();
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
- if (!inPipeline.resources.pipelines) {
144
- return resourcePipelines;
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
- const pipelineEntries = Object.entries(pipelines);
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).pipeline;
153
- const resourcePipelineName = resourcePipelineAlias;
154
- let urlBeforeFix = resourcePipelineObj.url;
155
- urlBeforeFix = urlBeforeFix.substring(0, urlBeforeFix.indexOf('?revision'));
156
- const fixedUrl = urlBeforeFix.replace('/_apis/pipelines/', '/_apis/build/builds/');
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 TFSServices.getItemContent(fixedUrl, this.token, 'get');
503
+ buildResponse = await this.tryGetBuildByIdWithFallback(projectName, runId);
160
504
  } catch (err: any) {
161
- logger.error(`Error fetching pipeline ${resourcePipelineName} : ${err.message}`);
505
+ logger.error(`Error fetching pipeline ${resourcePipelineAlias} run ${runId} : ${err.message}`);
162
506
  }
163
- if (
164
- buildResponse &&
165
- buildResponse.definition.type === 'build' &&
166
- buildResponse.repository.type === 'TfsGit'
167
- ) {
168
- let resourcePipelineToAdd = {
169
- name: resourcePipelineName,
170
- buildId: resourcePipelineObj.id,
171
- definitionId: buildResponse.definition.id,
172
- buildNumber: buildResponse.buildNumber,
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
- if (!resourcePipelines.has(resourcePipelineToAdd)) {
177
- resourcePipelines.add(resourcePipelineToAdd);
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 resourceRepositories: Map<number, any> = new Map();
536
+ ): Promise<ResourceRepository[]> {
537
+ const resourceRepositoriesById: Map<string, ResourceRepository> = new Map();
198
538
 
199
- if (!inPipeline.resources.repositories) {
200
- return resourceRepositories;
539
+ const repositories = inPipeline?.resources?.repositories;
540
+ if (!repositories) {
541
+ return [];
201
542
  }
202
- const repositories = inPipeline.resources.repositories;
543
+
203
544
  for (const prop in repositories) {
204
545
  const resourceRepo = repositories[prop];
205
- if (resourceRepo.repository.type !== 'azureReposGit') {
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
- if (!resourceRepositories.has(Number(repoId))) {
217
- resourceRepositories.set(Number(repoId), resourceRepository);
558
+ const key = String(repoId);
559
+ if (!resourceRepositoriesById.has(key)) {
560
+ resourceRepositoriesById.set(key, resourceRepository);
218
561
  }
219
562
  }
220
- return [...resourceRepositories.values()];
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' || run.result !== 'canceled'
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.workItemDiscussionCache.set(id, sorted);
832
- return includeAllHistory ? sorted : sorted.slice(0, 1);
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.workItemDiscussionCache.set(id, sorted);
838
- return includeAllHistory ? sorted : sorted.slice(0, 1);
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(/&nbsp;/gi, ' ')
874
+ .replace(/&amp;/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 text = String(c?.text ?? c?.renderedText ?? '').trim();
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 text = String(historyChange?.newValue ?? historyChange?.value ?? '').trim();
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 ?? '';