Repository: openshift/sippy
Pull request #2426: trt-2058: Show Triaged Issues in TestDetails
Author: login: neisw
Labels: name: approved
description: Indicates a PR has been approved by an approver from all required OWNERS files.
name: lgtm
description: Indicates that a PR is ready to be merged.
name: jira/valid-reference
description: Indicates that this PR references a valid Jira ticket of any type.
Created: 2025-03-26T12:17:10Z | Merged: 2025-03-26T20:07:01Z

- Adds the Triaged Tests view used in the Regressed Tests Modal Dialog to Test Details when there is 1 or more associated Triage records
- Marks Triaged JobRuns with `T` instead of `F`

![image](https://github.com/user-attachments/assets/abb344a5-0817-496f-ab7f-b226dc840ee9)



Diff:
diff --git a/pkg/api/componentreadiness/test_details.go b/pkg/api/componentreadiness/test_details.go
index 40e6d49d12..07e3dd1426 100644
--- a/pkg/api/componentreadiness/test_details.go
+++ b/pkg/api/componentreadiness/test_details.go
@@ -351,6 +351,7 @@ func (c *ComponentReportGenerator) internalGenerateTestDetailsReport(ctx context
 		},
 	}
 	var resolvedIssueCompensation int
+	var incidents []crtype.TriagedIncident
 	approvedRegression := regressionallowances.IntentionalRegressionFor(c.ReqOptions.SampleRelease.Release, result.ColumnIdentification, c.ReqOptions.TestIDOption.TestID)
 	var baseRegression *regressionallowances.IntentionalRegression
 	activeProductRegression := false
@@ -361,13 +362,13 @@ func (c *ComponentReportGenerator) internalGenerateTestDetailsReport(ctx context
 	}
 	// ignore triage if we have an intentional regression
 	if approvedRegression == nil {
-		resolvedIssueCompensation, activeProductRegression, _ = c.triagedIncidentsFor(ctx, result.ReportTestIdentification)
+		resolvedIssueCompensation, activeProductRegression, incidents = c.triagedIncidentsFor(ctx, result.ReportTestIdentification)
 	}
 
 	var totalBaseFailure, totalBaseSuccess, totalBaseFlake, totalSampleFailure, totalSampleSuccess, totalSampleFlake int
 	var perJobBaseFailure, perJobBaseSuccess, perJobBaseFlake, perJobSampleFailure, perJobSampleSuccess, perJobSampleFlake int
 
-	report := crtype.TestDetailsAnalysis{}
+	report := crtype.TestDetailsAnalysis{TriagedIncidents: incidents}
 	for prowJob, baseStatsList := range baseStatus {
 		jobStats := crtype.TestDetailsJobStats{
 			JobName: prowJob,
diff --git a/pkg/apis/api/componentreport/types.go b/pkg/apis/api/componentreport/types.go
index 288a52000e..2cca4917dd 100644
--- a/pkg/apis/api/componentreport/types.go
+++ b/pkg/apis/api/componentreport/types.go
@@ -256,7 +256,8 @@ func (r ReportTestStats) IsTriaged() bool {
 // multiple different analyses run.
 type TestDetailsAnalysis struct {
 	ReportTestStats
-	JobStats []TestDetailsJobStats `json:"job_stats,omitempty"`
+	JobStats         []TestDetailsJobStats `json:"job_stats,omitempty"`
+	TriagedIncidents []TriagedIncident     `json:"incidents,omitempty"`
 }
 
 // ReportTestDetails is the top level API response for test details reports.
diff --git a/sippy-ng/src/component_readiness/CompReadyTestDetailRow.js b/sippy-ng/src/component_readiness/CompReadyTestDetailRow.js
index ac9ac6d2f0..25907c3682 100644
--- a/sippy-ng/src/component_readiness/CompReadyTestDetailRow.js
+++ b/sippy-ng/src/component_readiness/CompReadyTestDetailRow.js
@@ -24,7 +24,7 @@ export default function CompReadyTestDetailRow(props) {
   // element: a test detail element
   // idx: array index of test detail element
   // showOnlyFailures: says to focus on job failures
-  const { element, idx, showOnlyFailures } = props
+  const { element, idx, showOnlyFailures, triagedURLs } = props
 
   const infoCell = (stats) => {
     return (
@@ -89,7 +89,11 @@ export default function CompReadyTestDetailRow(props) {
                     }}
                   >
                     <Typography className={classes.crCellName}>
-                      {jobRun.test_stats.failure_count > 0 ? 'F' : 'S'}
+                      {jobRun.test_stats.failure_count > 0
+                        ? triagedURLs[jobRun.job_url] !== undefined
+                          ? 'T'
+                          : 'F'
+                        : 'S'}
                     </Typography>
                   </a>
                 )
@@ -129,4 +133,5 @@ CompReadyTestDetailRow.propTypes = {
   element: PropTypes.object.isRequired,
   idx: PropTypes.number.isRequired,
   showOnlyFailures: PropTypes.bool.isRequired,
+  triagedURLs: PropTypes.array.isRequired,
 }
diff --git a/sippy-ng/src/component_readiness/CompReadyTestPanel.js b/sippy-ng/src/component_readiness/CompReadyTestPanel.js
index f73d91a8a3..c295795f2e 100644
--- a/sippy-ng/src/component_readiness/CompReadyTestPanel.js
+++ b/sippy-ng/src/component_readiness/CompReadyTestPanel.js
@@ -167,6 +167,17 @@ export default function CompReadyTestPanel(props) {
     return null
   }
 
+  let urls = []
+  if (data?.incidents?.length) {
+    data.incidents.forEach((incident) => {
+      incident?.job_runs?.forEach((job_run) => {
+        if (job_run.url !== undefined) {
+          urls[job_run.url] = true
+        }
+      })
+    })
+  }
+
   return (
     <Fragment>
       <Grid container spacing={2} style={{ marginTop: '10px' }}>
@@ -242,6 +253,7 @@ export default function CompReadyTestPanel(props) {
                       element={element}
                       idx={idx}
                       showOnlyFailures={showOnlyFailures}
+                      triagedURLs={urls}
                     ></CompReadyTestDetailRow>
                   )
                 })
diff --git a/sippy-ng/src/component_readiness/CompReadyTestReport.js b/sippy-ng/src/component_readiness/CompReadyTestReport.js
index da0f01046d..6bd6ea8a46 100644
--- a/sippy-ng/src/component_readiness/CompReadyTestReport.js
+++ b/sippy-ng/src/component_readiness/CompReadyTestReport.js
@@ -17,6 +17,7 @@ import {
   gotFetchError,
   makePageTitle,
   makeRFC3339Time,
+  mergeIncidents,
   noDataTable,
 } from './CompReadyUtils'
 import { ComponentReadinessStyleContext } from './ComponentReadiness'
@@ -43,6 +44,7 @@ import Table from '@mui/material/Table'
 import TableBody from '@mui/material/TableBody'
 import TableCell from '@mui/material/TableCell'
 import TableRow from '@mui/material/TableRow'
+import TriagedIncidentsPanel from './TriagedIncidentsPanel'
 
 // Big query requests take a while so give the user the option to
 // abort in case they inadvertently requested a huge dataset.
@@ -234,16 +236,12 @@ export default function CompReadyTestReport(props) {
   }
 
   const columnNames = getColumns(data)
-  if (columnNames[0] === 'Cancelled' || columnNames[0] == 'None') {
+  if (columnNames[0] === 'Cancelled' || columnNames[0] === 'None') {
     return (
       <CompReadyCancelled message={columnNames[0]} apiCallStr={apiCallStr} />
     )
   }
 
-  const handleFailuresOnlyChange = (event) => {
-    setShowOnlyFailures(event.target.checked)
-  }
-
   const [statusStr, assessmentIcon] = getStatusAndIcon(
     data.analyses[0].status,
     0,
@@ -398,6 +396,18 @@ View the [test details report|${document.location.href}] for additional context.
         </Grid>
       </Grid>
 
+      {data.analyses[0].incidents && data.analyses[0].incidents.length > 0 ? (
+        <Fragment>
+          <h2>Triaged Tests</h2>
+          <TriagedIncidentsPanel
+            triagedIncidents={mergeIncidents(data.analyses[0].incidents, data)}
+          />
+        </Fragment>
+      ) : (
+        // no incidents
+        <Fragment />
+      )}
+
       <h2>Regression Report</h2>
 
       <Table>
diff --git a/sippy-ng/src/component_readiness/CompReadyUtils.js b/sippy-ng/src/component_readiness/CompReadyUtils.js
index 4889551930..1a070060a2 100644
--- a/sippy-ng/src/component_readiness/CompReadyUtils.js
+++ b/sippy-ng/src/component_readiness/CompReadyUtils.js
@@ -490,7 +490,7 @@ export function validateData(data) {
     )
     return ['No data']
   }
-  if (data.rows[0].component == 'None' || !data.rows[0].columns) {
+  if (data.rows[0].component === 'None' || !data.rows[0].columns) {
     return ['No data']
   }
   if (data.rows[0].component === 'Cancelled') {
@@ -501,6 +501,50 @@ export function validateData(data) {
   return ['']
 }
 
+// groupIncidentIncidents expects data from CR api call and passes the
+// incident as the details portion for the grouped incident
+export function groupIncidentIncidents(incidents, groupedIncidents) {
+  incidents.forEach((incident) => {
+    if (incident.incidents && incident.incidents.length > 0) {
+      groupIncidents(incident.incidents, groupedIncidents, incident)
+    }
+  })
+}
+
+// groupIncidents uses the incident_group_id to create a logical grouping of triaged incidents
+export function groupIncidents(incidents, groupedIncidents, detailsIncident) {
+  incidents.forEach((i) => {
+    if (!groupedIncidents.has(i.incident_group_id)) {
+      groupedIncidents.set(i.incident_group_id, {
+        issue: i.issue,
+        incidents: [],
+      })
+    }
+    let incidentsGroup = groupedIncidents.get(i.incident_group_id)
+    i.details = detailsIncident
+    incidentsGroup.incidents = incidentsGroup.incidents.concat(i)
+  })
+}
+
+// createGroupedIncidentArray converts the map into an array of grouped incidents
+export function createGroupedIncidentArray(groupedIncidents) {
+  return Array.from(groupedIncidents, ([group_id, grouped_incidents]) => ({
+    group_id,
+    grouped_incidents,
+  }))
+}
+
+// mergeIncidents expects a data object from TestDetails and passes it as
+// the details portion of the grouped incident
+export function mergeIncidents(incidents, detailsIncident) {
+  let groupedIncidents = new Map()
+  groupIncidents(incidents, groupedIncidents, detailsIncident)
+  return createGroupedIncidentArray(groupedIncidents)
+}
+
+// mergeRegressionData takes the data from CR api and organizes the data
+// for groupedIncidents, regressedTests (untriaged) and combined list of untriaged and triaged tests
+// all used in the RegressedTestsModal dialog
 export function mergeRegressionData(data) {
   let ret = validateData(data)
   if (ret[0] !== '') {
@@ -521,21 +565,7 @@ export function mergeRegressionData(data) {
       if (column.triaged_incidents && column.triaged_incidents.length > 0) {
         allRegressions = allRegressions.concat(column.triaged_incidents)
 
-        column.triaged_incidents.forEach((incident) => {
-          if (incident.incidents && incident.incidents.length > 0) {
-            incident.incidents.forEach((i) => {
-              if (!groupedIncidents.has(i.incident_group_id)) {
-                groupedIncidents.set(i.incident_group_id, {
-                  issue: i.issue,
-                  incidents: [],
-                })
-              }
-              let incidentsGroup = groupedIncidents.get(i.incident_group_id)
-              i.details = incident
-              incidentsGroup.incidents = incidentsGroup.incidents.concat(i)
-            })
-          }
-        })
+        groupIncidentIncidents(column.triaged_incidents, groupedIncidents)
       }
     })
   })
@@ -556,14 +586,11 @@ export function mergeRegressionData(data) {
   })
   allRegressions = allRegressions.map((item, index) => ({ ...item, id: index }))
 
-  let arrayOfIncidents = Array.from(
-    groupedIncidents,
-    ([group_id, grouped_incidents]) => ({
-      group_id,
-      grouped_incidents,
-    })
-  )
-  return [regressedTests, allRegressions, arrayOfIncidents]
+  return [
+    regressedTests,
+    allRegressions,
+    createGroupedIncidentArray(groupedIncidents),
+  ]
 }
 
 export const Search = styled('div')(({ theme }) => ({
diff --git a/sippy-ng/src/component_readiness/RegressedTestsModal.js b/sippy-ng/src/component_readiness/RegressedTestsModal.js
index bd29b2c0e4..4b38892806 100644
--- a/sippy-ng/src/component_readiness/RegressedTestsModal.js
+++ b/sippy-ng/src/component_readiness/RegressedTestsModal.js
@@ -61,7 +61,7 @@ export default function RegressedTestsModal(props) {
           >
             <Tab label="Untriaged Regressions" {...tabProps(0)} />
             <Tab label="Regressed Tests" {...tabProps(1)} />
-            <Tab label="Triaged Incidents" {...tabProps(2)} />
+            <Tab label="Triaged Tests" {...tabProps(2)} />
           </Tabs>
           <RegressedTestsTabPanel activeIndex={activeTabIndex} index={0}>
             <RegressedTestsPanel
diff --git a/sippy-ng/src/component_readiness/TriagedTestJobRuns.js b/sippy-ng/src/component_readiness/TriagedTestJobRuns.js
index bebcad1ff6..abf4bced42 100644
--- a/sippy-ng/src/component_readiness/TriagedTestJobRuns.js
+++ b/sippy-ng/src/component_readiness/TriagedTestJobRuns.js
@@ -55,5 +55,5 @@ export default function TriagedTestJobRuns(props) {
 }
 
 TriagedTestJobRuns.propTypes = {
-  jobRuns: PropTypes.object.isRequired,
+  jobRuns: PropTypes.array.isRequired,
 }


Reviews:
user: login: dgoodwin
body: Otherwise looks great and a fantastic idea. 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T17:02:44Z
user: login: neisw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T17:38:37Z

Review comments:
diff_hunk: @@ -167,6 +167,21 @@ export default function CompReadyTestPanel(props) {
     return null
   }
 
+  let urls = []
+  if (data && data.incidents && data.incidents.length > 0) {
+    for (let i = 0; i < data.incidents.length; i++) {
+      let incident = data.incidents[i]
+      if (incident && incident.job_runs && incident.job_runs.length > 0) {
+        for (let z = 0; z < incident.job_runs.length; z++) {
+          let job_run = incident.job_runs[z]
+          if (job_run.url !== undefined && urls[job_run.url] === undefined) {
+            urls[job_run.url] = true
+          }
+        }
+      }
+    }
+  }
+
path: sippy-ng/src/component_readiness/CompReadyTestPanel.js
user: login: dgoodwin
body: ChatGPT special here but I was curious, does this code work? If so we should all educate ourselves on some of this syntax and start using it more, the optional chaining instead is really neat if it's not giving me lies.

```
const urls = new Set();

if (data?.incidents?.length) {
  data.incidents.forEach(incident => {
    incident?.job_runs?.forEach(job_run => {
      if (job_run.url !== undefined) {
        urls.add(job_run.url);
      }
    });
  });
}
```
created_at: 2025-03-26T16:59:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -501,6 +501,41 @@ export function validateData(data) {
   return ['']
 }
 
+export function groupIncidentIncidents(incidents, groupedIncidents) {
+  incidents.forEach((incident) => {
+    if (incident.incidents && incident.incidents.length > 0) {
+      groupIncidents(incident.incidents, groupedIncidents, incident)
+    }
+  })
+}
+
+export function groupIncidents(incidents, groupedIncidents, detailsIncident) {
+  incidents.forEach((i) => {
+    if (!groupedIncidents.has(i.incident_group_id)) {
+      groupedIncidents.set(i.incident_group_id, {
+        issue: i.issue,
+        incidents: [],
+      })
+    }
+    let incidentsGroup = groupedIncidents.get(i.incident_group_id)
+    i.details = detailsIncident
+    incidentsGroup.incidents = incidentsGroup.incidents.concat(i)
+  })
+}
+
+export function createGroupedIncidentArray(groupedIncidents) {
+  return Array.from(groupedIncidents, ([group_id, grouped_incidents]) => ({
+    group_id,
+    grouped_incidents,
+  }))
+}
+
+export function mergeIncidents(incidents, detailsIncident) {
+  let groupedIncidents = new Map()
+  groupIncidents(incidents, groupedIncidents, detailsIncident)
+  return createGroupedIncidentArray(groupedIncidents)
+}
path: sippy-ng/src/component_readiness/CompReadyUtils.js
user: login: dgoodwin
body: I could use some comments on these functions, a little hard to put the picture together of what's going on.
created_at: 2025-03-26T17:01:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -167,6 +167,21 @@ export default function CompReadyTestPanel(props) {
     return null
   }
 
+  let urls = []
+  if (data && data.incidents && data.incidents.length > 0) {
+    for (let i = 0; i < data.incidents.length; i++) {
+      let incident = data.incidents[i]
+      if (incident && incident.job_runs && incident.job_runs.length > 0) {
+        for (let z = 0; z < incident.job_runs.length; z++) {
+          let job_run = incident.job_runs[z]
+          if (job_run.url !== undefined && urls[job_run.url] === undefined) {
+            urls[job_run.url] = true
+          }
+        }
+      }
+    }
+  }
+
path: sippy-ng/src/component_readiness/CompReadyTestPanel.js
user: login: neisw
body: Heh, kindof knew I was going to get dinged on that.  Tripped me up when I first tried it so I fell back to old school for loops.  Should have gone to ChatGPT, Thanks! 
created_at: 2025-03-26T17:38:37Z
author_association: CONTRIBUTOR

PR comments:
user: login: openshift-ci-robot
created_at: 2025-03-26T12:17:14Z
body: @neisw: This pull request references [trt-2058](https://issues.redhat.com//browse/trt-2058) which is a valid jira issue.

Warning: The referenced jira issue has an invalid target version for the target branch this PR targets: expected the story to target the "4.19.0" version, but no target version was set.

<details>

In response to [this](https://github.com/openshift/sippy/pull/2426):

>- Adds the Triaged Tests view used in the Regressed Tests Modal Dialog to Test Details when there is 1 or more associated Triage records
>- Marks Triaged JobRuns with `T` instead of `F`
>
>![image](https://github.com/user-attachments/assets/abb344a5-0817-496f-ab7f-b226dc840ee9)
>


Instructions for interacting with me using PR comments are available [here](https://prow.ci.openshift.org/command-help?repo=openshift%2Fsippy).  If you have questions or suggestions related to my behavior, please file an issue against the [openshift-eng/jira-lifecycle-plugin](https://github.com/openshift-eng/jira-lifecycle-plugin/issues/new) repository.
</details>
author_association: NONE
user: login: neisw
created_at: 2025-03-26T12:34:14Z
body: /retest-required
author_association: CONTRIBUTOR
user: login: neisw
created_at: 2025-03-26T13:09:11Z
body: /retest-required
author_association: CONTRIBUTOR
user: login: neisw
created_at: 2025-03-26T14:37:18Z
body: /test e2e
author_association: CONTRIBUTOR
user: login: neisw
created_at: 2025-03-26T19:19:43Z
body: /retest-required
author_association: CONTRIBUTOR
user: login: openshift-ci[bot]
created_at: 2025-03-26T19:20:35Z
body: @neisw: all tests **passed!**


[Full PR test history](https://prow.ci.openshift.org/pr-history?org=openshift&repo=sippy&pr=2426). [Your PR dashboard](https://prow.ci.openshift.org/pr?query=is:pr+state:open+author:neisw).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
author_association: CONTRIBUTOR
user: login: dgoodwin
created_at: 2025-03-26T20:00:22Z
body: /lgtm
author_association: CONTRIBUTOR
user: login: openshift-ci[bot]
created_at: 2025-03-26T20:02:33Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/openshift/sippy/pull/2426#issuecomment-2755619255" title="LGTM">dgoodwin</a>*, *<a href="https://github.com/openshift/sippy/pull/2426#" title="Author self-approved">neisw</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=openshift%2Fsippy).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/openshift/sippy/blob/main/OWNERS)~~ [dgoodwin,neisw]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR