├── tests ├── __init__.py ├── resources │ └── api │ │ └── 1.0 │ │ └── merge.json ├── test_client.py └── test_pullrequests.py ├── MANIFEST.in ├── requirements.txt ├── .gitignore ├── .travis.yml ├── stashy ├── admin │ ├── __init__.py │ ├── groups.py │ └── users.py ├── compat.py ├── __init__.py ├── allrepos.py ├── fileinfo.py ├── diffs.py ├── ssh.py ├── errors.py ├── pullrequestdiffs.py ├── projects.py ├── branch_permissions.py ├── client.py ├── permissions.py ├── helpers.py ├── pullrequests.py └── repos.py ├── CHANGELOG.md ├── LICENSE ├── docs ├── api.rst ├── index.rst ├── Makefile └── conf.py ├── setup.py ├── README.md └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | include *.txt 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | decorator>=3.4.0 2 | requests>=2.5.1 3 | mock 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | *.py[cod] 3 | *.egg 4 | *.egg-info 5 | /dist 6 | /build 7 | /pip-log.txt 8 | /docs/_build 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.6 4 | - 2.7 5 | - 3.3 6 | - 3.5 7 | install: pip install -r requirements.txt 8 | script: python setup.py test 9 | -------------------------------------------------------------------------------- /stashy/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from ..helpers import Nested, ResourceBase 2 | 3 | from .groups import Groups 4 | from .users import Users 5 | from ..permissions import Permissions 6 | 7 | 8 | class Admin(ResourceBase): 9 | groups = Nested(Groups) 10 | users = Nested(Users) 11 | permissions = Nested(Permissions) 12 | -------------------------------------------------------------------------------- /tests/resources/api/1.0/merge.json: -------------------------------------------------------------------------------- 1 | { 2 | "canMerge": false, 3 | "conflicted": false, 4 | "vetoes": [ 5 | { 6 | "summaryMessage": "You may not merge after 6pm on a Friday.", 7 | "detailedMessage": "It is likely that your Blood Alcohol Content (BAC) exceeds the threshold for making sensible decisions regarding pull requests. Please try again on Monday." 8 | } 9 | ] 10 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## [0.3.0] - 2015-10-24 6 | ### Added 7 | - Support /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/comments [POST] (@akhaku) 8 | - Add changelog (@akhaku) 9 | 10 | ## [0.2.0] - 2015-09-16 11 | ### Added 12 | - Basic support for /repos (@jterk) 13 | -------------------------------------------------------------------------------- /stashy/compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | _ver = sys.version_info 4 | 5 | #: Python 2.x? 6 | is_py2 = (_ver[0] == 2) 7 | 8 | #: Python 3.x? 9 | is_py3 = (_ver[0] == 3) 10 | 11 | 12 | if is_py2: 13 | def update_doc(method, newdoc): 14 | method.im_func.func_doc = newdoc 15 | 16 | basestring = basestring 17 | elif is_py3: 18 | def update_doc(method, newdoc): 19 | method.__doc__ = newdoc 20 | 21 | basestring = (str, bytes) 22 | -------------------------------------------------------------------------------- /stashy/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1" 2 | 3 | from .client import Stash 4 | 5 | def connect(url, username, password, verify=True): 6 | """Connect to a Stash instance given a username and password. 7 | 8 | This is only recommended via SSL. If you need are using 9 | self-signed certificates, you can use verify=False to ignore SSL 10 | verifcation. 11 | """ 12 | return Stash(url, username, password, verify=verify) 13 | 14 | __all__ = ['connect'] 15 | -------------------------------------------------------------------------------- /stashy/allrepos.py: -------------------------------------------------------------------------------- 1 | from .helpers import ResourceBase, IterableResource 2 | from .repos import Repository 3 | from .compat import update_doc 4 | 5 | class Repos(ResourceBase, IterableResource): 6 | def __getitem__(self, item): 7 | """ 8 | Return a :class:`Repository` object for operations on a specific repository 9 | """ 10 | return Repository(item, self.url(item), self._client, self) 11 | 12 | update_doc(Repos.all, """Retreive repositories from Stash""") 13 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from stashy.client import StashClient 3 | 4 | 5 | class TestStashClient(TestCase): 6 | def test_url_without_slash_prefix(self): 7 | client = StashClient("http://example.com/stash") 8 | self.assertEqual("http://example.com/stash/rest/api/1.0/admin/groups", client.url("api/1.0/admin/groups")) 9 | 10 | def test_url_with_slash_prefix(self): 11 | client = StashClient("http://example.com/stash") 12 | self.assertEqual("http://example.com/stash/rest/api/1.0/admin/groups", client.url("/api/1.0/admin/groups")) 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Rising Oak LLC 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | --- 3 | .. automodule:: stashy 4 | :members: connect 5 | .. automodule:: stashy.admin 6 | .. automodule:: stashy.admin.users 7 | .. autoclass:: stashy.admin.users.Users 8 | :members: 9 | .. automodule:: stashy.admin.groups 10 | .. autoclass:: stashy.admin.groups.Groups 11 | :members: 12 | .. automodule:: stashy.permissions 13 | .. autoclass:: stashy.permissions.Users 14 | :members: 15 | .. autoclass:: stashy.permissions.Groups 16 | :members: 17 | .. automodule:: stashy.projects 18 | .. autoclass:: stashy.projects.Projects 19 | :members: 20 | .. autoclass:: stashy.projects.Project 21 | :members: 22 | .. automodule:: stashy.repos 23 | .. autoclass:: stashy.repos.Repos 24 | :members: 25 | .. autoclass:: stashy.repos.Repository 26 | :members: 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | def read(fname): 5 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 6 | 7 | setup(name='stashy', 8 | version="0.4", 9 | description='Python API client for the Atlassian Stash REST API', 10 | long_description=read('README.rst'), 11 | url='http://github.com/RisingOak/stashy', 12 | download_url = 'https://github.com/RisingOak/stashy/tarball/0.1', 13 | author='Cosmin Stejerean', 14 | author_email='cosmin@offbytwo.com', 15 | license='Apache License 2.0', 16 | packages=['stashy', 'stashy.admin'], 17 | test_suite = 'tests', 18 | #scripts=['bin/stash'], 19 | #tests_require=open('test-requirements.txt').readlines(), 20 | install_requires=open('requirements.txt').readlines(), 21 | classifiers=[ 22 | 'Development Status :: 3 - Alpha', 23 | 'License :: OSI Approved :: Apache Software License', 24 | 'Topic :: Software Development :: Libraries' 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. stashy documentation master file, created by 2 | sphinx-quickstart on Sun Mar 10 17:38:40 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | ======================== 7 | Documentation for stashy 8 | ======================== 9 | 10 | .. rubric:: Python API client for the Atlassian Stash REST API 11 | 12 | Installation 13 | ------------ 14 | 15 | :: 16 | 17 | pip install stashy 18 | 19 | Usage 20 | ----- 21 | 22 | :: 23 | 24 | import stashy 25 | stash = stashy.connect("http://localhost:7990/stash", "admin", "admin") 26 | 27 | Examples 28 | -------- 29 | 30 | Retrieve all groups:: 31 | 32 | stash.admin.groups.list() 33 | 34 | Retrieve all users that match a given filter:: 35 | 36 | stash.admin.users.list(filter="admin") 37 | 38 | Add a user to a group:: 39 | 40 | stash.admin.groups.add_user('stash-users', 'admin') 41 | 42 | Iterate over all projects (that you have access to):: 43 | 44 | stash.projects.list() 45 | 46 | List all the repositories in a given project:: 47 | 48 | stash.projects[PROJECT].repos.list() 49 | 50 | List all the commits in a pull request:: 51 | 52 | list(stash.projects[PROJECT].repos[REPO].pull_requests.commits()) 53 | 54 | Contents: 55 | --------- 56 | 57 | .. toctree:: 58 | :maxdepth: 2 59 | 60 | api 61 | 62 | Indices and tables 63 | ------------------ 64 | 65 | * :ref:`genindex` 66 | * :ref:`modindex` 67 | * :ref:`search` 68 | -------------------------------------------------------------------------------- /tests/test_pullrequests.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from unittest import TestCase 4 | try: 5 | from urlparse import urlparse 6 | except ImportError: 7 | from urllib.parse import urlparse 8 | from requests.models import Response 9 | from mock import patch 10 | 11 | from stashy.client import StashClient 12 | from stashy.pullrequests import PullRequest 13 | 14 | 15 | def fake_urlopen(stash_client, url): 16 | """ 17 | A stub urlopen() implementation that load json responses from 18 | the filesystem. 19 | """ 20 | # Map path from url to a file 21 | parsed_url = urlparse(url) 22 | resource_file = os.path.normpath( 23 | '%s/resources/%s.json' % (os.path.dirname(os.path.abspath(__file__)), parsed_url.path)) 24 | # Must return Response 25 | resp = Response() 26 | resp.status_code = 200 27 | resp.json = lambda: json.loads(open(resource_file, mode='rb').read().decode('utf-8')) 28 | return resp 29 | 30 | 31 | class TestPullRequest(TestCase): 32 | def setUp(self): 33 | self.client = StashClient("http://example.com/stash") 34 | 35 | @patch('stashy.client.StashClient.get', fake_urlopen) 36 | def test_can_merge(self): 37 | pr = PullRequest(1, '', self.client, None) 38 | assert pr.can_merge() is False 39 | 40 | @patch('stashy.client.StashClient.get', fake_urlopen) 41 | def test_merge_vetoes(self): 42 | pr = PullRequest(1, '', self.client, None) 43 | vetoes = pr.merge_info() 44 | 45 | assert len(vetoes['vetoes']) == 1 46 | -------------------------------------------------------------------------------- /stashy/fileinfo.py: -------------------------------------------------------------------------------- 1 | class FileInfo: 2 | def __init__(self, file_info): 3 | self.components = file_info["components"] 4 | self.parent = file_info["parent"] 5 | self.name = file_info["name"] 6 | self.extension = file_info["extension"] 7 | self.to_string = file_info["toString"] 8 | 9 | def _get_components(self): 10 | return self._components 11 | 12 | def _set_components(self, value): 13 | self._components = value 14 | 15 | def _get_parent(self): 16 | return self._parent 17 | 18 | def _set_parent(self, value): 19 | self._parent = value 20 | 21 | def _get_name(self): 22 | return self._name 23 | 24 | def _set_name(self, value): 25 | self._name = value 26 | 27 | def _get_extension(self): 28 | return self._extension 29 | 30 | def _set_extension(self, value): 31 | self._extension = value 32 | 33 | def _get_to_string(self): 34 | return self._toString 35 | 36 | def _set_to_string(self, value): 37 | self._toString = value 38 | 39 | components = property(_get_components, _set_components, doc="The components the file reside in.") 40 | 41 | parent = property(_get_parent, _set_parent, doc="The parent folder the file resides in.") 42 | 43 | name = property(_get_name, _set_name, doc="The name of the file.") 44 | 45 | extension = property(_get_extension, _set_extension, doc="The extension of the file.") 46 | 47 | toString = property(_get_to_string, _set_to_string, 48 | doc="A string value representing the full path from repository root.") 49 | -------------------------------------------------------------------------------- /stashy/diffs.py: -------------------------------------------------------------------------------- 1 | from .fileinfo import FileInfo 2 | 3 | 4 | class Diff: 5 | def __init__(self, diff_file): 6 | if diff_file["source"] is not None: 7 | self.source = FileInfo(diff_file["source"]) 8 | else: 9 | self.source = None 10 | self.destination = FileInfo(diff_file["destination"]) 11 | self.hunks = diff_file["hunks"] 12 | self.truncated = diff_file["truncated"] 13 | self.line_comments = diff_file["lineComments"] 14 | 15 | def _get_source(self): 16 | return self._source 17 | 18 | def _set_source(self, value): 19 | self._source = value 20 | 21 | def _get_destination(self): 22 | return self._destination 23 | 24 | def _set_destination(self, value): 25 | self._destination = value 26 | 27 | def _get_hunks(self): 28 | return self._hunks 29 | 30 | def _set_hunks(self, value): 31 | self._hunks = value 32 | 33 | def _get_truncated(self): 34 | return self._truncated 35 | 36 | def _set_truncated(self, value): 37 | self._truncated = value 38 | 39 | def _get_line_comments(self): 40 | return self._line_comments 41 | 42 | def _set_line_comments(self, value): 43 | self._line_comments = value 44 | 45 | source = property(_get_source, _set_source, doc="The source of a file in the diff.") 46 | 47 | destination = property(_get_destination, _set_destination, doc="The destination of a file in the diff.") 48 | 49 | hunks = property(_get_hunks, _set_hunks, doc="A dictionary showing the hunks changed in the file difference.") 50 | 51 | truncated = property(_get_truncated, _set_truncated, 52 | doc="Whether it has been truncated? I'm not actually sure what this value represents from the JSON response.") 53 | 54 | line_comments = property(_get_line_comments, _set_line_comments, 55 | doc="The comments that have been made against a file in the pull request.") 56 | -------------------------------------------------------------------------------- /stashy/ssh.py: -------------------------------------------------------------------------------- 1 | # from .helpers import Nested, ResourceBase, IterableResource 2 | from .helpers import ResourceBase, IterableResource 3 | from .errors import ok_or_error, response_or_error 4 | from .compat import update_doc 5 | 6 | 7 | class SshFilteredIterableResource(IterableResource): 8 | def all(self, user=None): 9 | """ 10 | Retrieve all the resources, optionally modified by filter. 11 | """ 12 | params = {} 13 | if user: 14 | params['user'] = user 15 | return self.paginate("", params) 16 | 17 | def list(self, user=None): 18 | """ 19 | Convenience method to return a list (rather than iterable) of all 20 | elements 21 | """ 22 | return list(self.all(user)) 23 | 24 | 25 | class Key(ResourceBase): 26 | def __init__(self, url, client, parent): 27 | super(Keys, self).__init__(url, client, parent) 28 | self._url = 'ssh/1.0/keys' 29 | 30 | def get(self): 31 | return self._client.get(self.url()) 32 | 33 | 34 | class Keys(ResourceBase, SshFilteredIterableResource): 35 | def __init__(self, url, client, parent): 36 | super(Keys, self).__init__(url, client, parent) 37 | self._url = 'ssh/1.0/keys' 38 | 39 | @ok_or_error 40 | def create(self, user, key, label=None): 41 | """ 42 | Adds the key for the supplied user. If label is not set then 43 | the comment part of the key is used as it. 44 | """ 45 | data = dict(text=key, label=label) 46 | params = dict(user=user) 47 | return self._client.post(self.url(""), data=data, params=params) 48 | 49 | @response_or_error 50 | def get(self, user): 51 | """ 52 | Retrieve the keys matching the supplied user. 53 | """ 54 | params = dict(user=user) 55 | return self._client.get(self.url(""), params=params) 56 | 57 | def __getitem__(self, item): 58 | return Key(item, self.url(item), self._client, self) 59 | 60 | 61 | update_doc(Keys.all, """Retrieve keys.""") 62 | -------------------------------------------------------------------------------- /stashy/errors.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from decorator import decorator 3 | 4 | class NotFoundException(Exception): 5 | def __init__(self, response): 6 | try: 7 | self.data = response.json() 8 | if 'errors' in self.data: 9 | msg = self.data['errors'][0]['message'] 10 | else: 11 | msg = str(self.data) 12 | except ValueError: 13 | msg = "Not found: " + response.url 14 | 15 | super(NotFoundException, self).__init__(msg) 16 | 17 | 18 | class GenericException(Exception): 19 | def __init__(self, response): 20 | try: 21 | self.data = response.json() 22 | msg = "%d: %s" % (response.status_code, self.data) 23 | except ValueError: 24 | msg = "Unknown error: %d(%s)" % (response.status_code, response.reason) 25 | 26 | super(GenericException, self).__init__(msg) 27 | 28 | 29 | class AuthenticationException(Exception): 30 | def __init__(self, response): 31 | try: 32 | msg = "%d: Invalid User / Password" % response.status_code 33 | except ValueError: 34 | msg = "Invalid Authentication" 35 | 36 | super(AuthenticationException,self).__init__(msg) 37 | 38 | def maybe_throw(response): 39 | if not response.ok: 40 | if response.status_code == 404: 41 | raise NotFoundException(response) 42 | elif response.status_code == 401: 43 | raise AuthenticationException(response) 44 | else: 45 | e = GenericException(response) 46 | try: 47 | e.data = response.json() 48 | except ValueError: 49 | e.content = response.content 50 | raise e 51 | 52 | 53 | @decorator 54 | def ok_or_error(fn, *args, **kw): 55 | response = fn(*args, **kw) 56 | maybe_throw(response) 57 | return response.ok 58 | 59 | 60 | @decorator 61 | def response_or_error(fn, *args, **kw): 62 | response = fn(*args, **kw) 63 | maybe_throw(response) 64 | try: 65 | return response.json() 66 | except ValueError: 67 | return response.text 68 | -------------------------------------------------------------------------------- /stashy/admin/groups.py: -------------------------------------------------------------------------------- 1 | from ..helpers import ResourceBase, FilteredIterableResource 2 | from ..errors import ok_or_error, response_or_error 3 | from ..compat import update_doc 4 | 5 | class Groups(ResourceBase, FilteredIterableResource): 6 | @response_or_error 7 | def add(self, group): 8 | """ 9 | Add a group, returns a dictionary containing the group name 10 | """ 11 | return self._client.post(self.url(), {}, params=dict(name=group)) 12 | 13 | @ok_or_error 14 | def delete(self, group): 15 | """ 16 | Delete a group. 17 | """ 18 | return self._client.delete(self.url(), params=dict(name=group)) 19 | 20 | @ok_or_error 21 | def add_user(self, group, user): 22 | """ 23 | Add a user to a group. 24 | """ 25 | return self._client.post(self.url("/add-user"), dict(context=group, itemName=user)) 26 | 27 | @ok_or_error 28 | def remove_user(self, group, user): 29 | """ 30 | Remove a user to a group. 31 | """ 32 | return self._client.post(self.url("/remove-user"), dict(context=group, itemName=user)) 33 | 34 | def more_members(self, group, filter=None): 35 | """ 36 | Retrieves a list of users that are members of a specified group. 37 | 38 | filter: return only users with usernames, display names or email addresses containing this string 39 | """ 40 | params = dict(context=group) 41 | if filter: 42 | params['filter'] = filter 43 | return self.paginate("/more-members", params) 44 | 45 | def more_non_members(self, group, filter=None): 46 | """ 47 | Retrieves a list of users that are not members of a specified group. 48 | 49 | filter: return only users with usernames, display names or email addresses containing this string 50 | """ 51 | params = dict(context=group) 52 | if filter: 53 | params['filter'] = filter 54 | return self.paginate("/more-non-members", params) 55 | 56 | 57 | update_doc(Groups.all, """ 58 | Returns an iterator that will walk all the groups, paginating as necessary. 59 | 60 | filter: if specified only group names containing the supplied string will be returned 61 | """) 62 | -------------------------------------------------------------------------------- /stashy/pullrequestdiffs.py: -------------------------------------------------------------------------------- 1 | from .diffs import Diff 2 | from .helpers import ResourceBase 3 | from .errors import response_or_error 4 | 5 | 6 | class PullRequestDiffRef(object): 7 | def __init__(self, project_key, repo_slug, id): 8 | self.project_key = project_key 9 | self.repo_slug = repo_slug 10 | self.id = id 11 | 12 | def to_dict(self): 13 | return dict(id=self.id, 14 | repository=dict(slug=self.repo_slug, 15 | project=dict(key=self.project_key), 16 | name=self.repo_slug)) 17 | 18 | 19 | class PullRequestDiff(ResourceBase): 20 | def __init__(self, url, client, parent): 21 | super(PullRequestDiff, self).__init__(url, client, parent) 22 | response = self.get() 23 | self.from_hash = response["fromHash"] 24 | self.to_hash = response["toHash"] 25 | self.context_lines = response["contextLines"] 26 | self.whitespace = response["whitespace"] 27 | self.diffs = [] 28 | for value in list(response["diffs"]): 29 | self.diffs.append(Diff(value)) 30 | 31 | @response_or_error 32 | def get(self): 33 | return self._client.get(self.url()) 34 | 35 | def _get_from_hash(self): 36 | return self._from_hash 37 | 38 | def _set_from_hash(self, value): 39 | self._from_hash = value 40 | 41 | def _get_to_hash(self): 42 | return self._to_hash 43 | 44 | def _set_to_hash(self, value): 45 | self._to_hash = value 46 | 47 | def _get_diffs(self): 48 | return self._diffs 49 | 50 | def _set_diffs(self, value): 51 | self._diffs = value 52 | 53 | def _get_context_lines(self): 54 | return self._context_lines 55 | 56 | def _set_context_lines(self, value): 57 | self._context_lines = value 58 | 59 | def _get_whitespace(self): 60 | return self._whitespace 61 | 62 | def _set_whitespace(self, value): 63 | self._whitespace = value 64 | 65 | from_hash = property(_get_from_hash, _set_from_hash) 66 | 67 | to_hash = property(_get_to_hash, _set_to_hash) 68 | 69 | diffs = property(_get_diffs, _set_diffs) 70 | 71 | context_lines = property(_get_context_lines, _set_context_lines) 72 | 73 | whitespace = property(_get_whitespace, _set_whitespace) 74 | -------------------------------------------------------------------------------- /stashy/projects.py: -------------------------------------------------------------------------------- 1 | from .helpers import Nested, ResourceBase, IterableResource 2 | from .permissions import ProjectPermissions 3 | from .errors import ok_or_error, response_or_error 4 | from .repos import Repos 5 | from .compat import update_doc 6 | 7 | 8 | class Project(ResourceBase): 9 | def __init__(self, key, url, client, parent): 10 | super(Project, self).__init__(url, client, parent) 11 | self._key = key 12 | 13 | @ok_or_error 14 | def delete(self): 15 | """ 16 | Delete the project 17 | """ 18 | return self._client.delete(self.url()) 19 | 20 | @response_or_error 21 | def update(self, new_key=None, name=None, description=None, avatar=None, public=None): 22 | """ 23 | Update project information. If supplied, avatar should be a base64 encoded image. 24 | 25 | None is used as a sentinel so use '' to clear a value. 26 | """ 27 | data = dict() 28 | if new_key is not None: 29 | data['key'] = new_key 30 | if name is not None: 31 | data['name'] = name 32 | if description is not None: 33 | data['description'] = description 34 | if avatar is not None: 35 | data['avatar'] = "data:image/png;base64," + avatar 36 | if public is not None: 37 | data['public'] = public 38 | 39 | return self._client.post(self.url(), data) 40 | 41 | @response_or_error 42 | def get(self): 43 | return self._client.get(self.url()) 44 | 45 | permissions = Nested(ProjectPermissions, relative_path="/permissions") 46 | repos = Nested(Repos) 47 | 48 | 49 | class Projects(ResourceBase, IterableResource): 50 | @response_or_error 51 | def get(self, project): 52 | """ 53 | Retrieve the project matching the supplied key. 54 | """ 55 | return self._client.get(self.url(project)) 56 | 57 | def __getitem__(self, item): 58 | return Project(item, self.url(item), self._client, self) 59 | 60 | 61 | @response_or_error 62 | def create(self, key, name, description='', avatar=None): 63 | """ 64 | Create a project. If supplied, avatar should be a base64 encoded image. 65 | """ 66 | data = dict(key=key, name=name, description=description) 67 | if avatar: 68 | data['avatar'] = "data:image/png;base64," + avatar 69 | return self._client.post(self.url(), data) 70 | 71 | 72 | update_doc(Projects.all, """Retrieve projects.""") 73 | -------------------------------------------------------------------------------- /stashy/branch_permissions.py: -------------------------------------------------------------------------------- 1 | from .helpers import ResourceBase, IterableResource, Nested 2 | from .errors import ok_or_error, response_or_error 3 | from .compat import update_doc 4 | 5 | API_NAME = 'branch-permissions' 6 | API_VERSION = '1.0' 7 | API_OVERRIDE_PATH = '{0}/{1}'.format(API_NAME, API_VERSION) 8 | 9 | class Permitted(ResourceBase, IterableResource): 10 | """Get-only resource that describes a permission record""" 11 | def __init__(self, url, client, parent): 12 | ResourceBase.__init__(self, url, client, parent, API_OVERRIDE_PATH) 13 | 14 | update_doc(Permitted.all, """Retrieve list of permitted entities for a repo""") 15 | 16 | 17 | class Restriction(ResourceBase): 18 | def __init__(self, id, url, client, parent): 19 | super(Restriction, self).__init__(url, client, parent, API_OVERRIDE_PATH) 20 | self._id = id 21 | 22 | @response_or_error 23 | def get(self): 24 | """ 25 | Retrieve a restriction 26 | """ 27 | return self._client.get(self.url()) 28 | 29 | @ok_or_error 30 | def delete(self): 31 | """ 32 | Delete a restriction 33 | """ 34 | return self._client.delete(self.url()) 35 | 36 | @response_or_error 37 | def update(self, value, users=None, groups=None, pattern=False): 38 | """ 39 | Re-restrict a branch, or set of branches defined by a pattern, to a set of users and/or groups 40 | """ 41 | data = dict(type=('PATTERN' if pattern else 'BRANCH'), value=value) 42 | 43 | if users is not None: 44 | data['users'] = users 45 | if groups is not None: 46 | data['groups'] = groups 47 | 48 | return self._client.put(self.url(""), data=data) 49 | 50 | 51 | class Restricted(ResourceBase, IterableResource): 52 | 53 | def __init__(self, url, client, parent): 54 | ResourceBase.__init__(self, url, client, parent, API_OVERRIDE_PATH) 55 | 56 | def __getitem__(self, item): 57 | return Restriction(item, self.url(item), self._client, self) 58 | 59 | @response_or_error 60 | def create(self, value, users=None, groups=None, pattern=False): 61 | """ 62 | Restrict a branch, or set of branches defined by a pattern, to a set of users and/or groups 63 | """ 64 | data = dict(type=('PATTERN' if pattern else 'BRANCH'), value=value) 65 | 66 | if users is not None: 67 | data['users'] = users 68 | if groups is not None: 69 | data['groups'] = groups 70 | 71 | return self._client.post(self.url(""), data=data) 72 | 73 | update_doc(Restricted.all, """Retrieve list of restrictions for a repo""") 74 | 75 | class BranchPermissions(ResourceBase): 76 | """Simple parent resource for this api, to distinguish permissions from permitted""" 77 | permitted = Nested(Permitted) 78 | restricted = Nested(Restricted) 79 | -------------------------------------------------------------------------------- /stashy/admin/users.py: -------------------------------------------------------------------------------- 1 | from ..helpers import ResourceBase, FilteredIterableResource 2 | from ..errors import ok_or_error, response_or_error 3 | from ..compat import update_doc 4 | 5 | class Users(ResourceBase, FilteredIterableResource): 6 | @response_or_error 7 | def add(self, name, password, displayName, emailAddress, addToDefaultGroup=True): 8 | """ 9 | Add a user, returns a dictionary containing information about the newly created user 10 | """ 11 | params = dict(name=name, 12 | password=password, 13 | displayName=displayName, 14 | emailAddress=emailAddress, 15 | addToDefaultGroup=addToDefaultGroup) 16 | 17 | return self._client.post(self.url(), params=params) 18 | 19 | @ok_or_error 20 | def delete(self, user): 21 | """ 22 | Delete a user. 23 | """ 24 | return self._client.delete(self.url(), params=dict(name=user)) 25 | 26 | @response_or_error 27 | def update(self, name, displayName=None, emailAddress=None): 28 | """ 29 | Update the user information, and return the updated user info. 30 | 31 | None is used as a sentinel value, use empty string if you mean to clear. 32 | """ 33 | 34 | data = dict(name=name) 35 | if displayName is not None: 36 | data['displayName'] = displayName 37 | if data is not None: 38 | data['emailAddress'] = emailAddress 39 | 40 | return self._client.put(self.url(), data) 41 | 42 | @ok_or_error 43 | def credentials(self, name, new_password): 44 | """ 45 | Update a user's password. 46 | """ 47 | 48 | data = dict(name=name, password=new_password, passwordConfirm=new_password) 49 | return self._client.put(self.url(), data) 50 | 51 | @ok_or_error 52 | def add_group(self, user, group): 53 | """ 54 | Add the given user to the given user. 55 | """ 56 | return self._client.post(self.url("/add-group"), dict(context=user, itemName=group)) 57 | 58 | @ok_or_error 59 | def remove_group(self, user, group): 60 | """ 61 | Remove the given user from the given group. 62 | """ 63 | return self._client.post(self.url("/remove-group"), dict(context=user, itemName=group)) 64 | 65 | def more_members(self, user, filter=None): 66 | """ 67 | Retrieves a list of groups the specified user is a member of. 68 | 69 | filter: if specified only groups with names containing the supplied string will be returned 70 | """ 71 | params = dict(context=user) 72 | if filter: 73 | params['filter'] = filter 74 | return self.paginate("/more-members", params) 75 | 76 | def more_non_members(self, user, filter=None): 77 | """ 78 | Retrieves a list of groups that the specified user is not a member of 79 | 80 | filter: if specified only groups with names containing the supplied string will be returned 81 | """ 82 | params = dict(context=user) 83 | if filter: 84 | params['filter'] = filter 85 | return self.paginate("/more-non-members", params) 86 | 87 | 88 | update_doc(Users.all, """ 89 | Returns an iterator that will walk all the users, paginating as necessary. 90 | 91 | filter: return only users with usernames, display name or email addresses containing the supplied string 92 | """) 93 | -------------------------------------------------------------------------------- /stashy/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | 4 | from .helpers import Nested, add_json_headers 5 | from .admin import Admin 6 | from .projects import Projects 7 | from .ssh import Keys 8 | from .compat import basestring 9 | from .allrepos import Repos 10 | 11 | class Stash(object): 12 | _url = "/" 13 | 14 | def __init__(self, base_url, username=None, password=None, oauth=None, verify=True, session=None): 15 | self._client = StashClient(base_url, username, password, oauth, verify, session=session) 16 | 17 | admin = Nested(Admin) 18 | projects = Nested(Projects) 19 | ssh = Nested(Keys) 20 | repos = Nested(Repos) 21 | 22 | def groups(self, filter=None): 23 | """ 24 | Consider using stash.admin.groups instead. 25 | """ 26 | return self.admin.groups.get(filter) 27 | 28 | def users(self, filter=None): 29 | """ 30 | Consider using stash.admin.users instead. 31 | """ 32 | return self.admin.users.get(filter) 33 | 34 | 35 | class StashClient(object): 36 | 37 | # the core api path will be used as an overridable default 38 | core_api_name = 'api' 39 | core_api_version = '1.0' 40 | core_api_path = '{0}/{1}'.format(core_api_name, core_api_version) 41 | 42 | branches_api_name = 'branch-utils' 43 | branches_api_version = '1.0' 44 | branches_api_path = '{0}/{1}'.format(branches_api_name, branches_api_version) 45 | 46 | keys_api_name = 'keys' 47 | keys_api_version = '1.0' 48 | keys_api_path = '{0}/{1}'.format(keys_api_name, keys_api_version) 49 | 50 | def __init__(self, base_url, username=None, password=None, oauth=None, verify=True, session=None): 51 | assert isinstance(base_url, basestring) 52 | 53 | if base_url.endswith("/"): 54 | self._base_url = base_url[:-1] 55 | else: 56 | self._base_url = base_url 57 | 58 | self._api_base = self._base_url + "/rest" 59 | 60 | if session is None: 61 | session = requests.Session() 62 | 63 | self._session = session 64 | self._session.verify = verify 65 | 66 | if oauth is not None: 67 | self._create_oauth_session(oauth) 68 | elif username is not None or password is not None: 69 | self._session.auth = (username, password) 70 | 71 | self._session.cookies = self._session.head(self.url("")).cookies 72 | self._session.headers.update({'Content-Type': 'application/json'}) 73 | 74 | def _create_oauth_session(self, oauth): 75 | from requests_oauthlib import OAuth1 76 | from oauthlib.oauth1 import SIGNATURE_RSA 77 | 78 | oauth = OAuth1( 79 | oauth['consumer_key'], 80 | rsa_key=oauth['key_cert'], 81 | signature_method=SIGNATURE_RSA, 82 | resource_owner_key=oauth['access_token'], 83 | resource_owner_secret=outh['access_token_secret'] 84 | ) 85 | self._session.auth = oauth 86 | 87 | def url(self, resource_path): 88 | assert isinstance(resource_path, basestring) 89 | if not resource_path.startswith("/"): 90 | resource_path = "/" + resource_path 91 | return self._api_base + resource_path 92 | 93 | def head(self, resource, **kw): 94 | return self._session.head(self.url(resource), **kw) 95 | 96 | def get(self, resource, **kw): 97 | return self._session.get(self.url(resource), **kw) 98 | 99 | def post(self, resource, data=None, **kw): 100 | if data: 101 | kw = add_json_headers(kw) 102 | data = json.dumps(data) 103 | return self._session.post(self.url(resource), data, **kw) 104 | 105 | def put(self, resource, data=None, **kw): 106 | if data: 107 | kw = add_json_headers(kw) 108 | data = json.dumps(data) 109 | return self._session.put(self.url(resource), data, **kw) 110 | 111 | def delete(self, resource, data=None,**kw): 112 | if data: 113 | data = json.dumps(data) 114 | kw = add_json_headers(kw) 115 | return self._session.request(method='DELETE', url=self.url( 116 | resource), data=data, **kw) 117 | return self._session.delete(self.url(resource), **kw) 118 | -------------------------------------------------------------------------------- /stashy/permissions.py: -------------------------------------------------------------------------------- 1 | from .helpers import ResourceBase, Nested, FilteredIterableResource 2 | from .errors import ok_or_error 3 | from .compat import update_doc 4 | 5 | class Groups(ResourceBase, FilteredIterableResource): 6 | def none(self, filter=None): 7 | """ 8 | Retrieve groups that have no granted permissions. 9 | 10 | filter: return only group names containing the supplied string will be returned 11 | """ 12 | params = {} 13 | if filter: 14 | params['filter'] = filter 15 | return self.paginate("/none", params) 16 | 17 | @ok_or_error 18 | def grant(self, group, permission): 19 | """ 20 | Promote or demote a user's permission level. 21 | 22 | Depending on context, you may use one of the following set of permissions: 23 | 24 | global permissions: 25 | 26 | * LICENSED_USER 27 | * PROJECT_CREATE 28 | * ADMIN 29 | * SYS_ADMIN 30 | 31 | project permissions: 32 | * PROJECT_READ 33 | * PROJECT_WRITE 34 | * PROJECT_ADMIN 35 | """ 36 | return self._client.put(self.url(), params=dict(name=group, permission=permission)) 37 | 38 | @ok_or_error 39 | def revoke(self, group): 40 | """ 41 | Revoke all permissions for a group. 42 | """ 43 | return self._client.delete(self.url(), params=dict(name=group)) 44 | 45 | 46 | class Users(ResourceBase, FilteredIterableResource): 47 | def none(self, filter=None): 48 | """ 49 | Retrieve users that have no granted permissions. 50 | 51 | filter: if specified only user names containing the supplied string will be returned 52 | """ 53 | params = {} 54 | if filter: 55 | params['filter'] = filter 56 | return self.paginate("/none", params) 57 | 58 | @ok_or_error 59 | def grant(self, user, permission): 60 | """ 61 | Promote or demote the permission level of a user. 62 | 63 | 64 | Depending on context, you may use one of the following set of permissions: 65 | 66 | global permissions: 67 | 68 | * LICENSED_USER 69 | * PROJECT_CREATE 70 | * ADMIN 71 | * SYS_ADMIN 72 | 73 | project permissions: 74 | * PROJECT_READ 75 | * PROJECT_WRITE 76 | * PROJECT_ADMIN 77 | 78 | repository permissions: 79 | * REPO_READ 80 | * REPO_WRITE 81 | * REPO_ADMIN 82 | 83 | """ 84 | return self._client.put(self.url(), params=dict(name=user, permission=permission)) 85 | 86 | @ok_or_error 87 | def revoke(self, user): 88 | """ 89 | Revoke all permissions for a user. 90 | """ 91 | return self._client.delete(self.url(), params=dict(name=user)) 92 | 93 | 94 | class Permissions(ResourceBase): 95 | groups = Nested(Groups) 96 | users = Nested(Users) 97 | 98 | 99 | class ProjectPermissions(Permissions): 100 | def _url_for(self, permission): 101 | return self.url().rstrip("/") + "/" + permission + "/all" 102 | 103 | @ok_or_error 104 | def grant(self, permission): 105 | """ 106 | Grant or revoke a project permission to all users, i.e. set the default permission. 107 | 108 | project permissions: 109 | * PROJECT_READ 110 | * PROJECT_WRITE 111 | * PROJECT_ADMIN 112 | 113 | """ 114 | return self._client.post(self._url_for(permission), params=dict(allow=True)) 115 | 116 | @ok_or_error 117 | def revoke(self, permission): 118 | """ 119 | Revoke a project permission from all users, i.e. revoke the default permission. 120 | 121 | project permissions: 122 | * PROJECT_READ 123 | * PROJECT_WRITE 124 | * PROJECT_ADMIN 125 | 126 | """ 127 | return self._client.post(self._url_for(permission), params=dict(allow=False)) 128 | 129 | class RepositoryPermissions(Permissions): 130 | def _url_for(self): 131 | return self.url().rstrip("/") + "/users" 132 | 133 | @ok_or_error 134 | def grant(self, user, permission): 135 | """ 136 | Grant or revoke a repository permission to all users, i.e. set the 137 | default permission. 138 | 139 | repository permissions: 140 | * REPO_READ 141 | * REPO_WRITE 142 | * REPO_ADMIN 143 | 144 | 145 | """ 146 | return self._client.put(self._url_for(), params=dict(name=user, 147 | permission=permission)) 148 | 149 | @ok_or_error 150 | def revoke(self, user): 151 | """ 152 | Revoke a repository permission from all users, i.e. revoke the default 153 | permission. 154 | 155 | repository permissions: 156 | * REPO_READ 157 | * REPO_WRITE 158 | * REPO_ADMIN 159 | 160 | """ 161 | return self._client.post(self._url_for(), params=dict(name=user)) 162 | 163 | 164 | update_doc(Groups.all, """ 165 | Returns groups that have been granted at least one permission. 166 | 167 | filter: return only group names containing the supplied string will be returned 168 | """) 169 | 170 | update_doc(Users.all, """ 171 | Returns users that have been granted at least one permission. 172 | 173 | filter: if specified only user names containing the supplied string will be returned 174 | """) 175 | -------------------------------------------------------------------------------- /stashy/helpers.py: -------------------------------------------------------------------------------- 1 | from .errors import maybe_throw 2 | 3 | 4 | def add_json_headers(kw): 5 | if 'headers' not in kw: 6 | kw['headers'] = {} 7 | 8 | headers = {'Content-type': 'application/json', 'Accept': 'application/json'} 9 | 10 | for header, value in headers.items(): 11 | kw['headers'][header] = value 12 | 13 | return kw 14 | 15 | 16 | class ResourceBase(object): 17 | def __init__(self, url, client, parent, 18 | api_path=None, 19 | branches_api_path=None, 20 | keys_api_path=None): 21 | self._client = client 22 | self._parent = parent 23 | if api_path is None: 24 | api_path = self._client.core_api_path 25 | branches_api_path = self._client.branches_api_path 26 | keys_api_path = self._client.keys_api_path 27 | if branches_api_path is None: 28 | branches_api_path = self._client.branches_api_path 29 | if keys_api_path is None: 30 | keys_api_path = self._client.keys_api_path 31 | 32 | # make sure we're only prefixing with one api path 33 | if url.startswith(api_path): 34 | self._url = url 35 | self._branchesurl = url.replace(api_path, branches_api_path) 36 | self._keysurl = url.replace(api_path, keys_api_path) 37 | elif url.startswith(self._client.core_api_path): 38 | self._url = url.replace(self._client.core_api_path, api_path) 39 | self._branchesurl = url.replace(self._client.core_api_path, 40 | branches_api_path) 41 | self._keysurl = url.replace(self._client.core_api_path, 42 | keys_api_path) 43 | else: 44 | if url.startswith('/'): 45 | url = url[1:] 46 | self._url = '{0}/{1}'.format(api_path, url) 47 | self._branchesurl = '{0}/{1}'.format(branches_api_path, url) 48 | self._keysurl = '{0}/{1}'.format(keys_api_path, url) 49 | 50 | 51 | 52 | 53 | def url(self, resource_url="", api_type=None): 54 | if resource_url and not resource_url.startswith("/"): 55 | resource_url = "/" + resource_url 56 | if api_type is None or api_type == 'core': 57 | if self._url.endswith("/"): 58 | url = self._url[:-1] 59 | else : 60 | url = self._url 61 | elif api_type == 'branches': 62 | if self._branchesurl.endswith("/"): 63 | url = self._branchesurl[:-1] 64 | else : 65 | url = self._branchesurl 66 | elif api_type == 'keys': 67 | if self._keysurl.endswith("/"): 68 | url = self._keysurl[:-1] 69 | else : 70 | url = self._keysurl 71 | else: 72 | raise ValueError('Unknown API ' + api_type) 73 | return url + resource_url 74 | 75 | def paginate(self, resource_url, params=None, values_key='values', 76 | api_type=None): 77 | url = self.url(resource_url, api_type) 78 | 79 | more = True 80 | start = None 81 | while more: 82 | kw = {} 83 | if params: 84 | kw['params'] = params 85 | if start is not None: 86 | kw.setdefault('params', {}) 87 | kw['params']['start'] = start 88 | 89 | response = self._client.get(url, **kw) 90 | maybe_throw(response) 91 | 92 | data = response.json() 93 | 94 | if not values_key in data: 95 | return 96 | for item in data[values_key]: 97 | yield item 98 | 99 | if data['isLastPage']: 100 | more = False 101 | else: 102 | more = True 103 | start = data['nextPageStart'] 104 | 105 | 106 | class IterableResource(object): 107 | def __iter__(self): 108 | """ 109 | Convenience method around self.all() 110 | """ 111 | return self.all() 112 | 113 | def all(self): 114 | """ 115 | Retrieve all the resources. 116 | """ 117 | return self.paginate("") 118 | 119 | def list(self): 120 | """ 121 | Convenience method to return a list (rather than iterable) of all elements 122 | """ 123 | return list(self.all()) 124 | 125 | 126 | class FilteredIterableResource(IterableResource): 127 | def all(self, filter=None): 128 | """ 129 | Retrieve all the resources, optionally modified by filter. 130 | """ 131 | params = {} 132 | if filter: 133 | params['filter'] = filter 134 | return self.paginate("", params) 135 | 136 | def list(self, filter=None): 137 | """ 138 | Convenience method to return a list (rather than iterable) of all elements 139 | """ 140 | return list(self.all(filter)) 141 | 142 | 143 | class Nested(object): 144 | def __init__(self, cls, relative_path=''): 145 | 146 | # nested object for clarity of usage, no effect on resource url 147 | if relative_path is None: 148 | self.relative_path = '' 149 | 150 | # default case, use lowercase class name 151 | elif not relative_path: 152 | self.relative_path = "/%s" % cls.__name__.lower() 153 | 154 | # explicit override of relative path 155 | else: 156 | if not relative_path.startswith("/"): 157 | relative_path = "/" + relative_path 158 | self.relative_path = relative_path 159 | self.cls = cls 160 | 161 | def __get__(self, instance, kind): 162 | parent_url = instance._url 163 | if parent_url.endswith("/"): 164 | parent_url = parent_url[:-1] 165 | 166 | url = parent_url + self.relative_path 167 | return self.cls(url=url, client=instance._client, parent=instance) 168 | 169 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stashy 2 | 3 | Python client for the [Atlassian Stash REST API](https://developer.atlassian.com/stash/docs/latest/reference/rest-api.html). Supports Python 2.6, 2.7 and 3.3. 4 | 5 | [![Build Status](https://travis-ci.org/RisingOak/stashy.png?branch=master)](https://travis-ci.org/RisingOak/stashy) 6 | 7 | ## Installation 8 | 9 | ``` 10 | pip install stashy 11 | ``` 12 | 13 | ## Usage 14 | ```python 15 | import stashy 16 | stash = stashy.connect("http://localhost:7990/stash", "admin", "admin") 17 | ``` 18 | 19 | ## Examples 20 | 21 | * Retrieve all groups 22 | 23 | ```python 24 | stash.admin.groups.list() 25 | ``` 26 | 27 | * Retrieve all users that match a given filter 28 | 29 | ```python 30 | stash.admin.users.list(filter="admin") 31 | ``` 32 | 33 | * Add a user to a group 34 | 35 | ```python 36 | stash.admin.groups.add_user('stash-users', 'admin') 37 | ``` 38 | 39 | * Iterate over all projects (that you have access to) 40 | 41 | ```python 42 | stash.projects.list() 43 | ``` 44 | 45 | * List all the repositories in a given project 46 | 47 | ```python 48 | stash.projects[PROJECT].repos.list() 49 | ``` 50 | 51 | * List all the commits in a pull request 52 | 53 | ```python 54 | list(stash.projects[PROJECT].repos[REPO].pull_requests.commits()) 55 | ``` 56 | 57 | * Show the diff of a pull request 58 | 59 | ```python 60 | stash.project[PROJECT].repos[REPO].pull_requests[PULL_REQUEST].diff() 61 | ``` 62 | 63 | * List all branch restrictions for a repo 64 | ```python 65 | stash.projects[PROJECT].repos[REPO].restricted.list() 66 | ``` 67 | 68 | * List all branch permission entities for a repo 69 | ```python 70 | stash.projects[PROJECT].repos[REPO].permitted.list() 71 | ``` 72 | 73 | ## Implemented 74 | 75 | ``` 76 | /admin/groups [DELETE, GET, POST] 77 | /admin/groups/add-user [POST] 78 | /admin/groups/more-members [GET] 79 | /admin/groups/more-non-members [GET] 80 | /admin/groups/remove-user [POST] 81 | /admin/users [GET, POST, DELETE, PUT] 82 | /admin/users/add-group [POST] 83 | /admin/users/credentials [PUT] 84 | /admin/users/more-members [GET] 85 | /admin/users/more-non-members [GET] 86 | /admin/users/remove-group [POST] 87 | /admin/permissions/groups [GET, PUT, DELETE] 88 | /admin/permissions/groups/none [GET] 89 | /admin/permissions/users [GET, PUT, DELETE] 90 | /admin/permissions/users/none [GET] 91 | /groups [GET] 92 | /projects [POST, GET] 93 | /projects/{projectKey} [DELETE, PUT, GET] 94 | /projects/{projectKey}/permissions/groups [GET, PUT, DELETE] 95 | /projects/{projectKey}/permissions/groups/none [GET] 96 | /projects/{projectKey}/permissions/users [GET, PUT, DELETE] 97 | /projects/{projectKey}/permissions/users/none [GET] 98 | /projects/{projectKey}/permissions/{permission}/all [GET, POST] 99 | /projects/{projectKey}/repos [POST, GET] 100 | /projects/{projectKey}/repos/{repositorySlug} [DELETE, POST, PUT, GET] 101 | /projects/{projectKey}/repos/{repositorySlug}/branches [GET, PUT, DELETE] 102 | /projects/{projectKey}/repos/{repositorySlug}/branches/default [GET, PUT] 103 | /projects/{projectKey}/repos/{repositorySlug}/branches/info/{changesetId} [GET] 104 | /projects/{projectKey}/repos/{repositorySlug}/changes [GET] 105 | /projects/{projectKey}/repos/{repositorySlug}/commits [GET] 106 | /projects/{projectKey}/repos/{repositorySlug}/permissions [GET, POST,DELETE] 107 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests [GET, POST] 108 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId} [GET, PUT] 109 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/activities [GET] 110 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/decline [POST] 111 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/merge [GET, POST] 112 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/reopen [POST] 113 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/approve [POST, DELETE] 114 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/changes [GET] 115 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/comments [POST] 116 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/commits [GET] 117 | /projects/{projectKey}/repos/{repositorySlug}/settings/hooks [GET] 118 | /projects/{projectKey}/repos/{repositorySlug}/settings/hooks/{hookKey} [GET] 119 | /projects/{projectKey}/repos/{repositorySlug}/settings/hooks/{hookKey}/enabled [PUT, DELETE] 120 | /projects/{projectKey}/repos/{repositorySlug}/settings/hooks/{hookKey}/settings [PUT, GET] 121 | /projects/{projectKey}/repos/{repositorySlug}/settings/pull-requests [GET, POST] 122 | /projects/{projectKey}/repos/{repositorySlug}/tags [GET] 123 | ``` 124 | 125 | ## Not yet implemented 126 | 127 | ``` 128 | /admin/mail-server [DELETE] 129 | /application-properties [GET] 130 | /hooks/{hookKey}/avatar [GET] 131 | /logs/logger/{loggerName} [GET] 132 | /logs/logger/{loggerName}/{levelName} [PUT] 133 | /logs/rootLogger [GET] 134 | /logs/rootLogger/{levelName} [PUT] 135 | /markup/preview [POST] 136 | /profile/recent/repos [GET] 137 | /projects/{projectKey}/avatar.png [GET, POST] 138 | /projects/{projectKey}/repos/{repositorySlug}/recreate [POST] 139 | /projects/{projectKey}/repos/{repositorySlug}/browse [GET] 140 | /projects/{projectKey}/repos/{repositorySlug}/browse/{path:.*} [GET] 141 | /projects/{projectKey}/repos/{repositorySlug}/commits/{changesetId:.*} [GET] 142 | /projects/{projectKey}/repos/{repositorySlug}/diff/{path:.*} [GET] 143 | /projects/{projectKey}/repos/{repositorySlug}/files [GET] 144 | /projects/{projectKey}/repos/{repositorySlug}/files/{path:.*} [GET] 145 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/comments/{commentId} [DELETE, PUT, GET] 146 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/diff [GET] 147 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/diff/{path:.*} [GET] 148 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/participants [GET, DELETE, POST] 149 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/watch [POST, DELETE] 150 | /users [GET, PUT] 151 | /users/credentials [PUT] 152 | ``` 153 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/stashy.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/stashy.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/stashy" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/stashy" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | stashy 2 | ====== 3 | 4 | Python client for the [Atlassian Stash REST API](https://developer.atlassian.com/stash/docs/latest/reference/rest-api.html). Supports Python 2.6, 2.7 and 3.3. 5 | 6 | 7 | |Build Status| 8 | 9 | Installation 10 | ------------ 11 | 12 | :: 13 | 14 | pip install stashy 15 | 16 | Usage 17 | ----- 18 | 19 | .. code:: python 20 | 21 | import stashy 22 | stash = stashy.connect("http://localhost:7990/stash", "admin", "admin") 23 | 24 | Examples 25 | -------- 26 | 27 | - Retrieve all groups 28 | 29 | .. code:: python 30 | 31 | stash.admin.groups.list() 32 | 33 | - Retrieve all users that match a given filter 34 | 35 | .. code:: python 36 | 37 | stash.admin.users.list(filter="admin") 38 | 39 | - Add a user to a group 40 | 41 | .. code:: python 42 | 43 | stash.admin.groups.add_user('stash-users', 'admin') 44 | 45 | - Iterate over all projects (that you have access to) 46 | 47 | .. code:: python 48 | 49 | stash.projects.list() 50 | 51 | - List all the repositories in a given project 52 | 53 | .. code:: python 54 | 55 | stash.projects[PROJECT].repos.list() 56 | 57 | - List all the commits in a pull request 58 | 59 | .. code:: python 60 | 61 | list(stash.projects[PROJECT].repos[REPO].pull_requests.commits()) 62 | 63 | - Show the diff of a pull request 64 | 65 | .. code:: python 66 | 67 | stash.project[PROJECT].repos[REPO].pull_requests[PULL_REQUEST].diff() 68 | 69 | - List all branch restrictions for a repo 70 | 71 | .. code:: python 72 | 73 | stash.projects[PROJECT].repos[REPO].restricted.list() 74 | 75 | - List all branch permission entities for a repo 76 | 77 | .. code:: python 78 | 79 | stash.projects[PROJECT].repos[REPO].permitted.list() 80 | 81 | Implemented 82 | ----------- 83 | 84 | :: 85 | 86 | /admin/groups [DELETE, GET, POST] 87 | /admin/groups/add-user [POST] 88 | /admin/groups/more-members [GET] 89 | /admin/groups/more-non-members [GET] 90 | /admin/groups/remove-user [POST] 91 | /admin/users [GET, POST, DELETE, PUT] 92 | /admin/users/add-group [POST] 93 | /admin/users/credentials [PUT] 94 | /admin/users/more-members [GET] 95 | /admin/users/more-non-members [GET] 96 | /admin/users/remove-group [POST] 97 | /admin/permissions/groups [GET, PUT, DELETE] 98 | /admin/permissions/groups/none [GET] 99 | /admin/permissions/users [GET, PUT, DELETE] 100 | /admin/permissions/users/none [GET] 101 | /groups [GET] 102 | /projects [POST, GET] 103 | /projects/{projectKey} [DELETE, PUT, GET] 104 | /projects/{projectKey}/permissions/groups [GET, PUT, DELETE] 105 | /projects/{projectKey}/permissions/groups/none [GET] 106 | /projects/{projectKey}/permissions/users [GET, PUT, DELETE] 107 | /projects/{projectKey}/permissions/users/none [GET] 108 | /projects/{projectKey}/permissions/{permission}/all [GET, POST] 109 | /projects/{projectKey}/repos [POST, GET] 110 | /projects/{projectKey}/repos/{repositorySlug} [DELETE, POST, PUT, GET] 111 | /projects/{projectKey}/repos/{repositorySlug}/branches [GET] 112 | /projects/{projectKey}/repos/{repositorySlug}/branches/default [GET, PUT] 113 | /projects/{projectKey}/repos/{repositorySlug}/changes [GET] 114 | /projects/{projectKey}/repos/{repositorySlug}/commits [GET] 115 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests [GET, POST] 116 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId} [GET, PUT] 117 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/activities [GET] 118 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/decline [POST] 119 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/merge [GET, POST] 120 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/reopen [POST] 121 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/approve [POST, DELETE] 122 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/changes [GET] 123 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/comments [POST] 124 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/commits [GET] 125 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/diff [GET] 126 | /projects/{projectKey}/repos/{repositorySlug}/settings/hooks [GET] 127 | /projects/{projectKey}/repos/{repositorySlug}/settings/hooks/{hookKey} [GET] 128 | /projects/{projectKey}/repos/{repositorySlug}/settings/hooks/{hookKey}/enabled [PUT, DELETE] 129 | /projects/{projectKey}/repos/{repositorySlug}/settings/hooks/{hookKey}/settings [PUT, GET] 130 | /projects/{projectKey}/repos/{repositorySlug}/settings/pull-requests [GET, POST] 131 | /projects/{projectKey}/repos/{repositorySlug}/tags [GET] 132 | 133 | Not yet implemented 134 | ------------------- 135 | 136 | :: 137 | 138 | /admin/mail-server [DELETE] 139 | /application-properties [GET] 140 | /hooks/{hookKey}/avatar [GET] 141 | /logs/logger/{loggerName} [GET] 142 | /logs/logger/{loggerName}/{levelName} [PUT] 143 | /logs/rootLogger [GET] 144 | /logs/rootLogger/{levelName} [PUT] 145 | /markup/preview [POST] 146 | /profile/recent/repos [GET] 147 | /projects/{projectKey}/avatar.png [GET, POST] 148 | /projects/{projectKey}/repos/{repositorySlug}/recreate [POST] 149 | /projects/{projectKey}/repos/{repositorySlug}/browse [GET] 150 | /projects/{projectKey}/repos/{repositorySlug}/browse/{path:.*} [GET] 151 | /projects/{projectKey}/repos/{repositorySlug}/commits/{changesetId:.*} [GET] 152 | /projects/{projectKey}/repos/{repositorySlug}/diff/{path:.*} [GET] 153 | /projects/{projectKey}/repos/{repositorySlug}/files [GET] 154 | /projects/{projectKey}/repos/{repositorySlug}/files/{path:.*} [GET] 155 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/comments/{commentId} [DELETE, PUT, GET] 156 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/diff/{path:.*} [GET] 157 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/participants [GET, DELETE, POST] 158 | /projects/{projectKey}/repos/{repositorySlug}/pull-requests/{pullRequestId}/watch [POST, DELETE] 159 | /users [GET, PUT] 160 | /users/credentials [PUT] 161 | 162 | .. |Build Status| image:: https://travis-ci.org/RisingOak/stashy.png?branch=master 163 | :target: https://travis-ci.org/RisingOak/stashy 164 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # stashy documentation build configuration file, created by 4 | # sphinx-quickstart on Sun Mar 10 17:38:40 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | sys.path.insert(0, os.path.abspath('..')) 20 | import stashy, stashy.client, stashy.admin, stashy.projects 21 | from stashy import __version__ 22 | 23 | # -- General configuration ----------------------------------------------------- 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be extensions 29 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 30 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.viewcode'] 31 | 32 | # Add any paths that contain templates here, relative to this directory. 33 | templates_path = ['_templates'] 34 | 35 | # The suffix of source filenames. 36 | source_suffix = '.rst' 37 | 38 | # The encoding of source files. 39 | #source_encoding = 'utf-8-sig' 40 | 41 | # The master toctree document. 42 | master_doc = 'index' 43 | 44 | # General information about the project. 45 | project = u'stashy' 46 | copyright = u'2013, Rising Oak LLC' 47 | 48 | # The version info for the project you're documenting, acts as replacement for 49 | # |version| and |release|, also used in various other places throughout the 50 | # built documents. 51 | # 52 | # The short X.Y version. 53 | version = __version__ 54 | # The full version, including alpha/beta/rc tags. 55 | release = version 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | #language = None 60 | 61 | # There are two options for replacing |today|: either, you set today to some 62 | # non-false value, then it is used: 63 | #today = '' 64 | # Else, today_fmt is used as the format for a strftime call. 65 | #today_fmt = '%B %d, %Y' 66 | 67 | # List of patterns, relative to source directory, that match files and 68 | # directories to ignore when looking for source files. 69 | exclude_patterns = ['_build'] 70 | 71 | # The reST default role (used for this markup: `text`) to use for all documents. 72 | #default_role = None 73 | 74 | # If true, '()' will be appended to :func: etc. cross-reference text. 75 | #add_function_parentheses = True 76 | 77 | # If true, the current module name will be prepended to all description 78 | # unit titles (such as .. function::). 79 | #add_module_names = True 80 | 81 | # If true, sectionauthor and moduleauthor directives will be shown in the 82 | # output. They are ignored by default. 83 | #show_authors = False 84 | 85 | # The name of the Pygments (syntax highlighting) style to use. 86 | pygments_style = 'sphinx' 87 | 88 | # A list of ignored prefixes for module index sorting. 89 | #modindex_common_prefix = [] 90 | 91 | 92 | # -- Options for HTML output --------------------------------------------------- 93 | 94 | # The theme to use for HTML and HTML Help pages. See the documentation for 95 | # a list of builtin themes. 96 | html_theme = 'default' 97 | 98 | # Theme options are theme-specific and customize the look and feel of a theme 99 | # further. For a list of options available for each theme, see the 100 | # documentation. 101 | #html_theme_options = {} 102 | 103 | # Add any paths that contain custom themes here, relative to this directory. 104 | #html_theme_path = [] 105 | 106 | # The name for this set of Sphinx documents. If None, it defaults to 107 | # " v documentation". 108 | #html_title = None 109 | 110 | # A shorter title for the navigation bar. Default is the same as html_title. 111 | #html_short_title = None 112 | 113 | # The name of an image file (relative to this directory) to place at the top 114 | # of the sidebar. 115 | #html_logo = None 116 | 117 | # The name of an image file (within the static path) to use as favicon of the 118 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 119 | # pixels large. 120 | #html_favicon = None 121 | 122 | # Add any paths that contain custom static files (such as style sheets) here, 123 | # relative to this directory. They are copied after the builtin static files, 124 | # so a file named "default.css" will overwrite the builtin "default.css". 125 | html_static_path = ['_static'] 126 | 127 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 128 | # using the given strftime format. 129 | #html_last_updated_fmt = '%b %d, %Y' 130 | 131 | # If true, SmartyPants will be used to convert quotes and dashes to 132 | # typographically correct entities. 133 | #html_use_smartypants = True 134 | 135 | # Custom sidebar templates, maps document names to template names. 136 | #html_sidebars = {} 137 | 138 | # Additional templates that should be rendered to pages, maps page names to 139 | # template names. 140 | #html_additional_pages = {} 141 | 142 | # If false, no module index is generated. 143 | #html_domain_indices = True 144 | 145 | # If false, no index is generated. 146 | #html_use_index = True 147 | 148 | # If true, the index is split into individual pages for each letter. 149 | #html_split_index = False 150 | 151 | # If true, links to the reST sources are added to the pages. 152 | #html_show_sourcelink = True 153 | 154 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 155 | #html_show_sphinx = True 156 | 157 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 158 | #html_show_copyright = True 159 | 160 | # If true, an OpenSearch description file will be output, and all pages will 161 | # contain a tag referring to it. The value of this option must be the 162 | # base URL from which the finished HTML is served. 163 | #html_use_opensearch = '' 164 | 165 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 166 | #html_file_suffix = None 167 | 168 | # Output file base name for HTML help builder. 169 | htmlhelp_basename = 'stashydoc' 170 | 171 | 172 | # -- Options for LaTeX output -------------------------------------------------- 173 | 174 | latex_elements = { 175 | # The paper size ('letterpaper' or 'a4paper'). 176 | #'papersize': 'letterpaper', 177 | 178 | # The font size ('10pt', '11pt' or '12pt'). 179 | #'pointsize': '10pt', 180 | 181 | # Additional stuff for the LaTeX preamble. 182 | #'preamble': '', 183 | } 184 | 185 | # Grouping the document tree into LaTeX files. List of tuples 186 | # (source start file, target name, title, author, documentclass [howto/manual]). 187 | latex_documents = [ 188 | ('index', 'stashy.tex', u'stashy Documentation', 189 | u'Cosmin Stejerean', 'manual'), 190 | ] 191 | 192 | # The name of an image file (relative to this directory) to place at the top of 193 | # the title page. 194 | #latex_logo = None 195 | 196 | # For "manual" documents, if this is true, then toplevel headings are parts, 197 | # not chapters. 198 | #latex_use_parts = False 199 | 200 | # If true, show page references after internal links. 201 | #latex_show_pagerefs = False 202 | 203 | # If true, show URL addresses after external links. 204 | #latex_show_urls = False 205 | 206 | # Documents to append as an appendix to all manuals. 207 | #latex_appendices = [] 208 | 209 | # If false, no module index is generated. 210 | #latex_domain_indices = True 211 | 212 | 213 | # -- Options for manual page output -------------------------------------------- 214 | 215 | # One entry per manual page. List of tuples 216 | # (source start file, name, description, authors, manual section). 217 | man_pages = [ 218 | ('index', 'stashy', u'stashy Documentation', 219 | [u'Cosmin Stejerean'], 1) 220 | ] 221 | 222 | # If true, show URL addresses after external links. 223 | #man_show_urls = False 224 | 225 | 226 | # -- Options for Texinfo output ------------------------------------------------ 227 | 228 | # Grouping the document tree into Texinfo files. List of tuples 229 | # (source start file, target name, title, author, 230 | # dir menu entry, description, category) 231 | texinfo_documents = [ 232 | ('index', 'stashy', u'stashy Documentation', 233 | u'Cosmin Stejerean', 'stashy', 'Python API client for the Atlassian Stash REST API', 234 | 'Miscellaneous'), 235 | ] 236 | 237 | # Documents to append as an appendix to all manuals. 238 | #texinfo_appendices = [] 239 | 240 | # If false, no module index is generated. 241 | #texinfo_domain_indices = True 242 | 243 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 244 | #texinfo_show_urls = 'footnote' 245 | -------------------------------------------------------------------------------- /stashy/pullrequests.py: -------------------------------------------------------------------------------- 1 | from .helpers import ResourceBase, IterableResource 2 | from .errors import ok_or_error, response_or_error 3 | from .compat import basestring 4 | from .pullrequestdiffs import PullRequestDiff 5 | import json 6 | 7 | 8 | class PullRequestRef(object): 9 | def __init__(self, project_key, repo_slug, id): 10 | self.project_key = project_key 11 | self.repo_slug = repo_slug 12 | self.id = id 13 | 14 | def to_dict(self): 15 | return dict(id=self.id, 16 | repository=dict(slug=self.repo_slug, 17 | project=dict(key=self.project_key), 18 | name=self.repo_slug)) 19 | 20 | 21 | class PullRequest(ResourceBase): 22 | def __init__(self, id, url, client, parent): 23 | super(PullRequest, self).__init__(url, client, parent) 24 | self._id = id 25 | 26 | @response_or_error 27 | def get(self): 28 | """ 29 | Retrieve a pull request. 30 | """ 31 | return self._client.get(self.url()) 32 | 33 | @response_or_error 34 | def update(self, version, title=None, description=None, reviewers=None): 35 | """ 36 | Update the title, description or reviewers of an existing pull request. 37 | 38 | Note: the reviewers list may be updated using this resource. However the author and participants list may not. 39 | """ 40 | data = dict(id=self._id, version=version) 41 | if title is not None: 42 | data['title'] = title 43 | if description is not None: 44 | data['description'] = description 45 | if reviewers is not None: 46 | data['reviewers'] = reviewers 47 | return self._client.put(self.url(), data=data) 48 | 49 | def activities(self, fromId=None, fromType=None): 50 | """ 51 | Retrieve a page of activity associated with a pull request. 52 | 53 | Activity items include comments, approvals, rescopes (i.e. adding and removing of commits), merges and more. 54 | 55 | Different types of activity items may be introduced in newer versions of Stash or by user installed plugins, 56 | so clients should be flexible enough to handle unexpected entity shapes in the returned page. 57 | 58 | fromId: (optional) the id of the activity item to use as the first item in the returned page 59 | fromType: (required if fromId is present) the type of the activity item specified by fromId 60 | """ 61 | params = dict() 62 | if fromId is not None: 63 | if fromType is None: 64 | raise ValueError("fromType is required when fromId is supplied") 65 | params['fromId'] = fromId 66 | params['fromType'] = fromType 67 | return self.paginate("/activities", params=params) 68 | 69 | @ok_or_error 70 | def decline(self, version=-1): 71 | """Decline a pull request.""" 72 | return self._client.post(self.url("/decline"), data=dict(version=version)) 73 | 74 | def can_merge(self): 75 | """ 76 | Test whether a pull request can be merged. 77 | 78 | A pull request may not be merged if: 79 | 80 | * there are conflicts that need to be manually resolved before merging; and/or 81 | * one or more merge checks have vetoed the merge. 82 | """ 83 | res = self.merge_info() 84 | return res['canMerge'] and not res['conflicted'] 85 | 86 | @response_or_error 87 | def merge_info(self): 88 | """ 89 | Show conflicts and vetoes of pull request. 90 | 91 | A pull request may not be merged if: 92 | 93 | * there are conflicts that need to be manually resolved before merging; and/or 94 | * one or more merge checks have vetoed the merge. 95 | """ 96 | return self._client.get(self.url("/merge")) 97 | 98 | @response_or_error 99 | def merge(self, version=-1): 100 | """ 101 | Merge the specified pull request. 102 | """ 103 | return self._client.post(self.url("/merge"), data=dict(version=version)) 104 | 105 | @response_or_error 106 | def reopen(self, version=-1): 107 | """ 108 | Re-open a declined pull request. 109 | """ 110 | return self._client.post(self.url("/reopen"), data=dict(version=version)) 111 | 112 | @response_or_error 113 | def approve(self): 114 | """ 115 | Approve a pull request as the current user. Implicitly adds the user as a participant if they are not already. 116 | """ 117 | return self._client.post(self.url("/approve")) 118 | 119 | @response_or_error 120 | def unapprove(self): 121 | """ 122 | Remove approval from a pull request as the current user. This does not remove the user as a participant. 123 | """ 124 | return self._client.delete(self.url("/approve")) 125 | 126 | def changes(self): 127 | """ 128 | Gets changes for the specified PullRequest. 129 | 130 | Note: This resource is currently not paged. The server will return at most one page. 131 | The server will truncate the number of changes to an internal maximum. 132 | """ 133 | return self.paginate("/changes") 134 | 135 | def commits(self): 136 | """ 137 | Retrieve changesets for the specified pull request. 138 | """ 139 | return self.paginate('/commits') 140 | 141 | def comments(self, srcPath='/'): 142 | """ 143 | Retrieve comments for the specified file in a pull request. 144 | """ 145 | return self.paginate('/comments?path=%s' % srcPath) 146 | 147 | @ok_or_error 148 | def comment(self, commentText, parentCommentId=-1, srcPath=None, fileLine=-1, lineType="CONTEXT", fileType="FROM"): 149 | """ 150 | Comment on a pull request. If parentCommentId is supplied, it the comment will be 151 | a child comment of the comment with the id parentCommentId. 152 | 153 | Note: see https://developer.atlassian.com/static/rest/stash/3.11.3/stash-rest.html#idp1448560 154 | srcPath: (optional) The path of the file to comment on. 155 | fileLine: (optional) The line of the file to comment on. 156 | lineType: (optional, defaults to CONTEXT) the type of chunk that is getting commented on. 157 | Either ADDED, REMOVED, or CONTEXT 158 | fileType: (optional, defaults to FROM) the version of the file to comment on. 159 | Either FROM, or TO 160 | """ 161 | data = dict(text=commentText) 162 | if parentCommentId is not -1: 163 | data['parent'] = dict(id=parentCommentId) 164 | elif srcPath is not None: 165 | data['anchor'] = dict(path=srcPath, srcPath=srcPath) 166 | if fileLine is not -1: 167 | data['anchor'].update(dict(line=fileLine, lineType=lineType, fileType=fileType)) 168 | return self._client.post(self.url("/comments"), data=data) 169 | 170 | def diff(self): 171 | """ 172 | Retrieve the diff for the specified pull request. 173 | """ 174 | return PullRequestDiff(self.url('/diff'), self._client, self) 175 | 176 | 177 | class PullRequests(ResourceBase, IterableResource): 178 | def all(self, direction='INCOMING', at=None, state='OPEN', order=None): 179 | """ 180 | Retrieve pull requests to or from the specified repository. 181 | 182 | direction: (optional, defaults to INCOMING) the direction relative to the specified repository. 183 | Either INCOMING or OUTGOING. 184 | at: (optional) a specific branch to find pull requests to or from. 185 | state: (optional, defaults to OPEN) only pull requests in the specified state will be returned. 186 | Either OPEN, DECLINED or MERGED. 187 | order: (optional) the order to return pull requests in, either OLDEST (as in: "oldest first") or NEWEST. 188 | """ 189 | 190 | params = {} 191 | 192 | if direction is not None: 193 | params['direction'] = direction 194 | if at is not None: 195 | params['at'] = at 196 | if state is not None: 197 | params['state'] = state 198 | if order is not None: 199 | params['order'] = order 200 | 201 | return self.paginate("", params=params) 202 | 203 | def _make_ref(self, ref, refName="the ref"): 204 | if isinstance(ref, basestring): 205 | repo = self._parent.get() 206 | return PullRequestRef(repo['project']['key'], repo['slug'], ref).to_dict() 207 | elif isinstance(ref, PullRequestRef): 208 | return ref.to_dict() 209 | elif isinstance(ref, dict): 210 | return ref 211 | else: 212 | raise ValueError(refName + " should be either a string, a dict, or a PullRequestRef") 213 | 214 | @response_or_error 215 | def create(self, title, fromRef, toRef, description='', state='OPEN', reviewers=None): 216 | """ 217 | Create a new pull request between two branches. 218 | """ 219 | data = dict(title=title, 220 | description=description, 221 | fromRef=self._make_ref(fromRef, "fromRef"), 222 | toRef=self._make_ref(toRef, "toRef"), 223 | state=state) 224 | 225 | if reviewers is not None: 226 | data['reviewers'] = [] 227 | for reviewer in reviewers: 228 | data['reviewers'].append({"user": dict(name=reviewer)}) 229 | 230 | return self._client.post(self.url(""), data=data) 231 | 232 | def __getitem__(self, item): 233 | """ 234 | Return a specific pull requests 235 | """ 236 | return PullRequest(item, self.url(str(item)), self._client, self) 237 | 238 | 239 | -------------------------------------------------------------------------------- /stashy/repos.py: -------------------------------------------------------------------------------- 1 | from .helpers import Nested, ResourceBase, IterableResource 2 | from .errors import ok_or_error, response_or_error 3 | from .permissions import Permissions, RepositoryPermissions 4 | from .pullrequests import PullRequests 5 | from .compat import update_doc 6 | from .branch_permissions import BranchPermissions 7 | 8 | class Hook(ResourceBase): 9 | def __init__(self, key, url, client, parent): 10 | super(Hook, self).__init__(url, client, parent) 11 | self._key = key 12 | 13 | @response_or_error 14 | def get(self): 15 | """ 16 | Retrieve a repository hook 17 | """ 18 | return self._client.get(self.url()) 19 | 20 | @response_or_error 21 | def enable(self, configuration=None): 22 | """ 23 | Enable a repository hook, optionally applying new configuration. 24 | """ 25 | return self._client.put(self.url("/enabled"), data=configuration) 26 | 27 | @response_or_error 28 | def disable(self): 29 | """ 30 | Disable a repository hook 31 | """ 32 | return self._client.delete(self.url("/enabled")) 33 | 34 | @response_or_error 35 | def settings(self): 36 | """ 37 | Retrieve the settings for a repository hook 38 | """ 39 | return self._client.get(self.url("/settings")) 40 | 41 | @response_or_error 42 | def configure(self, configuration): 43 | """ 44 | Modify the settings for a repository hook 45 | """ 46 | return self._client.put(self.url("/settings"), data=configuration) 47 | 48 | 49 | class Hooks(ResourceBase, IterableResource): 50 | def all(self, type=None): 51 | """ 52 | Retrieve hooks for this repository, optionally filtered by type. 53 | 54 | type: Valid values are PRE_RECEIVE or POST_RECEIVE 55 | """ 56 | params=None 57 | if type is not None: 58 | params = dict(type=type) 59 | return self.paginate("", params=params) 60 | 61 | def list(self, type=None): 62 | """ 63 | Convenience method to return a list (rather than iterable) of all elements 64 | """ 65 | return list(self.all(type=type)) 66 | 67 | def __getitem__(self, item): 68 | """ 69 | Return a :class:`Hook` object for operations on a specific hook 70 | """ 71 | return Hook(item, self.url(item), self._client, self) 72 | 73 | 74 | class PullRequests(ResourceBase): 75 | def __init__(self, url, client, parent): 76 | super(PullRequests, self).__init__(url, client, parent) 77 | 78 | @response_or_error 79 | def get(self): 80 | """ 81 | Retrieve the settings for a pull requests workflow 82 | """ 83 | return self._client.get(self.url()) 84 | 85 | @response_or_error 86 | def configure(self, configuration=None): 87 | """ 88 | Modify the settings for a pull requests workflow 89 | """ 90 | return self._client.post(self.url(), data=configuration) 91 | 92 | 93 | class Settings(ResourceBase): 94 | hooks = Nested(Hooks) 95 | pullrequests = Nested(PullRequests, relative_path="/pull-requests") 96 | 97 | 98 | class Repository(ResourceBase): 99 | def __init__(self, slug, url, client, parent): 100 | super(Repository, self).__init__(url, client, parent) 101 | self._slug = slug 102 | 103 | @response_or_error 104 | def delete(self): 105 | """ 106 | Schedule the repository to be deleted 107 | """ 108 | return self._client.delete(self.url()) 109 | 110 | @response_or_error 111 | def update(self, name): 112 | """ 113 | Update the name of a repository. 114 | 115 | The repository's slug is derived from its name. If the name changes the slug may also change. 116 | """ 117 | return self._client.post(self.url(), data=dict(name=name)) 118 | 119 | @response_or_error 120 | def get(self): 121 | """ 122 | Retrieve the repository 123 | """ 124 | return self._client.get(self.url()) 125 | 126 | @response_or_error 127 | def fork(self, name = None, project = None): 128 | """ 129 | Fork the repository. 130 | 131 | name - Specifies the forked repository's name 132 | Defaults to the name of the origin repository if not specified 133 | project - Specifies the forked repository's target project by key 134 | Defaults to the current user's personal project if not specified 135 | 136 | """ 137 | data = dict() 138 | if name is not None: 139 | data['name'] = name 140 | if project is not None: 141 | data['project'] = {"key": project} 142 | 143 | return self._client.post(self.url(), data=data) 144 | 145 | def forks(self): 146 | """ 147 | Retrieve repositories which have been forked from this one. 148 | """ 149 | return self.paginate('/forks') 150 | 151 | @response_or_error 152 | def tags(self, filterText=None, orderBy=None): 153 | """ 154 | Retrieve the tags matching the supplied filterText param. 155 | """ 156 | params = {} 157 | if filterText is not None: 158 | params['filterText'] = filterText 159 | if orderBy is not None: 160 | params['orderBy'] = orderBy 161 | return self._client.get(self.url('/tags'), params=params) 162 | 163 | @response_or_error 164 | def _get_default_branch(self): 165 | return self._client.get(self.url('/branches/default')) 166 | 167 | @ok_or_error 168 | def _set_default_branch(self, value): 169 | return self._client.put(self.url('/branches/default'), data=dict(id=value)) 170 | 171 | @ok_or_error 172 | def create_branch(self, value, origin_branch='master'): 173 | return self._client.post(self.url('/branches', api_type='branches'), 174 | data=dict(name=value, startPoint= 175 | "refs/heads/%s" % origin_branch)) 176 | 177 | @ok_or_error 178 | def delete_branch(self, value): 179 | return self._client.delete(self.url('/branches', api_type='branches'), 180 | data=dict(name=value, 181 | dryRun='false')) 182 | @response_or_error 183 | def get_branch_info(self, changesetId): 184 | return self._client.get(self.url('/branches/info/%s' % changesetId, 185 | api_type='branches')) 186 | 187 | def keys(self): 188 | """ 189 | Retrieve the access keys associated with the repo 190 | """ 191 | return self.paginate('/ssh', api_type='keys') 192 | 193 | @ok_or_error 194 | def add_key(self, key_text, permission): 195 | return self._client.post(self.url('/ssh', api_type='keys'), 196 | data=dict( 197 | key=dict(text=key_text), 198 | permission=permission)) 199 | 200 | def branches(self, filterText=None, orderBy=None, details=None): 201 | """ 202 | Retrieve the branches matching the supplied filterText param. 203 | """ 204 | params = {} 205 | if filterText is not None: 206 | params['filterText'] = filterText 207 | if orderBy is not None: 208 | params['orderBy'] = orderBy 209 | if details is not None: 210 | params['details'] = details 211 | return self.paginate('/branches', params=params) 212 | 213 | default_branch = property(_get_default_branch, _set_default_branch, doc="Get or set the default branch") 214 | 215 | def files(self, path='', at=None): 216 | """ 217 | Retrieve a page of files from particular directory of a repository. The search is done 218 | recursively, so all files from any sub-directory of the specified directory will be returned. 219 | """ 220 | params = {} 221 | if at is not None: 222 | params['at'] = at 223 | return self.paginate('/files/' + path, params) 224 | 225 | def browse(self, path='', at=None, type=False, blame='', noContent=''): 226 | """ 227 | Retrieve a page of content for a file path at a specified revision. 228 | """ 229 | params = {} 230 | if at is not None: 231 | params['at'] = at 232 | if type: 233 | params['type'] = type 234 | return response_or_error(lambda: self._client.get(self.url('/browse/' + path), params=params))() 235 | else: 236 | if blame: 237 | params['blame'] = blame 238 | if noContent: 239 | params['noContent'] = noContent 240 | 241 | return self.paginate("/browse/" + path, params=params, values_key='lines') 242 | 243 | def changes(self, until, since=None): 244 | """ 245 | Retrieve a page of changes made in a specified commit. 246 | 247 | since: the changeset to which until should be compared to produce a page of changes. 248 | If not specified the parent of the until changeset is used. 249 | 250 | until: the changeset to retrieve file changes for. 251 | """ 252 | params = dict(until=until) 253 | if since is not None: 254 | params['since'] = since 255 | return self.paginate('/changes', params=params) 256 | 257 | def commits(self, until, since=None, path=None): 258 | """ 259 | Retrieve a page of changesets from a given starting commit or between two commits. 260 | The commits may be identified by hash, branch or tag name. 261 | 262 | since: the changeset id or ref (exclusively) to restrieve changesets after 263 | until: the changeset id or ref (inclusively) to retrieve changesets before. 264 | path: an optional path to filter changesets by. 265 | 266 | Support for withCounts is not implement. 267 | """ 268 | params = dict(until=until, withCounts=False) 269 | if since is not None: 270 | params['since'] = since 271 | if path is not None: 272 | params['path'] = path 273 | return self.paginate('/commits', params=params) 274 | 275 | permissions = Nested(Permissions) 276 | repo_permissions = Nested(RepositoryPermissions, 277 | relative_path="/permissions") 278 | 279 | pull_requests = Nested(PullRequests, relative_path="/pull-requests") 280 | settings = Nested(Settings) 281 | branch_permissions = Nested(BranchPermissions, relative_path=None) 282 | 283 | 284 | class Repos(ResourceBase, IterableResource): 285 | @response_or_error 286 | def create(self, name, scmId="git", forkable=True): 287 | """ 288 | Create a repository with the given name 289 | """ 290 | return self._client.post(self.url(), data={"name": name, 291 | "scmId": scmId, 292 | "forkable": forkable, 293 | }) 294 | 295 | def __getitem__(self, item): 296 | """ 297 | Return a :class:`Repository` object for operations on a specific repository 298 | """ 299 | return Repository(item, self.url(item), self._client, self) 300 | 301 | 302 | update_doc(Repos.all, """Retrieve repositories from the project""") 303 | --------------------------------------------------------------------------------