mirror of
https://github.com/openSUSE/osc.git
synced 2025-11-03 12:58:54 +01:00
Add '--timeline' option to 'git-obs pr get'
This commit is contained in:
@@ -236,3 +236,63 @@ Scenario: Rebase a pull request checkout to non fast-forwardable changes
|
||||
v123
|
||||
v1.1
|
||||
"""
|
||||
|
||||
|
||||
# broken due to https://github.com/go-gitea/gitea/issues/35152
|
||||
# @destructive
|
||||
# Scenario: Display timeline associated with a pull request
|
||||
# # change title
|
||||
# Given I execute git-obs with args "api -X PATCH /repos/pool/test-GitPkgA/issues/1 --data '{{"title": "NEW TITLE"}}'"
|
||||
# # add comment
|
||||
# And I execute git-obs with args "pr comment --message 'test comment' 'pool/test-GitPkgA#1'"
|
||||
# # close PR
|
||||
# And I execute git-obs with args "api -X PATCH /repos/pool/test-GitPkgA/issues/1 --data '{{"state": "closed"}}'"
|
||||
# # reopen PR
|
||||
# And I execute git-obs with args "api -X PATCH /repos/pool/test-GitPkgA/pulls/1 --data '{{"state": "open"}}'"
|
||||
# # set assignees
|
||||
# And I execute git-obs with args "api -X PATCH /repos/pool/test-GitPkgA/pulls/1 --data '{{"assignees": ["alice", "bob"]}}'"
|
||||
# # unset assignee
|
||||
# And I execute git-obs with args "api -X PATCH /repos/pool/test-GitPkgA/pulls/1 --data '{{"assignees": ["bob"]}}'"
|
||||
# # change target branch
|
||||
# And I execute git-obs with args "api -X POST /repos/pool/test-GitPkgA/branches --data '{{"new_branch_name": "new-branch", "old_branch_name": "factory"}}'"
|
||||
# And I execute git-obs with args "api -X PATCH /repos/pool/test-GitPkgA/pulls/1 --data '{{"base": "new-branch"}}'"
|
||||
# # schedule merge
|
||||
# And I execute git-obs with args "pr merge 'pool/test-GitPkgA#1'"
|
||||
# # cancel the scheduled merge
|
||||
# And I execute git-obs with args "pr cancel-scheduled-merge 'pool/test-GitPkgA#1'"
|
||||
# # merge
|
||||
# And I execute git-obs with args "pr merge 'pool/test-GitPkgA#1' --now"
|
||||
# When I execute git-obs with args "pr get 'pool/test-GitPkgA#1' --timeline"
|
||||
# Then stdout matches
|
||||
# """
|
||||
# ID : pool/test-GitPkgA#1
|
||||
# URL : .*
|
||||
# Title : NEW TITLE
|
||||
# State : closed
|
||||
# Draft : no
|
||||
# Merged : yes
|
||||
# Allow edit : no
|
||||
# Author : Admin \(admin@example.com\)
|
||||
# Source : Admin/test-GitPkgA, branch: factory, commit: ........................................
|
||||
# Target : pool/test-GitPkgA, branch: new-branch, commit: ........................................
|
||||
# Description : some text
|
||||
#
|
||||
# Timeline:
|
||||
# ....-..-.. ..:.. Admin pushed 2 commits
|
||||
# ....-..-.. ..:.. Admin changed title
|
||||
# \| from 'Change version' to 'NEW TITLE'
|
||||
# ....-..-.. ..:.. Admin commented
|
||||
# \| test comment
|
||||
# ....-..-.. ..:.. Admin closed the pull request
|
||||
# ....-..-.. ..:.. Admin reopened the pull request
|
||||
# ....-..-.. ..:.. Admin assigned the pull request to Alice
|
||||
# ....-..-.. ..:.. Admin assigned the pull request to Bob
|
||||
# ....-..-.. ..:.. Admin unassigned the pull request from Alice
|
||||
# ....-..-.. ..:.. Admin changed target branch from 'factory' to 'new-branch'
|
||||
# ....-..-.. ..:.. Admin scheduled the pull request to auto merge when all checks succeed
|
||||
# ....-..-.. ..:.. Admin canceled auto merging the pull request when all checks succeed
|
||||
# ....-..-.. ..:.. Admin merged commit ........................................ to new-branch
|
||||
# ....-..-.. ..:.. Admin referenced the pull request from commit
|
||||
# \| http://localhost:{context.podman.container.ports[gitea_http]}/pool/test-GitPkgA/commit/........................................
|
||||
# \| Merge pull request 'NEW TITLE' \(#1\) from Admin/test-GitPkgA:factory into new-branch
|
||||
# """
|
||||
|
||||
@@ -22,6 +22,11 @@ class PullRequestGetCommand(osc.commandline_git.GitObsCommand):
|
||||
action="store_true",
|
||||
help="Show patches associated with the pull requests",
|
||||
)
|
||||
self.add_argument(
|
||||
"--timeline",
|
||||
action="store_true",
|
||||
help="Show timelines of the pull requests",
|
||||
)
|
||||
|
||||
def run(self, args):
|
||||
from osc import gitea_api
|
||||
@@ -43,6 +48,18 @@ class PullRequestGetCommand(osc.commandline_git.GitObsCommand):
|
||||
raise
|
||||
print(pr_obj.to_human_readable_string())
|
||||
|
||||
if args.timeline:
|
||||
print()
|
||||
print(tty.colorize("Timeline:", "bold"))
|
||||
timeline = gitea_api.IssueTimelineEntry.list(self.gitea_conn, owner, repo, pull)
|
||||
for entry in timeline:
|
||||
text, body = entry.format()
|
||||
if text is None:
|
||||
continue
|
||||
print(f"{gitea_api.dt_sanitize(entry.created_at)} {entry.user} {text}")
|
||||
for line in (body or "").strip().splitlines():
|
||||
print(f" | {line}")
|
||||
|
||||
if args.patch:
|
||||
print("")
|
||||
print(tty.colorize("Patch:", "bold"))
|
||||
|
||||
@@ -13,8 +13,10 @@ from .conf import Config
|
||||
from .conf import Login
|
||||
from .fork import Fork
|
||||
from .git import Git
|
||||
from .issue_timeline_entry import IssueTimelineEntry
|
||||
from .json import json_dumps
|
||||
from .pr import PullRequest
|
||||
from .pr_review import PullRequestReview
|
||||
from .repo import Repo
|
||||
from .ssh_key import SSHKey
|
||||
from .tardiff import TarDiff
|
||||
|
||||
@@ -193,5 +193,24 @@ class UserDoesNotExist(GiteaException):
|
||||
return result
|
||||
|
||||
|
||||
class PullRequestReviewDoesNotExist(GiteaException):
|
||||
RESPONSE_STATUS = 404
|
||||
# models/issues/review.go: return fmt.Sprintf("review does not exist [id: %d]", err.ID)
|
||||
RESPONSE_MESSAGE_RE = [
|
||||
re.compile(r"review does not exist \[id: (?P<review_id>.+)\]"),
|
||||
]
|
||||
|
||||
def __init__(self, response: GiteaHTTPResponse, owner: str, repo: str, number: str, review_id: str):
|
||||
super().__init__(response)
|
||||
self.owner = owner
|
||||
self.repo = repo
|
||||
self.number = number
|
||||
self.review_id = review_id
|
||||
|
||||
def __str__(self):
|
||||
result = f"Pull request '{self.owner}/{self.repo}#{self.number}' does not contain review with ID '{self.review_id}'"
|
||||
return result
|
||||
|
||||
|
||||
# gather all exceptions from this module that inherit from GiteaException
|
||||
EXCEPTION_CLASSES = [i for i in globals().values() if hasattr(i, "RESPONSE_MESSAGE_RE") and inspect.isclass(i) and issubclass(i, GiteaException)]
|
||||
|
||||
250
osc/gitea_api/issue_timeline_entry.py
Normal file
250
osc/gitea_api/issue_timeline_entry.py
Normal file
@@ -0,0 +1,250 @@
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from .common import GiteaModel
|
||||
from .common import dt_sanitize
|
||||
from .connection import Connection
|
||||
from .pr_review import PullRequestReview
|
||||
from .user import User
|
||||
|
||||
|
||||
class IssueTimelineEntry(GiteaModel):
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return self._data["type"]
|
||||
|
||||
@property
|
||||
def body(self) -> str:
|
||||
return self._data["body"]
|
||||
|
||||
@property
|
||||
def user(self) -> str:
|
||||
return self._data["user"]["login"]
|
||||
|
||||
@property
|
||||
def user_obj(self) -> User:
|
||||
return User(self._data["user"], response=self._response)
|
||||
|
||||
@property
|
||||
def created_at(self) -> str:
|
||||
return self._data["created_at"]
|
||||
|
||||
@property
|
||||
def updated_at(self) -> str:
|
||||
return self._data["updated_at"]
|
||||
|
||||
@property
|
||||
def created_updated_str(self) -> str:
|
||||
result = dt_sanitize(self.created_at)
|
||||
if self.updated_at and self.updated_at != self.created_at:
|
||||
result += f" (updated: {dt_sanitize(self.updated_at)})"
|
||||
return result
|
||||
|
||||
@property
|
||||
def review_id(self) -> Optional[int]:
|
||||
return self._data["review_id"]
|
||||
|
||||
@property
|
||||
def review_obj(self) -> Optional[PullRequestReview]:
|
||||
from .exceptions import PullRequestReviewDoesNotExist
|
||||
|
||||
if not self.review_id:
|
||||
return None
|
||||
try:
|
||||
return PullRequestReview.get(self._conn, self.pr_owner, self.pr_repo, self.pr_number, str(self.review_id))
|
||||
except PullRequestReviewDoesNotExist:
|
||||
# reviews can be removed from the database, but their IDs remain in other places
|
||||
return None
|
||||
|
||||
@property
|
||||
def pr_owner(self) -> str:
|
||||
from .pr import PullRequest
|
||||
|
||||
return PullRequest.get_owner_repo_number(self._data["pull_request_url"])[0]
|
||||
|
||||
@property
|
||||
def pr_repo(self) -> str:
|
||||
from .pr import PullRequest
|
||||
|
||||
return PullRequest.get_owner_repo_number(self._data["pull_request_url"])[1]
|
||||
|
||||
@property
|
||||
def pr_number(self) -> int:
|
||||
from .pr import PullRequest
|
||||
|
||||
return PullRequest.get_owner_repo_number(self._data["pull_request_url"])[2]
|
||||
|
||||
def format(self):
|
||||
handler = getattr(self, f"_format_{self.type}", None)
|
||||
if handler is None:
|
||||
return (self.type, self.body)
|
||||
if not callable(handler):
|
||||
raise TypeError(f"Handler for {self.type} is not callable")
|
||||
return handler() # pylint: disable=not-callable
|
||||
|
||||
def _format_assignees(self):
|
||||
if self._data["removed_assignee"]:
|
||||
return f"unassigned the pull request from {self._data['assignee']['login']}", None
|
||||
return f"assigned the pull request to {self._data['assignee']['login']}", None
|
||||
|
||||
def _format_change_target_branch(self):
|
||||
return f"changed target branch from '{self._data['old_ref']}' to '{self._data['new_ref']}'", None
|
||||
|
||||
def _format_change_title(self):
|
||||
return "changed title", f"from '{self._data['old_title']}' to '{self._data['new_title']}'"
|
||||
|
||||
def _format_comment(self):
|
||||
return "commented", self.body
|
||||
|
||||
def _format_comment_ref(self):
|
||||
return "referenced the pull request", self._data["ref_comment"]["html_url"]
|
||||
|
||||
def _format_commit_ref(self):
|
||||
import urllib.parse
|
||||
from osc.util import xml as osc_xml
|
||||
|
||||
node = osc_xml.xml_fromstring(self.body)
|
||||
assert node.tag == "a"
|
||||
|
||||
netloc = self._conn.host
|
||||
if self._conn.port:
|
||||
netloc += f":{self._conn.port}"
|
||||
url = urllib.parse.urlunsplit((self._conn.scheme, netloc, node.attrib["href"], "", ""))
|
||||
body = f"{url}\n{node.text}".strip()
|
||||
|
||||
return f"referenced the pull request from commit", body
|
||||
|
||||
def _format_close(self):
|
||||
return "closed the pull request", self.body
|
||||
|
||||
def _format_delete_branch(self):
|
||||
return f"deleted branch '{self._data['old_ref']}'", None
|
||||
|
||||
def _format_dismiss_review(self):
|
||||
return f"dismissed {self.review_obj.user}'s review", self.body
|
||||
|
||||
def _format_merge_pull(self):
|
||||
from .pr import PullRequest
|
||||
|
||||
pr_obj = PullRequest.get(self._conn, self.pr_owner, self.pr_repo, self.pr_number)
|
||||
return f"merged commit {pr_obj.merge_commit} to {pr_obj.base_branch}", None
|
||||
|
||||
def _format_pull_cancel_scheduled_merge(self):
|
||||
return "canceled auto merging the pull request when all checks succeed", None
|
||||
|
||||
def _format_pull_push(self):
|
||||
import json
|
||||
|
||||
data = json.loads(self.body)
|
||||
len_commits = len(data["commit_ids"])
|
||||
return f"{'force-' if data['is_force_push'] else ''}pushed {len_commits} commit{'s' if len_commits > 1 else ''}", None
|
||||
|
||||
def _format_pull_ref(self):
|
||||
return "referenced the pull request", f"{self._data['ref_issue']['html_url']}\n{self._data['ref_issue']['title']}"
|
||||
|
||||
def _format_pull_scheduled_merge(self):
|
||||
return "scheduled the pull request to auto merge when all checks succeed", None
|
||||
|
||||
def _format_reopen(self):
|
||||
return "reopened the pull request", self.body
|
||||
|
||||
def _format_review(self):
|
||||
messages = {
|
||||
"APPROVED": "approved",
|
||||
"REQUEST_CHANGES": "declined",
|
||||
"COMMENTED": "commented",
|
||||
}
|
||||
msg = messages.get(self.review_obj.state, self.review_obj.state)
|
||||
return f"{msg} the review", self.body
|
||||
|
||||
def _format_review_request(self):
|
||||
reviewer = self._data["assignee"]["login"] if self._data["assignee"] else self._data["assignee_team"]["name"]
|
||||
return f"requested review from {reviewer}", self.body
|
||||
|
||||
# unused; we are not interested in these types of entries
|
||||
|
||||
def _format_added_deadline(self):
|
||||
return None, None
|
||||
|
||||
def _format_modified_deadline(self):
|
||||
return None, None
|
||||
|
||||
def _format_removed_deadline(self):
|
||||
return None, None
|
||||
|
||||
def _format_pin(self):
|
||||
return None, None
|
||||
|
||||
def _format_unpin(self):
|
||||
return None, None
|
||||
|
||||
def _format_change_time_estimate(self):
|
||||
return None, None
|
||||
|
||||
def _format_project(self):
|
||||
return None, None
|
||||
|
||||
def _format_project_board(self):
|
||||
return None, None
|
||||
|
||||
def _format_start_tracking(self):
|
||||
return None, None
|
||||
|
||||
def _format_stop_tracking(self):
|
||||
return None, None
|
||||
|
||||
def _format_add_time_manual(self):
|
||||
return None, None
|
||||
|
||||
def _format_delete_time_manual(self):
|
||||
return None, None
|
||||
|
||||
def _format_cancel_tracking(self):
|
||||
return None, None
|
||||
|
||||
def _format_label(self):
|
||||
return None, None
|
||||
|
||||
def _format_milestone(self):
|
||||
return None, None
|
||||
|
||||
def _format_lock(self):
|
||||
return None, None
|
||||
|
||||
def _format_unlock(self):
|
||||
return None, None
|
||||
|
||||
def _format_add_dependency(self):
|
||||
return None, None
|
||||
|
||||
def _format_remove_dependency(self):
|
||||
return None, None
|
||||
|
||||
# TODO: find a reproducer for formatting the following entries
|
||||
# def _format_issue_ref(self):
|
||||
# def _format_code(self):
|
||||
# def _format_change_issue_ref(self):
|
||||
|
||||
@classmethod
|
||||
def list(
|
||||
cls,
|
||||
conn: Connection,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
) -> List["IssueTimelineEntry"]:
|
||||
"""
|
||||
List issue timeline entries (applicable to issues and pull request).
|
||||
|
||||
:param conn: Gitea ``Connection`` instance.
|
||||
:param owner: Owner of the repo.
|
||||
:param repo: Name of the repo.
|
||||
:param number: Number of the pull request in owner/repo.
|
||||
"""
|
||||
q = {
|
||||
"limit": -1,
|
||||
}
|
||||
url = conn.makeurl("repos", owner, repo, "issues", str(number), "timeline", query=q)
|
||||
response = conn.request("GET", url)
|
||||
obj_list = [cls(i, response=response, conn=conn) for i in response.json() or []]
|
||||
return obj_list
|
||||
135
osc/gitea_api/pr_review.py
Normal file
135
osc/gitea_api/pr_review.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from .common import GiteaModel
|
||||
from .common import dt_sanitize
|
||||
from .connection import Connection
|
||||
from .user import User
|
||||
|
||||
|
||||
class PullRequestReview(GiteaModel):
|
||||
@property
|
||||
def commit(self) -> str:
|
||||
return self._data["commit_id"]
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
return self._data["state"]
|
||||
|
||||
@property
|
||||
def dismissed(self) -> str:
|
||||
return self._data["dismissed"]
|
||||
|
||||
@property
|
||||
def user(self) -> Optional[str]:
|
||||
if not self._data["user"]:
|
||||
return None
|
||||
return self._data["user"]["login"]
|
||||
|
||||
@property
|
||||
def user_obj(self) -> Optional[str]:
|
||||
if not self._data["user"]:
|
||||
return None
|
||||
return User(self._data["user"])
|
||||
|
||||
@property
|
||||
def team(self) -> Optional[str]:
|
||||
if not self._data["team"]:
|
||||
return None
|
||||
return self._data["team"]["name"]
|
||||
|
||||
@property
|
||||
def who(self) -> str:
|
||||
return self.user if self.user else f"@{self.team}"
|
||||
|
||||
@property
|
||||
def who_login_full_name_email(self) -> str:
|
||||
return self.user_obj.login_full_name_email if self.user_obj else f"@{self.team}"
|
||||
|
||||
@property
|
||||
def created_at(self) -> str:
|
||||
return self._data["submitted_at"]
|
||||
|
||||
@property
|
||||
def updated_at(self) -> str:
|
||||
return self._data["updated_at"]
|
||||
|
||||
@property
|
||||
def created_updated_str(self) -> str:
|
||||
result = dt_sanitize(self.created_at)
|
||||
if self.updated_at and self.updated_at != self.created_at:
|
||||
result += f" (updated: {dt_sanitize(self.updated_at)})"
|
||||
return result
|
||||
|
||||
@property
|
||||
def body(self) -> str:
|
||||
return self._data["body"]
|
||||
|
||||
@property
|
||||
def pr_owner(self) -> str:
|
||||
from .pr import PullRequest
|
||||
|
||||
return PullRequest.get_owner_repo_number(self._data["pull_request_url"])[0]
|
||||
|
||||
@property
|
||||
def pr_repo(self) -> str:
|
||||
from .pr import PullRequest
|
||||
|
||||
return PullRequest.get_owner_repo_number(self._data["pull_request_url"])[1]
|
||||
|
||||
@property
|
||||
def pr_number(self) -> int:
|
||||
from .pr import PullRequest
|
||||
|
||||
return PullRequest.get_owner_repo_number(self._data["pull_request_url"])[2]
|
||||
|
||||
@property
|
||||
def comments_count(self) -> int:
|
||||
return self._data["comments_count"]
|
||||
|
||||
@classmethod
|
||||
def get(
|
||||
cls,
|
||||
conn: Connection,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
review_id: int
|
||||
) -> "PullRequestReview":
|
||||
"""
|
||||
Get a review associated with a pull request.
|
||||
|
||||
:param conn: Gitea ``Connection`` instance.
|
||||
:param owner: Owner of the repo.
|
||||
:param repo: Name of the repo.
|
||||
:param number: Number of the pull request in owner/repo.
|
||||
:param review_id: ID of the review.
|
||||
"""
|
||||
url = conn.makeurl("repos", owner, repo, "pulls", str(number), "reviews", review_id)
|
||||
response = conn.request("GET", url, context={"owner": owner, "repo": repo, "number": number})
|
||||
obj = cls(response.json(), response=response, conn=conn)
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def list(
|
||||
cls,
|
||||
conn: Connection,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
) -> List["PullRequestReview"]:
|
||||
"""
|
||||
List reviews associated with a pull request.
|
||||
|
||||
:param conn: Gitea ``Connection`` instance.
|
||||
:param owner: Owner of the repo.
|
||||
:param repo: Name of the repo.
|
||||
:param number: Number of the pull request in owner/repo.
|
||||
"""
|
||||
q = {
|
||||
"limit": -1,
|
||||
}
|
||||
url = conn.makeurl("repos", owner, repo, "pulls", str(number), "reviews", query=q)
|
||||
response = conn.request("GET", url)
|
||||
obj_list = [cls(i, response=response, conn=conn) for i in response.json()]
|
||||
return obj_list
|
||||
Reference in New Issue
Block a user