diff options
author | Matias Linares <matias.linares@comprandoengrupo.net> | 2025-02-25 17:05:51 -0300 |
---|---|---|
committer | Matias Linares <matias.linares@comprandoengrupo.net> | 2025-02-27 11:57:57 -0300 |
commit | 265a1db778542623e42f1b35857579c25160df88 (patch) | |
tree | 8696da64c52d8b15b21c3a442b9fc8c4747134aa | |
parent | c5f854946a40b1ec278dfa999b3aaba9109dfc42 (diff) | |
download | kodereviewer-265a1db778542623e42f1b35857579c25160df88.tar.gz |
Add reviewer dialog and begin new diff views
-rw-r--r-- | .gitignore | 5 | ||||
-rw-r--r-- | kodereviewer/diff_parser.py | 146 | ||||
-rw-r--r-- | kodereviewer/models/diff_file.py | 87 | ||||
-rw-r--r-- | kodereviewer/models/file.py | 1 | ||||
-rw-r--r-- | kodereviewer/network_manager.py | 39 | ||||
-rw-r--r-- | kodereviewer/qml/FilesChangedPage.qml | 21 | ||||
-rw-r--r-- | kodereviewer/qml/FilesDrawer.qml | 1 | ||||
-rw-r--r-- | kodereviewer/qml/PullRequestDescription.qml | 2 | ||||
-rw-r--r-- | kodereviewer/qml/ReviewWindow.qml | 76 | ||||
-rw-r--r-- | pyproject.toml | 3 | ||||
-rw-r--r-- | tests/__init__.py | 0 | ||||
-rw-r--r-- | tests/diff_parser_tests.py | 22 |
12 files changed, 395 insertions, 8 deletions
@@ -1,6 +1,7 @@ *.json qmltypes -tests/ +mytests/ +tmp/ # Byte-compiled / optimized / DLL files __pycache__/ @@ -163,4 +164,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/
\ No newline at end of file +#.idea/ diff --git a/kodereviewer/diff_parser.py b/kodereviewer/diff_parser.py new file mode 100644 index 0000000..378c795 --- /dev/null +++ b/kodereviewer/diff_parser.py @@ -0,0 +1,146 @@ +import logging +import re +from collections.abc import Sequence + +from rich.logging import RichHandler + +HEADER_RE = re.compile(r'@@ -(?P<original_start_line>\d+),(?P<original_line_count>\d+)' + r' \+(?P<new_start_line>\d+),(?P<new_line_count>\d+)' + r' @@ ?(?P<context>.*)') + +logging.basicConfig( + level="INFO", format="%(message)s", datefmt="[%X]", handlers=[RichHandler()] +) +logger = logging.getLogger(__name__) + + +class SideBySideDiffLine: + line: int + original: str + new: str + + def __init__(self, line: int, original: str, new: str): + self.line = line + self.original = original + self.new = new + + +class Hunk: + header: str + lines: list[str] + + original_start_line: int + original_line_count: int + new_start_line: int + new_line_count: int + context: str + + original: list[str] + new: list[str] + + def __init__(self, hunk_str: str): + header, *body = hunk_str.splitlines() + self.header = header + self.lines = body + + self.parse_header(header) + self.parse_body(body) + + def parse_header(self, header: str) -> None: + """Parses a header. + The format is: @@ -l,s +l,s @@ optional section heading + where + l: starting line + s: number of lines + + example: @@ -13,10 +13,15 @@ on: + """ + match = HEADER_RE.match(header) + if match: + self.original_start_line = int(match.group('original_start_line')) + self.original_line_count = int(match.group('original_line_count')) + self.new_start_line = int(match.group('new_start_line')) + self.new_line_count = int(match.group('new_line_count')) + self.context = match.group('context') + else: + raise ValueError(f'Error parsing {header}') + + def parse_body(self, lines: list[str]) -> None: + self.original = [] + self.new = [] + + for line in lines: + if line.startswith('-'): + self.original.append(line) + elif line.startswith('+'): + self.new.append(line) + else: + self.original.append(line) + self.new.append(line) + + def side_by_side(self) -> Sequence[SideBySideDiffLine]: + start = min(self.original_start_line, self.new_start_line) + end = max(self.original_start_line + self.original_line_count, self.new_start_line, self.new_line_count) + retval: list[SideBySideDiffLine] = [] + original_line_counter = 0 + new_line_counter = 0 + + for line in range(start, end): + original = '' + new = '' + if line in range(self.original_start_line, self.original_start_line + self.original_line_count): + original = self.original[original_line_counter] + original_line_counter += 1 + + if line in range(self.new_start_line, self.new_start_line + self.new_line_count): + new = self.new[new_line_counter] + new_line_counter += 1 + + retval.append(SideBySideDiffLine(line, original, new)) + + return retval + + +class GithubDiffParser: + diff: str + + def __init__(self, diff: str): + self.diff = diff + + def hunks(self) -> list[Hunk]: + retval: list[Hunk] = [] + + hunk_str = '' + for line in self.diff.splitlines(): + if line.startswith('@@'): + logger.info('adding %s', line) + if hunk_str != '': + retval.append(Hunk(hunk_str)) + hunk_str = '' + hunk_str += line + hunk_str += '\n' + if hunk_str != '': + retval.append(Hunk(hunk_str)) + return retval + +if __name__ == '__main__': + from rich.console import Console + from rich.table import Table + + import sys + filename = sys.argv[-1] + + with open(filename) as fp: + parser = GithubDiffParser(fp.read()) + for hunk in parser.hunks(): + + table = Table(title=hunk.context) + table.add_column('line') + table.add_column('original') + table.add_column('new') + + for diff_line in hunk.side_by_side(): + table.add_row(str(diff_line.line), diff_line.original, diff_line.new) + + console = Console() + console.print(table) diff --git a/kodereviewer/models/diff_file.py b/kodereviewer/models/diff_file.py new file mode 100644 index 0000000..c84eeca --- /dev/null +++ b/kodereviewer/models/diff_file.py @@ -0,0 +1,87 @@ +"""Model used for line numbers in Editor.qml""" + +import logging +from enum import auto, IntEnum +from typing import Optional + +import rich +from PySide6.QtCore import QAbstractListModel, QByteArray, QModelIndex, QObject, QPersistentModelIndex, QStandardPaths, QUrl, Qt, Signal, Slot, Property +from PySide6.QtQuick import QQuickTextDocument +from PySide6.QtQml import QmlElement + +from kodereviewer.diff_parser import Diff, DiffBlock + +QML_IMPORT_NAME = "org.deprecated.kodereviewer" +QML_IMPORT_MAJOR_VERSION = 1 + +logger = logging.getLogger(__name__) + + +@QmlElement +class DiffFileModel(QAbstractListModel): + + _diff_str: str + _diff: Diff + blocks: list[DiffBlock] = [] + + class Roles(IntEnum): + Content = Qt.ItemDataRole.UserRole + 1 + LineCount = auto() + + def __init__(self, *args, **kwargs): + self._diff_str = '' + super().__init__(*args, **kwargs) + + + def get_diff(self): + return self._diff_str + + def set_diff(self, diff): + if diff == self._diff_str: + return + self._diff_str = diff + self._diff = Diff(diff) + self.diffChanged.emit() + self.resetModel() + + diffChanged = Signal() + diff = Property(QObject, fget=get_diff, fset=set_diff, + notify=diffChanged) + + def data(self, + index: QModelIndex | QPersistentModelIndex, + role: int = Qt.ItemDataRole.DisplayRole) -> object: + + if not index.isValid(): + logger.debug('index not valid') + return + + if self._diff_str is None: + return + + row = index.row() + if row < 0 or row > self.rowCount(): + logger.debug(f'row: {row}') + return + + block = self.blocks[row] + + if role == self.Roles.Content: + return block.content + if role == self.Roles.LineCount: + return len(block.content.split("\n")) + + def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int: + return len(self.blocks) + + def roleNames(self) -> dict[int, QByteArray]: + return { + self.Roles.Content: QByteArray(b'content'), + } + + + @Slot() + def resetModel(self) -> None: + logger.debug('reseting model?') + self.beginResetModel() + self.endResetModel() diff --git a/kodereviewer/models/file.py b/kodereviewer/models/file.py index c8e4da6..cc3f8be 100644 --- a/kodereviewer/models/file.py +++ b/kodereviewer/models/file.py @@ -27,6 +27,7 @@ class FileItem: name: str parent: Self | None patch: str | None = None + sha: str | None = None children: list[Self] = field(default_factory=list) def append(self, child: Self): diff --git a/kodereviewer/network_manager.py b/kodereviewer/network_manager.py index cbe5160..faee87e 100644 --- a/kodereviewer/network_manager.py +++ b/kodereviewer/network_manager.py @@ -51,7 +51,6 @@ class NetworkManager(QObject): headers.append(QHttpHeaders.WellKnownHeader.Authorization, f'Bearer {github_token}') headers.append(QHttpHeaders.WellKnownHeader.UserAgent, "kodereviewer") - print(f'Authorization: Bearer {github_token}') self._request_factory.setCommonHeaders(headers) self._manager.finished.connect(self.reply_finished) @@ -66,6 +65,7 @@ class NetworkManager(QObject): self.projectChanged.emit() base_url = f'https://api.github.com/repos/{self._project.owner}/{self._project.name}' + logger.info(f'got base url: "{base_url}"') self._request_factory.setBaseUrl(base_url) project = Property(Project, fget=project, fset=set_project) @@ -102,7 +102,13 @@ class NetworkManager(QObject): pull_request_number = int(match.groups()[0]) pull_request: Optional[PullRequest] = self._project.find_pull_request(pull_request_number) if pull_request is not None: - pull_request.load_reviews(response_body) + + if reply.operation() == QNetworkAccessManager.Operation.GetOperation: + pull_request.load_reviews(response_body) + elif reply.operation() == QNetworkAccessManager.Operation.PostOperation: + logger.info(response_body) + else: + logger.info(f'Unknown operation: {reply.operation()}') else: logger.info(f"Can't handle {reply.url()}") @@ -134,6 +140,8 @@ class NetworkManager(QObject): ) ) + # Reviews + @Slot(int) def getPullRequestReviews(self, pull_request_number: int) -> None: self._manager.get( @@ -157,3 +165,30 @@ class NetworkManager(QObject): 'comments': [] } self._manager.post(request, json.dumps(data).encode()) + + @Slot(int, str, str, str, int, int) + def createReviewComment( + self, + pull_request_number: int, + body: str, + path: str, + commit_id: str, + line: int, + start_line: int, + ) -> None: + request = self._request_factory.createRequest( + f'/pulls/{pull_request_number}/comments' + ) + data = { + 'body': body, + 'commit_id': commit_id, + 'path': path, + 'start_line': start_line, + 'start_side': 'RIGHT', + 'line': line, + 'side': 'RIGHT' + + } + logger.info('Creating review comment') + logger.info(data) + self._manager.post(request, json.dumps(data).encode()) diff --git a/kodereviewer/qml/FilesChangedPage.qml b/kodereviewer/qml/FilesChangedPage.qml index fe2aaf6..25c1164 100644 --- a/kodereviewer/qml/FilesChangedPage.qml +++ b/kodereviewer/qml/FilesChangedPage.qml @@ -30,6 +30,7 @@ Kirigami.ScrollablePage { onPullRequestChanged: { root.currentFile = "" root.currentText = "" + root.currentSha = "" } Component.onCompleted: { @@ -85,7 +86,7 @@ Kirigami.ScrollablePage { id: editor Layout.fillWidth: true Layout.fillHeight: true - file: root.currentFile + file: root.currentFile + '.diff' text: root.currentText MouseArea { @@ -108,7 +109,21 @@ Kirigami.ScrollablePage { onTriggered: { console.log("triggered review") console.log(`start line: ${editor.selectionStartLine()} to ${editor.selectionEndLine()}`) - popup.open() + + const component = Qt.createComponent("ReviewWindow.qml") + print(applicationWindow().project) + const params = { + project: applicationWindow().project, + pullRequestNumber: root.pullRequest.number, + diffText: root.currentText, + path: root.currentFile, + startLine: editor.selectionStartLine(), + endLine: editor.selectionEndLine(), + commitId: root.pullRequest.last_commit + } + component.createObject(applicationWindow(), params) + + //popup.open() } } } @@ -120,7 +135,7 @@ Kirigami.ScrollablePage { target: contextDrawer function onFileSelected(filename, text) { console.log("file changed!") - root.currentFile = filename + '.diff' + root.currentFile = filename root.currentText = text } } diff --git a/kodereviewer/qml/FilesDrawer.qml b/kodereviewer/qml/FilesDrawer.qml index 3f98290..c4cfddf 100644 --- a/kodereviewer/qml/FilesDrawer.qml +++ b/kodereviewer/qml/FilesDrawer.qml @@ -1,3 +1,4 @@ +pragma ComponentBehavior: Bound import QtQuick import QtQuick.Controls as QQC2 import QtQuick.Layouts diff --git a/kodereviewer/qml/PullRequestDescription.qml b/kodereviewer/qml/PullRequestDescription.qml index 329d288..7a91d14 100644 --- a/kodereviewer/qml/PullRequestDescription.qml +++ b/kodereviewer/qml/PullRequestDescription.qml @@ -126,7 +126,7 @@ Kirigami.FormLayout { Kirigami.Separator { Kirigami.FormData.isSection: true - Kirigami.FormData.label: "Reviewers" + Kirigami.FormData.label: i18n("Assignees") } Loader { diff --git a/kodereviewer/qml/ReviewWindow.qml b/kodereviewer/qml/ReviewWindow.qml new file mode 100644 index 0000000..b3d58cc --- /dev/null +++ b/kodereviewer/qml/ReviewWindow.qml @@ -0,0 +1,76 @@ +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.kde.kirigamiaddons.components as KirigamiComponents + +import org.deprecated.kodereviewer 1.0 + +Kirigami.ApplicationWindow { + id: root + height: Kirigami.Units.gridUnit * 30 + width: Kirigami.Units.gridUnit * 30 + minimumWidth: Kirigami.Units.gridUnit * 15 + minimumHeight: Kirigami.Units.gridUnit * 20 + + property Project project + + property NetworkManager connection: NetworkManager { + project: root.project + } + + property int pullRequestNumber + property string diffText: '' + property string path: '/src/bla.c++' + property int startLine: 1 + property int endLine: 2 + property string commitId: '' + + title: i18n("New review") + + + pageStack.initialPage: Kirigami.ScrollablePage { + id: page + title: root.path + + Kirigami.FlexColumn { + Editor { + Layout.fillWidth: true + + text: root.diffText + } + MarkdownTextArea { + id: commentTextArea + placeholderText: "Say something" + Layout.fillWidth: true + } + } + + footer: RowLayout { + Item { + Layout.fillWidth: true + } + QQC2.Button { + text: i18n("Comment now") + icon.name: "document-send-symbolic" + onClicked: { + root.submit() + } + } + QQC2.Button { + text: i18n("Add review (TODO)") + enabled: false + + } + } + } + + function submit() { + connection.createReviewComment(pullRequestNumber, commentTextArea.text, + path, commitId, endLine, startLine) + } +} diff --git a/pyproject.toml b/pyproject.toml index 657ab63..ff00bc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,3 +44,6 @@ kodereviewer = "kodereviewer.app:main" [tool.pyright] venv = "venv" venvPath = "." + +[tool.pytest.ini_options] +python_files = ["*_tests.py"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py diff --git a/tests/diff_parser_tests.py b/tests/diff_parser_tests.py new file mode 100644 index 0000000..023430e --- /dev/null +++ b/tests/diff_parser_tests.py @@ -0,0 +1,22 @@ +from kodereviewer.diff_parser import Hunk + + +class TestHunk: + def test_parse_header(self): + hunk = Hunk('@@ -13,10 +13,15 @@ on:') + + assert hunk.original_start_line == 13 + assert hunk.original_line_count == 10 + assert hunk.new_start_line == 13 + assert hunk.new_line_count == 15 + assert hunk.context == 'on:' + + def test_parse_hunk_different_lines(self): + hunk = Hunk('@@ -68,5 +81,10 @@ jobs:') + + assert hunk.original_start_line == 68 + assert hunk.original_line_count == 5 + + assert hunk.new_start_line == 81 + assert hunk.new_line_count == 10 + |