Completed
Push — refactor-and-polish ( 267dc3...03473b )
by Michael
08:44
created

GitRepository.pull_requests_since_latest_version()   A

Complexity

Conditions 2

Size

Total Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
c 1
b 0
f 0
dl 0
loc 5
rs 9.4285
1
from enum import Enum
2
import re
3
import shlex
4
5
import attr
0 ignored issues
show
Configuration introduced by
The import attr could not be resolved.

This can be caused by one of the following:

1. Missing Dependencies

This error could indicate a configuration issue of Pylint. Make sure that your libraries are available by adding the necessary commands.

# .scrutinizer.yml
before_commands:
    - sudo pip install abc # Python2
    - sudo pip3 install abc # Python3
Tip: We are currently not using virtualenv to run pylint, when installing your modules make sure to use the command for the correct version.

2. Missing __init__.py files

This error could also result from missing __init__.py files in your module folders. Make sure that you place one file in each sub-folder.

Loading history...
6
import semantic_version
0 ignored issues
show
Configuration introduced by
The import semantic_version could not be resolved.

This can be caused by one of the following:

1. Missing Dependencies

This error could indicate a configuration issue of Pylint. Make sure that your libraries are available by adding the necessary commands.

# .scrutinizer.yml
before_commands:
    - sudo pip install abc # Python2
    - sudo pip3 install abc # Python3
Tip: We are currently not using virtualenv to run pylint, when installing your modules make sure to use the command for the correct version.

2. Missing __init__.py files

This error could also result from missing __init__.py files in your module folders. Make sure that you place one file in each sub-folder.

Loading history...
7
import uritemplate
0 ignored issues
show
Configuration introduced by
The import uritemplate could not be resolved.

This can be caused by one of the following:

1. Missing Dependencies

This error could indicate a configuration issue of Pylint. Make sure that your libraries are available by adding the necessary commands.

# .scrutinizer.yml
before_commands:
    - sudo pip install abc # Python2
    - sudo pip3 install abc # Python3
Tip: We are currently not using virtualenv to run pylint, when installing your modules make sure to use the command for the correct version.

2. Missing __init__.py files

This error could also result from missing __init__.py files in your module folders. Make sure that you place one file in each sub-folder.

Loading history...
8
import requests
9
import giturlparse
0 ignored issues
show
Configuration introduced by
The import giturlparse could not be resolved.

This can be caused by one of the following:

1. Missing Dependencies

This error could indicate a configuration issue of Pylint. Make sure that your libraries are available by adding the necessary commands.

# .scrutinizer.yml
before_commands:
    - sudo pip install abc # Python2
    - sudo pip3 install abc # Python3
Tip: We are currently not using virtualenv to run pylint, when installing your modules make sure to use the command for the correct version.

2. Missing __init__.py files

This error could also result from missing __init__.py files in your module folders. Make sure that you place one file in each sub-folder.

Loading history...
10
from plumbum.cmd import git
0 ignored issues
show
Configuration introduced by
The import plumbum.cmd could not be resolved.

This can be caused by one of the following:

1. Missing Dependencies

This error could indicate a configuration issue of Pylint. Make sure that your libraries are available by adding the necessary commands.

# .scrutinizer.yml
before_commands:
    - sudo pip install abc # Python2
    - sudo pip3 install abc # Python3
Tip: We are currently not using virtualenv to run pylint, when installing your modules make sure to use the command for the correct version.

2. Missing __init__.py files

This error could also result from missing __init__.py files in your module folders. Make sure that you place one file in each sub-folder.

Loading history...
11
12
GITHUB_MERGED_PULL_REQUEST = re.compile(
13
    r'^([0-9a-f]{5,40}) Merge pull request #(\w+)'
14
)
15
GITHUB_PULL_REQUEST_API = (
16
    'https://api.github.com/repos{/owner}{/repo}/issues{/number}'
17
)
18
GITHUB_LABEL_API = (
19
    'https://api.github.com/repos{/owner}{/repo}/labels'
20
)
21
22
23
def changes_to_release_type(repository):
24
    pull_requests = repository.pull_requests_since_latest_version
25
26
    labels = set([
27
        label_name
28
        for pull_request in pull_requests
29
        for label_name in pull_request.label_names
30
    ])
31
32
    descriptions = [
33
        '\n'.join([
34
            pull_request.title, pull_request.description
35
        ])
36
        for pull_request in pull_requests
37
    ]
38
39
    return determine_release(
40
        repository.latest_version,
41
        descriptions,
42
        labels
43
    )
44
45
46
def determine_release(latest_version, descriptions, labels):
47
    if 'BREAKING CHANGE' in descriptions:
48
        return 'major', ReleaseType.BREAKING_CHANGE, latest_version.next_major()
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (80/79).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
49
    elif 'enhancement' in labels:
50
        return 'minor', ReleaseType.FEATURE, latest_version.next_minor()
51
    elif 'bug' in labels:
52
        return 'patch', ReleaseType.FIX, latest_version.next_patch()
53
    else:
54
        return None, ReleaseType.NO_CHANGE, latest_version
55
56
57
class ReleaseType(str, Enum):
58
    NO_CHANGE = 'no-changes'
59
    BREAKING_CHANGE = 'breaking'
60
    FEATURE = 'feature'
61
    FIX = 'fix'
62
63
64
@attr.s
65
class Release(object):
66
67
    release_date = attr.ib()
68
    version = attr.ib()
69
    description = attr.ib(default=attr.Factory(str))
70
    name = attr.ib(default=attr.Factory(str))
71
    changes = attr.ib(default=attr.Factory(dict))
72
73
    @property
74
    def title(self):
75
        return '{version} ({release_date})'.format(
76
            version=self.version,
77
            release_date=self.release_date
78
        ) + (' ' + self.name) if self.name else ''
79
80
81
@attr.s
82
class PullRequest(object):
83
    number = attr.ib()
84
    title = attr.ib()
85
    description = attr.ib()
86
    author = attr.ib()
87
    body = attr.ib()
88
    user = attr.ib()
89
    labels = attr.ib(default=attr.Factory(list))
90
91
    @property
92
    def description(self):
93
        return self.body
94
95
    @property
96
    def author(self):
97
        return self.user['login']
98
99
    @property
100
    def label_names(self):
101
        return [
102
            label['name']
103
            for label in self.labels
104
        ]
105
106
    @classmethod
107
    def from_github(cls, api_response):
108
        return cls(**api_response)
109
110
111
@attr.s
112
class GitRepository(object):
113
    VERSION_ZERO = semantic_version.Version('0.0.0')
114
    # TODO: handle multiple remotes (cookiecutter [non-owner maintainer])
115
    REMOTE_NAME = 'origin'
116
117
    auth_token = attr.ib(default=None)
118
119
    @property
120
    def remote_url(self):
121
        return git(shlex.split('config --get remote.{}.url'.format(
122
            self.REMOTE_NAME
123
        )))
124
125
    @property
126
    def parsed_repo(self):
127
        return giturlparse.parse(self.remote_url)
128
129
    @property
130
    def repo(self):
131
        return self.parsed_repo.repo
132
133
    @property
134
    def owner(self):
135
        return self.parsed_repo.owner
136
137
    @property
138
    def platform(self):
139
        return self.parsed_repo.platform
140
141
    @property
142
    def is_github(self):
143
        return self.parsed_repo.github
144
145
    @property
146
    def is_bitbucket(self):
147
        return self.parsed_repo.bitbucket
148
149
    @property
150
    def commit_history(self):
151
        return [
152
            commit_message
153
            for commit_message in git(shlex.split(
154
                'log --oneline --no-color'
155
            )).split('\n')
156
            if commit_message
157
        ]
158
159
    @property
160
    def first_commit_sha(self):
161
        return git(
162
            'rev-list', '--max-parents=0', 'HEAD'
163
        )
164
165
    @property
166
    def tags(self):
167
        return git(shlex.split('tag --list')).split('\n')
168
169
    @property
170
    def versions(self):
171
        versions = []
172
        for tag in self.tags:
173
            try:
174
                versions.append(semantic_version.Version(tag))
175
            except ValueError:
176
                pass
177
        return versions
178
179
    @property
180
    def latest_version(self) -> semantic_version.Version:
181
        return max(self.versions) if self.versions else self.VERSION_ZERO
182
183
    def merges_since(self, version=None):
184
        if version == semantic_version.Version('0.0.0'):
185
            version = self.first_commit_sha
186
187
        revision_range = ' {}..HEAD'.format(version) if version else ''
188
189
        merge_commits = git(shlex.split(
190
            'log --oneline --merges --no-color{}'.format(revision_range)
191
        )).split('\n')
192
        return merge_commits
193
194
    @property
195
    def merges_since_latest_version(self):
196
        return self.merges_since(self.latest_version)
197
198
    @property
199
    def files_modified_in_last_commit(self):
200
        return git(shlex.split('diff --name -only --diff -filter=d'))
201
202
    @property
203
    def dirty_files(self):
204
        return [
205
            modified_path
206
            for modified_path in git(shlex.split('-c color.status=false status --short --branch'))
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (98/79).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
207
            if modified_path.startswith(' M')
208
        ]
209
210
    @classmethod
211
    def add(cls, files_to_add):
212
        return git(['add'] + files_to_add)
213
214
    @classmethod
215
    def commit(cls, message):
216
        return git(shlex.split(
217
            'commit --message="{}" '.format(message)
218
        ))
219
220
    @classmethod
221
    def tag(cls, version):
222
        # TODO: signed tags
223
        return git(
224
            shlex.split('tag --annotate {version} --message="{version}"'.format(
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (80/79).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
225
                version=version
226
            ))
227
        )
228
229
    @classmethod
230
    def push(cls, tags=False):
231
        return git(['push'] + ['--tags'] if tags else [])
232
233
    # TODO: cached_property
234
    @property
235
    def pull_requests_since_latest_version(self):
0 ignored issues
show
Coding Style Naming introduced by
The name pull_requests_since_latest_version does not conform to the attribute naming conventions ([a-z_][a-z0-9_]{2,30}$).

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
236
        return [
237
            PullRequest.from_github(self.github_pull_request(pull_request_number))
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (82/79).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
238
            for pull_request_number in self.pull_request_numbers_since_latest_version
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (85/79).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
239
        ]
240
241
    @property
242
    def pull_request_numbers_since_latest_version(self):
0 ignored issues
show
Coding Style Naming introduced by
The name pull_request_numbers_since_latest_version does not conform to the attribute naming conventions ([a-z_][a-z0-9_]{2,30}$).

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
243
        pull_request_numbers = []
244
245
        for commit_msg in self.merges_since(self.latest_version):
246
247
            matches = GITHUB_MERGED_PULL_REQUEST.findall(commit_msg)
248
249
            if matches:
250
                _, pull_request_number = matches[0]
251
                pull_request_numbers.append(pull_request_number)
252
253
        return pull_request_numbers
254
255
    def github_pull_request(self, pr_num):
256
        pull_request_api_url = uritemplate.expand(
257
            GITHUB_PULL_REQUEST_API,
258
            dict(
259
                owner=self.owner,
260
                repo=self.repo,
261
                number=pr_num
262
            ),
263
        )
264
265
        return requests.get(
266
            pull_request_api_url,
267
            headers={
268
                'Authorization': 'token {}'.format(self.auth_token)
269
            },
270
        ).json()
271
272
    # TODO: cached_property
273
    # TODO: move to test fixture
274
    def github_labels(self):
275
276
        labels_api_url = uritemplate.expand(
277
            GITHUB_LABEL_API,
278
            dict(
279
                owner=self.owner,
280
                repo=self.repo,
281
            ),
282
        )
283
284
        return requests.get(
285
            labels_api_url,
286
            headers={
287
                'Authorization': 'token {}'.format(self.auth_token)
288
            },
289
        ).json()
290
291