Skip to content
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

GitHub issue handler #56

Merged
merged 13 commits into from
Jun 13, 2024
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
.env
.venv/
.idea/
pgdata/
webdata/
*__pycache__/
.vscode/
5 changes: 5 additions & 0 deletions blank.env
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ DB_PASS=<Not Set>
DB_SERVICE=postgres
DB_PORT=5432

# Git auth token for automatically creating issue from error reports
GIT_AUTH_TOKEN=<Not Set>
# Github repo to create issues on (e.g mantidproject/errorreports)
GIT_ISSUE_REPO=<Not Set>

# Can be found Slack settings
SLACK_WEBHOOK_URL=<Not Set>
SLACK_ERROR_REPORTS_CHANNEL=#error-reports
Expand Down
3 changes: 3 additions & 0 deletions web/.flake8
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@

exclude =
services/migrations,
services/github_issue_manager/test_search_for_matching_stacktrace.py,
services/github_issue_manager/test_trim_stacktrace.py,

1 change: 1 addition & 0 deletions web/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ gunicorn
gevent
psycopg2-binary
requests
PyGithub
Empty file.
168 changes: 168 additions & 0 deletions web/services/github_issue_manager/github_issue_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
from services.models import ErrorReport, GithubIssue

import re
import pathlib
import os
import logging
from string import Template
from github import Github, Auth

logger = logging.getLogger()
line_exp = re.compile(r"\s*File \".*(mantid|mantidqt|mantidqtinterfaces|"
r"workbench|scripts|plugins)"
r"(\/|\\)(.*)(\", line \d+, in \S+)")
alt_line_exp = re.compile(r"\s*(at line \d+ in )\'.*(mantid|mantidqt|"
r"mantidqtinterfaces|workbench|scripts|plugins)"
r"(\/|\\)(.*)\'")
ISSUE_TEXT = Template("""
Name: $name
Email: $email

Mantid version: $version
OS: $os

**Additional Information**
$info

**Stack trace**
```$stacktrace```
""")
COMMENT_TEXT = Template("""
Name: $name
Email: $email

Mantid version: $version
OS: $os

**Additional Information**
$info
""")


def get_or_create_github_issue(report) -> GithubIssue | None:
"""
Given the stacktrace from the report, search for database entries with the
same trace. If found and there is a linked github issue, leave a comment
with the report's key information. If not, create a new issue.

Return None in the following cases:
- There is no stack trace and no additional information in the report
- A GIT_AUTH_TOKEN has not been set
- The bug has already been submitted by the user (identified via the uid)
and they have not left any additional information

Args:
report: The report recieved by ErrorViewSet

Returns:
GithubIssue | None: A reference to a new or existing GithubIssue table
entry, or None
"""
if not report.get('stacktrace') and not report.get('textBox'):
logger.info('No stacktrace or info in the report; skipping github'
' issue interaction')
return None

git_access_token = os.getenv('GIT_AUTH_TOKEN')
issue_repo = os.getenv('GIT_ISSUE_REPO')
if not git_access_token:
logger.info('No GIT_AUTH_TOKEN provided; skipping github issue'
' interaction')
return None

auth = Auth.Token(git_access_token)
g = Github(auth=auth)
repo = g.get_repo(issue_repo)

github_issue = _search_for_matching_stacktrace(report["stacktrace"])
if github_issue and issue_repo == github_issue.repoName:
issue_number = github_issue.issueNumber
if (_search_for_repeat_user(report['uid'], github_issue) and
not report['textBox']):
return github_issue

comment_text = COMMENT_TEXT.substitute(
name=report['name'],
email=report['email'],
os=report['osReadable'],
version=report['mantidVersion'],
info=report['textBox']
)
issue = repo.get_issue(number=int(issue_number))
issue.create_comment(comment_text)
logger.info(f'Added comment to issue {issue.url})')
return github_issue
else:
issue_text = ISSUE_TEXT.substitute(
name=report['name'],
email=report['email'],
os=report['osReadable'],
version=report['mantidVersion'],
info=report['textBox'],
stacktrace=report['stacktrace']
)
error_report_label = repo.get_label("Error Report")
issue = repo.create_issue(title="Automatic error report",
labels=[error_report_label],
body=issue_text)
logger.info(f'Created issue {issue.url})')
return GithubIssue.objects.create(repoName=issue_repo,
issueNumber=issue.number)


def _trim_stacktrace(stacktrace: str) -> str:
"""
Returns a trimmed and os non-specific version of the stacktrace given
"""
return '\n'.join([_stacktrace_line_trimer(line) for line in
stacktrace.split('\n')])


def _stacktrace_line_trimer(line: str) -> str:
"""
Returns a trimmed and os non-specific version of the stacktrace line given
"""
match = line_exp.match(line)
if match:
path = pathlib.PureWindowsPath(
os.path.normpath("".join(match.group(1, 2, 3)))
)
return path.as_posix() + match.group(4)

match = alt_line_exp.match(line)
if match:
path = pathlib.PureWindowsPath(
os.path.normpath("".join(match.groups(2, 3, 4)))
)
return match.group(1) + path.as_posix()

return line


def _search_for_matching_stacktrace(trace: str) -> GithubIssue | None:
"""
Search the database for a matching stack trace (irrespective of os, local
install location etc.)

Args:
trace (str): Raw stack trace from the report

Returns:
str | None: Either a GithubIssue entry, or None
"""
if not trace:
return None
trimmed_trace = _trim_stacktrace(trace)
for raw_trace, github_issue in ErrorReport.objects.exclude(
githubIssue__isnull=True).values_list('stacktrace', 'githubIssue'):
if _trim_stacktrace(raw_trace) == trimmed_trace:
return GithubIssue.objects.get(id=github_issue)
return None


def _search_for_repeat_user(uid: str, github_issue: GithubIssue) -> bool:
jhaigh0 marked this conversation as resolved.
Show resolved Hide resolved
"""
Return true if the user id has already submitted the same error
"""
return any([uid == entry_uid for entry_uid in ErrorReport.objects.filter(
githubIssue=github_issue).values_list('uid')])
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from django.test import TestCase
from services.models import ErrorReport, GithubIssue
from services.github_issue_manager.github_issue_manager import _search_for_matching_stacktrace


class MatchingStackTraceSearchTest(TestCase):
entries = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the following would not be matched by the regex:

File "/opt/mantidworkbench6.9/lib/python3.10/site-packages/mantid/simpleapi.py", line 1083, in __call__

and also the following kind of thing that I found in the error reports slack channel:

Exception: MDWorkspace::getExperimentInfo(): expInfoIndex is out of range.
  at line 152 in '/usr/local/anaconda/envs/mantid-dev/plugins/python/algorithms/ConvertWANDSCDtoQ.py'

(' File "/home/username/mantidworkbench/lib/python3.8/site-packages/mantidqt/widgets/memorywidget/memoryview.py", line 98, in _set_value'
' @Slot(int, float, float)'
'KeyboardInterrupt',
'1'),
(r' File "C:\MantidInstall\bin\mantidqt\widgets\workspacedisplay\matrix\table_view_model.py", line 172, in data'
' return str(self.relevant_data(row)[index.column()])'
'OverflowError: can\'t convert negative int to unsigned',
'2'),
(r' File "C:\MantidInstall\bin\mantidqt\widgets\codeeditor\interpreter.py", line 363, in _on_exec_error'
' self.view.editor.updateProgressMarker(lineno, True)'
'RuntimeError: wrapped C/C++ object of type ScriptEditor has been deleted',
'3'),
(r' File "C:\MantidInstall\bin\lib\site-packages\mantidqt\widgets\plotconfigdialog\curvestabwidget\presenter.py", line 367, in line_apply_to_all'
' self.apply_properties()'
r' File "C:\MantidInstall\bin\lib\site-packages\mantidqt\widgets\plotconfigdialog\curvestabwidget\presenter.py", line 69, in apply_properties'
' FigureErrorsManager.toggle_errors(curve, view_props)'
r' File "C:\MantidInstall\bin\lib\site-packages\workbench\plotting\figureerrorsmanager.py", line 108, in toggle_errors'
' hide_errors = view_props.hide_errors or view_props.hide'
r' File "C:\MantidInstall\bin\lib\site-packages\mantidqt\widgets\plotconfigdialog\curvestabwidget\__init__.py", line 137, in __getattr__'
' return self[item]'
'KeyError: \'hide_errors\'',
'4'),
]

def setUp(self):
defaults = {
'uid': '123',
'host': 'test_host',
'dateTime': '2014-12-08T18:50:35.817942000',
'osName': 'Liunx',
'osArch': 'x86_64',
'osVersion': 'ubuntu',
'ParaView': '3.98.1',
'mantidVersion': '6.6.0',
'mantidSha1': 'e9423bdb34b07213a69caa90913e40307c17c6cc'
}
for trace, issue_number in self.entries:
issue = GithubIssue.objects.create(repoName="my/repo", issueNumber=issue_number)
ErrorReport.objects.create(stacktrace=trace, githubIssue=issue, **defaults)

def test_retrieve_issue_number_with_identical_trace(self):
for trace, issue_number in self.entries:
self.assertEqual(issue_number, _search_for_matching_stacktrace(trace).issueNumber)

def test_retrieve_issue_number_with_different_path_seperators(self):
for trace, issue_number in self.entries:
altered_trace = trace.replace('/', '\\') if '/' in trace else trace.replace('\\', '/')
self.assertEqual(issue_number, _search_for_matching_stacktrace(altered_trace).issueNumber)

def test_different_user_name_yields_same_issue_number(self):
trace, issue_number = self.entries[0]
trace.replace('username', 'different_username')
self.assertEqual(issue_number, _search_for_matching_stacktrace(trace).issueNumber)

def test_different_install_location_yields_same_issue_number(self):
trace, issue_number = self.entries[1]
trace.replace('MantidInstall', 'my\\mantid\\install')
self.assertEqual(issue_number, _search_for_matching_stacktrace(trace).issueNumber)

45 changes: 45 additions & 0 deletions web/services/github_issue_manager/test_trim_stacktrace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from services.github_issue_manager.github_issue_manager import _trim_stacktrace, _stacktrace_line_trimer
import unittest


class TrimStacktraceTest(unittest.TestCase):

def test_user_specific_dirs_are_removed(self):
username = "my_cool_user_name"
test_trace = f'File "/home/{username}/mantidworkbench/lib/python3.8/site-packages/mantidqt/widgets/memorywidget/memoryview.py", line 98, in _set_value'\
' @Slot(int, float, float)'\
'KeyboardInterrupt'
self.assertNotIn(username, _trim_stacktrace(test_trace))

def test_line_trimmer_file_lines(self):
examples = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you might want to add the above examples to this test too

r'File "C:\MantidInstall\bin\lib\site-packages\mantidqtinterfaces\Muon\GUI\Common\thread_model.py", line 98, in warning':
r'mantidqtinterfaces/Muon/GUI/Common/thread_model.py", line 98, in warning',
r'File "/opt/mantidworkbench6.8/lib/python3.10/site-packages/workbench/plotting/figurewindow.py", line 130, in dropEvent':
r'workbench/plotting/figurewindow.py", line 130, in dropEvent',
r'File "D:\Mantid\Software\MantidInstall\bin\lib\site-packages\mantidqt\widgets\codeeditor\execution.py", line 153, in execute':
r'mantidqt/widgets/codeeditor/execution.py", line 153, in execute',
r'File "/opt/mantidworkbenchnightly/scripts/ExternalInterfaces/mslice/presenters/workspace_manager_presenter.py", line 112, in _save_to_ads':
r'scripts/ExternalInterfaces/mslice/presenters/workspace_manager_presenter.py", line 112, in _save_to_ads',
r"at line 152 in '/usr/local/anaconda/envs/mantid-dev/plugins/python/algorithms/ConvertWANDSCDtoQ.py'":
r'at line 152 in plugins/python/algorithms/ConvertWANDSCDtoQ.py',
r'File "/opt/mantidworkbench6.9/lib/python3.10/site-packages/mantid/simpleapi.py", line 1083, in __call__':
r'mantid/simpleapi.py", line 1083, in __call__'
}
for original, expected_trim in examples.items():
self.assertEqual(_stacktrace_line_trimer(original), expected_trim)

def test_line_trimmer_other_lines(self):
examples = {
"OverflowError: can't convert negative int to unsigned",
"self.view.editor.updateProgressMarker(lineno, True)",
"Exception: unknown",
"ax.make_legend()",
"KeyError: 'hide_errors'"
}
for line in examples:
self.assertEqual(_stacktrace_line_trimer(line), line)


if __name__ == '__main__':
unittest.main()
28 changes: 28 additions & 0 deletions web/services/migrations/0007_add_issue_number_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 3.2.23 on 2024-03-15 11:10

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('services', '0006_extend_stacktrace_length'),
]

operations = [
migrations.AddField(
model_name='errorreport',
name='githubIssueNumber',
field=models.CharField(blank=True, default='', max_length=16),
),
migrations.AlterField(
model_name='errorreport',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='userdetails',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]
31 changes: 31 additions & 0 deletions web/services/migrations/0008_add_github_issue_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 3.2.23 on 2024-04-15 15:05

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('services', '0007_add_issue_number_field'),
]

operations = [
migrations.CreateModel(
name='GithubIssue',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('repoName', models.CharField(blank=True, default='', help_text="'user/repo_name': for example 'mantidproject/mantid'", max_length=200)),
('issueNumber', models.CharField(blank=True, default='', max_length=16)),
],
),
migrations.RemoveField(
model_name='errorreport',
name='githubIssueNumber',
),
migrations.AddField(
model_name='errorreport',
name='githubIssue',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='services.githubissue'),
),
]
Loading