diff options
-rw-r--r-- | kodereviewer/app.py | 8 | ||||
-rw-r--r-- | kodereviewer/data.py | 115 | ||||
-rw-r--r-- | kodereviewer/mdconverter.py | 30 | ||||
-rw-r--r-- | kodereviewer/models/__init__.py | 9 | ||||
-rw-r--r-- | kodereviewer/models/comments.py | 89 | ||||
-rw-r--r-- | kodereviewer/models/project.py (renamed from kodereviewer/project_model.py) | 0 | ||||
-rw-r--r-- | kodereviewer/models/pull_request.py (renamed from kodereviewer/pull_request_model.py) | 13 | ||||
-rw-r--r-- | kodereviewer/network_manager.py | 25 | ||||
-rw-r--r-- | kodereviewer/project.py | 13 | ||||
-rw-r--r-- | kodereviewer/qml/CommentDelegate.qml | 77 | ||||
-rw-r--r-- | kodereviewer/qml/Main.qml | 27 | ||||
-rw-r--r-- | kodereviewer/qml/ProjectListPage.qml | 4 | ||||
-rw-r--r-- | kodereviewer/qml/PullRequestPage.qml | 84 |
13 files changed, 414 insertions, 80 deletions
diff --git a/kodereviewer/app.py b/kodereviewer/app.py index a2eb5f4..430b5cf 100644 --- a/kodereviewer/app.py +++ b/kodereviewer/app.py @@ -9,10 +9,8 @@ from PySide6.QtGui import QGuiApplication from PySide6.QtCore import QUrl, QByteArray from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType -from kodereviewer.mdconverter import MdConverter -from kodereviewer.project_model import ProjectModel -from kodereviewer.pull_request_model import PullRequestModel from kodereviewer.network_manager import NetworkManager +from kodereviewer.models import CommentModel, PullRequestModel, ProjectModel def main(): @@ -34,8 +32,9 @@ def main(): if not os.environ.get("QT_QUICK_CONTROLS_STYLE"): os.environ["QT_QUICK_CONTROLS_STYLE"] = "org.kde.desktop" - qmlRegisterType(MdConverter, "org.deprecated.kodereviewer", 1, 0, "MdConverter") qmlRegisterType(ProjectModel, "org.deprecated.kodereviewer", 1, 0, "ProjectModel") + + qmlRegisterType(CommentModel, "org.deprecated.kodereviewer", 1, 0, "CommentModel") qmlRegisterType(PullRequestModel, "org.deprecated.kodereviewer", 1, 0, "PullRequestModel") qmlRegisterType(NetworkManager, "org.deprecated.kodereviewer", 1, 0, "NetworkManager") @@ -43,6 +42,7 @@ def main(): engine.rootContext().setContextObject(localized_context) base_path = os.path.abspath(os.path.dirname(__file__)) engine.addImportPath(f"file://{base_path}/qml") + engine.addImportPath(f"file://{base_path}/qml/delegates") url = QUrl(f"file://{base_path}/qml/Main.qml") engine.load(url) diff --git a/kodereviewer/data.py b/kodereviewer/data.py index 7d3b73a..eb86097 100644 --- a/kodereviewer/data.py +++ b/kodereviewer/data.py @@ -1,8 +1,9 @@ from datetime import datetime from enum import Enum -from typing import Any, Optional +import json +from typing import Any, Optional, Self -from PySide6.QtCore import QObject, QSettings, Signal, Slot, Property, qDebug +from PySide6.QtCore import QByteArray, QObject, QSettings, Signal, Slot, Property, qDebug from PySide6.QtNetwork import QHttpHeaders, QNetworkAccessManager, QNetworkReply, QNetworkRequestFactory from PySide6.QtQml import QmlElement @@ -29,6 +30,23 @@ class Label(QObject): self.description = data['description'] +class Comment(QObject): + body: str + reactions: dict[str, int] + + created_at: datetime + updated_at: datetime + + user: User + + def __init__(self, data: dict[str, Any]): + super().__init__() + self.body = data['body'] + self.reactions = data['reactions'] + self.created_at = datetime.fromisoformat(data['created_at']) + self.updated_at = datetime.fromisoformat(data['updated_at']) + self.user = User(data['user']) + class State(Enum): OPEN = 'open' @@ -38,33 +56,82 @@ class State(Enum): class PullRequest(QObject): - number: int - title: str - state: State - url: str - body: str | None - created_at: datetime - updated_at: datetime + _number: int + _title: str + _state: State + _url: str + _body: str | None + _created_at: datetime + _updated_at: datetime - user: User - assignee: User | None - labels: list[str] + _user: User + _assignee: User | None + _labels: list[Label] + _comments: list[Comment] + + commentsLoaded = Signal() def __init__(self, data: dict[str, Any]): super().__init__() - self.number = data['number'] - self.state = data['state'] - self.title = data['title'] - self.url = data['html_url'] - self.body = data['body'] - self.created_at = datetime.fromisoformat(data['created_at']) - self.updated_at = datetime.fromisoformat(data['updated_at']) - - self.user = User(data['user']) - self.assignee = None + self._number = data['number'] + self._state = data['state'] + self._title = data['title'] + self._url = data['html_url'] + self._body = data['body'] + self._created_at = datetime.fromisoformat(data['created_at']) + self._updated_at = datetime.fromisoformat(data['updated_at']) + + self._user = User(data['user']) + self._assignee = None if data['assignee'] is not None: - self.assignee = User(data['assignee']) + self._assignee = User(data['assignee']) + + self._labels = [Label(label) for label in data['labels']] + + # At first this is empty until it's updated with a request + self._comments = [] + + def __eq__(self, other: object) -> bool: + if isinstance(other, PullRequest): + return self._number == other._number + return False + + def __str__(self) -> str: + return self._title + + @Property(int, constant=True) + def number(self) -> int: + return self._number + + @Property(str, constant=True) + def title(self) -> str: + return self._title + + @Property(str, constant=True) + def state(self) -> str: + return self._state.value + + @Property(str, constant=True) + def url(self) -> str: + return self._url + + @Property(str, constant=True) + def body(self) -> str: + if self._body is not None: + return self._body + return '' + + @Property(datetime, constant=True) + def created_at(self) -> datetime: + return self._created_at - labels = [Label(label) for label in data['labels']] + @Property(str, constant=True) + def username(self) -> str: + return self._user.username + def load_comments(self, response: QByteArray) -> None: + data = json.loads(response.toStdString()) + self._comments = [Comment(comment) for comment in data] + print(f'emiting new comments {len(self._comments)}') + self.commentsLoaded.emit() diff --git a/kodereviewer/mdconverter.py b/kodereviewer/mdconverter.py deleted file mode 100644 index 1d0a653..0000000 --- a/kodereviewer/mdconverter.py +++ /dev/null @@ -1,30 +0,0 @@ -from markdown import markdown -from PySide6.QtCore import QObject, Signal, Slot, Property -from PySide6.QtQml import QmlElement - -QML_IMPORT_NAME = "org.deprecated.kodereviewer" -QML_IMPORT_MAJOR_VERSION = 1 - - -@QmlElement -class MdConverter(QObject): - """A simple markdown converter""" - - sourceTextChanged = Signal() - - def __init__(self, _source_text=""): - super().__init__() - self._source_text = _source_text - - @Property(str, notify=sourceTextChanged) - def sourceText(self): - return self._source_text - - @sourceText.setter - def sourceText(self, val: str): - self._source_text = val - self.sourceTextChanged.emit() - - @Slot(result=str) - def mdFormat(self): - return markdown(self._source_text) diff --git a/kodereviewer/models/__init__.py b/kodereviewer/models/__init__.py new file mode 100644 index 0000000..853b618 --- /dev/null +++ b/kodereviewer/models/__init__.py @@ -0,0 +1,9 @@ +from kodereviewer.models.comments import CommentModel +from kodereviewer.models.project import ProjectModel +from kodereviewer.models.pull_request import PullRequestModel + +__all__ = [ + 'CommentModel', + 'ProjectModel', + 'PullRequestModel', +] diff --git a/kodereviewer/models/comments.py b/kodereviewer/models/comments.py new file mode 100644 index 0000000..06de3d7 --- /dev/null +++ b/kodereviewer/models/comments.py @@ -0,0 +1,89 @@ +from enum import auto, IntEnum +from typing import Optional + +from PySide6.QtCore import QAbstractListModel, QByteArray, QModelIndex, QObject, QPersistentModelIndex, QStandardPaths, QUrl, Qt, Signal, Slot, Property +from PySide6.QtQml import QmlElement + +from kodereviewer.data import Comment, PullRequest + +QML_IMPORT_NAME = "org.deprecated.kodereviewer" +QML_IMPORT_MAJOR_VERSION = 1 + + +@QmlElement +class CommentModel(QAbstractListModel): + + _pull_request: Optional[PullRequest] + + class Roles(IntEnum): + Body = Qt.ItemDataRole.UserRole + 1 + CreatedAt = auto() + UpdatedAt = auto() + Username = auto() + AvatarUrl = auto() + + + def __init__(self): + super().__init__() + self._pull_request = None + + def get_pull_request(self) -> Optional[PullRequest]: + return self._pull_request + + def set_pull_request(self, pull_request: Optional[PullRequest]) -> None: + if pull_request is None: + return + + if self._pull_request is not None and self._pull_request == pull_request: + return + + print(f"Setting up pull request to {pull_request}") + self.beginResetModel() + self._pull_request = pull_request + self._pull_request.commentsLoaded.connect(self._reset_model) + self.endResetModel() + self.pullRequestChanged.emit() + + pullRequestChanged = Signal() + pullRequest = Property(PullRequest, fget=get_pull_request, fset=set_pull_request, + notify=pullRequestChanged) + + def _reset_model(self) -> None: + self.beginResetModel() + self.endResetModel() + + def data(self, + index: QModelIndex | QPersistentModelIndex, + role: int = Qt.ItemDataRole.DisplayRole) -> object: + if self._pull_request is None: + return None + + comment: Comment = self._pull_request._comments[index.row()] + + if role == self.Roles.Body: + return comment.body + if role == self.Roles.CreatedAt: + return comment.created_at.strftime("%Y-%M-%d %H:%m") + if role == self.Roles.UpdatedAt: + return comment.updated_at.strftime("%Y-%M-%d %H:%m") + if role == self.Roles.Username: + return comment.user.username + if role == self.Roles.AvatarUrl: + return comment.user.avatar_url + if role == Qt.ItemDataRole.DisplayRole: + return comment.body + return None + + def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int: + if self._pull_request is None: + return 0 + return len(self._pull_request._comments) + + def roleNames(self) -> dict[int, QByteArray]: + return { + self.Roles.Body: QByteArray(b'body'), + self.Roles.CreatedAt: QByteArray(b'createdAt'), + self.Roles.UpdatedAt: QByteArray(b'updatedAt'), + self.Roles.Username: QByteArray(b'username'), + self.Roles.AvatarUrl: QByteArray(b'avatarUrl'), + } diff --git a/kodereviewer/project_model.py b/kodereviewer/models/project.py index c676157..c676157 100644 --- a/kodereviewer/project_model.py +++ b/kodereviewer/models/project.py diff --git a/kodereviewer/pull_request_model.py b/kodereviewer/models/pull_request.py index e08bf6e..7d08efa 100644 --- a/kodereviewer/pull_request_model.py +++ b/kodereviewer/models/pull_request.py @@ -54,12 +54,21 @@ class PullRequestModel(QAbstractListModel): def set_project(self, project: Optional[Project])-> None: if project is None: return + self._project = project - print("Connecting!") - self._project.pullRequestChanged.connect(self._reset_model) + assert self._project is not None + + self._project.pullRequestsChanged.connect(self._reset_model) project = Property(Project, fget=get_project, fset=set_project) + @Slot(int, result=PullRequest) + def get(self, index: int) -> Optional[PullRequest]: + if self._project is not None: + return self._project.pullRequests[index] + return None + + def _reset_model(self) -> None: print("Reseting pull request model") self.beginResetModel() diff --git a/kodereviewer/network_manager.py b/kodereviewer/network_manager.py index 2d8b8aa..62b6047 100644 --- a/kodereviewer/network_manager.py +++ b/kodereviewer/network_manager.py @@ -1,3 +1,4 @@ +import re from typing import Optional from PySide6.QtCore import QObject, QSettings, Signal, Slot, Property, qDebug @@ -9,6 +10,10 @@ from kodereviewer.project import Project QML_IMPORT_NAME = "org.deprecated.kodereviewer" QML_IMPORT_MAJOR_VERSION = 1 +PULL_REQUEST_LIST_URL = re.compile(r'/pulls$') +COMMENT_LIST_URL = re.compile(r'/issues/(\d+)/comments') + + @QmlElement class NetworkManager(QObject): @@ -51,8 +56,26 @@ class NetworkManager(QObject): project = Property(Project, fget=project, fset=set_project) def reply_finished(self, reply: QNetworkReply): - self._project.load_pull_requests(reply.readAll()) + if self._project is None: + print('Project not set') + return + + response_body = reply.readAll() + + if PULL_REQUEST_LIST_URL.search(reply.url().toString()): + self._project.load_pull_requests(response_body) + elif (match := COMMENT_LIST_URL.search(reply.url().toString())): + pull_request_number = int(match.groups()[0]) + pull_request = self._project.find_pull_request(pull_request_number) + if pull_request is not None: + pull_request.load_comments(response_body) + else: + print(f"Can't handle {reply.url()}") @Slot() def getPullRequests(self) -> None: self._manager.get(self._request_factory.createRequest("/pulls")) + + @Slot(int) + def getPullRequestComments(self, number: int) -> None: + self._manager.get(self._request_factory.createRequest(f'/issues/{number}/comments')) diff --git a/kodereviewer/project.py b/kodereviewer/project.py index 669ee90..d96d47b 100644 --- a/kodereviewer/project.py +++ b/kodereviewer/project.py @@ -1,4 +1,6 @@ import json +from typing import Optional + from PySide6.QtCore import QByteArray, QObject, QUrl, Signal, Slot, Property from PySide6.QtQml import QmlElement @@ -16,7 +18,7 @@ class Project(QObject): _url: QUrl _pull_requests: list[PullRequest] - pullRequestChanged = Signal() + pullRequestsChanged = Signal() def __init__(self, name: str, owner: str, url: QUrl): super().__init__() @@ -46,4 +48,11 @@ class Project(QObject): self._pull_requests = [ PullRequest(pr) for pr in data ] - self.pullRequestChanged.emit() + self.pullRequestsChanged.emit() + + def find_pull_request(self, number: int) -> Optional[PullRequest]: + for pr in self._pull_requests: + if pr.number == number: + return pr + + return None diff --git a/kodereviewer/qml/CommentDelegate.qml b/kodereviewer/qml/CommentDelegate.qml new file mode 100644 index 0000000..bac75d0 --- /dev/null +++ b/kodereviewer/qml/CommentDelegate.qml @@ -0,0 +1,77 @@ +import QtQml +import QtQuick 6.7 +import QtQuick.Layouts 6.7 +import QtQuick.Controls 6.7 as QQC2 + +import org.kde.kirigami as Kirigami +import org.kde.kirigamiaddons.components as KirigamiComponents + +Kirigami.AbstractCard { + id: root + required property url avatarUrl + required property string createdAt + required property string username + required property string body + + header: RowLayout { + KirigamiComponents.Avatar { + name: root.username + source: root.avatarUrl + Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium + Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium + } + Kirigami.Heading { + Layout.fillWidth: true + id: commentHeading + level: 3 + text: `@${root.username}` + } + QQC2.Label { + text: new Date(root.createdAt).toLocaleString(Qt.locale(), Locale.ShortFormat) + } + Kirigami.ActionToolBar { + Layout.fillWidth: false + actions: [ + Kirigami.Action { + icon.name: "overflow-menu" + + Kirigami.Action { + text: "Edit" + icon.name: "edit-comment" + } + + Kirigami.Action { + text: "Delete" + icon.name: "delete-comment" + } + } + ] + + position: QQC2.ToolBar.Header + } + } + + contentItem: Item { + implicitWidth: delegateLayout.implicitWidth + implicitHeight: delegateLayout.implicitHeight + ColumnLayout { + id: delegateLayout + spacing: Kirigami.Units.largeSpacing + anchors { + top: parent.top + left: parent.left + right: parent.right + } + + Kirigami.Separator { + Layout.fillWidth: true + } + QQC2.Label { + Layout.fillWidth: true + text: root.body + textFormat: Text.MarkdownText + wrapMode: Text.WordWrap + } + } + } +} diff --git a/kodereviewer/qml/Main.qml b/kodereviewer/qml/Main.qml index 0b41a05..e7496a6 100644 --- a/kodereviewer/qml/Main.qml +++ b/kodereviewer/qml/Main.qml @@ -53,33 +53,28 @@ Kirigami.ApplicationWindow { ProjectListPage { connection: root.connection project: root.project + + onPullRequestSelected: pr => { + print(pr) + pullRequestPageLoader.item.pullRequest = pr + + } } } } Loader { - id: placeHolderPageLoader + id: pullRequestPageLoader active: false - sourceComponent: Component { - Kirigami.Page { - - Kirigami.Theme.colorSet: Kirigami.Theme.View - Kirigami.Theme.inherit: false - title: "Select a pull request" - spacing: Kirigami.Units.largeSpacing * 2 - Kirigami.PlaceholderMessage { - anchors.centerIn: parent - icon.name: "org.deprecated.kodereviewer" - text: "Select a pull request" - } - } + sourceComponent: PullRequestPage { + connection: root.connection } } onProjectSelected: { projectListPageLoader.active = true - placeHolderPageLoader.active = true + pullRequestPageLoader.active = true pageStack.replace(projectListPageLoader.item) - pageStack.push(placeHolderPageLoader.item) + pageStack.push(pullRequestPageLoader.item) } } diff --git a/kodereviewer/qml/ProjectListPage.qml b/kodereviewer/qml/ProjectListPage.qml index 334799e..7ffa8b9 100644 --- a/kodereviewer/qml/ProjectListPage.qml +++ b/kodereviewer/qml/ProjectListPage.qml @@ -14,7 +14,7 @@ Kirigami.Page { required property NetworkManager connection required property Project project - + signal pullRequestSelected(var pullRequest) readonly property int currentWidth: _private.currentWidth + 1 @@ -77,6 +77,8 @@ Kirigami.Page { text: `${number} - ${title}` icon.name: "vcs-merge-request" + + onClicked: root.pullRequestSelected(pullRequestModel.get(index)) } } } diff --git a/kodereviewer/qml/PullRequestPage.qml b/kodereviewer/qml/PullRequestPage.qml new file mode 100644 index 0000000..19defdc --- /dev/null +++ b/kodereviewer/qml/PullRequestPage.qml @@ -0,0 +1,84 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtCore +import QtQuick.Controls as QQC2 +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami +import org.kde.kirigamiaddons.formcard as FormCard + +import org.deprecated.kodereviewer 1.0 + +Kirigami.ScrollablePage { + id: root + + property var pullRequest + property NetworkManager connection + + Kirigami.Theme.colorSet: Kirigami.Theme.View + Kirigami.Theme.inherit: false + + + CommentModel { + id: commentModel + pullRequest: root.pullRequest + + onPullRequestChanged: root.connection.getPullRequestComments(pullRequest.number) + } + + ListView { + id: listView + model: commentModel + + spacing: Kirigami.Units.largeSpacing * 2 + topMargin: Kirigami.Units.largeSpacing * 2 + rightMargin: Kirigami.Units.largeSpacing * 2 + leftMargin: Kirigami.Units.largeSpacing * 2 + bottomMargin: Kirigami.Units.largeSpacing * 2 // + commentToolbar.heigh + + header: ColumnLayout { + id: headerLayout + visible: !!root.pullRequest + width: ListView.view ? ListView.view.width - ListView.view.leftMargin - ListView.view.rightMargin : 0 + + Kirigami.Heading { + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + leftPadding: Kirigami.Units.largeSpacing + rightPadding: Kirigami.Units.largeSpacing + level: 1 + text: root.pullRequest ? root.pullRequest.title : "" + wrapMode: Text.WordWrap + } + + Kirigami.ListSectionHeader { + Layout.fillWidth: true + text: "description" + } + + QQC2.Label { + Layout.fillWidth: true + Layout.fillHeight: false + leftPadding: Kirigami.Units.largeSpacing + rightPadding: Kirigami.Units.largeSpacing + text: root.pullRequest ? root.pullRequest.body : "" + textFormat: Text.MarkdownText + wrapMode: Text.WordWrap + } + + Kirigami.ListSectionHeader { + Layout.fillWidth: true + text: "Comments" + } + } + + delegate: CommentDelegate {} + + Kirigami.PlaceholderMessage { + visible: !root.pullRequest + anchors.centerIn: parent + icon.name: "org.deprecated.kodereviewer" + text: "Select a pull request" + } + } +} |