diff --git a/treeherder/webapp/api/push.py b/treeherder/webapp/api/push.py index 2a780282602..b8ecebb5443 100644 --- a/treeherder/webapp/api/push.py +++ b/treeherder/webapp/api/push.py @@ -28,6 +28,11 @@ logger = logging.getLogger(__name__) +def count_unique_test_failures(failures): + """Count unique test names regardless of platform/config.""" + return len(set(f["testName"] for f in failures)) + + class PushViewSet(viewsets.ViewSet): """ View for ``push`` records @@ -297,7 +302,9 @@ def health_summary(self, request, project): push ) - test_failure_count = len(push_health_test_failures["needInvestigation"]) + test_failure_count = count_unique_test_failures( + push_health_test_failures["needInvestigation"] + ) build_failure_count = len(push_health_build_failures) lint_failure_count = len(push_health_lint_failures) test_in_progress_count = 0 @@ -400,7 +407,7 @@ def health(self, request, project): status = push.get_status() total_failures = ( - len(push_health_test_failures["needInvestigation"]) + count_unique_test_failures(push_health_test_failures["needInvestigation"]) + len(build_failures) + len(lint_failures) ) @@ -413,7 +420,9 @@ def health(self, request, project): { "revision": revision, "repo": repository.name, - "needInvestigation": len(push_health_test_failures["needInvestigation"]), + "needInvestigation": count_unique_test_failures( + push_health_test_failures["needInvestigation"] + ), "author": push.author, }, ) diff --git a/ui/push-health/Action.jsx b/ui/push-health/Action.jsx index 7f15c01b753..b465551862f 100644 --- a/ui/push-health/Action.jsx +++ b/ui/push-health/Action.jsx @@ -46,6 +46,7 @@ class Action extends PureComponent { investigateTest, unInvestigateTest, updatePushHealth, + decisionTaskMap, } = this.props; const groupedTests = this.getTestGroups(tests); @@ -69,6 +70,7 @@ class Action extends PureComponent { investigateTest={investigateTest} unInvestigateTest={unInvestigateTest} updatePushHealth={updatePushHealth} + decisionTaskMap={decisionTaskMap} /> ))} @@ -85,6 +87,11 @@ Action.propTypes = { revision: PropTypes.string.isRequired, currentRepo: PropTypes.shape({}).isRequired, notify: PropTypes.func.isRequired, + decisionTaskMap: PropTypes.shape({}), +}; + +Action.defaultProps = { + decisionTaskMap: {}, }; export default Action; diff --git a/ui/push-health/ClassificationGroup.jsx b/ui/push-health/ClassificationGroup.jsx index 4223937c04f..624cdc4e93b 100644 --- a/ui/push-health/ClassificationGroup.jsx +++ b/ui/push-health/ClassificationGroup.jsx @@ -4,19 +4,18 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCaretDown, faCaretRight, - faRedo, + faCheck, } from '@fortawesome/free-solid-svg-icons'; import { Row, Collapse, - ButtonGroup, DropdownButton, Button, Dropdown, } from 'react-bootstrap'; import groupBy from 'lodash/groupBy'; -import JobModel from '../models/job'; +import { confirmFailure, canConfirmFailure } from '../helpers/job'; import Action from './Action'; @@ -40,19 +39,27 @@ class ClassificationGroup extends React.PureComponent { })); }; - retriggerAll = (times) => { - const { tests, notify, currentRepo, jobs } = this.props; - // Reduce down to the unique jobs + confirmFailureAll = () => { + const { tests, notify, currentRepo, jobs, decisionTaskMap } = this.props; + // Reduce down to the unique jobs that can have confirm-failure run const testJobs = tests.reduce( (acc, test) => ({ ...acc, - ...jobs[test.jobName].reduce((fjAcc, job) => ({ [job.id]: job }), {}), + ...jobs[test.jobName].reduce((fjAcc, job) => { + if (canConfirmFailure(job)) { + return { ...fjAcc, [job.id]: job }; + } + return fjAcc; + }, {}), }), {}, ); const uniqueJobs = Object.values(testJobs); - JobModel.retrigger(uniqueJobs, currentRepo, notify, times); + // Call confirmFailure for each unique job + uniqueJobs.forEach((job) => { + confirmFailure(job, notify, decisionTaskMap, currentRepo); + }); }; getTestsByAction = (tests) => { @@ -98,12 +105,15 @@ class ClassificationGroup extends React.PureComponent { investigateTest, unInvestigateTest, updatePushHealth, + decisionTaskMap, } = this.props; const expandIcon = detailsShowing ? faCaretDown : faCaretRight; const expandTitle = detailsShowing ? 'Click to collapse' : 'Click to expand'; - const groupLength = Object.keys(tests).length; + // Count unique test cases (by testName), regardless of platform/config + const uniqueTestNames = new Set(tests.map((test) => test.testName)); + const groupLength = uniqueTestNames.size; const testsByAction = this.getTestsByAction(tests); return ( @@ -132,43 +142,20 @@ class ClassificationGroup extends React.PureComponent { {hasRetriggerAll && groupLength > 0 && detailsShowing && (
- - - - - - {[5, 10, 15].map((times) => ( - this.retriggerAll(times)} - className="pointable" - tag="a" - > - Retrigger all {times} times - - ))} - - - + ))}
@@ -269,6 +257,7 @@ ClassificationGroup.propTypes = { groupedBy: PropTypes.string, setOrderedBy: PropTypes.func, setGroupedBy: PropTypes.func, + decisionTaskMap: PropTypes.shape({}), }; ClassificationGroup.defaultProps = { @@ -280,6 +269,7 @@ ClassificationGroup.defaultProps = { groupedBy: 'path', setOrderedBy: () => {}, setGroupedBy: () => {}, + decisionTaskMap: {}, }; export default ClassificationGroup; diff --git a/ui/push-health/Health.jsx b/ui/push-health/Health.jsx index 03ba7b0701a..2df3362d556 100644 --- a/ui/push-health/Health.jsx +++ b/ui/push-health/Health.jsx @@ -49,6 +49,7 @@ export default class Health extends React.PureComponent { regressionsGroupBy: params.get('regressionsGroupBy') || 'path', showIntermittentAlert: localStorage.getItem('dismissedIntermittentAlert') !== 'true', + decisionTaskMap: {}, }; } @@ -104,6 +105,7 @@ export default class Health extends React.PureComponent { updatePushHealth = async () => { const { repo, revision, status } = this.state; + const { notify } = this.props; if (status) { const { running, pending, completed } = status; @@ -117,6 +119,19 @@ export default class Health extends React.PureComponent { const { data, failureStatus } = await PushModel.getHealth(repo, revision); const newState = !failureStatus ? data : { failureMessage: data }; + // Fetch decision task map if we have a push ID + if (data && data.id) { + try { + const decisionTaskMap = await PushModel.getDecisionTaskMap( + [data.id], + notify, + ); + newState.decisionTaskMap = decisionTaskMap; + } catch { + // Decision task map fetch failed, but we can still show the health data + } + } + this.setState(newState); return newState; }; @@ -177,6 +192,7 @@ export default class Health extends React.PureComponent { regressionsOrderBy, regressionsGroupBy, showIntermittentAlert, + decisionTaskMap, } = this.state; const { tests, commitHistory, linting, builds } = metrics; @@ -320,6 +336,7 @@ export default class Health extends React.PureComponent { investigateTest={this.investigateTest} unInvestigateTest={this.unInvestigateTest} updatePushHealth={this.updatePushHealth} + decisionTaskMap={decisionTaskMap} /> diff --git a/ui/push-health/PlatformConfig.jsx b/ui/push-health/PlatformConfig.jsx index c43b7dfc750..e187d8e0c9b 100644 --- a/ui/push-health/PlatformConfig.jsx +++ b/ui/push-health/PlatformConfig.jsx @@ -2,11 +2,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Button, Row, Col } from 'react-bootstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faRedo } from '@fortawesome/free-solid-svg-icons'; +import { faCheck } from '@fortawesome/free-solid-svg-icons'; import sortBy from 'lodash/sortBy'; -import JobModel from '../models/job'; -import { addAggregateFields } from '../helpers/job'; +import { + addAggregateFields, + confirmFailure, + canConfirmFailure, +} from '../helpers/job'; import { shortDateFormat } from '../helpers/display'; import SimpleTooltip from '../shared/SimpleTooltip'; @@ -61,10 +64,12 @@ class PlatformConfig extends React.PureComponent { } }; - retriggerTask = async (task) => { - const { notify, currentRepo } = this.props; + confirmFailureTask = async (task) => { + const { notify, currentRepo, decisionTaskMap } = this.props; - JobModel.retrigger([task], currentRepo, notify); + if (canConfirmFailure(task)) { + confirmFailure(task, notify, decisionTaskMap, currentRepo); + } }; render() { @@ -121,13 +126,13 @@ class PlatformConfig extends React.PureComponent { ); })} @@ -151,10 +156,12 @@ PlatformConfig.propTypes = { currentRepo: PropTypes.shape({}).isRequired, notify: PropTypes.func.isRequired, updateParamsAndState: PropTypes.func.isRequired, + decisionTaskMap: PropTypes.shape({}), }; PlatformConfig.defaultProps = { testName: '', + decisionTaskMap: {}, }; export default PlatformConfig; diff --git a/ui/push-health/Test.jsx b/ui/push-health/Test.jsx index de4b448298c..50e3268f8e6 100644 --- a/ui/push-health/Test.jsx +++ b/ui/push-health/Test.jsx @@ -1,25 +1,17 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { - Button, - Collapse, - Nav, - Navbar, - ButtonGroup, - Dropdown, - Form, -} from 'react-bootstrap'; +import { Button, Collapse, Nav, Navbar, Form } from 'react-bootstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCaretDown, faCaretRight, - faRedo, + faCheck, } from '@fortawesome/free-solid-svg-icons'; import { create, destroy } from '../helpers/http'; import { getProjectUrl } from '../helpers/location'; import { investigatedTestsEndPoint } from '../helpers/url'; -import JobModel from '../models/job'; +import { confirmFailure, canConfirmFailure } from '../helpers/job'; import Clipboard from '../shared/Clipboard'; import PlatformConfig from './PlatformConfig'; @@ -59,23 +51,31 @@ class Test extends PureComponent { }); }; - retriggerSelected = (times) => { - const { notify, currentRepo, jobs } = this.props; + confirmFailureSelected = () => { + const { notify, currentRepo, jobs, decisionTaskMap } = this.props; const { selectedTests } = this.state; - // Reduce down to the unique jobs + // Reduce down to the unique jobs that can have confirm-failure run const testJobs = Array.from(selectedTests) .filter((test) => test.isInvestigated) .reduce( (acc, test) => ({ ...acc, - ...jobs[test.jobName].reduce((_, job) => ({ [job.id]: job }), {}), + ...jobs[test.jobName].reduce((fjAcc, job) => { + if (canConfirmFailure(job)) { + return { ...fjAcc, [job.id]: job }; + } + return fjAcc; + }, {}), }), {}, ); const uniqueJobs = Object.values(testJobs); - JobModel.retrigger(uniqueJobs, currentRepo, notify, times); + // Call confirmFailure for each unique job + uniqueJobs.forEach((job) => { + confirmFailure(job, notify, decisionTaskMap, currentRepo); + }); }; markAsInvestigated = async () => { @@ -218,6 +218,7 @@ class Test extends PureComponent { selectedJobName, selectedTaskId, updateParamsAndState, + decisionTaskMap, } = this.props; const { clipboardVisible, @@ -267,42 +268,21 @@ class Test extends PureComponent {