summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMatias Linares <matias.linares@comprandoengrupo.net>2024-09-22 15:37:36 -0300
committerMatias Linares <matias.linares@comprandoengrupo.net>2024-09-22 15:37:36 -0300
commitc651ae6d7a11c77a607543a1afae863b20b6d174 (patch)
tree812d3b8fdb1b6d32b8046f0d55f882ed6db991ff
parent504d29accac51c537d5dcd42b129deb6f7463457 (diff)
downloadkodereviewer-c651ae6d7a11c77a607543a1afae863b20b6d174.tar.gz
Pull request description and comments working
-rw-r--r--kodereviewer/app.py8
-rw-r--r--kodereviewer/data.py115
-rw-r--r--kodereviewer/mdconverter.py30
-rw-r--r--kodereviewer/models/__init__.py9
-rw-r--r--kodereviewer/models/comments.py89
-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.py25
-rw-r--r--kodereviewer/project.py13
-rw-r--r--kodereviewer/qml/CommentDelegate.qml77
-rw-r--r--kodereviewer/qml/Main.qml27
-rw-r--r--kodereviewer/qml/ProjectListPage.qml4
-rw-r--r--kodereviewer/qml/PullRequestPage.qml84
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"
+ }
+ }
+}