From 25c768ee2ee4ae60b328771884634c2ff6aafaa2 Mon Sep 17 00:00:00 2001 From: Faerbit Date: Tue, 16 Nov 2021 19:57:09 +0100 Subject: [PATCH] Implement "New issue" dialog --- .gitignore | 2 +- Pipfile | 14 ++++ Pipfile.lock | 201 ++++++++++++++++++++++++++++++++++++++++++++++ config.py | 39 +++++++++ exceptions.py | 2 + main.py | 30 +++++-- new_task.py | 39 +++++++++ task_completer.py | 125 ++++++++++++++++++++++++++++ 8 files changed, 444 insertions(+), 8 deletions(-) create mode 100644 Pipfile create mode 100644 Pipfile.lock create mode 100644 config.py create mode 100644 exceptions.py create mode 100644 new_task.py create mode 100644 task_completer.py diff --git a/.gitignore b/.gitignore index b58ada1..763c227 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ - +test.py # Created by https://www.gitignore.io/api/python,pycharm+all # Edit at https://www.gitignore.io/?templates=python,pycharm+all diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..3788417 --- /dev/null +++ b/Pipfile @@ -0,0 +1,14 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +requests-futures = "*" +pyside2 = "*" + +[dev-packages] +ipython = "*" + +[requires] +python_version = "3" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..ea56280 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,201 @@ +{ + "_meta": { + "hash": { + "sha256": "3b71908cf3ecbacde9e592c855ee3b65686e920c23df69d66dd2dff2cd4afe8b" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", + "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" + ], + "version": "==2021.10.8" + }, + "charset-normalizer": { + "hashes": [ + "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0", + "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b" + ], + "markers": "python_version >= '3'", + "version": "==2.0.7" + }, + "idna": { + "hashes": [ + "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", + "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" + ], + "markers": "python_version >= '3'", + "version": "==3.3" + }, + "pyside2": { + "hashes": [ + "sha256:0558ced3bcd7f9da638fa8b7709dba5dae82a38728e481aac8b9058ea22fcdd9", + "sha256:081d8c8a6c65fb1392856a547814c0c014e25ac04b38b987d9a3483e879e9634", + "sha256:087a0b719bb967405ea85fd202757c761f1fc73d0e2397bc3a6a15376782ee75", + "sha256:1316aa22dd330df096daf7b0defe9c00297a66e0b4907f057aaa3e88c53d1aff", + "sha256:4f17a0161995678110447711d685fcd7b15b762810e8f00f6dc239bffb70a32e", + "sha256:976cacf01ef3b397a680f9228af7d3d6273b9254457ad4204731507c1f9e6c3c" + ], + "index": "pypi", + "version": "==5.15.2" + }, + "requests": { + "hashes": [ + "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", + "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==2.26.0" + }, + "requests-futures": { + "hashes": [ + "sha256:35547502bf1958044716a03a2f47092a89efe8f9789ab0c4c528d9c9c30bc148", + "sha256:633804c773b960cef009efe2a5585483443c6eac3c39cc64beba2884013bcdd9" + ], + "index": "pypi", + "version": "==1.0.0" + }, + "shiboken2": { + "hashes": [ + "sha256:03f41b0693b91c7f89627f1085a4ecbe8591c03f904118a034854d935e0e766c", + "sha256:14a33169cf1bd919e4c4c4408fffbcd424c919a3f702df412b8d72b694e4c1d5", + "sha256:4aee1b91e339578f9831e824ce2a1ec3ba3a463f41fda8946b4547c7eb3cba86", + "sha256:89c157a0e2271909330e1655892e7039249f7b79a64a443d52c512337065cde0", + "sha256:ae8ca41274cfa057106268b6249674ca669c5b21009ec49b16d77665ab9619ed", + "sha256:edc12a4df2b5be7ca1e762ab94e331ba9e2fbfe3932c20378d8aa3f73f90e0af" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '3.10'", + "version": "==5.15.2" + }, + "urllib3": { + "hashes": [ + "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece", + "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.26.7" + } + }, + "develop": { + "backcall": { + "hashes": [ + "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e", + "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255" + ], + "version": "==0.2.0" + }, + "decorator": { + "hashes": [ + "sha256:7b12e7c3c6ab203a29e157335e9122cb03de9ab7264b137594103fd4a683b374", + "sha256:e59913af105b9860aa2c8d3272d9de5a56a4e608db9a2f167a8480b323d529a7" + ], + "markers": "python_version >= '3.5'", + "version": "==5.1.0" + }, + "ipython": { + "hashes": [ + "sha256:4f69d7423a5a1972f6347ff233e38bbf4df6a150ef20fbb00c635442ac3060aa", + "sha256:a658beaf856ce46bc453366d5dc6b2ddc6c481efd3540cb28aa3943819caac9f" + ], + "index": "pypi", + "version": "==7.29.0" + }, + "jedi": { + "hashes": [ + "sha256:18456d83f65f400ab0c2d3319e48520420ef43b23a086fdc05dff34132f0fb93", + "sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707" + ], + "markers": "python_version >= '3.6'", + "version": "==0.18.0" + }, + "matplotlib-inline": { + "hashes": [ + "sha256:a04bfba22e0d1395479f866853ec1ee28eea1485c1d69a6faf00dc3e24ff34ee", + "sha256:aed605ba3b72462d64d475a21a9296f400a19c4f74a31b59103d2a99ffd5aa5c" + ], + "markers": "python_version >= '3.5'", + "version": "==0.1.3" + }, + "parso": { + "hashes": [ + "sha256:12b83492c6239ce32ff5eed6d3639d6a536170723c6f3f1506869f1ace413398", + "sha256:a8c4922db71e4fdb90e0d0bc6e50f9b273d3397925e5e60a717e719201778d22" + ], + "markers": "python_version >= '3.6'", + "version": "==0.8.2" + }, + "pexpect": { + "hashes": [ + "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937", + "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c" + ], + "markers": "sys_platform != 'win32'", + "version": "==4.8.0" + }, + "pickleshare": { + "hashes": [ + "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca", + "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56" + ], + "version": "==0.7.5" + }, + "prompt-toolkit": { + "hashes": [ + "sha256:449f333dd120bd01f5d296a8ce1452114ba3a71fae7288d2f0ae2c918764fa72", + "sha256:48d85cdca8b6c4f16480c7ce03fd193666b62b0a21667ca56b4bb5ad679d1170" + ], + "markers": "python_full_version >= '3.6.2'", + "version": "==3.0.22" + }, + "ptyprocess": { + "hashes": [ + "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", + "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" + ], + "version": "==0.7.0" + }, + "pygments": { + "hashes": [ + "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380", + "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6" + ], + "markers": "python_version >= '3.5'", + "version": "==2.10.0" + }, + "setuptools": { + "hashes": [ + "sha256:94ee891f4759150cded601a6beb6b08400413aefd0267b692f3f8c6e0bb238e7", + "sha256:fb537610c2dfe77b5896e3ee53dd53fbdd9adc48076c8f28cee3a30fb59a5038" + ], + "markers": "python_version >= '3.6'", + "version": "==59.1.1" + }, + "traitlets": { + "hashes": [ + "sha256:059f456c5a7c1c82b98c2e8c799f39c9b8128f6d0d46941ee118daace9eb70c7", + "sha256:2d313cc50a42cd6c277e7d7dc8d4d7fedd06a2c215f78766ae7b1a66277e0033" + ], + "markers": "python_version >= '3.7'", + "version": "==5.1.1" + }, + "wcwidth": { + "hashes": [ + "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", + "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" + ], + "version": "==0.2.5" + } + } +} diff --git a/config.py b/config.py new file mode 100644 index 0000000..79254db --- /dev/null +++ b/config.py @@ -0,0 +1,39 @@ +import os +from configparser import ConfigParser +from pathlib import Path + +from PySide2 import QtCore + +from exceptions import FimeFrackingException + + +def dequotify(string): + if string.startswith(('"', "'")) and string.endswith(('"', "'")): + return string[1:-1] + else: + return string + + +class Config: + def __init__(self): + self._configparser = ConfigParser() + config_dir_path = Path(QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.ConfigLocation)) + config_path = config_dir_path / "fimefracking" / "fimefracking.conf" + if config_path.exists(): + with open(config_path) as f: + config_text = f.read() + config_text = "[DEFAULT]\n" + config_text + self._configparser.read_string(config_text) + if (not self._configparser.has_option("DEFAULT", "jira_url") or + not self._configparser.has_option("DEFAULT", "jira_token")): + raise FimeFrackingException(f'Please add config file {config_path} ' + f'with config keys "jira_url" and "jira_token" in INI style') + + @property + def jira_url(self): + return dequotify(self._configparser["DEFAULT"]["jira_url"]) + + @property + def jira_token(self): + return dequotify(self._configparser["DEFAULT"]["jira_token"]) + diff --git a/exceptions.py b/exceptions.py new file mode 100644 index 0000000..f57fdb1 --- /dev/null +++ b/exceptions.py @@ -0,0 +1,2 @@ +class FimeFrackingException(Exception): + pass diff --git a/main.py b/main.py index 861c182..e164c03 100755 --- a/main.py +++ b/main.py @@ -5,8 +5,11 @@ import signal from functools import partial from PySide2 import QtCore, QtGui, QtWidgets +from PySide2.QtWidgets import QMessageBox from data import Tasks, Log +from exceptions import FimeFrackingException +from new_task import NewTask from task_edit import TaskEdit from report import Report @@ -23,6 +26,15 @@ class App: self.menu = QtWidgets.QMenu() + self.new_task = NewTask(None) + self.new_task.accepted.connect(self.new_task_selected) + + self.taskEdit = TaskEdit(None) + self.taskEdit.accepted.connect(self.tasks_edited) + + self.reportDialog = Report(None) + self.reportDialog.accepted.connect(self.report_done) + self.tray = QtWidgets.QSystemTrayIcon() self.tray.setIcon(icon) self.tray.setContextMenu(self.menu) @@ -30,11 +42,9 @@ class App: self.tray.setToolTip("fimefracking") self.update_tray_menu() - self.taskEdit = TaskEdit(None) - self.taskEdit.accepted.connect(self.tasks_edited) - - self.reportDialog = Report(None) - self.reportDialog.accepted.connect(self.report_done) + @QtCore.Slot() + def new_task_selected(self): + print(f"dialog input: {self.new_task.task_text}") @QtCore.Slot() def tasks_edited(self): @@ -68,6 +78,9 @@ class App: self.menu.addSeparator() + new_action = self.menu.addAction("New task") + new_action.triggered.connect(self.new_task.show) + new_action = self.menu.addAction("Edit tasks") new_action.triggered.connect(self.edit_tasks) @@ -103,5 +116,8 @@ class App: if __name__ == "__main__": - app = App() - app.run() + try: + app = App() + app.run() + except FimeFrackingException as e: + QMessageBox.critical(None, "Error", str(e), QMessageBox.Ok) diff --git a/new_task.py b/new_task.py new file mode 100644 index 0000000..a2a89a2 --- /dev/null +++ b/new_task.py @@ -0,0 +1,39 @@ +from PySide2 import QtGui, QtWidgets + +from task_completer import TaskCompleter + + +class NewTask(QtWidgets.QDialog): + def __init__(self, parent, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self.setWindowTitle("New Tasks") + + self.line_edit = QtWidgets.QLineEdit() + completer = TaskCompleter() + self.line_edit.setCompleter(completer) + self.line_edit.textChanged.connect(completer.update_picker) + + cancel_button = QtWidgets.QPushButton() + cancel_button.setText("OK") + cancel_button.setIcon(QtGui.QIcon.fromTheme("dialog-ok-apply")) + cancel_button.pressed.connect(self.accept) + + ok_button = QtWidgets.QPushButton() + ok_button.setText("Cancel") + ok_button.setIcon(QtGui.QIcon.fromTheme("dialog-cancel")) + ok_button.pressed.connect(self.reject) + + blayout = QtWidgets.QHBoxLayout() + blayout.addSpacing(300) + blayout.addWidget(ok_button) + blayout.addWidget(cancel_button) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.line_edit) + layout.addLayout(blayout) + self.setLayout(layout) + self.resize(500, 0) + + @property + def task_text(self): + return self.line_edit.text() diff --git a/task_completer.py b/task_completer.py new file mode 100644 index 0000000..358dc5b --- /dev/null +++ b/task_completer.py @@ -0,0 +1,125 @@ +import os +import sys +import traceback +from functools import reduce +from queue import Queue, Empty +from urllib.parse import urlparse, parse_qs + +from PySide2 import QtCore +from PySide2.QtCore import QTimer +from PySide2.QtWidgets import QCompleter +from requests_futures.sessions import FuturesSession + +from config import Config + + +class TaskCompleter(QCompleter): + def __init__(self, parent=None, *args, **kwargs): + super().__init__([], parent, *args, **kwargs) + self.setFilterMode(QtCore.Qt.MatchFlag.MatchContains) + self.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + self.session = FuturesSession() + self.config = Config() + self.picker_url = os.path.join(self.config.jira_url, "rest/api/2/issue/picker") + self.search_url = os.path.join(self.config.jira_url, "rest/api/2/search") + self.text = "" + self.response_text = "" + self.model_string_list = [] + self.escalate = False + self.update_timer = QTimer(self) + self.update_timer.timeout.connect(self.process_response) + self.update_timer.setInterval(250) + self.queue = Queue() + + @QtCore.Slot() + def process_response(self): + try: + while not self.queue.empty(): + result_dict = self.queue.get_nowait() + if result_dict["response_text"] == self.text: + if self.text == self.response_text: + self.response_text = result_dict["response_text"] + self.model_string_list = result_dict["result"] + else: + self.model_string_list += result_dict["result"] + self.model().setStringList(self.model_string_list) + self.complete() + except Empty: + return + + @QtCore.Slot(str) + def update_picker(self, text): + self.text = text + if self.text == self.currentCompletion(): + # do not update, after auto completion was used + return + if self.escalate: + self.update_search() + if not self.update_timer.isActive(): + self.update_timer.start() + future = self.session.get( + url=self.picker_url, + params={ + "query": self.text + }, + headers={ + "Authorization": f"Bearer {self.config.jira_token}", + "Accept": "application/json", + }, + ) + future.add_done_callback(self.picker_response_callback) + + def picker_response_callback(self, future): + try: + result = future.result() + parsed = urlparse(result.request.url) + response_text = parse_qs(parsed.query)["query"][0] + issues = reduce(lambda x, y: x + (y["issues"]), result.json()["sections"], []) + extracted = list(map(lambda x: f"{x['key']} {x['summaryText']}", issues)) + if extracted: + self.queue.put({ + "response_text": response_text, + "result": extracted, + }) + else: + if not self.escalate: + print("No picker results. Escalating") + self.escalate = True + self.update_search() + except Exception: + print("Ignoring exception, as it only breaks autocompletion:", file=sys.stderr) + print(traceback.format_exc(), file=sys.stderr) + return + + def update_search(self): + for jql in [f"text = {self.text}", f"key = {self.text}"]: + future = self.session.get( + url=self.search_url, + params={ + "jql": jql, + "maxResults": 10, + "fields": "key,summary", + }, + headers={ + "Authorization": f"Bearer {self.config.jira_token}", + "Accept": "application/json", + }, + ) + future.add_done_callback(self.search_response_callback) + + def search_response_callback(self, future): + try: + result = future.result() + json_result = result.json() + parsed = urlparse(result.request.url) + response_text = parse_qs(parsed.query)["jql"][0].split()[2] + if "issues" in json_result: + extracted = list(map(lambda x: f"{x['key']} {x['fields']['summary']}", json_result["issues"])) + self.queue.put({ + "response_text": response_text, + "result": extracted, + }) + except Exception: + print("Ignoring exception, as it only breaks autocompletion:", file=sys.stderr) + print(traceback.format_exc(), file=sys.stderr) + return