diff --git a/.gitignore b/.gitignore index 3dd8f2d..3b4c140 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ .env +.venv/ +.idea/ pgdata/ webdata/ +*__pycache__/ +.vscode/ diff --git a/blank.env b/blank.env index 30fa9dd..8f7bdeb 100644 --- a/blank.env +++ b/blank.env @@ -10,6 +10,11 @@ DB_PASS= DB_SERVICE=postgres DB_PORT=5432 +# Git auth token for automatically creating issue from error reports +GIT_AUTH_TOKEN= +# Github repo to create issues on (e.g mantidproject/errorreports) +GIT_ISSUE_REPO= + # Can be found Slack settings SLACK_WEBHOOK_URL= SLACK_ERROR_REPORTS_CHANNEL=#error-reports diff --git a/web/.flake8 b/web/.flake8 index 9231a29..1c97031 100644 --- a/web/.flake8 +++ b/web/.flake8 @@ -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, + diff --git a/web/requirements.txt b/web/requirements.txt index 095085b..18aafde 100644 --- a/web/requirements.txt +++ b/web/requirements.txt @@ -6,3 +6,4 @@ gunicorn gevent psycopg2-binary requests +PyGithub diff --git a/web/services/github_issue_manager/__init__.py b/web/services/github_issue_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/services/github_issue_manager/github_issue_manager.py b/web/services/github_issue_manager/github_issue_manager.py new file mode 100644 index 0000000..9142a96 --- /dev/null +++ b/web/services/github_issue_manager/github_issue_manager.py @@ -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: + """ + 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')]) diff --git a/web/services/github_issue_manager/test_search_for_matching_stacktrace.py b/web/services/github_issue_manager/test_search_for_matching_stacktrace.py new file mode 100644 index 0000000..fff81fd --- /dev/null +++ b/web/services/github_issue_manager/test_search_for_matching_stacktrace.py @@ -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 = [ + (' 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) + diff --git a/web/services/github_issue_manager/test_trim_stacktrace.py b/web/services/github_issue_manager/test_trim_stacktrace.py new file mode 100644 index 0000000..9add7a0 --- /dev/null +++ b/web/services/github_issue_manager/test_trim_stacktrace.py @@ -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 = { + 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() diff --git a/web/services/migrations/0007_add_issue_number_field.py b/web/services/migrations/0007_add_issue_number_field.py new file mode 100644 index 0000000..ba41380 --- /dev/null +++ b/web/services/migrations/0007_add_issue_number_field.py @@ -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'), + ), + ] diff --git a/web/services/migrations/0008_add_github_issue_model.py b/web/services/migrations/0008_add_github_issue_model.py new file mode 100644 index 0000000..be9374d --- /dev/null +++ b/web/services/migrations/0008_add_github_issue_model.py @@ -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'), + ), + ] diff --git a/web/services/models.py b/web/services/models.py index 5c7a364..62c140f 100644 --- a/web/services/models.py +++ b/web/services/models.py @@ -46,6 +46,10 @@ class ErrorReport(models.Model): default="", null="True") stacktrace = models.CharField(max_length=10000, default="") + githubIssue = models.ForeignKey('GithubIssue', + on_delete=models.SET_NULL, + blank=True, + null=True) def removePIIData(reports): # Delete identifiable parts of chosen reports @@ -73,6 +77,15 @@ def clearOrphanedRecords(): no_refs.delete() +class GithubIssue(models.Model): + repoName = models.CharField(max_length=200, + default="", + blank=True, + help_text="'user/repo_name': for example " + "'mantidproject/mantid'") + issueNumber = models.CharField(max_length=16, default="", blank=True) + + def notify_report_received(sender, instance, signal, *args, **kwargs): """ Send a notification to the defined endpoint when a new error @@ -107,6 +120,10 @@ def notify_report_received(sender, instance, signal, *args, **kwargs): and textBox in TEST_VALUES)): return + if instance.githubIssue: + issue_link = (f"https://github.com/{instance.githubIssue.repoName}" + f"/issues/{instance.githubIssue.issueNumber}") + notification_thread = threading.Thread( target=send_notification_to_slack, args=(name, email, @@ -114,7 +131,8 @@ def notify_report_received(sender, instance, signal, *args, **kwargs): instance.stacktrace, instance.application, instance.mantidVersion, - instance.osReadable)) + instance.osReadable, + issue_link)) notification_thread.start() diff --git a/web/services/tasks.py b/web/services/tasks.py index 4949956..f415ba5 100644 --- a/web/services/tasks.py +++ b/web/services/tasks.py @@ -1,8 +1,18 @@ from django.conf import settings import requests import logging +from string import Template logger = logging.getLogger('NotificationLogger') +SLACK_MESSAGE = Template(""" +Name: $name Email: $email +Additional text: +$add_text +Stack Trace: +$stacktrace +Using: $application $version on $os +$issue_link +""") def send_notification_to_slack(name, @@ -11,7 +21,8 @@ def send_notification_to_slack(name, stacktrace, application, version, - os + os, + github_issue_link ): """Sends a notification about a new error report to the slack channel defined in the settings @@ -24,26 +35,16 @@ def send_notification_to_slack(name, slack_webhook_url = settings.SLACK_WEBHOOK_URL if not slack_webhook_url: return - text = """Name: {} Email: {} - Additional text: - {} - Stack Trace: - {} - Using: {} {} on {} - """.format( - name if name - else settings.SLACK_ERROR_REPORTS_EMPTY_FIELD_TEXT, - email if email else settings.SLACK_ERROR_REPORTS_EMPTY_FIELD_TEXT, - additional_text if additional_text - else settings.SLACK_ERROR_REPORTS_EMPTY_FIELD_TEXT, - stacktrace if stacktrace - else settings.SLACK_ERROR_REPORTS_EMPTY_FIELD_TEXT, - application if application - else settings.SLACK_ERROR_REPORTS_EMPTY_FIELD_TEXT, - version if version - else settings.SLACK_ERROR_REPORTS_EMPTY_FIELD_TEXT, - os if os - else settings.SLACK_ERROR_REPORTS_EMPTY_FIELD_TEXT) + text = SLACK_MESSAGE.substitute( + name=_string_or_empty_field(name), + email=_string_or_empty_field(name), + add_text=_string_or_empty_field(additional_text), + stacktrace=_string_or_empty_field(stacktrace), + application=_string_or_empty_field(application), + version=_string_or_empty_field(version), + os=_string_or_empty_field(os), + issue_link=_string_or_empty_field(github_issue_link) + ) requests.post(slack_webhook_url, json={ 'channel': settings.SLACK_ERROR_REPORTS_CHANNEL, @@ -53,9 +54,13 @@ def send_notification_to_slack(name, }) +def _string_or_empty_field(value: str): + return value if value else settings.SLACK_ERROR_REPORTS_EMPTY_FIELD_TEXT + + def send_logging_output_to_slack(message): slack_webhook_url = settings.SLACK_WEBHOOK_URL - if slack_webhook_url is None: + if not slack_webhook_url: return requests.post(slack_webhook_url, json={ diff --git a/web/services/views.py b/web/services/views.py index fc46992..9c33e4a 100644 --- a/web/services/views.py +++ b/web/services/views.py @@ -1,4 +1,7 @@ from services.models import ErrorReport, UserDetails +from services.github_issue_manager.github_issue_manager import ( + get_or_create_github_issue +) from services.constants import input_box_max_length from rest_framework import response, viewsets, views from rest_framework.decorators import api_view @@ -114,6 +117,8 @@ def saveErrorReport(report): else: user = None + github_issue = get_or_create_github_issue(report) + obj, created = \ ErrorReport.objects.get_or_create(osReadable=osReadable, application=application, @@ -130,7 +135,8 @@ def saveErrorReport(report): exitCode=exitCode, user=user, textBox=textBox, - stacktrace=stacktrace) + stacktrace=stacktrace, + githubIssue=github_issue) if not created: obj.save()