Skip to content
Closed
19 changes: 12 additions & 7 deletions config/local_settings_test.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
GITHUB_CONFIG = {
'repositories': [
('pretenders', 'deploystream'),
('pretenders', 'pretenders'),
('txels', 'autojenkins'),
('txels', 'ddt'),
('txels', 'apitopy'),
],
'organization': 'pretenders'
}

SPRINTLY_CONFIG = {
Expand All @@ -19,3 +13,14 @@

GIT_CONFIG = {
}

try:
from non_github_settings import GITHUB_USERNAME, GITHUB_PASSWORD
GITHUB_CONFIG['username'] = GITHUB_USERNAME
GITHUB_CONFIG['password'] = GITHUB_PASSWORD
except ImportError:
print ("Failed to import from non_github_settings. \n"
"You need GITHUB_PASSWORD and GITHUB_USERNAME defined in a module "
"named ``non_github_settings`` in order to run the tests."
)
raise
46 changes: 23 additions & 23 deletions deploystream/apps/feature/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#-*- coding: utf-8 -*-

from deploystream import app
from deploystream.exceptions import UnknownProviderException
from deploystream.providers.interfaces import (
IBuildInfoProvider, IPlanningProvider, ISourceCodeControlProvider)
from .models import Branch, BuildInfo, Feature
Expand All @@ -22,42 +23,41 @@ def get_all_features(providers):
return all_features


def get_feature_info(feature_id, providers):
def get_feature_info(feature_provider, feature_id, providers):
"""
Get the information associated with the given feature from the providers
given.

``planning``, ``source_code`` and ``build_info`` are relevant providers to
be called.
:param feature_provider:
The name of the planning provider who knows of this feature.

:param feature_id:
The planning-provider specific id for the feature.

:param providers:
A dictionary of all providers.

:raises:
UnknownProviderException - if no such name found.
"""
# TODO: since features may come from various origins, we need
# at this stage to either use a feature id that is a string such as
# "github:pretenders/deploystream:15" or to have additional arguments
# for provider and project. In any case we probably need providers to
# have an identifying string such as "github", "jira", "sprintly"...

# First get feature info from the management providers
# This needs rewriting according to previous paragraph. For now:
# Only one management provider should know about this feature,
# so we stop on first success
feature = None
for provider in providers[IPlanningProvider]:
feature = Feature(provider, None,
**provider.get_feature_info(feature_id))
if feature:
break
if feature_provider not in providers:
raise UnknownProviderException(feature_provider)

# First get feature info from the management provider
planning_provider = providers[feature_provider]

if not feature:
return
feature = Feature(planning_provider,
**planning_provider.get_feature_info(feature_id))

# Then get any branch info from any source control providers
for provider in providers[ISourceCodeControlProvider]:
for branch_data in provider.get_repo_branches_involved(feature_id):
for branch_data in provider.get_repo_branches_involved(
feature_id, app.config['HIERARCHY_REGEXES']):
feature.add_branch(Branch(*branch_data, provider=provider))

# Use that branch info, along with configuration regexes to create a
# hierarchy of the branches involved in the feature.
feature.create_hierarchy_trees(app.config['HIERARCHY_REGEXES'])
feature.create_hierarchy_trees()

# Ask source control providers for merging information at this point.
for provider in providers[ISourceCodeControlProvider]:
Expand Down
9 changes: 7 additions & 2 deletions deploystream/apps/feature/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def __init__(self, provider, project, id, title,
self.branches = []
self.trees = []

def create_hierarchy_trees(self, regexes):
def create_hierarchy_trees(self):
"Create hierarchy trees - one for each repo."
pass

Expand All @@ -60,9 +60,13 @@ class Branch(object):
``branch_name`` - The name of the branch.
``latest_commit`` - The head commmit, or latest revision in this
branch.
``level`` - The numerical level that this branch falls in the
hierarchy for the feature - where 0 is the highest
level.
``provider`` - The provider instance that found this branch
information.


Instances are eventually populated with these values:

``build_info`` - Build information for this particular branch.
Expand All @@ -75,7 +79,7 @@ class Branch(object):
or would have the same parent if one existed.
"""

def __init__(self, repo_name, branch_name, latest_commit, provider):
def __init__(self, repo_name, branch_name, latest_commit, level, provider):
self.parent = None
self.children = []
self.siblings = [] # Will be needed in the cases where we have no
Expand All @@ -84,6 +88,7 @@ def __init__(self, repo_name, branch_name, latest_commit, provider):
self.repo_name = repo_name
self.branch_name = branch_name
self.latest_commit = latest_commit
self.level = level
self._provider = provider


Expand Down
12 changes: 8 additions & 4 deletions deploystream/apps/feature/views.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from functools import wraps

from flask import json, Response
from flask import json, Response, abort

from deploystream import app
from deploystream.apps.feature.lib import get_feature_info, get_all_features
from deploystream.lib.transforms import nativify
from deploystream.decorators import needs_providers
from deploystream.exceptions import UnknownProviderException


def as_json(func):
Expand All @@ -29,9 +30,12 @@ def list_features(providers):
return features


@app.route('/features/<feature_id>', methods=['GET'])
@app.route('/features/<source_id>/<feature_id>', methods=['GET'])
@needs_providers
@as_json
def view_feature(feature_id, providers):
feature = get_feature_info(feature_id, providers)
def view_feature(source_id, feature_id, providers):
try:
feature = get_feature_info(source_id, feature_id, providers)
except UnknownProviderException:
abort(404)
return feature
4 changes: 4 additions & 0 deletions deploystream/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ class MissingTokenException(Exception):
def __init__(self, missing_token, *args, **kwargs):
super(Exception, self).__init__(*args, **kwargs)
self.missing_token = missing_token


class UnknownProviderException(Exception):
pass
52 changes: 52 additions & 0 deletions deploystream/lib/hierarchy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import re


def create_single_regex(feature_id, hierarchical_regexes):
"""
Create a single regex to be used to find which level a branch is on.

:param feature_id:
The id of the feature. This is substituted into the
``hierarchical_regexes`` if they use {FEATURE_ID} anywhere.

:param hierarchical_regexes:
A list of regexes to be joined into one single regex.

:returns:
A single regex for easier matching.
"""
subs = []
for index, regex in enumerate(hierarchical_regexes):
subs.append("(?P<level_{0}>^{1}$)".format(index, regex))
full_regex = "|".join(subs)
full_regex = full_regex.format(FEATURE_ID=feature_id)
return full_regex


def match_with_levels(feature_id, branch, hierarchical_regexes):
"""
Filter and return the branches in appropriate levels.

:param feature_id:
The feature to filter the branch names on.

:param branches:
A list of branch names to filter.

:param hierarchical_regexes:
A list of regexes assumed to be in descending order of branch status.

:returns:
The positional index that the branch should be found in. Or None if it
does not match.
"""
regex = create_single_regex(feature_id, hierarchical_regexes)

result = re.match(regex, branch)
if not result:
return None

for level, match in result.groupdict().items():
if match:
index = int(level.split('level_')[1])
return index
1 change: 1 addition & 0 deletions deploystream/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def get_providers(configs, session):
ISourceCodeControlProvider]:
if is_implementation(provider, iface):
providers[iface].append(provider)
providers[provider.name] = provider
print("INFO: Initialised provider {0}".format(name))
except Exception:
print("ERROR: Failed to initialise provider {0}: {1}"
Expand Down
51 changes: 44 additions & 7 deletions deploystream/providers/github/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import github3
import re
from zope import interface

from deploystream.providers.interfaces import IPlanningProvider
from deploystream.lib import transforms
from deploystream.lib import transforms, hierarchy


__all__ = ['GithubProvider']
Expand All @@ -25,21 +26,39 @@ class GithubProvider(object):
name = 'github'
oauth_token_name = name

def __init__(self, token, organization=None, **kwargs):
def __init__(self, token, organization=None, repositories=None, **kwargs):
"""
Initialise the provider by giving it GitHub credentials and repos.

:param organization:
The name of the organization who's repository issues should be
identified in GitHub. If ``None`` then the authenticated
user's issues will be tracked.
identified in GitHub. If ``None`` and no ``repositories`` given,
then the authenticated user's issues will be tracked.

:param repositories:
A list of tuples containing (<owner>, <name>) that identify
a repository in GitHub. This is only looked at if ``organization``
is ``None``.
"""
self.github = github3.login(token=token)
if not organization:
self.repositories = list(self.github.iter_repos())

if token is None and "username" in kwargs and "password" in kwargs:
# We can login using username and password for testing purposes
self.github = github3.login(
kwargs['username'],
password=kwargs['password']
)
else:
self.github = github3.login(token=token)

if organization:
org = self.github.organization(organization)
self.repositories = list(org.iter_repos())
elif repositories:
self.repositories = []
for owner, repo in repositories:
self.repositories.append(self.github.repository(owner, repo))
else:
self.repositories = list(self.github.iter_repos())

def get_features(self, **filters):
"""
Expand Down Expand Up @@ -85,3 +104,21 @@ def get_oauth_data(self):
'scope': 'repo'
},
}

def get_repo_branches_involved(self, feature_id, hierarchy_regexes):
branch_list = []

for repo in self.repositories:
for branch in repo.iter_branches():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting not to need git clones for this.
I've been thinking of the difficulties of eventually combining git/github providers. Won't be easy to come up with a cleanly layered solution.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(or even just matching Jira/Sprintly projects to actual collections of repos in e.g. Github).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as we treat github repos as an independent source from the local git we should be fine. (Where independence means they have their own hierarchy trees etc)

level = hierarchy.match_with_levels(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about this? It goes against the philosophy that I've been adopting to keep providers as dumb as possible.

An alternative would be that the provider defines a def iter_branches(self) method - returning repo name, branch name and head commit - and in https://github.com/pretenders/deploystream/pull/73/files#L0R54 we loop through each performing the relevant hierarchy.match_with_levels function.

The second approach would make test writing a lot easier too.

Which way do you prefer?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm going to take the view that each source code provider is responsible for it's own level producing - and that they may have different ways of calculating that - so therefore leaving the call to match_with_levels inside github's provider.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK I haven't given this enough thought TBH.

feature_id, branch.name, hierarchy_regexes)
if level is None:
continue
branch_list.append({
"repo_name": repo.name,
"branch_name": branch.name,
"latest_commit": branch.commit.sha,
"level": level,
})

return branch_list
9 changes: 9 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,12 @@ def load_fixture(filename):
with file(os.path.join(TEST_DATA, filename)) as f:
contents = f.read()
return contents


DEFAULT_HIERARCHY_REGEXES = [
'master',
'develop',
'story/{FEATURE_ID}(/[a-z]*)?',
'dev/{FEATURE_ID}/[a-z]*',
'[a-zA-Z]*/{FEATURE_ID}/[a-zA-Z]*'
]
Empty file added tests/integration/__init__.py
Empty file.
32 changes: 32 additions & 0 deletions tests/integration/test_github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from mock import Mock, patch
from nose.tools import assert_equal, assert_true

from deploystream.providers.github import GithubProvider
from deploystream.providers.interfaces import (
IPlanningProvider, IOAuthProvider, is_implementation)
from tests import DEFAULT_HIERARCHY_REGEXES
from deploystream import app


def test_get_repo_branches_involved():
"Test ``get_repo_branches_involved`` using ``pretenders/dummyrepo`` repo."
github_provider = GithubProvider(
token=None,
username=app.config['GITHUB_CONFIG']['username'],
password=app.config['GITHUB_CONFIG']['password'],
repositories=[('pretenders', 'dummyrepo')]
)
branches = github_provider.get_repo_branches_involved(101,
hierarchy_regexes=DEFAULT_HIERARCHY_REGEXES)

assert_equal(2, len(branches))
assert_true({
"repo_name": "dummyrepo",
"branch_name": "master",
"latest_commit": '0f6eefefc14f362a2c6f804df69aa83bac48c20b',
"level": 0} in branches)
assert_true({
"repo_name": "dummyrepo",
"branch_name": "story/101/fred",
"latest_commit": "0f6eefefc14f362a2c6f804df69aa83bac48c20b",
"level": 2} in branches)
22 changes: 22 additions & 0 deletions tests/test_feature/test_lib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env python
#-*- coding: utf-8 -*-
from mock import Mock
from nose.tools import assert_equals

from deploystream.providers.interfaces import IPlanningProvider
from deploystream.apps.feature.lib import get_all_features

NON_ASCII_STRING = u"都بيببيðéáöþ"


def test_non_ascii_chars():
mock_provider = Mock()
mock_provider.get_features.return_value = [{
"project": NON_ASCII_STRING,
"id": NON_ASCII_STRING,
'title': NON_ASCII_STRING
}]

resp = get_all_features({IPlanningProvider: [mock_provider]})

assert_equals(resp[0].title, NON_ASCII_STRING)
Loading