Skip to content

Bug 1923923 - Allow creation or update (annotation) of an internal issue #8532

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4f7760d
API to open internal issue
vrigal Feb 25, 2025
e6deb22
Fix warnings about wrong prop types in PinBoard.jsx
vrigal Feb 27, 2025
85ccfe8
[UI] Move bug summary to generic helpers
vrigal Feb 27, 2025
fd10612
[UI] Display a form to file internal issue by default
vrigal Feb 27, 2025
29b87c3
Suggestions
vrigal Mar 6, 2025
41c2fe8
Move occurrences setting to the frontend
vrigal Mar 6, 2025
d092522
[UI] Add Internal Failure component
vrigal Mar 7, 2025
7ade4e4
[UI] Fix context for the Internal Issue component
vrigal Mar 7, 2025
21c4f5e
[UI] Add the same summary transformation that in BugFiler
vrigal Mar 7, 2025
8792c92
[UI] Create initial internal issue
vrigal Mar 7, 2025
a4a1b98
Fix the API and annotate from the UI
vrigal Mar 10, 2025
b59f6a0
[UI] Fix auto classify on callback
vrigal Mar 13, 2025
c630759
Fix occurrences count
vrigal Mar 13, 2025
8f4d619
[UI] Support directly adding an internal bug
vrigal Mar 13, 2025
0389e13
Automatically open bug filer on third internal issue occurrence
vrigal Mar 14, 2025
21b3a1a
Format
vrigal Mar 14, 2025
240d5ec
Fix typo and occurrence check before opening the bug filer
vrigal Mar 20, 2025
c46f0e6
Always allow force filling a bug
vrigal Apr 18, 2025
786e2c1
Swap internal and bugzilla buttons when occurrences >= 3
vrigal Apr 18, 2025
beabae2
Increment occurrence on succesful internal issue creation
vrigal Apr 18, 2025
3172265
Display summary length help + validation in internal issue filer
vrigal Apr 18, 2025
3a8b25a
Count occurrences when classifying a pinned issue
vrigal Apr 25, 2025
9dda37c
Support generic lookup to bug suggestion when opening the bug filer
vrigal Apr 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions treeherder/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion treeherder/model/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,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()
Expand Down
14 changes: 14 additions & 0 deletions treeherder/webapp/api/internal_issue.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions treeherder/webapp/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -445,3 +446,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
4 changes: 4 additions & 0 deletions treeherder/webapp/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
hash,
infra_compare,
intermittents_view,
internal_issue,
investigated_test,
job_log_url,
jobs,
Expand Down Expand Up @@ -181,4 +182,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"
),
]
76 changes: 76 additions & 0 deletions ui/helpers/bug.js
Original file line number Diff line number Diff line change
@@ -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]] : [];
};
4 changes: 4 additions & 0 deletions ui/helpers/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ export const thEvents = {
applyNewJobs: 'apply-new-jobs-EVT',
filtersUpdated: 'filters-updated-EVT',
clearPinboard: 'clear-pinboard-EVT',
internalIssueClassification: 'internal-issue-classification-EVT',
};

export const thBugSuggestionLimit = 20;
Expand All @@ -362,3 +363,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;
25 changes: 19 additions & 6 deletions ui/job-view/details/PinBoard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,15 @@ class PinBoard extends React.Component {
const message = `Error saving bug association for ${job.platform} ${job.job_type_name}`;
notify(formatModelError(response, message), 'danger');
});

// In case the bug is still an internal issue, check if the required number of occurrence is reached to open a bug
if (!bug.id) {
window.dispatchEvent(
new CustomEvent(thEvents.internalIssueClassification, {
detail: { internalBugId: bug.internal_id },
}),
);
}
});
};

Expand Down Expand Up @@ -358,16 +367,20 @@ 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;

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);
}
}
Expand Down Expand Up @@ -491,7 +504,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 });
Expand Down Expand Up @@ -712,8 +725,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,
Expand Down
82 changes: 3 additions & 79 deletions ui/shared/BugFiler.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,79 +28,9 @@ import {
} from '../helpers/url';
import { confirmFailure } from '../helpers/job';
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);
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -284,12 +214,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;
Expand Down Expand Up @@ -696,7 +620,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 (
Expand Down
Loading