From be601cab41a8c14ff63b150b92df0623d5778383 Mon Sep 17 00:00:00 2001 From: Shrikanth Upadhayaya Date: Wed, 14 Jun 2023 14:13:44 -0700 Subject: [PATCH 1/3] Add APIs for creating comments and comment attachments --- allspice/allspice.py | 35 +++++++++- allspice/apiobject.py | 159 ++++++++++++++++++++++++++++++++++++++---- tests/test_api.py | 99 +++++++++++++++++++++++++- 3 files changed, 277 insertions(+), 16 deletions(-) diff --git a/allspice/allspice.py b/allspice/allspice.py index c032a8b..ee8cff6 100644 --- a/allspice/allspice.py +++ b/allspice/allspice.py @@ -1,6 +1,6 @@ import logging import json -from typing import List, Dict, Union +from typing import List, Dict, Union, Optional from frozendict import frozendict import requests @@ -152,8 +152,37 @@ def requests_delete(self, endpoint: str): self.logger.error(message) raise Exception(message) - def requests_post(self, endpoint: str, data: dict): - request = self.requests.post(self.__get_url(endpoint), headers=self.headers, data=json.dumps(data)) + def requests_post( + self, + endpoint: str, + data: Optional[dict] = None, + params: Optional[dict] = None, + files: Optional[dict] = None, + ): + """ + Make a POST call to the endpoint. + + :param endpoint: The path to the endpoint + :param data: A dictionary for JSON data + :param params: A dictionary of query params + :param files: A dictionary of files, see requests.post. Using both files and data + can lead to unexpected results! + :return: The JSON response parsed as a dict + """ + + args = { + "headers": self.headers.copy(), + } + if data is not None: + args["data"] = json.dumps(data) + if params is not None: + args["params"] = params + if files is not None: + args["headers"].pop("Content-type") + args["files"] = files + + request = self.requests.post(self.__get_url(endpoint), **args) + if request.status_code not in [200, 201, 202]: if ("already exists" in request.text or "e-mail already in use" in request.text): self.logger.warning(request.text) diff --git a/allspice/apiobject.py b/allspice/apiobject.py index 5f2f579..634ee93 100644 --- a/allspice/apiobject.py +++ b/allspice/apiobject.py @@ -1,6 +1,7 @@ import logging from datetime import datetime -from typing import List, Tuple, Dict, Sequence, Optional, Union, Set +from functools import cached_property +from typing import List, Tuple, Dict, Sequence, Optional, Union, Set, IO from .baseapiobject import ReadonlyApiObject, ApiObject from .exceptions import * @@ -708,24 +709,149 @@ def request(cls, allspice_client: 'AllSpice', owner: str, repo: str, number: str return cls._request(allspice_client, {"owner": owner, "repo": repo, "number": number}) +class Attachment(ReadonlyApiObject): + """ + An asset attached to a comment. + + You cannot edit or delete the attachment from this object - see the instance methods + Comment.edit_attachment and delete_attachment for that. + """ + + def __init__(self, allspice_client): + super().__init__(allspice_client) + + def __eq__(self, other): + if not isinstance(other, Attachment): + return False + + return self.uuid == other.uuid + + def __hash__(self): + return hash(self.uuid) + + class Comment(ApiObject): + API_OBJECT = """/repos/{owner}/{repo}/issues/comments/{id}""" + GET_ATTACHMENTS_PATH = """/repos/{owner}/{repo}/issues/comments/{id}/assets""" + ATTACHMENT_PATH = """/repos/{owner}/{repo}/issues/comments/{id}/assets/{attachment_id}""" def __init__(self, allspice_client): super().__init__(allspice_client) def __eq__(self, other): - if not isinstance(other, Comment): return False + if not isinstance(other, Comment): + return False return self.repo == other.repo and self.id == other.id def __hash__(self): return hash(self.repo) ^ hash(self.id) + @classmethod + def request( + cls, + allspice_client: 'AllSpice', + owner: str, + repo: str, + id: str + ) -> 'Comment': + return cls._request(allspice_client, {"owner": owner, "repo": repo, "id": id}) + _fields_to_parsers = { "user": lambda allspice_client, r: User.parse_response(allspice_client, r), "created_at": lambda allspice_client, t: Util.convert_time(t), "updated_at": lambda allspice_client, t: Util.convert_time(t), } + _patchable_fields = { + "body" + } + + @property + def parent_url(self) -> str: + """URL of the parent of this comment (the issue or the pull request)""" + + if self.issue_url is not None: + return self.issue_url + else: + return self.pull_request_url + + @cached_property + def repository(self) -> Repository: + """The repository this comment was posted on.""" + + owner_name, repo_name = self.parent_url.split("/")[-4:-2] + return Repository.request(self.allspice_client, owner_name, repo_name) + + def __fields_for_path(self): + return { + "owner": self.repository.owner.username, + "repo": self.repository.name, + "id": self.id, + } + + def commit(self): + values = self.get_dirty_fields() + + self.allspice_client.requests_patch( + self.API_OBJECT.format(**self.__fields_for_path()), data=values + ) + self.dirty_fields = {} + + def delete(self): + self.allspice_client.requests_delete( + self.API_OBJECT.format(**self.__fields_for_path()) + ) + self.deleted = True + + def get_attachments(self): + results = self.allspice_client.requests_get( + self.GET_ATTACHMENTS_PATH.format(**self.__fields_for_path()) + ) + return [Attachment.parse_response(self.allspice_client, result) for result in results] + + def create_attachment(self, file: IO, name: Optional[str] = None) -> Attachment: + args = { + "files": {"attachment": file}, + } + if name is not None: + args["params"] = {"name": name} + + result = self.allspice_client.requests_post( + self.GET_ATTACHMENTS_PATH.format(**self.__fields_for_path()), + **args, + ) + return Attachment.parse_response(self.allspice_client, result) + + def edit_attachment(self, attachment: Attachment, data: dict) -> Attachment: + """ + Edit an attachment. + + The list of params that can be edited is available at + https://hub.allspice.io/api/swagger#/issue/issueEditIssueCommentAttachment + + :param attachment: The attachment to be edited + :param data: The data parameter should be a dictionary of the fields to edit. + :return: The edited attachment + """ + + args = { + **self.__fields_for_path(), + "attachment_id": attachment.id, + } + result = self.allspice_client.requests_patch( + self.ATTACHMENT_PATH.format(**args), + data=data, + ) + return Attachment.parse_response(self.allspice_client, result) + + def delete_attachment(self, attachment: Attachment): + args = { + **self.__fields_for_path(), + "attachment_id": attachment.id, + } + self.allspice_client.requests_delete(self.ATTACHMENT_PATH.format(**args)) + attachment.deleted = True + class Commit(ReadonlyApiObject): @@ -757,7 +883,7 @@ def parse_response(cls, allspice_client, result) -> 'Commit': class Issue(ApiObject): API_OBJECT = """/repos/{owner}/{repo}/issues/{index}""" # GET_TIME = """/repos/%s/%s/issues/%s/times""" # - GET_COMMENTS = """/repos/%s/%s/issues/comments""" + GET_COMMENTS = """/repos/{owner}/{repo}/issues/{index}/comments""" CREATE_ISSUE = """/repos/{owner}/{repo}/issues""" OPENED = "open" @@ -840,20 +966,29 @@ def add_time(self, time: int, created: str = None, user_name: User = None): path, data={"created": created, "time": int(time), "user_name": user_name} ) - def get_comments(self) -> List[ApiObject]: + def get_comments(self) -> List[Comment]: results = self.allspice_client.requests_get( - Issue.GET_COMMENTS % (self.owner.username, self.repo.name) + self.GET_COMMENTS.format( + owner=self.owner.username, + repo=self.repo.name, + index=self.number + ) ) - allProjectComments = [ - Comment.parse_response(self.allspice_client, result) for result in results - ] - # Comparing the issue id with the URL seems to be the only (!) way to get to the comments of one issue + return [ - comment - for comment in allProjectComments - if comment.issue_url.endswith("/" + str(self.number)) + Comment.parse_response(self.allspice_client, result) for result in results ] + def create_comment(self, body: str) -> Comment: + path = self.GET_COMMENTS.format( + owner=self.owner.username, + repo=self.repo.name, + index=self.number + ) + + response = self.allspice_client.requests_post(path, data={"body": body}) + return Comment.parse_response(self.allspice_client, response) + class Team(ApiObject): API_OBJECT = """/teams/{id}""" # diff --git a/tests/test_api.py b/tests/test_api.py index 1d5f4ea..2ac5742 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,7 +3,8 @@ import pytest import uuid -from allspice import AllSpice, User, Organization, Team, Repository, Issue, Milestone +from allspice import AllSpice, User, Organization, Team, Repository, Issue, Milestone, \ + Comment from allspice import NotFoundException, AlreadyExistsException # put a ".token" file into your directory containg only the token for AllSpice Hub @@ -345,6 +346,102 @@ def test_change_issue(instance): assert len([issue for issue in issues if issue.milestone is not None and issue.milestone.title == ms_title]) > 0 + +def test_create_issue_comment(instance): + org = Organization.request(instance, test_org) + repo = Repository.request(instance, org.username, test_repo) + issue = repo.get_issues()[0] + comment = issue.create_comment("this is a comment") + assert comment.body == "this is a comment" + assert comment.user.username == "test" + + +def test_get_issue_comments(instance): + org = Organization.request(instance, test_org) + repo = Repository.request(instance, org.username, test_repo) + issue = repo.get_issues()[0] + comments = issue.get_comments() + assert len(comments) > 0 + assert comments[0].body == "this is a comment" + + +def test_edit_issue_comment(instance): + org = Organization.request(instance, test_org) + repo = Repository.request(instance, org.username, test_repo) + issue = repo.get_issues()[0] + comment = issue.get_comments()[0] + comment.body = "this is a new comment" + comment.commit() + assert comment.body == "this is a new comment" + comment_id = comment.id + comment2 = Comment.request(instance, org.username, repo.name, comment_id) + assert comment2.body == "this is a new comment" + + +def test_delete_issue_comment(instance): + org = Organization.request(instance, test_org) + repo = Repository.request(instance, org.username, test_repo) + issue = repo.get_issues()[0] + comment = issue.get_comments()[0] + comment_id = comment.id + comment.delete() + with pytest.raises(NotFoundException) as _: + Comment.request(instance, org.username, repo.name, comment_id) + + +def test_create_issue_attachment(instance): + org = Organization.request(instance, test_org) + repo = Repository.request(instance, org.username, test_repo) + issue = repo.get_issues()[0] + comment = issue.create_comment("this is a comment that will have an attachment") + attachment = comment.create_attachment(open("requirements.txt", "rb")) + assert attachment.name == "requirements.txt" + assert attachment.download_count == 0 + + +def test_create_issue_attachment_with_name(instance): + org = Organization.request(instance, test_org) + repo = Repository.request(instance, org.username, test_repo) + issue = repo.get_issues()[0] + comment = issue.create_comment("this is a comment that will have an attachment") + attachment = comment.create_attachment(open("requirements.txt", "rb"), + "something else.txt") + assert attachment.name == "something else.txt" + assert attachment.download_count == 0 + + +def test_get_issue_attachments(instance): + org = Organization.request(instance, test_org) + repo = Repository.request(instance, org.username, test_repo) + issue = repo.get_issues()[0] + comment = issue.get_comments()[0] + attachments = comment.get_attachments() + assert len(attachments) > 0 + assert attachments[0].name == "requirements.txt" + + +def test_edit_issue_attachment(instance): + org = Organization.request(instance, test_org) + repo = Repository.request(instance, org.username, test_repo) + issue = repo.get_issues()[0] + comment = issue.get_comments()[0] + attachment = comment.get_attachments()[0] + comment.edit_attachment(attachment, {"name": "this is a new attachment"}) + del attachment + attachment2 = comment.get_attachments()[0] + assert attachment2.name == "this is a new attachment" + + +def test_delete_issue_attachment(instance): + org = Organization.request(instance, test_org) + repo = Repository.request(instance, org.username, test_repo) + issue = repo.get_issues()[0] + comment = issue.get_comments()[0] + attachment = comment.get_attachments()[0] + comment.delete_attachment(attachment) + assert len(comment.get_attachments()) == 0 + + def test_team_get_org(instance): org = Organization.request(instance, test_org) user = instance.get_user_by_name(test_user) From 95327253647984eeebd431de1b87c763488f926e Mon Sep 17 00:00:00 2001 From: Shrikanth Upadhayaya Date: Wed, 14 Jun 2023 17:00:11 -0700 Subject: [PATCH 2/3] Fix a formatting nit in Issue --- allspice/apiobject.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/allspice/apiobject.py b/allspice/apiobject.py index 634ee93..d4ce5ce 100644 --- a/allspice/apiobject.py +++ b/allspice/apiobject.py @@ -893,7 +893,8 @@ def __init__(self, allspice_client): super().__init__(allspice_client) def __eq__(self, other): - if not isinstance(other, Issue): return False + if not isinstance(other, Issue): + return False return self.repo == other.repo and self.id == other.id def __hash__(self): From 6d1c686649f4860c01cccb157f120f7084995378 Mon Sep 17 00:00:00 2001 From: Shrikanth Upadhayaya Date: Tue, 20 Jun 2023 16:05:46 -0700 Subject: [PATCH 3/3] Add links to swagger docs for endpoints --- allspice/apiobject.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/allspice/apiobject.py b/allspice/apiobject.py index d4ce5ce..477ee87 100644 --- a/allspice/apiobject.py +++ b/allspice/apiobject.py @@ -803,13 +803,32 @@ def delete(self): ) self.deleted = True - def get_attachments(self): + def get_attachments(self) -> List[Attachment]: + """ + Get all attachments on this comment. This returns Attachment objects, which + contain a link to download the attachment. + + https://hub.allspice.io/api/swagger#/issue/issueListIssueCommentAttachments + """ + results = self.allspice_client.requests_get( self.GET_ATTACHMENTS_PATH.format(**self.__fields_for_path()) ) - return [Attachment.parse_response(self.allspice_client, result) for result in results] + return [Attachment.parse_response(self.allspice_client, result) for result in + results] def create_attachment(self, file: IO, name: Optional[str] = None) -> Attachment: + """ + Create an attachment on this comment. + + https://hub.allspice.io/api/swagger#/issue/issueCreateIssueCommentAttachment + + :param file: The file to attach. This should be a file-like object. + :param name: The name of the file. If not provided, the name of the file will be + used. + :return: The created attachment. + """ + args = { "files": {"attachment": file}, } @@ -845,6 +864,8 @@ def edit_attachment(self, attachment: Attachment, data: dict) -> Attachment: return Attachment.parse_response(self.allspice_client, result) def delete_attachment(self, attachment: Attachment): + """https://hub.allspice.io/api/swagger#/issue/issueDeleteIssueCommentAttachment""" + args = { **self.__fields_for_path(), "attachment_id": attachment.id, @@ -968,6 +989,8 @@ def add_time(self, time: int, created: str = None, user_name: User = None): ) def get_comments(self) -> List[Comment]: + """https://hub.allspice.io/api/swagger#/issue/issueGetComments""" + results = self.allspice_client.requests_get( self.GET_COMMENTS.format( owner=self.owner.username, @@ -981,6 +1004,8 @@ def get_comments(self) -> List[Comment]: ] def create_comment(self, body: str) -> Comment: + """https://hub.allspice.io/api/swagger#/issue/issueCreateComment""" + path = self.GET_COMMENTS.format( owner=self.owner.username, repo=self.repo.name,