Completed
Push — publish ( ab6fe5...6057b5 )
by Michael
06:08
created

GitRepository.github_pull_request()   A

Complexity

Conditions 1

Size

Total Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
c 1
b 0
f 0
dl 0
loc 14
rs 9.4285
1
import re
2
import shlex
3
4
import attr
5
import semantic_version
6
import uritemplate
7
import requests
8
import giturlparse
9
from plumbum.cmd import git
10
11
MERGED_PULL_REQUEST = re.compile(
12
    r'^([0-9a-f]{5,40}) Merge pull request #(\w+)'
13
)
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_request_labels = set()
25
    changes = repository.changes_since_last_version
26
27
    for change in changes:
28
        for label in change.labels:
29
            pull_request_labels.add(label)
30
31
    change_descriptions = [
32
        '\n'.join([change.title, change.description]) for change in changes
33
    ]
34
35
    current_version = repository.latest_version
36
    if 'BREAKING CHANGE' in change_descriptions:
37
        return 'major', Release.BREAKING_CHANGE, current_version.next_major()
38
    elif 'enhancement' in pull_request_labels:
39
        return 'minor', Release.FEATURE, current_version.next_minor()
40
    elif 'bug' in pull_request_labels:
41
        return 'patch', Release.FIX, current_version.next_patch()
42
    else:
43
        return None, Release.NO_CHANGE, current_version
44
45
46
@attr.s
47
class Release:
48
    NO_CHANGE = 'nochanges'
49
    BREAKING_CHANGE = 'breaking'
50
    FEATURE = 'feature'
51
    FIX = 'fix'
52
53
    release_date = attr.ib()
54
    version = attr.ib()
55
    description = attr.ib()
56
    name = attr.ib(default=attr.Factory(str))
57
    changes = attr.ib(default=attr.Factory(dict))
58
59
    @property
60
61
62
@attr.s
63
class PullRequest:
64
    number = attr.ib()
65
    title = attr.ib()
66
    description = attr.ib()
67
    # default is 'body' key
68
    author = attr.ib()
69
    labels = attr.ib(default=attr.Factory(list))
70
71
    @classmethod
72
    def from_github(cls, api_response):
73
        return PullRequest(
74
            number = api_response['number'],
75
            title = api_response['title'],
76
            description = api_response['body'],
77
            author = api_response['user']['login'],
78
            labels = [
79
                label['name']
80
                for label in api_response['labels']
81
                # label['colour'] => https://gist.github.com/MicahElliott/719710
82
            ],
83
            # labels need a description => map for default github tags
84
        )
85
86
87
@attr.s
88
class GitRepository:
89
    VERSION_ZERO = semantic_version.Version('0.0.0')
90
    # TODO: handle multiple remotes (cookiecutter [non-owner maintainer])
91
    REMOTE_NAME = 'origin'
92
93
    auth_token = attr.ib(default=None)
94
95
    @property
96
    def remote_url(self):
97
        return git(shlex.split('config --get remote.{}.url'.format(
98
            self.REMOTE_NAME
99
        )))
100
101
    @property
102
    def parsed_repo(self):
103
        return giturlparse.parse(self.remote_url)
104
105
    @property
106
    def repo(self):
107
        return self.parsed_repo.repo
108
109
    @property
110
    def owner(self):
111
        return self.parsed_repo.owner
112
113
    @property
114
    def platform(self):
115
        return self.parsed_repo.platform
116
117
    @property
118
    def is_github(self):
119
        return self.parsed_repo.github
120
121
    @property
122
    def is_bitbucket(self):
123
        return self.parsed_repo.bitbucket
124
125
    @property
126
    def commit_history(self):
127
        return [
128
            commit_message
129
            for commit_message in git(shlex.split(
130
                'log --oneline --no-color'
131
            )).split('\n')
132
            if commit_message
133
        ]
134
135
    @property
136
    def first_commit_sha(self):
137
        return git(
138
            'rev-list', '--max-parents=0', 'HEAD'
139
        )
140
141
    @property
142
    def tags(self):
143
        return git(shlex.split('tag --list')).split('\n')
144
145
    @property
146
    def versions(self):
147
        versions = []
148
        for tag in self.tags:
149
            try:
150
                versions.append(semantic_version.Version(tag))
151
            except ValueError:
152
                pass
153
        return versions
154
155
    @property
156
    def latest_version(self) -> semantic_version.Version:
157
        return max(self.versions) if self.versions else self.VERSION_ZERO
158
159
    def merges_since(self, version=None):
160
        if version == semantic_version.Version('0.0.0'):
161
            version = self.first_commit_sha
162
163
        revision_range = ' {}..HEAD'.format(version) if version else ''
164
165
        merge_commits = git(shlex.split(
166
            'log --oneline --merges --no-color{}'.format(revision_range)
167
        )).split('\n')
168
        return merge_commits
169
170
    # TODO: pull_requests_since(version=None)
171
    # TODO: cached_property
172
    @property
173
    def changes_since_last_version(self):
174
        pull_requests = []
175
176
        for index, commit_msg in enumerate(self.merges_since(self.latest_version)):
177
            matches = MERGED_PULL_REQUEST.findall(commit_msg)
178
179
            if matches:
180
                _, pull_request_number = matches[0]
181
182
                pull_requests.append(PullRequest.from_github(
183
                    self.github_pull_request(pull_request_number)
184
                ))
185
        return pull_requests
186
187
    def github_pull_request(self, pr_num):
188
        pull_request_api_url = uritemplate.expand(
189
            GITHUB_PULL_REQUEST_API,
190
            dict(
191
                owner=self.owner,
192
                repo=self.repo,
193
                number=pr_num
194
            ),
195
        )
196
197
        return requests.get(
198
            pull_request_api_url,
199
            headers={
200
                'Authorization': 'token {}'.format(self.auth_token)
201
            },
202
        ).json()
203
204
    # TODO: cached_property
205
    # TODO: move to test fixture
206
    def github_labels(self):
207
208
        labels_api_url = uritemplate.expand(
209
            GITHUB_LABEL_API,
210
            dict(
211
                owner=self.owner,
212
                repo=self.repo,
213
            ),
214
        )
215
216
        return requests.get(
217
            labels_api_url,
218
            headers={
219
                'Authorization': 'token {}'.format(self.auth_token)
220
            },
221
        ).json()
222
223
224
225