diff --git a/treeherder/config/settings.py b/treeherder/config/settings.py index bdb86b79bf7..de39c2e966b 100644 --- a/treeherder/config/settings.py +++ b/treeherder/config/settings.py @@ -448,9 +448,8 @@ MAX_ERROR_LINES = 40 FAILURE_LINES_CUTOFF = 150 -# Required number of occurrences in a given time window before being prompted to file a bug in Bugzilla +# Count internal issue annotations in a limited time window (before prompting user to file a bug in Bugzilla) INTERNAL_OCCURRENCES_DAYS_WINDOW = 7 -INTERNAL_OCCURRENCES_COUNT = 3 # Perfherder # Default minimum regression threshold for perfherder is 2% (otherwise diff --git a/treeherder/model/models.py b/treeherder/model/models.py index 49453ee7b03..ab5f46f6b0a 100644 --- a/treeherder/model/models.py +++ b/treeherder/model/models.py @@ -261,7 +261,7 @@ def serialize(self): attrs["occurrences"] = None if attrs["id"] is None: # Only fetch occurrences for internal issues. It causes one extra query - attrs["occurrences"] = BugJobMap.objects.filter( + attrs["occurrences"] = self.jobmap.filter( created__gte=timezone.now() - datetime.timedelta(days=settings.INTERNAL_OCCURRENCES_DAYS_WINDOW) ).count() diff --git a/treeherder/webapp/api/internal_issue.py b/treeherder/webapp/api/internal_issue.py new file mode 100644 index 00000000000..33cb63d6701 --- /dev/null +++ b/treeherder/webapp/api/internal_issue.py @@ -0,0 +1,14 @@ +from rest_framework import generics + +from treeherder.webapp.api.serializers import InternalIssueSerializer + + +class CreateInternalIssue(generics.CreateAPIView): + """ + Create a Bugscache entry, not necessarilly linked to a real Bugzilla ticket. + In case it already exists, update its occurrences. + + Returns the number of occurrences of this bug in the last week. + """ + + serializer_class = InternalIssueSerializer diff --git a/treeherder/webapp/api/serializers.py b/treeherder/webapp/api/serializers.py index 30ef154e67b..b04f62202ff 100644 --- a/treeherder/webapp/api/serializers.py +++ b/treeherder/webapp/api/serializers.py @@ -2,6 +2,7 @@ from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist +from django.utils import timezone from rest_framework import serializers from treeherder.changelog.models import Changelog @@ -446,3 +447,22 @@ class InvestigatedTestsSerializers(serializers.ModelSerializer): class Meta: model = models.InvestigatedTests fields = ("id", "test", "job_name", "job_symbol") + + +class InternalIssueSerializer(serializers.ModelSerializer): + internal_id = serializers.IntegerField(source="id", read_only=True) + + class Meta: + model = models.Bugscache + fields = ("internal_id", "summary") + + def create(self, validated_data): + """Build or retrieve a bug already reported for a similar FailureLine""" + try: + bug, _ = models.Bugscache.objects.get_or_create( + **validated_data, defaults={"modified": timezone.now()} + ) + except models.Bugscache.MultipleObjectsReturned: + # Take last modified in case a conflict happens + bug = models.Bugscache.objects.filter(**validated_data).order_by("modified").first() + return bug diff --git a/treeherder/webapp/api/urls.py b/treeherder/webapp/api/urls.py index f9e596c61b1..1172be69435 100644 --- a/treeherder/webapp/api/urls.py +++ b/treeherder/webapp/api/urls.py @@ -13,6 +13,7 @@ groups, infra_compare, intermittents_view, + internal_issue, investigated_test, job_log_url, jobs, @@ -175,4 +176,7 @@ ), re_path(r"^csp-report/$", csp_report.csp_report_collector, name="csp-report"), re_path(r"^schema/", get_schema_view(title="Treeherder Rest API"), name="openapi-schema"), + re_path( + r"^internal_issue/", internal_issue.CreateInternalIssue.as_view(), name="internal_issue" + ), ] diff --git a/ui/helpers/bug.js b/ui/helpers/bug.js new file mode 100644 index 00000000000..e18308f7d61 --- /dev/null +++ b/ui/helpers/bug.js @@ -0,0 +1,76 @@ +export const omittedLeads = [ + 'TEST-UNEXPECTED-FAIL', + 'PROCESS-CRASH', + 'TEST-UNEXPECTED-ERROR', + 'REFTEST ERROR', +]; +/* + * Find the first thing in the summary line that looks like a filename. + */ +const findFilename = (summary) => { + // Take left side of any reftest comparisons, as the right side is the reference file + // eslint-disable-next-line prefer-destructuring + summary = summary.split('==')[0]; + // Take the leaf node of unix paths + summary = summary.split('/').pop(); + // Take the leaf node of Windows paths + summary = summary.split('\\').pop(); + // Remove leading/trailing whitespace + summary = summary.trim(); + // If there's a space in what's remaining, take the first word + // eslint-disable-next-line prefer-destructuring + summary = summary.split(' ')[0]; + return summary; +}; +/* + * Remove extraneous junk from the start of the summary line + * and try to find the failing test name from what's left + */ +export const parseSummary = (suggestion) => { + let summary = suggestion.search; + const searchTerms = suggestion.search_terms; + // Strip out some extra stuff at the start of some failure paths + let re = /file:\/\/\/.*?\/build\/tests\/reftest\/tests\//gi; + summary = summary.replace(re, ''); + re = /chrome:\/\/mochitests\/content\/a11y\//gi; + summary = summary.replace(re, ''); + re = /http:\/\/([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+):([0-9]+)\/tests\//gi; + summary = summary.replace(re, ''); + re = /xpcshell([-a-zA-Z0-9]+)?.(ini|toml):/gi; + summary = summary.replace(re, ''); + summary = summary.replace('/_mozilla/', 'mozilla/tests/'); + // We don't want to include "REFTEST" when it's an unexpected pass + summary = summary.replace( + 'REFTEST TEST-UNEXPECTED-PASS', + 'TEST-UNEXPECTED-PASS', + ); + const summaryParts = summary.split(' | '); + + // If the search_terms used for finding bug suggestions + // contains any of the omittedLeads, that lead is needed + // for the full string match, so don't omit it in this case. + // If it's not needed, go ahead and omit it. + if (searchTerms.length && summaryParts.length > 1) { + omittedLeads.forEach((lead) => { + if (!searchTerms[0].includes(lead) && summaryParts[0].includes(lead)) { + summaryParts.shift(); + } + }); + } + + // Some of the TEST-FOO bits aren't removed from the summary, + // so we sometimes end up with them instead of the test path here. + const summaryName = + summaryParts[0].startsWith('TEST-') && summaryParts.length > 1 + ? summaryParts[1] + : summaryParts[0]; + const possibleFilename = findFilename(summaryName); + + return [summaryParts, possibleFilename]; +}; + +export const getCrashSignatures = (failureLine) => { + const crashRegex = /(\[@ .+\])/g; + const crashSignatures = failureLine.search.match(crashRegex); + return crashSignatures ? [crashSignatures[0]] : []; +}; diff --git a/ui/helpers/constants.js b/ui/helpers/constants.js index 0261c90b745..00954de549f 100644 --- a/ui/helpers/constants.js +++ b/ui/helpers/constants.js @@ -361,3 +361,6 @@ export const sxsJobTypeName = 'perftest-linux-side-by-side'; export const sxsTaskName = 'side-by-side'; export const geckoProfileTaskName = 'geckoprofile'; + +// Number of internal issue classifications to open a bug in Bugzilla +export const requiredInternalOccurrences = 3; diff --git a/ui/job-view/details/PinBoard.jsx b/ui/job-view/details/PinBoard.jsx index 63a4105b972..02ac445338c 100644 --- a/ui/job-view/details/PinBoard.jsx +++ b/ui/job-view/details/PinBoard.jsx @@ -358,7 +358,7 @@ class PinBoard extends React.Component { ); }; - isNumber = (text) => !text || /^[0-9]*$/.test(text); + isValidBugNumber = (text) => !text || /^i?[0-9]*$/.test(text); saveEnteredBugNumber = () => { const { newBugNumber, enteringBugNumber } = this.state; @@ -366,8 +366,12 @@ class PinBoard extends React.Component { if (enteringBugNumber) { if (!newBugNumber) { this.toggleEnterBugNumber(false); - } else if (this.isNumber(newBugNumber)) { - this.props.addBug({ id: parseInt(newBugNumber, 10) }); + } else if (this.isValidBugNumber(newBugNumber)) { + if (newBugNumber[0] === 'i') { + this.props.addBug({ internal_id: newBugNumber.slice(1) }); + } else { + this.props.addBug({ id: parseInt(newBugNumber, 10) }); + } this.toggleEnterBugNumber(false); } } @@ -491,7 +495,7 @@ class PinBoard extends React.Component { pattern="[0-9]*" className="add-related-bugs-input" placeholder="enter bug number" - invalid={!this.isNumber(newBugNumber)} + invalid={!this.isValidBugNumber(newBugNumber)} onKeyPress={this.bugNumberKeyPress} onChange={(ev) => { this.setState({ newBugNumber: ev.target.value }); @@ -712,8 +716,8 @@ PinBoard.propTypes = { isStaff: PropTypes.bool.isRequired, isPinBoardVisible: PropTypes.bool.isRequired, pinnedJobs: PropTypes.shape({}).isRequired, - pinnedJobBugs: PropTypes.shape({}).isRequired, - newBug: PropTypes.string.isRequired, + pinnedJobBugs: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + newBug: PropTypes.shape({}).isRequired, addBug: PropTypes.func.isRequired, removeBug: PropTypes.func.isRequired, unPinJob: PropTypes.func.isRequired, diff --git a/ui/shared/BugFiler.jsx b/ui/shared/BugFiler.jsx index e124d17c10d..026c811d4af 100644 --- a/ui/shared/BugFiler.jsx +++ b/ui/shared/BugFiler.jsx @@ -27,79 +27,9 @@ import { getApiUrl, } from '../helpers/url'; import { create } from '../helpers/http'; +import { omittedLeads, parseSummary, getCrashSignatures } from '../helpers/bug'; import { notify } from '../job-view/redux/stores/notifications'; -const omittedLeads = [ - 'TEST-UNEXPECTED-FAIL', - 'PROCESS-CRASH', - 'TEST-UNEXPECTED-ERROR', - 'REFTEST ERROR', -]; -/* - * Find the first thing in the summary line that looks like a filename. - */ -const findFilename = (summary) => { - // Take left side of any reftest comparisons, as the right side is the reference file - // eslint-disable-next-line prefer-destructuring - summary = summary.split('==')[0]; - // Take the leaf node of unix paths - summary = summary.split('/').pop(); - // Take the leaf node of Windows paths - summary = summary.split('\\').pop(); - // Remove leading/trailing whitespace - summary = summary.trim(); - // If there's a space in what's remaining, take the first word - // eslint-disable-next-line prefer-destructuring - summary = summary.split(' ')[0]; - return summary; -}; -/* - * Remove extraneous junk from the start of the summary line - * and try to find the failing test name from what's left - */ -const parseSummary = (suggestion) => { - let summary = suggestion.search; - const searchTerms = suggestion.search_terms; - // Strip out some extra stuff at the start of some failure paths - let re = /file:\/\/\/.*?\/build\/tests\/reftest\/tests\//gi; - summary = summary.replace(re, ''); - re = /chrome:\/\/mochitests\/content\/a11y\//gi; - summary = summary.replace(re, ''); - re = /http:\/\/([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+):([0-9]+)\/tests\//gi; - summary = summary.replace(re, ''); - re = /xpcshell([-a-zA-Z0-9]+)?.(ini|toml):/gi; - summary = summary.replace(re, ''); - summary = summary.replace('/_mozilla/', 'mozilla/tests/'); - // We don't want to include "REFTEST" when it's an unexpected pass - summary = summary.replace( - 'REFTEST TEST-UNEXPECTED-PASS', - 'TEST-UNEXPECTED-PASS', - ); - const summaryParts = summary.split(' | '); - - // If the search_terms used for finding bug suggestions - // contains any of the omittedLeads, that lead is needed - // for the full string match, so don't omit it in this case. - // If it's not needed, go ahead and omit it. - if (searchTerms.length && summaryParts.length > 1) { - omittedLeads.forEach((lead) => { - if (!searchTerms[0].includes(lead) && summaryParts[0].includes(lead)) { - summaryParts.shift(); - } - }); - } - - // Some of the TEST-FOO bits aren't removed from the summary, - // so we sometimes end up with them instead of the test path here. - const summaryName = - summaryParts[0].startsWith('TEST-') && summaryParts.length > 1 - ? summaryParts[1] - : summaryParts[0]; - const possibleFilename = findFilename(summaryName); - - return [summaryParts, possibleFilename]; -}; - export class BugFilerClass extends React.Component { constructor(props) { super(props); @@ -133,7 +63,7 @@ export class BugFilerClass extends React.Component { summaryString = summaryString.replace(re, ''); } - const crashSignatures = this.getCrashSignatures(suggestion); + const crashSignatures = getCrashSignatures(suggestion); const keywords = []; let isAssertion = [ @@ -283,12 +213,6 @@ export class BugFilerClass extends React.Component { this.findProductByPath(); } - getCrashSignatures(failureLine) { - const crashRegex = /(\[@ .+\])/g; - const crashSignatures = failureLine.search.match(crashRegex); - return crashSignatures ? [crashSignatures[0]] : []; - } - getUnhelpfulSummaryReason(summary) { const { suggestion } = this.props; const searchTerms = suggestion.search_terms; @@ -681,7 +605,7 @@ export class BugFilerClass extends React.Component { selectedProduct, } = this.state; const searchTerms = suggestion.search_terms; - const crashSignatures = this.getCrashSignatures(suggestion); + const crashSignatures = getCrashSignatures(suggestion); const unhelpfulSummaryReason = this.getUnhelpfulSummaryReason(summary); return ( diff --git a/ui/shared/InternalIssueFiler.jsx b/ui/shared/InternalIssueFiler.jsx new file mode 100644 index 00000000000..bd330c0dc3a --- /dev/null +++ b/ui/shared/InternalIssueFiler.jsx @@ -0,0 +1,220 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { + Button, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Input, + Label, +} from 'reactstrap'; + +import { create } from '../helpers/http'; +import { getApiUrl } from '../helpers/url'; +import { parseSummary, getCrashSignatures } from '../helpers/bug'; +import { notify } from '../job-view/redux/stores/notifications'; + +export class InternalIssueFilerClass extends React.Component { + constructor(props) { + super(props); + + const { suggestion, jobGroupName, jobTypeName } = props; + + const parsedSummary = parseSummary(suggestion); + let summaryString = parsedSummary[0].join(' | '); + if (jobGroupName.toLowerCase().includes('reftest')) { + const re = /layout\/reftests\//gi; + summaryString = summaryString.replace(re, ''); + } + + const crashSignatures = getCrashSignatures(suggestion); + + if (crashSignatures.length > 0) { + const parts = summaryString.split(' | '); + summaryString = `${parts[0]} | single tracking bug`; + } + + const isAssertion = [ + /ASSERTION:/, // binary code + /assertion fail/i, // JavaScript + /assertion count \d+ is \w+ than expected \d+ assertion/, // layout + ].some((regexp) => regexp.test(summaryString)); + + const jg = jobGroupName.toLowerCase(); + if ( + jg.includes('xpcshell') || + jg.includes('mochitest') || + jg.includes('web platform tests') || + jg.includes('reftest') || + jg.includes('talos') || + jobTypeName.includes('junit') || + jobTypeName.includes('marionette') + ) { + // simple hack to make sure we have a testcase in the summary + let isTestPath = [ + /.*test_.*\.js/, // xpcshell + /.*test_.*\.html/, // mochitest + /.*test_.*\.xhtml/, // mochitest-chrome + /.*browser_.*\.html/, // b-c + /.*browser_.*\.js/, // b-c + /.*test_.*\.py/, // marionette + /.*\.ini/, // when we have a failure on shutdown (crash/leak/timeout) + /.*\.toml/, // when we have a failure on shutdown (crash/leak/timeout) + /.*org.mozilla.geckoview.test.*/, // junit + ].some((regexp) => regexp.test(summaryString)); + + if (jg.includes('talos')) { + isTestPath = [ + /.*PROCESS-CRASH \| .*/, // crashes + ].some((regexp) => regexp.test(suggestion.search)); + } else if (jg.includes('web platform tests') || jg.includes('reftest')) { + // account for .html?blah... | failure message + isTestPath = [ + /.*\.js(\?.*| )\|/, + /.*\.html(\?.*| )\|/, + /.*\.htm(\?.*| )\|/, + /.*\.xhtml(\?.*| )\|/, + /.*\.xht(\?.*| )\|/, + /.*\.mp4 \|/, // reftest specific + /.*\.webm \|/, // reftest specific + / \| .*\.js(\?.*)?/, // crash format + / \| .*\.html(\?.*)?/, + / \| .*\.htm(\?.*)?/, + / \| .*\.xhtml(\?.*)?/, + / \| .*\.xht(\?.*)?/, + / \| .*.mp4/, // reftest specific + / \| .*\.webm/, // reftest specific + ].some((regexp) => regexp.test(summaryString)); + } + + if (crashSignatures.length > 0) { + isTestPath = false; + const parts = summaryString.split(' | '); + summaryString = `${parts[0]} | single tracking bug`; + } + + // trimming params from end of a test case name when filing for stb + let trimParams = false; + + // only handle straight forward reftest pixel/color errors + if ( + isTestPath && + jobGroupName.includes('reftest') && + !/.*image comparison, max difference.*/.test(summaryString) + ) { + isTestPath = false; + } else if ( + jg.includes('web platform tests') || + jobTypeName.includes('marionette') + ) { + trimParams = true; + } + + // If not leak + if (!isAssertion && isTestPath) { + const parts = summaryString.split(' | '); + // split('?') is for removing `?params...` from the test name + if (parts.length === 2 || parts.length === 1) { + summaryString = `${ + trimParams ? parts[0].split('?')[0].split(' ')[0] : parts[0] + } | single tracking bug`; + } else if (parts.length === 3) { + summaryString = `${ + trimParams ? parts[1].split('?')[0].split(' ')[0] : parts[1] + } | single tracking bug`; + } + } + } + + this.state = { + summary: `Intermittent ${summaryString}`, + }; + } + + submitInternalIssue = async () => { + const { summary } = this.state; + const { notify, successCallback, toggle } = this.props; + + if (summary.length > 255) { + notify( + 'Please ensure the summary is no more than 255 characters', + 'danger', + ); + return; + } + + const resp = await create(getApiUrl('/internal_issue/'), { summary }); + if (resp?.failureStatus && resp.failureStatus >= 400) { + const msg = + typeof resp?.data === 'string' ? resp.data : resp.failureStatus; + notify(msg, 'danger'); + } else { + notify('Error line reported as an internal issue', 'success'); + toggle(); + successCallback(resp.data); + } + }; + + render() { + const { isOpen, toggle } = this.props; + const { summary } = this.state; + + return ( +
+ + + Intermittent Internal Issue Filer + + +
+ +
+ + this.setState({ summary: evt.target.value }) + } + /> + 255 ? 'text-danger' : 'text-success' + }`} + > + {summary.length} + +
+
+
+ + {' '} + + +
+
+ ); + } +} + +InternalIssueFilerClass.propTypes = { + isOpen: PropTypes.bool.isRequired, + toggle: PropTypes.func.isRequired, + suggestion: PropTypes.shape({}).isRequired, + jobGroupName: PropTypes.string.isRequired, + jobTypeName: PropTypes.string.isRequired, + successCallback: PropTypes.func.isRequired, + notify: PropTypes.func.isRequired, +}; + +export default connect(null, { notify })(InternalIssueFilerClass); diff --git a/ui/shared/tabs/failureSummary/BugListItem.jsx b/ui/shared/tabs/failureSummary/BugListItem.jsx index d7f88c96c51..c046d329021 100644 --- a/ui/shared/tabs/failureSummary/BugListItem.jsx +++ b/ui/shared/tabs/failureSummary/BugListItem.jsx @@ -2,35 +2,76 @@ import React from 'react'; import PropTypes from 'prop-types'; import Highlighter from 'react-highlight-words'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faThumbtack } from '@fortawesome/free-solid-svg-icons'; +import { faBug, faThumbtack } from '@fortawesome/free-solid-svg-icons'; import { Button } from 'reactstrap'; import { getSearchWords } from '../../../helpers/display'; import { getBugUrl } from '../../../helpers/url'; +import { requiredInternalOccurrences } from '../../../helpers/constants'; function BugListItem(props) { - const { bug, suggestion, bugClassName, title, selectedJob, addBug } = props; + const { + bug, + suggestion, + bugClassName, + title, + selectedJob, + addBug, + toggleBugFiler, + } = props; const bugUrl = getBugUrl(bug.id); const duplicateBugUrl = bug.dupe_of ? getBugUrl(bug.dupe_of) : undefined; + const internalOccurrenceButton = ( + + ); + const bugzillaButton = ( + + ); return (
  • - {!!addBug && ( - - )} + {!!addBug && + bug.occurrences < requiredInternalOccurrences && + internalOccurrenceButton} + {!bug.bugzilla_id && + bug.occurrences >= requiredInternalOccurrences && + bugzillaButton} + {bug.bugzilla_id} i{bug.internal_id} {!bug.id && ( - - {bug.summary} ({bug.occurrences} occurrences) + + + ({bug.occurrences} occurrences{' '} + ) + + {bug.summary} + {!!addBug && + bug.occurrences >= requiredInternalOccurrences && + internalOccurrenceButton} + {!bug.bugzilla_id && + bug.occurrences < requiredInternalOccurrences && + bugzillaButton} )} {bug.id && ( @@ -78,6 +119,7 @@ BugListItem.propTypes = { bugClassName: PropTypes.string, title: PropTypes.string, addBug: PropTypes.func, + toggleBugFiler: PropTypes.func.isRequired, }; BugListItem.defaultProps = { diff --git a/ui/shared/tabs/failureSummary/FailureSummaryTab.jsx b/ui/shared/tabs/failureSummary/FailureSummaryTab.jsx index 0a8c09df0d0..38c6f2c9d33 100644 --- a/ui/shared/tabs/failureSummary/FailureSummaryTab.jsx +++ b/ui/shared/tabs/failureSummary/FailureSummaryTab.jsx @@ -4,10 +4,15 @@ import { Button } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSpinner } from '@fortawesome/free-solid-svg-icons'; -import { thBugSuggestionLimit, thEvents } from '../../../helpers/constants'; +import { + thBugSuggestionLimit, + thEvents, + requiredInternalOccurrences, +} from '../../../helpers/constants'; import { getResultState, isReftest } from '../../../helpers/job'; import { getReftestUrl } from '../../../helpers/url'; import BugFiler from '../../BugFiler'; +import InternalIssueFiler from '../../InternalIssueFiler'; import BugSuggestionsModel from '../../../models/bugSuggestions'; import ErrorsList from './ErrorsList'; @@ -20,6 +25,7 @@ class FailureSummaryTab extends React.Component { this.state = { isBugFilerOpen: false, + isInternalIssueFilerOpen: false, suggestions: [], errors: [], bugSuggestionsLoading: false, @@ -52,12 +58,28 @@ class FailureSummaryTab extends React.Component { }); }; + fileInternalIssue = (suggestion) => { + const { selectedJob, pinJob } = this.props; + + pinJob(selectedJob); + this.setState({ + isInternalIssueFilerOpen: true, + suggestion, + }); + }; + toggleBugFiler = () => { this.setState((prevState) => ({ isBugFilerOpen: !prevState.isBugFilerOpen, })); }; + toggleInternalIssueFiler = () => { + this.setState((prevState) => ({ + isInternalIssueFilerOpen: !prevState.isInternalIssueFilerOpen, + })); + }; + bugFilerCallback = (data) => { const { addBug } = this.props; @@ -67,6 +89,31 @@ class FailureSummaryTab extends React.Component { window.open(data.url); }; + internalIssueFilerCallback = async (data) => { + const { addBug } = this.props; + const { suggestion, suggestions } = this.state; + + await addBug({ ...data, newBug: `i${data.internal_id}` }); + window.dispatchEvent(new CustomEvent(thEvents.saveClassification)); + + // Try matching an internal bug already fetched with enough occurences + const internalBugs = suggestions + .map((s) => s.bugs.open_recent) + .flat() + .filter((bug) => bug.id === null); + const existingBug = internalBugs.filter( + (bug) => bug.internal_id === data.internal_id, + )[0]; + // Check if we reached the required number of occurrence to open a bug in Bugzilla + if ( + existingBug && + existingBug.occurrences >= requiredInternalOccurrences - 1 + ) { + existingBug.occurrences += 1; + this.fileBug(suggestion); + } + }; + loadBugSuggestions = () => { const { selectedJob } = this.props; @@ -154,6 +201,7 @@ class FailureSummaryTab extends React.Component { } = this.props; const { isBugFilerOpen, + isInternalIssueFilerOpen, suggestion, bugSuggestionsLoading, suggestions, @@ -205,6 +253,9 @@ class FailureSummaryTab extends React.Component { index={index} suggestion={suggestion} toggleBugFiler={() => this.fileBug(suggestion)} + toggleInternalIssueFiler={() => + this.fileInternalIssue(suggestion) + } selectedJob={selectedJob} addBug={addBug} repoName={repoName} @@ -304,6 +355,17 @@ class FailureSummaryTab extends React.Component { platform={selectedJob.platform} /> )} + + {isInternalIssueFilerOpen && ( + + )} ); } diff --git a/ui/shared/tabs/failureSummary/SuggestionsListItem.jsx b/ui/shared/tabs/failureSummary/SuggestionsListItem.jsx index 07d2673c193..8937ed4bf00 100644 --- a/ui/shared/tabs/failureSummary/SuggestionsListItem.jsx +++ b/ui/shared/tabs/failureSummary/SuggestionsListItem.jsx @@ -3,7 +3,10 @@ import PropTypes from 'prop-types'; import { Button } from 'reactstrap'; import { Link } from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faBug, faFilter } from '@fortawesome/free-solid-svg-icons'; +import { + faCircleExclamation, + faFilter, +} from '@fortawesome/free-solid-svg-icons'; import Clipboard from '../../Clipboard'; import logviewerIcon from '../../../img/logviewerIcon.png'; @@ -45,6 +48,7 @@ export default class SuggestionsListItem extends React.Component { const { suggestion, toggleBugFiler, + toggleInternalIssueFiler, selectedJob, addBug, repoName, @@ -68,6 +72,7 @@ export default class SuggestionsListItem extends React.Component { suggestion={suggestion} selectedJob={selectedJob} addBug={addBug} + toggleBugFiler={toggleBugFiler} bugClassName={ developerMode ? 'text-darker-secondary small-text' : '' } @@ -164,10 +169,10 @@ export default class SuggestionsListItem extends React.Component { className="bg-light py-1 px-2 mr-2" outline style={{ fontSize: '8px' }} - onClick={() => toggleBugFiler(suggestion)} - title="file a bug for this failure" + onClick={() => toggleInternalIssueFiler(suggestion)} + title="File an internal issue for this failure" > - + {suggestion.showNewButton && ( @@ -230,6 +235,7 @@ SuggestionsListItem.propTypes = { selectedJob: PropTypes.shape({}).isRequired, suggestion: PropTypes.shape({}).isRequired, toggleBugFiler: PropTypes.func.isRequired, + toggleInternalIssueFiler: PropTypes.func.isRequired, developerMode: PropTypes.bool.isRequired, addBug: PropTypes.func, };