Skip to content

Commit

Permalink
Merge pull request #56 from mantidproject/github_issue_handler
Browse files Browse the repository at this point in the history
GitHub issue handler
  • Loading branch information
thomashampson committed Jun 13, 2024
2 parents 3048e68 + dc9d5f2 commit 932bde4
Show file tree
Hide file tree
Showing 13 changed files with 404 additions and 24 deletions.
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:
"""
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 = [
(' 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 = {
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

0 comments on commit 932bde4

Please sign in to comment.