Repository: atlassian-api/atlassian-python-api

Issue:
[BitBucket Cloud] Unable to obtain commits of a PR
There is an API available to obtain commits in a PR https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-pull-request-id-commits-get

But there is no corresponding function in the PullRequest object. 

I want to obtain the list of commits in a PR

I am using the latest version of the library

Discussion:

Solution patch:
diff --git a/atlassian/bitbucket/cloud/repositories/pullRequests.py b/atlassian/bitbucket/cloud/repositories/pullRequests.py
index c3264a4..0829819 100644
--- a/atlassian/bitbucket/cloud/repositories/pullRequests.py
+++ b/atlassian/bitbucket/cloud/repositories/pullRequests.py
@@ -2,6 +2,7 @@
 
 from ..base import BitbucketCloudBase
 from .diffstat import DiffStat
+from ...cloud.repositories.commits import Commit
 from ..common.builds import Build
 from ..common.comments import Comment
 from ..common.users import User, Participant
@@ -311,6 +312,12 @@ class PullRequest(BitbucketCloudBase):
 
         return self.post("comments", data)
 
+    @property
+    def commits(self):
+        """Returns generator object for the Commits in the PullRequest"""
+        for commit in self._get_paged("commits"):
+            yield Commit(commit, **self._new_session_args)
+
     def tasks(self):
         """
         Returns generator object of the tasks endpoint
diff --git a/atlassian/bitbucket/cloud/workspaces/__init__.py b/atlassian/bitbucket/cloud/workspaces/__init__.py
index 19d1c17..f40768e 100644
--- a/atlassian/bitbucket/cloud/workspaces/__init__.py
+++ b/atlassian/bitbucket/cloud/workspaces/__init__.py
@@ -3,6 +3,7 @@
 from requests import HTTPError
 from ..base import BitbucketCloudBase
 
+from .members import WorkspaceMembers
 from .permissions import Permissions
 from .projects import Projects
 from ..repositories import WorkspaceRepositories
@@ -88,6 +89,7 @@ class Workspace(BitbucketCloudBase):
         self.__permissions = Permissions(self.url_joiner(self.url, "permissions"), **self._new_session_args)
         self.__projects = Projects(self.get_link("projects"), **self._new_session_args)
         self.__repositories = WorkspaceRepositories(self.get_link("repositories"), **self._new_session_args)
+        self.__members = WorkspaceMembers(self.get_link("members"), **self._new_session_args)
 
     @property
     def name(self):
@@ -137,3 +139,8 @@ class Workspace(BitbucketCloudBase):
     def repositories(self):
         """The workspace repositories"""
         return self.__repositories
+
+    @property
+    def members(self):
+        """The workspace members"""
+        return self.__members
diff --git a/atlassian/bitbucket/cloud/workspaces/members.py b/atlassian/bitbucket/cloud/workspaces/members.py
new file mode 100644
index 0000000..c770eb4
--- /dev/null
+++ b/atlassian/bitbucket/cloud/workspaces/members.py
@@ -0,0 +1,62 @@
+# coding=utf-8
+
+from ..base import BitbucketCloudBase
+
+
+class WorkspaceMembers(BitbucketCloudBase):
+    def __init__(self, url, *args, **kwargs):
+        super(WorkspaceMembers, self).__init__(url, *args, **kwargs)
+
+    def __get_object(self, data):
+        return WorkspaceMember(data, **self._new_session_args)
+
+    def each(self):
+        """
+        Get all members in the workspace
+
+        :return: A generator for the member objects
+
+        API docs: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-workspaces/#api-workspaces-workspace-members-get
+        """
+        for member in self._get_paged(None):
+            yield self.__get_object(member)
+
+        return
+
+    def get(self, member):
+        """
+        Returns the requested member
+
+        :param member: string: Member's UUID or Atlassian ID.
+
+        :return: The requested Member object
+
+        API docs: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-workspaces/#api-workspaces-workspace-members-member-get
+        """
+
+        return self.__get_object(super(WorkspaceMembers, self).get(member))
+
+
+class WorkspaceMember(BitbucketCloudBase):
+    def __init__(self, data, *args, **kwargs):
+        super(WorkspaceMember, self).__init__(None, *args, data=data, expected_type="workspace_membership", **kwargs)
+
+    @property
+    def links(self):
+        """The member links"""
+        return self.get_data("links")
+
+    @property
+    def type(self):
+        """The member type"""
+        return self.get_data("type")
+
+    @property
+    def user(self):
+        """The member user dictionary"""
+        return self.get_data("user")
+
+    @property
+    def workspace(self):
+        """The member workspace dictionary"""
+        return self.get_data("workspace")
diff --git a/atlassian/jira.py b/atlassian/jira.py
index 15059e8..f85eac3 100644
--- a/atlassian/jira.py
+++ b/atlassian/jira.py
@@ -1966,11 +1966,12 @@ class Jira(AtlassianRestAPI):
             "webSudoPassword": self.password,
             "webSudoIsPost": "false",
         }
-        answer = self.get("secure/admin/WebSudoAuthenticate.jspa", self.form_token_headers)
+        answer = self.get("secure/admin/WebSudoAuthenticate.jspa", self.form_token_headers, not_json_response=True)
+        decoded_answer = answer.decode()
         atl_token = None
-        if answer:
+        if decoded_answer:
             atl_token = (
-                answer.split('<meta id="atlassian-token" name="atlassian-token" content="')[1]
+                decoded_answer.split('<meta id="atlassian-token" name="atlassian-token" content="')[1]
                 .split("\n")[0]
                 .split('"')[0]
             )
@@ -2110,12 +2111,83 @@ class Jira(AtlassianRestAPI):
         return self.projects(included_archived, expand)
 
     def projects(self, included_archived=None, expand=None):
-        """Returns all projects which are visible for the currently logged-in user.
+        """
+        Returns all projects which are visible for the currently logged-in user.
+        If no user is logged in, it returns the list of projects that are visible when using anonymous access.
+        :param included_archived: boolean whether to include archived projects in response, default: false
+        :param expand:
+        :return:
+        """
+        if self.cloud:
+            return self.projects_from_cloud(
+                included_archived=included_archived,
+                expand=expand,
+            )
+        else:
+            return self.projects_from_server(
+                included_archived=included_archived,
+                expand=expand,
+            )
+
+    def projects_from_cloud(self, included_archived=None, expand=None):
+        """
+        Returns all projects which are visible for the currently logged-in user.
+        Cloud version should use the ``paginated``endpoint to get pages of projects, as the old endpoint is deprecated.
+        If no user is logged in, it returns the list of projects that are visible when using anonymous access.
+        :param included_archived: boolean whether to include archived projects in response, default: false
+        :param expand:
+        :return:
+        """
+        if not self.cloud:
+            raise ValueError("``projects_from_cloud`` method is only available for Jira Cloud platform")
+
+        projects = self.paginated_projects(
+            included_archived=included_archived,
+            expand=expand,
+        )
+        while not projects.get("isLast"):
+            projects["values"].extend(
+                self.paginated_projects(
+                    included_archived=included_archived,
+                    expand=expand,
+                    url=projects["nextPage"],
+                )["values"]
+            )
+        return projects["values"]
+
+    def paginated_projects(self, included_archived=None, expand=None, url=None):
+        """
+        Returns a page of projects which are visible for the currently logged-in user.
+        Method to be used only for Jira Cloud platform, until tests on Jira Server are executed.
         If no user is logged in, it returns the list of projects that are visible when using anonymous access.
         :param included_archived: boolean whether to include archived projects in response, default: false
         :param expand:
+        :param url: url to get the next page of projects, default: false (first page)
         :return:
         """
+        if not self.cloud:
+            raise ValueError("``projects_from_cloud`` method is only available for Jira Cloud platform")
+
+        params = {}
+        if included_archived:
+            params["includeArchived"] = included_archived
+        if expand:
+            params["expand"] = expand
+        page_url = url or self.resource_url("project/search")
+        is_url_absolute = bool(page_url.lower().startswith("http"))
+        return self.get(page_url, params=params, absolute=is_url_absolute)
+
+    def projects_from_server(self, included_archived=None, expand=None):
+        """
+        Returns all projects which are visible for the currently logged-in user.
+        If no user is logged in, it returns the list of projects that are visible when using anonymous access.
+        :param included_archived: boolean whether to include archived projects in response, default: false
+        :param expand:
+        :return:
+        """
+        if self.cloud:
+            raise ValueError("``projects_from_server`` method is only available for Jira Server platform")
+
         params = {}
         if included_archived:
             params["includeArchived"] = included_archived
@@ -2180,7 +2252,7 @@ class Jira(AtlassianRestAPI):
         """
         base_url = self.resource_url("project")
         url = "{base_url}/{key}/archive".format(base_url=base_url, key=key)
-        return self.put(url)
+        return self.post(url)
 
     def project(self, key, expand=None):
         """
@@ -4039,12 +4111,12 @@ api-group-workflows/#api-rest-api-2-workflow-search-get)
         return self.get(url, params=params)
 
     # noinspection PyIncorrectDocstring
-    def tempo_4_timesheets_find_worklogs(self, **params):
+    def tempo_4_timesheets_find_worklogs(self, date_from=None, date_to=None, **params):
         """
         Find existing worklogs with searching parameters.
         NOTE: check if you are using correct types for the parameters!
-        :param from: string From Date
-        :param to: string To Date
+        :param date_from: string From Date
+        :param date_to: string To Date
         :param worker: Array of strings
         :param taskId: Array of integers
         :param taskKey: Array of strings
@@ -4066,6 +4138,11 @@ api-group-workflows/#api-rest-api-2-workflow-search-get)
         :param offset: integer
         """
 
+        if date_from:
+            params["from"] = date_from
+        if date_to:
+            params["to"] = date_to
+
         url = "rest/tempo-timesheets/4/worklogs/search"
         return self.post(url, data=params)
 
diff --git a/docs/bitbucket.rst b/docs/bitbucket.rst
index 0dd3d54..35d1979 100755
--- a/docs/bitbucket.rst
+++ b/docs/bitbucket.rst
@@ -413,6 +413,13 @@ Bitbucket Cloud
     # Delete repository_variable
     repository_variable.delete()
 
+    # Get a list of workspace members
+    workplace.members.each()
+
+    # Get a specific workspace member
+    workplace.members.get("a-user-account-id")
+    workplace.members.get("{a-user-uuid}")
+
 Pipelines management
 --------------------
 
diff --git a/docs/jira.rst b/docs/jira.rst
index 593e413..79d1e3f 100644
--- a/docs/jira.rst
+++ b/docs/jira.rst
@@ -74,7 +74,10 @@ Manage users
     jira.user_get_websudo()
 
     # Fuzzy search using emailAddress or displayName
-    jira.user_find_by_user_string(query, start=0, limit=50, include_inactive_users=False)
+    jira.user_find_by_user_string(query="a.user@example.com", start=0, limit=50, include_inactive_users=False)
+    jira.user_find_by_user_string(query="a.user", start=0, limit=50, include_inactive_users=False)
+    jira.user_find_by_user_string(query="a user")
+    jira.user_find_by_user_string(account_id="a-users-account-id")
 
     # Get groups of a user. This API is only available for Jira Cloud platform.
     jira.get_user_groups(account_id)
@@ -109,11 +112,26 @@ Manage projects
 
     # Get all projects
     # Returns all projects which are visible for the currently logged in user.
-    jira.projects(included_archived=None)
+    jira.projects(included_archived=None, expand=None)
 
     # Get all project alternative call
     # Returns all projects which are visible for the currently logged in user.
-    jira.get_all_projects(included_archived=None)
+    jira.get_all_projects(included_archived=None, expand=None)
+
+    # Get all projects only for Jira Cloud
+    # Returns all projects which are visible for the currently logged in user.
+    jira.projects_from_cloud(included_archived=None, expand=None)
+
+    # Get one page of projects
+    # Returns a paginated list of projects visible for the currently logged in user.
+    # Use the url formatting to get a specific page as shown here:
+    # url = f"{self.resource_url("project/search")}?startAt={start_at}&maxResults={max_results}"
+    # Defaults to the first page, which returns a nextPage url when available.
+    jira.projects_paginated(included_archived=None, expand=None, url=None)
+
+    # Get all projects only for Jira Server
+    # Returns all projects which are visible for the currently logged in user.
+    jira.projects_from_server(included_archived=None, expand=None)
 
     # Delete project
     jira.delete_project(key)
@@ -521,8 +539,8 @@ TEMPO
     # Look at the tempo docs for additional information:
     # https://www.tempo.io/server-api-documentation/timesheets#operation/searchWorklogs
     # NOTE: check if you are using correct types for the parameters!
-    #     :param from: string From Date
-    #     :param to: string To Date
+    #     :param date_from: string From Date
+    #     :param date_to: string To Date
     #     :param worker: Array of strings
     #     :param taskId: Array of integers
     #     :param taskKey: Array of strings
@@ -542,7 +560,7 @@ TEMPO
     #     :param pageNo: integer
     #     :param maxResults: integer
     #     :param offset: integer
-    jira.tempo_4_timesheets_find_worklogs(**params)
+    jira.tempo_4_timesheets_find_worklogs(date_from=None, date_to=None, **params)
 
     # :PRIVATE:
     # Get Tempo timesheet worklog by issue key or id.
diff --git a/examples/bitbucket/bitbucket_pr_commits.py b/examples/bitbucket/bitbucket_pr_commits.py
new file mode 100644
index 0000000..a93993f
--- /dev/null
+++ b/examples/bitbucket/bitbucket_pr_commits.py
@@ -0,0 +1,12 @@
+from atlassian.bitbucket import Cloud
+
+bitbucket = Cloud(url='https://api.bitbucket.org/', token='random_string_token')
+
+pr_id = 1
+
+if __name__ == "__main__":
+    data = bitbucket.repositories.get(workspace="workspace1", repo_slug="repository1")
+    pr = data.pullrequests.get(pr_id)
+    print(f"For Pull Request ID = {pr.id}")
+    for pr_commit in pr.commits:
+        print(f"Commit Id={pr_commit.hash}, Message={pr_commit.message}")
diff --git a/temp_file.md b/temp_file.md
new file mode 100644
index 0000000..e69de29


Test patch:
diff --git a/tests/test_bitbucket_cloud_oo.py b/tests/test_bitbucket_cloud_oo.py
index 1ad2feb..6239724 100644
--- a/tests/test_bitbucket_cloud_oo.py
+++ b/tests/test_bitbucket_cloud_oo.py
@@ -7,7 +7,7 @@ from datetime import datetime
 from atlassian import Bitbucket
 from atlassian.bitbucket import Cloud
 from atlassian.bitbucket.cloud.common.users import User
-from atlassian.bitbucket.cloud.repositories.pullRequests import Comment, Participant, PullRequest, Build, Task
+from atlassian.bitbucket.cloud.repositories.pullRequests import Comment, Commit, Participant, PullRequest, Build, Task
 
 BITBUCKET = None
 try:
@@ -173,6 +173,10 @@ class TestPullRequests:
     def tc2(self):
         return CLOUD.workspaces.get("TestWorkspace1").repositories.get("testrepository1").pullrequests
 
+    @pytest.fixture(scope="module")
+    def tc3(self):
+        return CLOUD.workspaces.get("TestWorkspace1").repositories.get("testrepository1").pullrequests.get(1).commits
+
     def test_id(self, tc1):
         assert tc1.id == 1
 
@@ -263,6 +267,14 @@ class TestPullRequests:
         assert len(reviewers) == 3
         assert isinstance(reviewers[0], User)
 
+    def test_commits(self, tc1):
+        pr_commits = list(tc1.commits)
+        assert len(pr_commits) == 1
+        commit_1 = pr_commits[0]
+        assert isinstance(commit_1, Commit)
+        assert commit_1.message == 'src created online with Bitbucket'
+
+
     def test_comment(self, tc1):
         msg = "hello world"
         com = tc1.comment(msg)