##// END OF EJS Templates
events: added support for pull-request-comment and commit-comment events....
marcink -
r4314:9d74b996 default
parent child Browse files
Show More
@@ -0,0 +1,26 b''
1 <div tal:define="css_class css_class|field.widget.css_class;
2 style style|field.widget.style;
3 oid oid|field.oid;
4 inline getattr(field.widget, 'inline', False)"
5 tal:omit-tag="not inline">
6 ${field.start_sequence()}
7 <div tal:repeat="choice values | field.widget.values"
8 tal:omit-tag="inline"
9 class="checkbox">
10 <div tal:define="(value, title, help_block) choice">
11 <input tal:attributes="checked value in cstruct;
12 class css_class;
13 style style"
14 type="checkbox"
15 name="checkbox"
16 value="${value}"
17 id="${oid}-${repeat.choice.index}"/>
18 <label for="${oid}-${repeat.choice.index}"
19 tal:attributes="class inline and 'checkbox-inline'">
20 ${title}
21 </label>
22 <p tal:condition="help_block" class="help-block">${help_block}</p>
23 </div>
24 </div>
25 ${field.end_sequence()}
26 </div>
@@ -1,78 +1,79 b''
1 1 # Copyright (C) 2016-2020 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import logging
20 20 from pyramid.threadlocal import get_current_registry
21 21 from rhodecode.events.base import RhodeCodeIntegrationEvent
22 22
23 23
24 24 log = logging.getLogger(__name__)
25 25
26 26
27 27 def trigger(event, registry=None):
28 28 """
29 29 Helper method to send an event. This wraps the pyramid logic to send an
30 30 event.
31 31 """
32 32 # For the first step we are using pyramids thread locals here. If the
33 33 # event mechanism works out as a good solution we should think about
34 34 # passing the registry as an argument to get rid of it.
35 35 event_name = event.__class__
36 36 log.debug('event %s sent for execution', event_name)
37 37 registry = registry or get_current_registry()
38 38 registry.notify(event)
39 39 log.debug('event %s triggered using registry %s', event_name, registry)
40 40
41 41 # Send the events to integrations directly
42 42 from rhodecode.integrations import integrations_event_handler
43 43 if isinstance(event, RhodeCodeIntegrationEvent):
44 44 integrations_event_handler(event)
45 45
46 46
47 47 from rhodecode.events.user import ( # pragma: no cover
48 48 UserPreCreate,
49 49 UserPostCreate,
50 50 UserPreUpdate,
51 51 UserRegistered,
52 52 UserPermissionsChange,
53 53 )
54 54
55 55 from rhodecode.events.repo import ( # pragma: no cover
56 56 RepoEvent, RepoCommitCommentEvent,
57 57 RepoPreCreateEvent, RepoCreateEvent,
58 58 RepoPreDeleteEvent, RepoDeleteEvent,
59 59 RepoPrePushEvent, RepoPushEvent,
60 60 RepoPrePullEvent, RepoPullEvent,
61 61 )
62 62
63 63 from rhodecode.events.repo_group import ( # pragma: no cover
64 64 RepoGroupEvent,
65 65 RepoGroupCreateEvent,
66 66 RepoGroupUpdateEvent,
67 67 RepoGroupDeleteEvent,
68 68 )
69 69
70 70 from rhodecode.events.pullrequest import ( # pragma: no cover
71 71 PullRequestEvent,
72 72 PullRequestCreateEvent,
73 73 PullRequestUpdateEvent,
74 74 PullRequestCommentEvent,
75 75 PullRequestReviewEvent,
76 76 PullRequestMergeEvent,
77 77 PullRequestCloseEvent,
78 PullRequestCommentEvent,
78 79 )
@@ -1,118 +1,122 b''
1 1 # Copyright (C) 2016-2020 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18 import logging
19 19 import datetime
20 20
21 21 from zope.cachedescriptors.property import Lazy as LazyProperty
22 22 from pyramid.threadlocal import get_current_request
23 23
24 24 from rhodecode.lib.utils2 import AttributeDict
25 25
26 26
27 27 # this is a user object to be used for events caused by the system (eg. shell)
28 28 SYSTEM_USER = AttributeDict(dict(
29 29 username='__SYSTEM__',
30 30 user_id='__SYSTEM_ID__'
31 31 ))
32 32
33 33 log = logging.getLogger(__name__)
34 34
35 35
36 36 class RhodecodeEvent(object):
37 37 """
38 38 Base event class for all RhodeCode events
39 39 """
40 40 name = "RhodeCodeEvent"
41 41 no_url_set = '<no server_url available>'
42 42
43 43 def __init__(self, request=None):
44 44 self._request = request
45 45 self.utc_timestamp = datetime.datetime.utcnow()
46 46
47 def __repr__(self):
48 return '<%s:(%s)>' % (self.__class__.__name__, self.name)
49
47 50 def get_request(self):
48 51 if self._request:
49 52 return self._request
50 53 return get_current_request()
51 54
52 55 @LazyProperty
53 56 def request(self):
54 57 return self.get_request()
55 58
56 59 @property
57 60 def auth_user(self):
58 61 if not self.request:
59 62 return
60 63
61 64 user = getattr(self.request, 'user', None)
62 65 if user:
63 66 return user
64 67
65 68 api_user = getattr(self.request, 'rpc_user', None)
66 69 if api_user:
67 70 return api_user
68 71
69 72 @property
70 73 def actor(self):
71 74 auth_user = self.auth_user
72 75 if auth_user:
73 76 instance = auth_user.get_instance()
74 77 if not instance:
75 78 return AttributeDict(dict(
76 79 username=auth_user.username,
77 80 user_id=auth_user.user_id,
78 81 ))
79 82 return instance
80 83
81 84 return SYSTEM_USER
82 85
83 86 @property
84 87 def actor_ip(self):
85 88 auth_user = self.auth_user
86 89 if auth_user:
87 90 return auth_user.ip_addr
88 91 return '<no ip available>'
89 92
90 93 @property
91 94 def server_url(self):
92 95 if self.request:
93 96 try:
94 97 return self.request.route_url('home')
95 98 except Exception:
96 99 log.exception('Failed to fetch URL for server')
97 100 return self.no_url_set
98 101
99 102 return self.no_url_set
100 103
101 104 def as_dict(self):
102 105 data = {
103 106 'name': self.name,
104 107 'utc_timestamp': self.utc_timestamp,
105 108 'actor_ip': self.actor_ip,
106 109 'actor': {
107 110 'username': self.actor.username,
108 111 'user_id': self.actor.user_id
109 112 },
110 113 'server_url': self.server_url
111 114 }
112 115 return data
113 116
114 117
115 118 class RhodeCodeIntegrationEvent(RhodecodeEvent):
116 119 """
117 120 Special subclass for Integration events
118 121 """
122 description = ''
@@ -1,155 +1,164 b''
1 1 # Copyright (C) 2016-2020 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import logging
20 20
21 21 from rhodecode.translation import lazy_ugettext
22 22 from rhodecode.events.repo import (
23 23 RepoEvent, _commits_as_dict, _issues_as_dict)
24 24
25 25 log = logging.getLogger(__name__)
26 26
27 27
28 28 class PullRequestEvent(RepoEvent):
29 29 """
30 30 Base class for pull request events.
31 31
32 32 :param pullrequest: a :class:`PullRequest` instance
33 33 """
34 34
35 35 def __init__(self, pullrequest):
36 36 super(PullRequestEvent, self).__init__(pullrequest.target_repo)
37 37 self.pullrequest = pullrequest
38 38
39 39 def as_dict(self):
40 40 from rhodecode.lib.utils2 import md5_safe
41 41 from rhodecode.model.pull_request import PullRequestModel
42 42 data = super(PullRequestEvent, self).as_dict()
43 43
44 44 commits = _commits_as_dict(
45 45 self,
46 46 commit_ids=self.pullrequest.revisions,
47 47 repos=[self.pullrequest.source_repo]
48 48 )
49 49 issues = _issues_as_dict(commits)
50 50 # calculate hashes of all commits for unique identifier of commits
51 51 # inside that pull request
52 52 commits_hash = md5_safe(':'.join(x.get('raw_id', '') for x in commits))
53 53
54 54 data.update({
55 55 'pullrequest': {
56 56 'title': self.pullrequest.title,
57 57 'issues': issues,
58 58 'pull_request_id': self.pullrequest.pull_request_id,
59 59 'url': PullRequestModel().get_url(
60 60 self.pullrequest, request=self.request),
61 61 'permalink_url': PullRequestModel().get_url(
62 62 self.pullrequest, request=self.request, permalink=True),
63 63 'shadow_url': PullRequestModel().get_shadow_clone_url(
64 64 self.pullrequest, request=self.request),
65 65 'status': self.pullrequest.calculated_review_status(),
66 66 'commits_uid': commits_hash,
67 67 'commits': commits,
68 68 }
69 69 })
70 70 return data
71 71
72 72
73 73 class PullRequestCreateEvent(PullRequestEvent):
74 74 """
75 75 An instance of this class is emitted as an :term:`event` after a pull
76 76 request is created.
77 77 """
78 78 name = 'pullrequest-create'
79 79 display_name = lazy_ugettext('pullrequest created')
80 description = lazy_ugettext('Event triggered after pull request was created')
80 81
81 82
82 83 class PullRequestCloseEvent(PullRequestEvent):
83 84 """
84 85 An instance of this class is emitted as an :term:`event` after a pull
85 86 request is closed.
86 87 """
87 88 name = 'pullrequest-close'
88 89 display_name = lazy_ugettext('pullrequest closed')
90 description = lazy_ugettext('Event triggered after pull request was closed')
89 91
90 92
91 93 class PullRequestUpdateEvent(PullRequestEvent):
92 94 """
93 95 An instance of this class is emitted as an :term:`event` after a pull
94 96 request's commits have been updated.
95 97 """
96 98 name = 'pullrequest-update'
97 99 display_name = lazy_ugettext('pullrequest commits updated')
100 description = lazy_ugettext('Event triggered after pull requests was updated')
98 101
99 102
100 103 class PullRequestReviewEvent(PullRequestEvent):
101 104 """
102 105 An instance of this class is emitted as an :term:`event` after a pull
103 106 request review has changed. A status defines new status of review.
104 107 """
105 108 name = 'pullrequest-review'
106 109 display_name = lazy_ugettext('pullrequest review changed')
110 description = lazy_ugettext('Event triggered after a review status of a '
111 'pull requests has changed to other.')
107 112
108 113 def __init__(self, pullrequest, status):
109 114 super(PullRequestReviewEvent, self).__init__(pullrequest)
110 115 self.status = status
111 116
112 117
113 118 class PullRequestMergeEvent(PullRequestEvent):
114 119 """
115 120 An instance of this class is emitted as an :term:`event` after a pull
116 121 request is merged.
117 122 """
118 123 name = 'pullrequest-merge'
119 124 display_name = lazy_ugettext('pullrequest merged')
125 description = lazy_ugettext('Event triggered after a successful merge operation '
126 'was executed on a pull request')
120 127
121 128
122 129 class PullRequestCommentEvent(PullRequestEvent):
123 130 """
124 131 An instance of this class is emitted as an :term:`event` after a pull
125 132 request comment is created.
126 133 """
127 134 name = 'pullrequest-comment'
128 135 display_name = lazy_ugettext('pullrequest commented')
136 description = lazy_ugettext('Event triggered after a comment was made on a code '
137 'in the pull request')
129 138
130 139 def __init__(self, pullrequest, comment):
131 140 super(PullRequestCommentEvent, self).__init__(pullrequest)
132 141 self.comment = comment
133 142
134 143 def as_dict(self):
135 144 from rhodecode.model.comment import CommentsModel
136 145 data = super(PullRequestCommentEvent, self).as_dict()
137 146
138 147 status = None
139 148 if self.comment.status_change:
140 149 status = self.comment.status_change[0].status
141 150
142 151 data.update({
143 152 'comment': {
144 153 'status': status,
145 154 'text': self.comment.text,
146 155 'type': self.comment.comment_type,
147 156 'file': self.comment.f_path,
148 157 'line': self.comment.line_no,
149 158 'url': CommentsModel().get_url(
150 159 self.comment, request=self.request),
151 160 'permalink_url': CommentsModel().get_url(
152 161 self.comment, request=self.request, permalink=True),
153 162 }
154 163 })
155 164 return data
@@ -1,370 +1,400 b''
1 1 # Copyright (C) 2016-2020 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import collections
20 20 import logging
21 21 import datetime
22 22
23 23 from rhodecode.translation import lazy_ugettext
24 24 from rhodecode.model.db import User, Repository, Session
25 25 from rhodecode.events.base import RhodeCodeIntegrationEvent
26 26 from rhodecode.lib.vcs.exceptions import CommitDoesNotExistError
27 27
28 28 log = logging.getLogger(__name__)
29 29
30 30
31 31 def _commits_as_dict(event, commit_ids, repos):
32 32 """
33 33 Helper function to serialize commit_ids
34 34
35 35 :param event: class calling this method
36 36 :param commit_ids: commits to get
37 37 :param repos: list of repos to check
38 38 """
39 39 from rhodecode.lib.utils2 import extract_mentioned_users
40 40 from rhodecode.lib.helpers import (
41 41 urlify_commit_message, process_patterns, chop_at_smart)
42 42 from rhodecode.model.repo import RepoModel
43 43
44 44 if not repos:
45 45 raise Exception('no repo defined')
46 46
47 47 if not isinstance(repos, (tuple, list)):
48 48 repos = [repos]
49 49
50 50 if not commit_ids:
51 51 return []
52 52
53 53 needed_commits = list(commit_ids)
54 54
55 55 commits = []
56 56 reviewers = []
57 57 for repo in repos:
58 58 if not needed_commits:
59 59 return commits # return early if we have the commits we need
60 60
61 61 vcs_repo = repo.scm_instance(cache=False)
62 62
63 63 try:
64 64 # use copy of needed_commits since we modify it while iterating
65 65 for commit_id in list(needed_commits):
66 66 if commit_id.startswith('tag=>'):
67 67 raw_id = commit_id[5:]
68 68 cs_data = {
69 69 'raw_id': commit_id, 'short_id': commit_id,
70 70 'branch': None,
71 71 'git_ref_change': 'tag_add',
72 72 'message': 'Added new tag {}'.format(raw_id),
73 73 'author': event.actor.full_contact,
74 74 'date': datetime.datetime.now(),
75 75 'refs': {
76 76 'branches': [],
77 77 'bookmarks': [],
78 78 'tags': []
79 79 }
80 80 }
81 81 commits.append(cs_data)
82 82
83 83 elif commit_id.startswith('delete_branch=>'):
84 84 raw_id = commit_id[15:]
85 85 cs_data = {
86 86 'raw_id': commit_id, 'short_id': commit_id,
87 87 'branch': None,
88 88 'git_ref_change': 'branch_delete',
89 89 'message': 'Deleted branch {}'.format(raw_id),
90 90 'author': event.actor.full_contact,
91 91 'date': datetime.datetime.now(),
92 92 'refs': {
93 93 'branches': [],
94 94 'bookmarks': [],
95 95 'tags': []
96 96 }
97 97 }
98 98 commits.append(cs_data)
99 99
100 100 else:
101 101 try:
102 102 cs = vcs_repo.get_commit(commit_id)
103 103 except CommitDoesNotExistError:
104 104 continue # maybe its in next repo
105 105
106 106 cs_data = cs.__json__()
107 107 cs_data['refs'] = cs._get_refs()
108 108
109 109 cs_data['mentions'] = extract_mentioned_users(cs_data['message'])
110 110 cs_data['reviewers'] = reviewers
111 111 cs_data['url'] = RepoModel().get_commit_url(
112 112 repo, cs_data['raw_id'], request=event.request)
113 113 cs_data['permalink_url'] = RepoModel().get_commit_url(
114 114 repo, cs_data['raw_id'], request=event.request,
115 115 permalink=True)
116 116 urlified_message, issues_data = process_patterns(
117 117 cs_data['message'], repo.repo_name)
118 118 cs_data['issues'] = issues_data
119 119 cs_data['message_html'] = urlify_commit_message(
120 120 cs_data['message'], repo.repo_name)
121 121 cs_data['message_html_title'] = chop_at_smart(
122 122 cs_data['message'], '\n', suffix_if_chopped='...')
123 123 commits.append(cs_data)
124 124
125 125 needed_commits.remove(commit_id)
126 126
127 127 except Exception:
128 128 log.exception('Failed to extract commits data')
129 129 # we don't send any commits when crash happens, only full list
130 130 # matters we short circuit then.
131 131 return []
132 132
133 133 missing_commits = set(commit_ids) - set(c['raw_id'] for c in commits)
134 134 if missing_commits:
135 135 log.error('Inconsistent repository state. '
136 136 'Missing commits: %s', ', '.join(missing_commits))
137 137
138 138 return commits
139 139
140 140
141 141 def _issues_as_dict(commits):
142 142 """ Helper function to serialize issues from commits """
143 143 issues = {}
144 144 for commit in commits:
145 145 for issue in commit['issues']:
146 146 issues[issue['id']] = issue
147 147 return issues
148 148
149 149
150 150 class RepoEvent(RhodeCodeIntegrationEvent):
151 151 """
152 152 Base class for events acting on a repository.
153 153
154 154 :param repo: a :class:`Repository` instance
155 155 """
156 156
157 157 def __init__(self, repo):
158 158 super(RepoEvent, self).__init__()
159 159 self.repo = repo
160 160
161 161 def as_dict(self):
162 162 from rhodecode.model.repo import RepoModel
163 163 data = super(RepoEvent, self).as_dict()
164 164
165 165 extra_fields = collections.OrderedDict()
166 166 for field in self.repo.extra_fields:
167 167 extra_fields[field.field_key] = field.field_value
168 168
169 169 data.update({
170 170 'repo': {
171 171 'repo_id': self.repo.repo_id,
172 172 'repo_name': self.repo.repo_name,
173 173 'repo_type': self.repo.repo_type,
174 174 'url': RepoModel().get_url(
175 175 self.repo, request=self.request),
176 176 'permalink_url': RepoModel().get_url(
177 177 self.repo, request=self.request, permalink=True),
178 178 'extra_fields': extra_fields
179 179 }
180 180 })
181 181 return data
182 182
183 183
184 184 class RepoCommitCommentEvent(RepoEvent):
185 185 """
186 186 An instance of this class is emitted as an :term:`event` after a comment is made
187 187 on repository commit.
188 188 """
189
190 name = 'repo-commit-comment'
191 display_name = lazy_ugettext('repository commit comment')
192 description = lazy_ugettext('Event triggered after a comment was made '
193 'on commit inside a repository')
194
189 195 def __init__(self, repo, commit, comment):
190 196 super(RepoCommitCommentEvent, self).__init__(repo)
191 197 self.commit = commit
192 198 self.comment = comment
193 199
194 name = 'repo-commit-comment'
195 display_name = lazy_ugettext('repository commit comment')
200 def as_dict(self):
201 data = super(RepoCommitCommentEvent, self).as_dict()
202 data['commit'] = {
203 'commit_id': self.commit.raw_id,
204 'commit_message': self.commit.message,
205 'commit_branch': self.commit.branch,
206 }
207
208 data['comment'] = {
209 'comment_id': self.comment.comment_id,
210 'comment_text': self.comment.text,
211 'comment_type': self.comment.comment_type,
212 'comment_f_path': self.comment.f_path,
213 'comment_line_no': self.comment.line_no,
214 }
215 return data
196 216
197 217
198 218 class RepoPreCreateEvent(RepoEvent):
199 219 """
200 220 An instance of this class is emitted as an :term:`event` before a repo is
201 221 created.
202 222 """
203 223 name = 'repo-pre-create'
204 224 display_name = lazy_ugettext('repository pre create')
225 description = lazy_ugettext('Event triggered before repository is created')
205 226
206 227
207 228 class RepoCreateEvent(RepoEvent):
208 229 """
209 230 An instance of this class is emitted as an :term:`event` whenever a repo is
210 231 created.
211 232 """
212 233 name = 'repo-create'
213 234 display_name = lazy_ugettext('repository created')
235 description = lazy_ugettext('Event triggered after repository was created')
214 236
215 237
216 238 class RepoPreDeleteEvent(RepoEvent):
217 239 """
218 240 An instance of this class is emitted as an :term:`event` whenever a repo is
219 241 created.
220 242 """
221 243 name = 'repo-pre-delete'
222 244 display_name = lazy_ugettext('repository pre delete')
245 description = lazy_ugettext('Event triggered before a repository is deleted')
223 246
224 247
225 248 class RepoDeleteEvent(RepoEvent):
226 249 """
227 250 An instance of this class is emitted as an :term:`event` whenever a repo is
228 251 created.
229 252 """
230 253 name = 'repo-delete'
231 254 display_name = lazy_ugettext('repository deleted')
255 description = lazy_ugettext('Event triggered after repository was deleted')
232 256
233 257
234 258 class RepoVCSEvent(RepoEvent):
235 259 """
236 260 Base class for events triggered by the VCS
237 261 """
238 262 def __init__(self, repo_name, extras):
239 263 self.repo = Repository.get_by_repo_name(repo_name)
240 264 if not self.repo:
241 265 raise Exception('repo by this name %s does not exist' % repo_name)
242 266 self.extras = extras
243 267 super(RepoVCSEvent, self).__init__(self.repo)
244 268
245 269 @property
246 270 def actor(self):
247 271 if self.extras.get('username'):
248 272 return User.get_by_username(self.extras['username'])
249 273
250 274 @property
251 275 def actor_ip(self):
252 276 if self.extras.get('ip'):
253 277 return self.extras['ip']
254 278
255 279 @property
256 280 def server_url(self):
257 281 if self.extras.get('server_url'):
258 282 return self.extras['server_url']
259 283
260 284 @property
261 285 def request(self):
262 286 return self.extras.get('request') or self.get_request()
263 287
264 288
265 289 class RepoPrePullEvent(RepoVCSEvent):
266 290 """
267 291 An instance of this class is emitted as an :term:`event` before commits
268 292 are pulled from a repo.
269 293 """
270 294 name = 'repo-pre-pull'
271 295 display_name = lazy_ugettext('repository pre pull')
296 description = lazy_ugettext('Event triggered before repository code is pulled')
272 297
273 298
274 299 class RepoPullEvent(RepoVCSEvent):
275 300 """
276 301 An instance of this class is emitted as an :term:`event` after commits
277 302 are pulled from a repo.
278 303 """
279 304 name = 'repo-pull'
280 305 display_name = lazy_ugettext('repository pull')
306 description = lazy_ugettext('Event triggered after repository code was pulled')
281 307
282 308
283 309 class RepoPrePushEvent(RepoVCSEvent):
284 310 """
285 311 An instance of this class is emitted as an :term:`event` before commits
286 312 are pushed to a repo.
287 313 """
288 314 name = 'repo-pre-push'
289 315 display_name = lazy_ugettext('repository pre push')
316 description = lazy_ugettext('Event triggered before the code is '
317 'pushed to a repository')
290 318
291 319
292 320 class RepoPushEvent(RepoVCSEvent):
293 321 """
294 322 An instance of this class is emitted as an :term:`event` after commits
295 323 are pushed to a repo.
296 324
297 325 :param extras: (optional) dict of data from proxied VCS actions
298 326 """
299 327 name = 'repo-push'
300 328 display_name = lazy_ugettext('repository push')
329 description = lazy_ugettext('Event triggered after the code was '
330 'pushed to a repository')
301 331
302 332 def __init__(self, repo_name, pushed_commit_ids, extras):
303 333 super(RepoPushEvent, self).__init__(repo_name, extras)
304 334 self.pushed_commit_ids = pushed_commit_ids
305 335 self.new_refs = extras.new_refs
306 336
307 337 def as_dict(self):
308 338 data = super(RepoPushEvent, self).as_dict()
309 339
310 340 def branch_url(branch_name):
311 341 return '{}/changelog?branch={}'.format(
312 342 data['repo']['url'], branch_name)
313 343
314 344 def tag_url(tag_name):
315 345 return '{}/files/{}/'.format(
316 346 data['repo']['url'], tag_name)
317 347
318 348 commits = _commits_as_dict(
319 349 self, commit_ids=self.pushed_commit_ids, repos=[self.repo])
320 350
321 351 last_branch = None
322 352 for commit in reversed(commits):
323 353 commit['branch'] = commit['branch'] or last_branch
324 354 last_branch = commit['branch']
325 355 issues = _issues_as_dict(commits)
326 356
327 357 branches = set()
328 358 tags = set()
329 359 for commit in commits:
330 360 if commit['refs']['tags']:
331 361 for tag in commit['refs']['tags']:
332 362 tags.add(tag)
333 363 if commit['branch']:
334 364 branches.add(commit['branch'])
335 365
336 366 # maybe we have branches in new_refs ?
337 367 try:
338 368 branches = branches.union(set(self.new_refs['branches']))
339 369 except Exception:
340 370 pass
341 371
342 372 branches = [
343 373 {
344 374 'name': branch,
345 375 'url': branch_url(branch)
346 376 }
347 377 for branch in branches
348 378 ]
349 379
350 380 # maybe we have branches in new_refs ?
351 381 try:
352 382 tags = tags.union(set(self.new_refs['tags']))
353 383 except Exception:
354 384 pass
355 385
356 386 tags = [
357 387 {
358 388 'name': tag,
359 389 'url': tag_url(tag)
360 390 }
361 391 for tag in tags
362 392 ]
363 393
364 394 data['push'] = {
365 395 'commits': commits,
366 396 'issues': issues,
367 397 'branches': branches,
368 398 'tags': tags,
369 399 }
370 400 return data
@@ -1,80 +1,83 b''
1 1 # Copyright (C) 2016-2020 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import logging
20 20
21 21 from rhodecode.translation import lazy_ugettext
22 22 from rhodecode.events.base import RhodeCodeIntegrationEvent
23 23
24 24
25 25 log = logging.getLogger(__name__)
26 26
27 27
28 28 class RepoGroupEvent(RhodeCodeIntegrationEvent):
29 29 """
30 30 Base class for events acting on a repository group.
31 31
32 32 :param repo: a :class:`RepositoryGroup` instance
33 33 """
34 34
35 35 def __init__(self, repo_group):
36 36 super(RepoGroupEvent, self).__init__()
37 37 self.repo_group = repo_group
38 38
39 39 def as_dict(self):
40 40 data = super(RepoGroupEvent, self).as_dict()
41 41 data.update({
42 42 'repo_group': {
43 43 'group_id': self.repo_group.group_id,
44 44 'group_name': self.repo_group.group_name,
45 45 'group_parent_id': self.repo_group.group_parent_id,
46 46 'group_description': self.repo_group.group_description,
47 47 'user_id': self.repo_group.user_id,
48 48 'created_by': self.repo_group.user.username,
49 49 'created_on': self.repo_group.created_on,
50 50 'enable_locking': self.repo_group.enable_locking,
51 51 }
52 52 })
53 53 return data
54 54
55 55
56 56 class RepoGroupCreateEvent(RepoGroupEvent):
57 57 """
58 58 An instance of this class is emitted as an :term:`event` whenever a
59 59 repository group is created.
60 60 """
61 61 name = 'repo-group-create'
62 62 display_name = lazy_ugettext('repository group created')
63 description = lazy_ugettext('Event triggered after a repository group was created')
63 64
64 65
65 66 class RepoGroupDeleteEvent(RepoGroupEvent):
66 67 """
67 68 An instance of this class is emitted as an :term:`event` whenever a
68 69 repository group is deleted.
69 70 """
70 71 name = 'repo-group-delete'
71 72 display_name = lazy_ugettext('repository group deleted')
73 description = lazy_ugettext('Event triggered after a repository group was deleted')
72 74
73 75
74 76 class RepoGroupUpdateEvent(RepoGroupEvent):
75 77 """
76 78 An instance of this class is emitted as an :term:`event` whenever a
77 79 repository group is updated.
78 80 """
79 81 name = 'repo-group-update'
80 82 display_name = lazy_ugettext('repository group update')
83 description = lazy_ugettext('Event triggered after a repository group was updated')
@@ -1,363 +1,428 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import colander
22 22 import string
23 23 import collections
24 24 import logging
25 25 import requests
26 26 import urllib
27 27 from requests.adapters import HTTPAdapter
28 28 from requests.packages.urllib3.util.retry import Retry
29 29
30 30 from mako import exceptions
31 31
32 32 from rhodecode.lib.utils2 import safe_str
33 33 from rhodecode.translation import _
34 34
35 35
36 36 log = logging.getLogger(__name__)
37 37
38 38
39 39 class UrlTmpl(string.Template):
40 40
41 41 def safe_substitute(self, **kws):
42 42 # url encode the kw for usage in url
43 43 kws = {k: urllib.quote(safe_str(v)) for k, v in kws.items()}
44 44 return super(UrlTmpl, self).safe_substitute(**kws)
45 45
46 46
47 47 class IntegrationTypeBase(object):
48 48 """ Base class for IntegrationType plugins """
49 49 is_dummy = False
50 50 description = ''
51 51
52 52 @classmethod
53 53 def icon(cls):
54 54 return '''
55 55 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
56 56 <svg
57 57 xmlns:dc="http://purl.org/dc/elements/1.1/"
58 58 xmlns:cc="http://creativecommons.org/ns#"
59 59 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
60 60 xmlns:svg="http://www.w3.org/2000/svg"
61 61 xmlns="http://www.w3.org/2000/svg"
62 62 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
63 63 xmlns:inkscape="http://setwww.inkscape.org/namespaces/inkscape"
64 64 viewBox="0 -256 1792 1792"
65 65 id="svg3025"
66 66 version="1.1"
67 67 inkscape:version="0.48.3.1 r9886"
68 68 width="100%"
69 69 height="100%"
70 70 sodipodi:docname="cog_font_awesome.svg">
71 71 <metadata
72 72 id="metadata3035">
73 73 <rdf:RDF>
74 74 <cc:Work
75 75 rdf:about="">
76 76 <dc:format>image/svg+xml</dc:format>
77 77 <dc:type
78 78 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
79 79 </cc:Work>
80 80 </rdf:RDF>
81 81 </metadata>
82 82 <defs
83 83 id="defs3033" />
84 84 <sodipodi:namedview
85 85 pagecolor="#ffffff"
86 86 bordercolor="#666666"
87 87 borderopacity="1"
88 88 objecttolerance="10"
89 89 gridtolerance="10"
90 90 guidetolerance="10"
91 91 inkscape:pageopacity="0"
92 92 inkscape:pageshadow="2"
93 93 inkscape:window-width="640"
94 94 inkscape:window-height="480"
95 95 id="namedview3031"
96 96 showgrid="false"
97 97 inkscape:zoom="0.13169643"
98 98 inkscape:cx="896"
99 99 inkscape:cy="896"
100 100 inkscape:window-x="0"
101 101 inkscape:window-y="25"
102 102 inkscape:window-maximized="0"
103 103 inkscape:current-layer="svg3025" />
104 104 <g
105 105 transform="matrix(1,0,0,-1,121.49153,1285.4237)"
106 106 id="g3027">
107 107 <path
108 108 d="m 1024,640 q 0,106 -75,181 -75,75 -181,75 -106,0 -181,-75 -75,-75 -75,-181 0,-106 75,-181 75,-75 181,-75 106,0 181,75 75,75 75,181 z m 512,109 V 527 q 0,-12 -8,-23 -8,-11 -20,-13 l -185,-28 q -19,-54 -39,-91 35,-50 107,-138 10,-12 10,-25 0,-13 -9,-23 -27,-37 -99,-108 -72,-71 -94,-71 -12,0 -26,9 l -138,108 q -44,-23 -91,-38 -16,-136 -29,-186 -7,-28 -36,-28 H 657 q -14,0 -24.5,8.5 Q 622,-111 621,-98 L 593,86 q -49,16 -90,37 L 362,16 Q 352,7 337,7 323,7 312,18 186,132 147,186 q -7,10 -7,23 0,12 8,23 15,21 51,66.5 36,45.5 54,70.5 -27,50 -41,99 L 29,495 Q 16,497 8,507.5 0,518 0,531 v 222 q 0,12 8,23 8,11 19,13 l 186,28 q 14,46 39,92 -40,57 -107,138 -10,12 -10,24 0,10 9,23 26,36 98.5,107.5 72.5,71.5 94.5,71.5 13,0 26,-10 l 138,-107 q 44,23 91,38 16,136 29,186 7,28 36,28 h 222 q 14,0 24.5,-8.5 Q 914,1391 915,1378 l 28,-184 q 49,-16 90,-37 l 142,107 q 9,9 24,9 13,0 25,-10 129,-119 165,-170 7,-8 7,-22 0,-12 -8,-23 -15,-21 -51,-66.5 -36,-45.5 -54,-70.5 26,-50 41,-98 l 183,-28 q 13,-2 21,-12.5 8,-10.5 8,-23.5 z"
109 109 id="path3029"
110 110 inkscape:connector-curvature="0"
111 111 style="fill:currentColor" />
112 112 </g>
113 113 </svg>
114 114 '''
115 115
116 116 def __init__(self, settings):
117 117 """
118 118 :param settings: dict of settings to be used for the integration
119 119 """
120 120 self.settings = settings
121 121
122 122 def settings_schema(self):
123 123 """
124 124 A colander schema of settings for the integration type
125 125 """
126 126 return colander.Schema()
127 127
128 def event_enabled(self, event):
129 """
130 Checks if submitted event is enabled based on the plugin settings
131 :param event:
132 :return: bool
133 """
134 allowed_events = self.settings['events']
135 if event.name not in allowed_events:
136 log.debug('event ignored: %r event %s not in allowed set of events %s',
137 event, event.name, allowed_events)
138 return False
139 return True
140
128 141
129 142 class EEIntegration(IntegrationTypeBase):
130 143 description = 'Integration available in RhodeCode EE edition.'
131 144 is_dummy = True
132 145
133 146 def __init__(self, name, key, settings=None):
134 147 self.display_name = name
135 148 self.key = key
136 149 super(EEIntegration, self).__init__(settings)
137 150
138 151
139 152 # Helpers #
140 153 # updating this required to update the `common_vars` as well.
141 154 WEBHOOK_URL_VARS = [
142 ('event_name', 'Unique name of the event type, e.g pullrequest-update'),
143 ('repo_name', 'Full name of the repository'),
144 ('repo_type', 'VCS type of repository'),
145 ('repo_id', 'Unique id of repository'),
146 ('repo_url', 'Repository url'),
155 # GENERAL
156 ('General', [
157 ('event_name', 'Unique name of the event type, e.g pullrequest-update'),
158 ('repo_name', 'Full name of the repository'),
159 ('repo_type', 'VCS type of repository'),
160 ('repo_id', 'Unique id of repository'),
161 ('repo_url', 'Repository url'),
162 ]
163 ),
147 164 # extra repo fields
148 ('extra:<extra_key_name>', 'Extra repo variables, read from its settings.'),
149
165 ('Repository', [
166 ('extra:<extra_key_name>', 'Extra repo variables, read from its settings.'),
167 ]
168 ),
150 169 # special attrs below that we handle, using multi-call
151 ('branch', 'Name of each branch submitted, if any.'),
152 ('branch_head', 'Head ID of pushed branch (full sha of last commit), if any.'),
153 ('commit_id', 'ID (full sha) of each commit submitted, if any.'),
154
170 ('Commit push - Multicalls', [
171 ('branch', 'Name of each branch submitted, if any.'),
172 ('branch_head', 'Head ID of pushed branch (full sha of last commit), if any.'),
173 ('commit_id', 'ID (full sha) of each commit submitted, if any.'),
174 ]
175 ),
155 176 # pr events vars
156 ('pull_request_id', 'Unique ID of the pull request.'),
157 ('pull_request_title', 'Title of the pull request.'),
158 ('pull_request_url', 'Pull request url.'),
159 ('pull_request_shadow_url', 'Pull request shadow repo clone url.'),
160 ('pull_request_commits_uid', 'Calculated UID of all commits inside the PR. '
161 'Changes after PR update'),
177 ('Pull request', [
178 ('pull_request_id', 'Unique ID of the pull request.'),
179 ('pull_request_title', 'Title of the pull request.'),
180 ('pull_request_url', 'Pull request url.'),
181 ('pull_request_shadow_url', 'Pull request shadow repo clone url.'),
182 ('pull_request_commits_uid', 'Calculated UID of all commits inside the PR. '
183 'Changes after PR update'),
184 ]
185 ),
186 # commit comment event vars
187 ('Commit comment', [
188 ('commit_comment_id', 'Unique ID of the comment made on a commit.'),
189 ('commit_comment_text', 'Text of commit comment.'),
190 ('commit_comment_type', 'Type of comment, e.g note/todo.'),
162 191
192 ('commit_comment_f_path', 'Optionally path of file for inline comments.'),
193 ('commit_comment_line_no', 'Line number of the file: eg o10, or n200'),
194
195 ('commit_comment_commit_id', 'Commit id that comment was left at.'),
196 ('commit_comment_commit_branch', 'Commit branch that comment was left at'),
197 ('commit_comment_commit_message', 'Commit message that comment was left at'),
198 ]
199 ),
163 200 # user who triggers the call
164 ('username', 'User who triggered the call.'),
165 ('user_id', 'User id who triggered the call.'),
201 ('Caller', [
202 ('username', 'User who triggered the call.'),
203 ('user_id', 'User id who triggered the call.'),
204 ]
205 ),
166 206 ]
167 207
168 208 # common vars for url template used for CI plugins. Shared with webhook
169 209 CI_URL_VARS = WEBHOOK_URL_VARS
170 210
171 211
172 212 class CommitParsingDataHandler(object):
173 213
174 214 def aggregate_branch_data(self, branches, commits):
175 215 branch_data = collections.OrderedDict()
176 216 for obj in branches:
177 217 branch_data[obj['name']] = obj
178 218
179 219 branches_commits = collections.OrderedDict()
180 220 for commit in commits:
181 221 if commit.get('git_ref_change'):
182 222 # special case for GIT that allows creating tags,
183 223 # deleting branches without associated commit
184 224 continue
185 225 commit_branch = commit['branch']
186 226
187 227 if commit_branch not in branches_commits:
188 228 _branch = branch_data[commit_branch] \
189 229 if commit_branch else commit_branch
190 230 branch_commits = {'branch': _branch,
191 231 'branch_head': '',
192 232 'commits': []}
193 233 branches_commits[commit_branch] = branch_commits
194 234
195 235 branch_commits = branches_commits[commit_branch]
196 236 branch_commits['commits'].append(commit)
197 237 branch_commits['branch_head'] = commit['raw_id']
198 238 return branches_commits
199 239
200 240
201 241 class WebhookDataHandler(CommitParsingDataHandler):
202 242 name = 'webhook'
203 243
204 244 def __init__(self, template_url, headers):
205 245 self.template_url = template_url
206 246 self.headers = headers
207 247
208 248 def get_base_parsed_template(self, data):
209 249 """
210 250 initially parses the passed in template with some common variables
211 251 available on ALL calls
212 252 """
213 253 # note: make sure to update the `WEBHOOK_URL_VARS` if this changes
214 254 common_vars = {
215 255 'repo_name': data['repo']['repo_name'],
216 256 'repo_type': data['repo']['repo_type'],
217 257 'repo_id': data['repo']['repo_id'],
218 258 'repo_url': data['repo']['url'],
219 259 'username': data['actor']['username'],
220 260 'user_id': data['actor']['user_id'],
221 261 'event_name': data['name']
222 262 }
223 263
224 264 extra_vars = {}
225 265 for extra_key, extra_val in data['repo']['extra_fields'].items():
226 266 extra_vars['extra__{}'.format(extra_key)] = extra_val
227 267 common_vars.update(extra_vars)
228 268
229 269 template_url = self.template_url.replace('${extra:', '${extra__')
230 270 for k, v in common_vars.items():
231 271 template_url = UrlTmpl(template_url).safe_substitute(**{k: v})
232 272 return template_url
233 273
234 274 def repo_push_event_handler(self, event, data):
235 275 url = self.get_base_parsed_template(data)
236 276 url_calls = []
237 277
238 278 branches_commits = self.aggregate_branch_data(
239 279 data['push']['branches'], data['push']['commits'])
240 280 if '${branch}' in url or '${branch_head}' in url or '${commit_id}' in url:
241 281 # call it multiple times, for each branch if used in variables
242 282 for branch, commit_ids in branches_commits.items():
243 283 branch_url = UrlTmpl(url).safe_substitute(branch=branch)
244 284
245 285 if '${branch_head}' in branch_url:
246 286 # last commit in the aggregate is the head of the branch
247 287 branch_head = commit_ids['branch_head']
248 288 branch_url = UrlTmpl(branch_url).safe_substitute(branch_head=branch_head)
249 289
250 290 # call further down for each commit if used
251 291 if '${commit_id}' in branch_url:
252 292 for commit_data in commit_ids['commits']:
253 293 commit_id = commit_data['raw_id']
254 294 commit_url = UrlTmpl(branch_url).safe_substitute(commit_id=commit_id)
255 295 # register per-commit call
256 296 log.debug(
257 297 'register %s call(%s) to url %s',
258 298 self.name, event, commit_url)
259 299 url_calls.append(
260 300 (commit_url, self.headers, data))
261 301
262 302 else:
263 303 # register per-branch call
264 304 log.debug('register %s call(%s) to url %s',
265 305 self.name, event, branch_url)
266 306 url_calls.append((branch_url, self.headers, data))
267 307
268 308 else:
269 309 log.debug('register %s call(%s) to url %s', self.name, event, url)
270 310 url_calls.append((url, self.headers, data))
271 311
272 312 return url_calls
273 313
314 def repo_commit_comment_handler(self, event, data):
315 url = self.get_base_parsed_template(data)
316 log.debug('register %s call(%s) to url %s', self.name, event, url)
317 comment_vars = [
318 ('commit_comment_id', data['comment']['comment_id']),
319 ('commit_comment_text', data['comment']['comment_text']),
320 ('commit_comment_type', data['comment']['comment_type']),
321
322 ('commit_comment_f_path', data['comment']['comment_f_path']),
323 ('commit_comment_line_no', data['comment']['comment_line_no']),
324
325 ('commit_comment_commit_id', data['commit']['commit_id']),
326 ('commit_comment_commit_branch', data['commit']['commit_branch']),
327 ('commit_comment_commit_message', data['commit']['commit_message']),
328 ]
329 for k, v in comment_vars:
330 url = UrlTmpl(url).safe_substitute(**{k: v})
331
332 return [(url, self.headers, data)]
333
274 334 def repo_create_event_handler(self, event, data):
275 335 url = self.get_base_parsed_template(data)
276 336 log.debug('register %s call(%s) to url %s', self.name, event, url)
277 337 return [(url, self.headers, data)]
278 338
279 339 def pull_request_event_handler(self, event, data):
280 340 url = self.get_base_parsed_template(data)
281 341 log.debug('register %s call(%s) to url %s', self.name, event, url)
282 342 pr_vars = [
283 343 ('pull_request_id', data['pullrequest']['pull_request_id']),
284 344 ('pull_request_title', data['pullrequest']['title']),
285 345 ('pull_request_url', data['pullrequest']['url']),
286 346 ('pull_request_shadow_url', data['pullrequest']['shadow_url']),
287 347 ('pull_request_commits_uid', data['pullrequest']['commits_uid']),
288 348 ]
289 349 for k, v in pr_vars:
290 350 url = UrlTmpl(url).safe_substitute(**{k: v})
291 351
292 352 return [(url, self.headers, data)]
293 353
294 354 def __call__(self, event, data):
295 355 from rhodecode import events
296 356
297 357 if isinstance(event, events.RepoPushEvent):
298 358 return self.repo_push_event_handler(event, data)
299 359 elif isinstance(event, events.RepoCreateEvent):
300 360 return self.repo_create_event_handler(event, data)
361 elif isinstance(event, events.RepoCommitCommentEvent):
362 return self.repo_commit_comment_handler(event, data)
301 363 elif isinstance(event, events.PullRequestEvent):
302 364 return self.pull_request_event_handler(event, data)
303 365 else:
304 366 raise ValueError(
305 'event type `%s` not in supported list: %s' % (
306 event.__class__, events))
367 'event type `{}` has no handler defined'.format(event.__class__))
307 368
308 369
309 370 def get_auth(settings):
310 371 from requests.auth import HTTPBasicAuth
311 372 username = settings.get('username')
312 373 password = settings.get('password')
313 374 if username and password:
314 375 return HTTPBasicAuth(username, password)
315 376 return None
316 377
317 378
318 379 def get_web_token(settings):
319 380 return settings['secret_token']
320 381
321 382
322 383 def get_url_vars(url_vars):
323 return '\n'.join(
324 '{} - {}'.format('${' + key + '}', explanation)
325 for key, explanation in url_vars)
384 items = []
385
386 for section, section_items in url_vars:
387 items.append('\n*{}*'.format(section))
388 for key, explanation in section_items:
389 items.append(' {} - {}'.format('${' + key + '}', explanation))
390 return '\n'.join(items)
326 391
327 392
328 393 def render_with_traceback(template, *args, **kwargs):
329 394 try:
330 395 return template.render(*args, **kwargs)
331 396 except Exception:
332 397 log.error(exceptions.text_error_template().render())
333 398 raise
334 399
335 400
336 401 STATUS_400 = (400, 401, 403)
337 402 STATUS_500 = (500, 502, 504)
338 403
339 404
340 405 def requests_retry_call(
341 406 retries=3, backoff_factor=0.3, status_forcelist=STATUS_400+STATUS_500,
342 407 session=None):
343 408 """
344 409 session = requests_retry_session()
345 410 response = session.get('http://example.com')
346 411
347 412 :param retries:
348 413 :param backoff_factor:
349 414 :param status_forcelist:
350 415 :param session:
351 416 """
352 417 session = session or requests.Session()
353 418 retry = Retry(
354 419 total=retries,
355 420 read=retries,
356 421 connect=retries,
357 422 backoff_factor=backoff_factor,
358 423 status_forcelist=status_forcelist,
359 424 )
360 425 adapter = HTTPAdapter(max_retries=retry)
361 426 session.mount('http://', adapter)
362 427 session.mount('https://', adapter)
363 428 return session
@@ -1,298 +1,330 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 from __future__ import unicode_literals
22 import deform
23 22 import logging
23
24 24 import colander
25
25 import deform.widget
26 26 from mako.template import Template
27 27
28 28 from rhodecode import events
29 from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc
29 30 from rhodecode.translation import _
30 31 from rhodecode.lib.celerylib import run_task
31 32 from rhodecode.lib.celerylib import tasks
32 33 from rhodecode.integrations.types.base import (
33 34 IntegrationTypeBase, render_with_traceback)
34 35
35 36
36 37 log = logging.getLogger(__name__)
37 38
38 39 REPO_PUSH_TEMPLATE_PLAINTEXT = Template('''
39 40 Commits:
40 41
41 42 % for commit in data['push']['commits']:
42 43 ${commit['url']} by ${commit['author']} at ${commit['date']}
43 44 ${commit['message']}
44 45 ----
45 46
46 47 % endfor
47 48 ''')
48 49
49 50 REPO_PUSH_TEMPLATE_HTML = Template('''
50 51 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
51 52 <html xmlns="http://www.w3.org/1999/xhtml">
52 53 <head>
53 54 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
54 55 <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
55 56 <title>${subject}</title>
56 57 <style type="text/css">
57 58 /* Based on The MailChimp Reset INLINE: Yes. */
58 59 #outlook a {padding:0;} /* Force Outlook to provide a "view in browser" menu link. */
59 60 body{width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0;}
60 61 /* Prevent Webkit and Windows Mobile platforms from changing default font sizes.*/
61 62 .ExternalClass {width:100%;} /* Force Hotmail to display emails at full width */
62 63 .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;}
63 64 /* Forces Hotmail to display normal line spacing. More on that: http://www.emailonacid.com/forum/viewthread/43/ */
64 65 #backgroundTable {margin:0; padding:0; line-height: 100% !important;}
65 66 /* End reset */
66 67
67 68 /* defaults for images*/
68 69 img {outline:none; text-decoration:none; -ms-interpolation-mode: bicubic;}
69 70 a img {border:none;}
70 71 .image_fix {display:block;}
71 72
72 73 body {line-height:1.2em;}
73 74 p {margin: 0 0 20px;}
74 75 h1, h2, h3, h4, h5, h6 {color:#323232!important;}
75 76 a {color:#427cc9;text-decoration:none;outline:none;cursor:pointer;}
76 77 a:focus {outline:none;}
77 78 a:hover {color: #305b91;}
78 79 h1 a, h2 a, h3 a, h4 a, h5 a, h6 a {color:#427cc9!important;text-decoration:none!important;}
79 80 h1 a:active, h2 a:active, h3 a:active, h4 a:active, h5 a:active, h6 a:active {color: #305b91!important;}
80 81 h1 a:visited, h2 a:visited, h3 a:visited, h4 a:visited, h5 a:visited, h6 a:visited {color: #305b91!important;}
81 82 table {font-size:13px;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}
82 83 table td {padding:.65em 1em .65em 0;border-collapse:collapse;vertical-align:top;text-align:left;}
83 84 input {display:inline;border-radius:2px;border-style:solid;border: 1px solid #dbd9da;padding:.5em;}
84 85 input:focus {outline: 1px solid #979797}
85 86 @media only screen and (-webkit-min-device-pixel-ratio: 2) {
86 87 /* Put your iPhone 4g styles in here */
87 88 }
88 89
89 90 /* Android targeting */
90 91 @media only screen and (-webkit-device-pixel-ratio:.75){
91 92 /* Put CSS for low density (ldpi) Android layouts in here */
92 93 }
93 94 @media only screen and (-webkit-device-pixel-ratio:1){
94 95 /* Put CSS for medium density (mdpi) Android layouts in here */
95 96 }
96 97 @media only screen and (-webkit-device-pixel-ratio:1.5){
97 98 /* Put CSS for high density (hdpi) Android layouts in here */
98 99 }
99 100 /* end Android targeting */
100 101
101 102 </style>
102 103
103 104 <!-- Targeting Windows Mobile -->
104 105 <!--[if IEMobile 7]>
105 106 <style type="text/css">
106 107
107 108 </style>
108 109 <![endif]-->
109 110
110 111 <!--[if gte mso 9]>
111 112 <style>
112 113 /* Target Outlook 2007 and 2010 */
113 114 </style>
114 115 <![endif]-->
115 116 </head>
116 117 <body>
117 118 <!-- Wrapper/Container Table: Use a wrapper table to control the width and the background color consistently of your email. Use this approach instead of setting attributes on the body tag. -->
118 119 <table cellpadding="0" cellspacing="0" border="0" id="backgroundTable" align="left" style="margin:1%;width:97%;padding:0;font-family:sans-serif;font-weight:100;border:1px solid #dbd9da">
119 120 <tr>
120 121 <td valign="top" style="padding:0;">
121 122 <table cellpadding="0" cellspacing="0" border="0" align="left" width="100%">
122 123 <tr><td style="width:100%;padding:7px;background-color:#202020" valign="top">
123 124 <a style="color:#eeeeee;text-decoration:none;" href="${instance_url}">
124 125 ${'RhodeCode'}
125 126 </a>
126 127 </td></tr>
127 128 <tr>
128 129 <td style="padding:15px;" valign="top">
129 130 % if data['push']['commits']:
130 131 % for commit in data['push']['commits']:
131 132 <a href="${commit['url']}">${commit['short_id']}</a> by ${commit['author']} at ${commit['date']} <br/>
132 133 ${commit['message_html']} <br/>
133 134 <br/>
134 135 % endfor
135 136 % else:
136 137 No commit data
137 138 % endif
138 139 </td>
139 140 </tr>
140 141 </table>
141 142 </td>
142 143 </tr>
143 144 </table>
144 145 <!-- End of wrapper table -->
145 146 <p><a style="margin-top:15px;margin-left:1%;font-family:sans-serif;font-weight:100;font-size:11px;color:#666666;text-decoration:none;" href="${instance_url}">
146 147 ${'This is a notification from RhodeCode. %(instance_url)s' % {'instance_url': instance_url}}
147 148 </a></p>
148 149 </body>
149 150 </html>
150 151 ''')
151 152
152 153
153 154 class EmailSettingsSchema(colander.Schema):
154 155 @colander.instantiate(validator=colander.Length(min=1))
155 156 class recipients(colander.SequenceSchema):
156 157 title = _('Recipients')
157 158 description = _('Email addresses to send push events to')
158 159 widget = deform.widget.SequenceWidget(min_len=1)
159 160
160 161 recipient = colander.SchemaNode(
161 162 colander.String(),
162 163 title=_('Email address'),
163 164 description=_('Email address'),
164 165 default='',
165 166 validator=colander.Email(),
166 167 widget=deform.widget.TextInputWidget(
167 168 placeholder='user@domain.com',
168 169 ),
169 170 )
170 171
171 172
172 173 class EmailIntegrationType(IntegrationTypeBase):
173 174 key = 'email'
174 175 display_name = _('Email')
175 176 description = _('Send repo push summaries to a list of recipients via email')
176 177
178 valid_events = [
179 events.RepoPushEvent
180 ]
181
177 182 @classmethod
178 183 def icon(cls):
179 184 return '''
180 185 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
181 186 <svg
182 187 xmlns:dc="http://purl.org/dc/elements/1.1/"
183 188 xmlns:cc="http://creativecommons.org/ns#"
184 189 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
185 190 xmlns:svg="http://www.w3.org/2000/svg"
186 191 xmlns="http://www.w3.org/2000/svg"
187 192 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
188 193 xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
189 194 viewBox="0 -256 1850 1850"
190 195 id="svg2989"
191 196 version="1.1"
192 197 inkscape:version="0.48.3.1 r9886"
193 198 width="100%"
194 199 height="100%"
195 200 sodipodi:docname="envelope_font_awesome.svg">
196 201 <metadata
197 202 id="metadata2999">
198 203 <rdf:RDF>
199 204 <cc:Work
200 205 rdf:about="">
201 206 <dc:format>image/svg+xml</dc:format>
202 207 <dc:type
203 208 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
204 209 </cc:Work>
205 210 </rdf:RDF>
206 211 </metadata>
207 212 <defs
208 213 id="defs2997" />
209 214 <sodipodi:namedview
210 215 pagecolor="#ffffff"
211 216 bordercolor="#666666"
212 217 borderopacity="1"
213 218 objecttolerance="10"
214 219 gridtolerance="10"
215 220 guidetolerance="10"
216 221 inkscape:pageopacity="0"
217 222 inkscape:pageshadow="2"
218 223 inkscape:window-width="640"
219 224 inkscape:window-height="480"
220 225 id="namedview2995"
221 226 showgrid="false"
222 227 inkscape:zoom="0.13169643"
223 228 inkscape:cx="896"
224 229 inkscape:cy="896"
225 230 inkscape:window-x="0"
226 231 inkscape:window-y="25"
227 232 inkscape:window-maximized="0"
228 233 inkscape:current-layer="svg2989" />
229 234 <g
230 235 transform="matrix(1,0,0,-1,37.966102,1282.678)"
231 236 id="g2991">
232 237 <path
233 238 d="m 1664,32 v 768 q -32,-36 -69,-66 -268,-206 -426,-338 -51,-43 -83,-67 -32,-24 -86.5,-48.5 Q 945,256 897,256 h -1 -1 Q 847,256 792.5,280.5 738,305 706,329 674,353 623,396 465,528 197,734 160,764 128,800 V 32 Q 128,19 137.5,9.5 147,0 160,0 h 1472 q 13,0 22.5,9.5 9.5,9.5 9.5,22.5 z m 0,1051 v 11 13.5 q 0,0 -0.5,13 -0.5,13 -3,12.5 -2.5,-0.5 -5.5,9 -3,9.5 -9,7.5 -6,-2 -14,2.5 H 160 q -13,0 -22.5,-9.5 Q 128,1133 128,1120 128,952 275,836 468,684 676,519 682,514 711,489.5 740,465 757,452 774,439 801.5,420.5 829,402 852,393 q 23,-9 43,-9 h 1 1 q 20,0 43,9 23,9 50.5,27.5 27.5,18.5 44.5,31.5 17,13 46,37.5 29,24.5 35,29.5 208,165 401,317 54,43 100.5,115.5 46.5,72.5 46.5,131.5 z m 128,37 V 32 q 0,-66 -47,-113 -47,-47 -113,-47 H 160 Q 94,-128 47,-81 0,-34 0,32 v 1088 q 0,66 47,113 47,47 113,47 h 1472 q 66,0 113,-47 47,-47 47,-113 z"
234 239 id="path2993"
235 240 inkscape:connector-curvature="0"
236 241 style="fill:currentColor" />
237 242 </g>
238 243 </svg>
239 244 '''
240 245
241 246 def settings_schema(self):
242 247 schema = EmailSettingsSchema()
248 schema.add(colander.SchemaNode(
249 colander.Set(),
250 widget=CheckboxChoiceWidgetDesc(
251 values=sorted(
252 [(e.name, e.display_name, e.description) for e in self.valid_events]
253 ),
254 ),
255 description="List of events activated for this integration",
256 name='events'
257 ))
243 258 return schema
244 259
245 260 def send_event(self, event):
246 data = event.as_dict()
247 log.debug('got event: %r', event)
261 log.debug('handling event %s with integration %s', event.name, self)
262
263 if event.__class__ not in self.valid_events:
264 log.debug('event %r not present in valid event list (%s)', event, self.valid_events)
265 return
266
267 if not self.event_enabled(event):
268 # NOTE(marcink): for legacy reasons we're skipping this check...
269 # since the email event haven't had any settings...
270 pass
248 271
272 handler = EmailEventHandler(self.settings)
273 handler(event, event_data=event.as_dict())
274
275
276 class EmailEventHandler(object):
277 def __init__(self, integration_settings):
278 self.integration_settings = integration_settings
279
280 def __call__(self, event, event_data):
249 281 if isinstance(event, events.RepoPushEvent):
250 repo_push_handler(data, self.settings)
282 self.repo_push_handler(event, event_data)
251 283 else:
252 284 log.debug('ignoring event: %r', event)
253 285
254
255 def repo_push_handler(data, settings):
256 commit_num = len(data['push']['commits'])
257 server_url = data['server_url']
286 def repo_push_handler(self, event, data):
287 commit_num = len(data['push']['commits'])
288 server_url = data['server_url']
258 289
259 if commit_num == 1:
260 if data['push']['branches']:
261 _subject = '[{repo_name}] {author} pushed {commit_num} commit on branches: {branches}'
262 else:
263 _subject = '[{repo_name}] {author} pushed {commit_num} commit'
264 subject = _subject.format(
265 author=data['actor']['username'],
266 repo_name=data['repo']['repo_name'],
267 commit_num=commit_num,
268 branches=', '.join(
269 branch['name'] for branch in data['push']['branches'])
270 )
271 else:
272 if data['push']['branches']:
273 _subject = '[{repo_name}] {author} pushed {commit_num} commits on branches: {branches}'
290 if commit_num == 1:
291 if data['push']['branches']:
292 _subject = '[{repo_name}] {author} pushed {commit_num} commit on branches: {branches}'
293 else:
294 _subject = '[{repo_name}] {author} pushed {commit_num} commit'
295 subject = _subject.format(
296 author=data['actor']['username'],
297 repo_name=data['repo']['repo_name'],
298 commit_num=commit_num,
299 branches=', '.join(
300 branch['name'] for branch in data['push']['branches'])
301 )
274 302 else:
275 _subject = '[{repo_name}] {author} pushed {commit_num} commits'
276 subject = _subject.format(
277 author=data['actor']['username'],
278 repo_name=data['repo']['repo_name'],
279 commit_num=commit_num,
280 branches=', '.join(
281 branch['name'] for branch in data['push']['branches']))
303 if data['push']['branches']:
304 _subject = '[{repo_name}] {author} pushed {commit_num} commits on branches: {branches}'
305 else:
306 _subject = '[{repo_name}] {author} pushed {commit_num} commits'
307 subject = _subject.format(
308 author=data['actor']['username'],
309 repo_name=data['repo']['repo_name'],
310 commit_num=commit_num,
311 branches=', '.join(
312 branch['name'] for branch in data['push']['branches']))
282 313
283 email_body_plaintext = render_with_traceback(
284 REPO_PUSH_TEMPLATE_PLAINTEXT,
285 data=data,
286 subject=subject,
287 instance_url=server_url)
314 email_body_plaintext = render_with_traceback(
315 REPO_PUSH_TEMPLATE_PLAINTEXT,
316 data=data,
317 subject=subject,
318 instance_url=server_url)
288 319
289 email_body_html = render_with_traceback(
290 REPO_PUSH_TEMPLATE_HTML,
291 data=data,
292 subject=subject,
293 instance_url=server_url)
320 email_body_html = render_with_traceback(
321 REPO_PUSH_TEMPLATE_HTML,
322 data=data,
323 subject=subject,
324 instance_url=server_url)
294 325
295 for email_address in settings['recipients']:
296 run_task(
297 tasks.send_email, email_address, subject,
298 email_body_plaintext, email_body_html)
326 recipients = self.integration_settings['recipients']
327 for email_address in recipients:
328 run_task(
329 tasks.send_email, email_address, subject,
330 email_body_plaintext, email_body_html)
@@ -1,255 +1,251 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 from __future__ import unicode_literals
22 22 import deform
23 23 import logging
24 24 import requests
25 25 import colander
26 26 import textwrap
27 27 from mako.template import Template
28 28 from rhodecode import events
29 from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc
29 30 from rhodecode.translation import _
30 31 from rhodecode.lib import helpers as h
31 32 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
32 33 from rhodecode.lib.colander_utils import strip_whitespace
33 34 from rhodecode.integrations.types.base import (
34 35 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback,
35 36 requests_retry_call)
36 37
37 38 log = logging.getLogger(__name__)
38 39
39 40 REPO_PUSH_TEMPLATE = Template('''
40 41 <b>${data['actor']['username']}</b> pushed to repo <a href="${data['repo']['url']}">${data['repo']['repo_name']}</a>:
41 42 <br>
42 43 <ul>
43 44 %for branch, branch_commits in branches_commits.items():
44 45 <li>
45 46 % if branch:
46 47 <a href="${branch_commits['branch']['url']}">branch: ${branch_commits['branch']['name']}</a>
47 48 % else:
48 49 to trunk
49 50 % endif
50 51 <ul>
51 52 % for commit in branch_commits['commits']:
52 53 <li><a href="${commit['url']}">${commit['short_id']}</a> - ${commit['message_html']}</li>
53 54 % endfor
54 55 </ul>
55 56 </li>
56 57 %endfor
57 58 ''')
58 59
59 60
60 61 class HipchatSettingsSchema(colander.Schema):
61 62 color_choices = [
62 63 ('yellow', _('Yellow')),
63 64 ('red', _('Red')),
64 65 ('green', _('Green')),
65 66 ('purple', _('Purple')),
66 67 ('gray', _('Gray')),
67 68 ]
68 69
69 70 server_url = colander.SchemaNode(
70 71 colander.String(),
71 72 title=_('Hipchat server URL'),
72 73 description=_('Hipchat integration url.'),
73 74 default='',
74 75 preparer=strip_whitespace,
75 76 validator=colander.url,
76 77 widget=deform.widget.TextInputWidget(
77 78 placeholder='https://?.hipchat.com/v2/room/?/notification?auth_token=?',
78 79 ),
79 80 )
80 81 notify = colander.SchemaNode(
81 82 colander.Bool(),
82 83 title=_('Notify'),
83 84 description=_('Make a notification to the users in room.'),
84 85 missing=False,
85 86 default=False,
86 87 )
87 88 color = colander.SchemaNode(
88 89 colander.String(),
89 90 title=_('Color'),
90 91 description=_('Background color of message.'),
91 92 missing='',
92 93 validator=colander.OneOf([x[0] for x in color_choices]),
93 94 widget=deform.widget.Select2Widget(
94 95 values=color_choices,
95 96 ),
96 97 )
97 98
98 99
99 100 class HipchatIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
100 101 key = 'hipchat'
101 102 display_name = _('Hipchat')
102 103 description = _('Send events such as repo pushes and pull requests to '
103 104 'your hipchat channel.')
104 105
105 106 @classmethod
106 107 def icon(cls):
107 108 return '''<?xml version="1.0" encoding="utf-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve"><g><g transform="translate(0.000000,511.000000) scale(0.100000,-0.100000)"><path fill="#205281" d="M4197.1,4662.4c-1661.5-260.4-3018-1171.6-3682.6-2473.3C219.9,1613.6,100,1120.3,100,462.6c0-1014,376.8-1918.4,1127-2699.4C2326.7-3377.6,3878.5-3898.3,5701-3730.5l486.5,44.5l208.9-123.3c637.2-373.4,1551.8-640.6,2240.4-650.9c304.9-6.9,335.7,0,417.9,75.4c185,174.7,147.3,411.1-89.1,548.1c-315.2,181.6-620,544.7-733.1,870.1l-51.4,157.6l472.7,472.7c349.4,349.4,520.7,551.5,657.7,774.2c784.5,1281.2,784.5,2788.5,0,4052.6c-236.4,376.8-794.8,966-1178.4,1236.7c-572.1,407.7-1264.1,709.1-1993.7,870.1c-267.2,58.2-479.6,75.4-1038,82.2C4714.4,4686.4,4310.2,4679.6,4197.1,4662.4z M5947.6,3740.9c1856.7-380.3,3127.6-1709.4,3127.6-3275c0-1000.3-534.4-1949.2-1466.2-2600.1c-188.4-133.6-287.8-226.1-301.5-284.4c-41.1-157.6,263.8-938.6,397.4-1020.8c20.5-10.3,34.3-44.5,34.3-75.4c0-167.8-811.9,195.3-1363.4,609.8l-181.6,137l-332.3-58.2c-445.3-78.8-1281.2-78.8-1702.6,0C2796-2569.2,1734.1-1832.6,1220.2-801.5C983.8-318.5,905,51.5,929,613.3c27.4,640.6,243.2,1192.1,685.1,1740.3c620,770.8,1661.5,1305.2,2822.8,1452.5C4806.9,3854,5553.7,3819.7,5947.6,3740.9z"/><path fill="#205281" d="M2381.5-345.9c-75.4-106.2-68.5-167.8,34.3-322c332.3-500.2,1010.6-928.4,1760.8-1120.2c417.9-106.2,1226.4-106.2,1644.3,0c712.5,181.6,1270.9,517.3,1685.4,1014C7681-561.7,7715.3-424.7,7616-325.4c-89.1,89.1-167.9,65.1-431.7-133.6c-835.8-630.3-2028-856.4-3086.5-585.8C3683.3-938.6,3142-685,2830.3-448.7C2576.8-253.4,2463.7-229.4,2381.5-345.9z"/></g></g><!-- Svg Vector Icons : http://www.onlinewebfonts.com/icon --></svg>'''
108 109
109 110 valid_events = [
110 111 events.PullRequestCloseEvent,
111 112 events.PullRequestMergeEvent,
112 113 events.PullRequestUpdateEvent,
113 114 events.PullRequestCommentEvent,
114 115 events.PullRequestReviewEvent,
115 116 events.PullRequestCreateEvent,
116 117 events.RepoPushEvent,
117 118 events.RepoCreateEvent,
118 119 ]
119 120
120 121 def send_event(self, event):
121 122 if event.__class__ not in self.valid_events:
122 log.debug('event not valid: %r', event)
123 log.debug('event %r not present in valid event list (%s)', event, self.valid_events)
123 124 return
124 125
125 allowed_events = self.settings['events']
126 if event.name not in allowed_events:
127 log.debug('event ignored: %r event %s not in allowed events %s',
128 event, event.name, allowed_events)
126 if not self.event_enabled(event):
129 127 return
130 128
131 129 data = event.as_dict()
132 130
133 131 text = '<b>%s<b> caused a <b>%s</b> event' % (
134 132 data['actor']['username'], event.name)
135 133
136 log.debug('handling hipchat event for %s', event.name)
137
138 134 if isinstance(event, events.PullRequestCommentEvent):
139 135 text = self.format_pull_request_comment_event(event, data)
140 136 elif isinstance(event, events.PullRequestReviewEvent):
141 137 text = self.format_pull_request_review_event(event, data)
142 138 elif isinstance(event, events.PullRequestEvent):
143 139 text = self.format_pull_request_event(event, data)
144 140 elif isinstance(event, events.RepoPushEvent):
145 141 text = self.format_repo_push_event(data)
146 142 elif isinstance(event, events.RepoCreateEvent):
147 143 text = self.format_repo_create_event(data)
148 144 else:
149 145 log.error('unhandled event type: %r', event)
150 146
151 147 run_task(post_text_to_hipchat, self.settings, text)
152 148
153 149 def settings_schema(self):
154 150 schema = HipchatSettingsSchema()
155 151 schema.add(colander.SchemaNode(
156 152 colander.Set(),
157 widget=deform.widget.CheckboxChoiceWidget(
153 widget=CheckboxChoiceWidgetDesc(
158 154 values=sorted(
159 [(e.name, e.display_name) for e in self.valid_events]
160 )
155 [(e.name, e.display_name, e.description) for e in self.valid_events]
156 ),
161 157 ),
162 description="Events activated for this integration",
158 description="List of events activated for this integration",
163 159 name='events'
164 160 ))
165 161
166 162 return schema
167 163
168 164 def format_pull_request_comment_event(self, event, data):
169 165 comment_text = data['comment']['text']
170 166 if len(comment_text) > 200:
171 167 comment_text = '{comment_text}<a href="{comment_url}">...<a/>'.format(
172 168 comment_text=h.html_escape(comment_text[:200]),
173 169 comment_url=data['comment']['url'],
174 170 )
175 171
176 172 comment_status = ''
177 173 if data['comment']['status']:
178 174 comment_status = '[{}]: '.format(data['comment']['status'])
179 175
180 176 return (textwrap.dedent(
181 177 '''
182 178 {user} commented on pull request <a href="{pr_url}">{number}</a> - {pr_title}:
183 179 >>> {comment_status}{comment_text}
184 180 ''').format(
185 181 comment_status=comment_status,
186 182 user=data['actor']['username'],
187 183 number=data['pullrequest']['pull_request_id'],
188 184 pr_url=data['pullrequest']['url'],
189 185 pr_status=data['pullrequest']['status'],
190 186 pr_title=h.html_escape(data['pullrequest']['title']),
191 187 comment_text=h.html_escape(comment_text)
192 188 )
193 189 )
194 190
195 191 def format_pull_request_review_event(self, event, data):
196 192 return (textwrap.dedent(
197 193 '''
198 194 Status changed to {pr_status} for pull request <a href="{pr_url}">#{number}</a> - {pr_title}
199 195 ''').format(
200 196 user=data['actor']['username'],
201 197 number=data['pullrequest']['pull_request_id'],
202 198 pr_url=data['pullrequest']['url'],
203 199 pr_status=data['pullrequest']['status'],
204 200 pr_title=h.html_escape(data['pullrequest']['title']),
205 201 )
206 202 )
207 203
208 204 def format_pull_request_event(self, event, data):
209 205 action = {
210 206 events.PullRequestCloseEvent: 'closed',
211 207 events.PullRequestMergeEvent: 'merged',
212 208 events.PullRequestUpdateEvent: 'updated',
213 209 events.PullRequestCreateEvent: 'created',
214 210 }.get(event.__class__, str(event.__class__))
215 211
216 212 return ('Pull request <a href="{url}">#{number}</a> - {title} '
217 213 '{action} by <b>{user}</b>').format(
218 214 user=data['actor']['username'],
219 215 number=data['pullrequest']['pull_request_id'],
220 216 url=data['pullrequest']['url'],
221 217 title=h.html_escape(data['pullrequest']['title']),
222 218 action=action
223 219 )
224 220
225 221 def format_repo_push_event(self, data):
226 222 branches_commits = self.aggregate_branch_data(
227 223 data['push']['branches'], data['push']['commits'])
228 224
229 225 result = render_with_traceback(
230 226 REPO_PUSH_TEMPLATE,
231 227 data=data,
232 228 branches_commits=branches_commits,
233 229 )
234 230 return result
235 231
236 232 def format_repo_create_event(self, data):
237 233 return '<a href="{}">{}</a> ({}) repository created by <b>{}</b>'.format(
238 234 data['repo']['url'],
239 235 h.html_escape(data['repo']['repo_name']),
240 236 data['repo']['repo_type'],
241 237 data['actor']['username'],
242 238 )
243 239
244 240
245 241 @async_task(ignore_result=True, base=RequestContextTask)
246 242 def post_text_to_hipchat(settings, text):
247 243 log.debug('sending %s to hipchat %s', text, settings['server_url'])
248 244 json_message = {
249 245 "message": text,
250 246 "color": settings.get('color', 'yellow'),
251 247 "notify": settings.get('notify', False),
252 248 }
253 249 req_session = requests_retry_call()
254 250 resp = req_session.post(settings['server_url'], json=json_message, timeout=60)
255 251 resp.raise_for_status() # raise exception on a failed request
@@ -1,353 +1,351 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 from __future__ import unicode_literals
22 22 import re
23 23 import time
24 24 import textwrap
25 25 import logging
26 26
27 27 import deform
28 28 import requests
29 29 import colander
30 30 from mako.template import Template
31 31
32 32 from rhodecode import events
33 from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc
33 34 from rhodecode.translation import _
34 35 from rhodecode.lib import helpers as h
35 36 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
36 37 from rhodecode.lib.colander_utils import strip_whitespace
37 38 from rhodecode.integrations.types.base import (
38 39 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback,
39 40 requests_retry_call)
40 41
41 42 log = logging.getLogger(__name__)
42 43
43 44
44 45 def html_to_slack_links(message):
45 46 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
46 47 r'<\1|\2>', message)
47 48
48 49
49 50 REPO_PUSH_TEMPLATE = Template('''
50 51 <%
51 52 def branch_text(branch):
52 53 if branch:
53 54 return 'on branch: <{}|{}>'.format(branch_commits['branch']['url'], branch_commits['branch']['name'])
54 55 else:
55 56 ## case for SVN no branch push...
56 57 return 'to trunk'
57 58 %> \
58 59
59 60 % for branch, branch_commits in branches_commits.items():
60 61 ${len(branch_commits['commits'])} ${'commit' if len(branch_commits['commits']) == 1 else 'commits'} ${branch_text(branch)}
61 62 % for commit in branch_commits['commits']:
62 63 `<${commit['url']}|${commit['short_id']}>` - ${commit['message_html']|html_to_slack_links}
63 64 % endfor
64 65 % endfor
65 66 ''')
66 67
67 68
68 69 class SlackSettingsSchema(colander.Schema):
69 70 service = colander.SchemaNode(
70 71 colander.String(),
71 72 title=_('Slack service URL'),
72 73 description=h.literal(_(
73 74 'This can be setup at the '
74 75 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
75 76 'slack app manager</a>')),
76 77 default='',
77 78 preparer=strip_whitespace,
78 79 validator=colander.url,
79 80 widget=deform.widget.TextInputWidget(
80 81 placeholder='https://hooks.slack.com/services/...',
81 82 ),
82 83 )
83 84 username = colander.SchemaNode(
84 85 colander.String(),
85 86 title=_('Username'),
86 87 description=_('Username to show notifications coming from.'),
87 88 missing='Rhodecode',
88 89 preparer=strip_whitespace,
89 90 widget=deform.widget.TextInputWidget(
90 91 placeholder='Rhodecode'
91 92 ),
92 93 )
93 94 channel = colander.SchemaNode(
94 95 colander.String(),
95 96 title=_('Channel'),
96 97 description=_('Channel to send notifications to.'),
97 98 missing='',
98 99 preparer=strip_whitespace,
99 100 widget=deform.widget.TextInputWidget(
100 101 placeholder='#general'
101 102 ),
102 103 )
103 104 icon_emoji = colander.SchemaNode(
104 105 colander.String(),
105 106 title=_('Emoji'),
106 107 description=_('Emoji to use eg. :studio_microphone:'),
107 108 missing='',
108 109 preparer=strip_whitespace,
109 110 widget=deform.widget.TextInputWidget(
110 111 placeholder=':studio_microphone:'
111 112 ),
112 113 )
113 114
114 115
115 116 class SlackIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
116 117 key = 'slack'
117 118 display_name = _('Slack')
118 119 description = _('Send events such as repo pushes and pull requests to '
119 120 'your slack channel.')
120 121
121 122 @classmethod
122 123 def icon(cls):
123 124 return '''<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid"><g><path d="M165.963541,15.8384262 C162.07318,3.86308197 149.212328,-2.69009836 137.239082,1.20236066 C125.263738,5.09272131 118.710557,17.9535738 122.603016,29.9268197 L181.550164,211.292328 C185.597902,222.478689 197.682361,228.765377 209.282098,225.426885 C221.381246,221.943607 228.756984,209.093246 224.896,197.21023 C224.749115,196.756984 165.963541,15.8384262 165.963541,15.8384262" fill="#DFA22F"></path><path d="M74.6260984,45.515541 C70.7336393,33.5422951 57.8727869,26.9891148 45.899541,30.8794754 C33.9241967,34.7698361 27.3710164,47.6306885 31.2634754,59.6060328 L90.210623,240.971541 C94.2583607,252.157902 106.34282,258.44459 117.942557,255.104 C130.041705,251.62282 137.417443,238.772459 133.556459,226.887344 C133.409574,226.436197 74.6260984,45.515541 74.6260984,45.515541" fill="#3CB187"></path><path d="M240.161574,166.045377 C252.136918,162.155016 258.688,149.294164 254.797639,137.31882 C250.907279,125.345574 238.046426,118.792393 226.07318,122.682754 L44.7076721,181.632 C33.5213115,185.677639 27.234623,197.762098 30.5731148,209.361836 C34.0563934,221.460984 46.9067541,228.836721 58.7897705,224.975738 C59.2430164,224.828852 240.161574,166.045377 240.161574,166.045377" fill="#CE1E5B"></path><path d="M82.507541,217.270557 C94.312918,213.434754 109.528131,208.491016 125.855475,203.186361 C122.019672,191.380984 117.075934,176.163672 111.76918,159.83423 L68.4191475,173.924721 L82.507541,217.270557" fill="#392538"></path><path d="M173.847082,187.591344 C190.235279,182.267803 205.467279,177.31777 217.195016,173.507148 C213.359213,161.70177 208.413377,146.480262 203.106623,130.146623 L159.75659,144.237115 L173.847082,187.591344" fill="#BB242A"></path><path d="M210.484459,74.7058361 C222.457705,70.8154754 229.010885,57.954623 225.120525,45.9792787 C221.230164,34.0060328 208.369311,27.4528525 196.393967,31.3432131 L15.028459,90.292459 C3.84209836,94.3380984 -2.44459016,106.422557 0.896,118.022295 C4.37718033,130.121443 17.227541,137.49718 29.1126557,133.636197 C29.5638033,133.489311 210.484459,74.7058361 210.484459,74.7058361" fill="#72C5CD"></path><path d="M52.8220328,125.933115 C64.6274098,122.097311 79.8468197,117.151475 96.1762623,111.84682 C90.8527213,95.4565246 85.9026885,80.2245246 82.0920656,68.4946885 L38.731541,82.5872787 L52.8220328,125.933115" fill="#248C73"></path><path d="M144.159475,96.256 C160.551869,90.9303607 175.785967,85.9803279 187.515803,82.1676066 C182.190164,65.7752131 177.240131,50.5390164 173.42741,38.807082 L130.068984,52.8996721 L144.159475,96.256" fill="#62803A"></path></g></svg>'''
124 125
125 126 valid_events = [
126 127 events.PullRequestCloseEvent,
127 128 events.PullRequestMergeEvent,
128 129 events.PullRequestUpdateEvent,
129 130 events.PullRequestCommentEvent,
130 131 events.PullRequestReviewEvent,
131 132 events.PullRequestCreateEvent,
132 133 events.RepoPushEvent,
133 134 events.RepoCreateEvent,
134 135 ]
135 136
136 137 def send_event(self, event):
138 log.debug('handling event %s with integration %s', event.name, self)
139
137 140 if event.__class__ not in self.valid_events:
138 log.debug('event not valid: %r', event)
141 log.debug('event %r not present in valid event list (%s)', event, self.valid_events)
139 142 return
140 143
141 allowed_events = self.settings['events']
142 if event.name not in allowed_events:
143 log.debug('event ignored: %r event %s not in allowed events %s',
144 event, event.name, allowed_events)
144 if not self.event_enabled(event):
145 145 return
146 146
147 147 data = event.as_dict()
148 148
149 149 # defaults
150 150 title = '*%s* caused a *%s* event' % (
151 151 data['actor']['username'], event.name)
152 152 text = '*%s* caused a *%s* event' % (
153 153 data['actor']['username'], event.name)
154 154 fields = None
155 155 overrides = None
156 156
157 log.debug('handling slack event for %s', event.name)
158
159 157 if isinstance(event, events.PullRequestCommentEvent):
160 158 (title, text, fields, overrides) \
161 159 = self.format_pull_request_comment_event(event, data)
162 160 elif isinstance(event, events.PullRequestReviewEvent):
163 161 title, text = self.format_pull_request_review_event(event, data)
164 162 elif isinstance(event, events.PullRequestEvent):
165 163 title, text = self.format_pull_request_event(event, data)
166 164 elif isinstance(event, events.RepoPushEvent):
167 165 title, text = self.format_repo_push_event(data)
168 166 elif isinstance(event, events.RepoCreateEvent):
169 167 title, text = self.format_repo_create_event(data)
170 168 else:
171 169 log.error('unhandled event type: %r', event)
172 170
173 171 run_task(post_text_to_slack, self.settings, title, text, fields, overrides)
174 172
175 173 def settings_schema(self):
176 174 schema = SlackSettingsSchema()
177 175 schema.add(colander.SchemaNode(
178 176 colander.Set(),
179 widget=deform.widget.CheckboxChoiceWidget(
177 widget=CheckboxChoiceWidgetDesc(
180 178 values=sorted(
181 [(e.name, e.display_name) for e in self.valid_events]
182 )
179 [(e.name, e.display_name, e.description) for e in self.valid_events]
180 ),
183 181 ),
184 description="Events activated for this integration",
182 description="List of events activated for this integration",
185 183 name='events'
186 184 ))
187 185
188 186 return schema
189 187
190 188 def format_pull_request_comment_event(self, event, data):
191 189 comment_text = data['comment']['text']
192 190 if len(comment_text) > 200:
193 191 comment_text = '<{comment_url}|{comment_text}...>'.format(
194 192 comment_text=comment_text[:200],
195 193 comment_url=data['comment']['url'],
196 194 )
197 195
198 196 fields = None
199 197 overrides = None
200 198 status_text = None
201 199
202 200 if data['comment']['status']:
203 201 status_color = {
204 202 'approved': '#0ac878',
205 203 'rejected': '#e85e4d'}.get(data['comment']['status'])
206 204
207 205 if status_color:
208 206 overrides = {"color": status_color}
209 207
210 208 status_text = data['comment']['status']
211 209
212 210 if data['comment']['file']:
213 211 fields = [
214 212 {
215 213 "title": "file",
216 214 "value": data['comment']['file']
217 215 },
218 216 {
219 217 "title": "line",
220 218 "value": data['comment']['line']
221 219 }
222 220 ]
223 221
224 222 template = Template(textwrap.dedent(r'''
225 223 *${data['actor']['username']}* left ${data['comment']['type']} on pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
226 224 '''))
227 225 title = render_with_traceback(
228 226 template, data=data, comment=event.comment)
229 227
230 228 template = Template(textwrap.dedent(r'''
231 229 *pull request title*: ${pr_title}
232 230 % if status_text:
233 231 *submitted status*: `${status_text}`
234 232 % endif
235 233 >>> ${comment_text}
236 234 '''))
237 235 text = render_with_traceback(
238 236 template,
239 237 comment_text=comment_text,
240 238 pr_title=data['pullrequest']['title'],
241 239 status_text=status_text)
242 240
243 241 return title, text, fields, overrides
244 242
245 243 def format_pull_request_review_event(self, event, data):
246 244 template = Template(textwrap.dedent(r'''
247 245 *${data['actor']['username']}* changed status of pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']} to `${data['pullrequest']['status']}`>:
248 246 '''))
249 247 title = render_with_traceback(template, data=data)
250 248
251 249 template = Template(textwrap.dedent(r'''
252 250 *pull request title*: ${pr_title}
253 251 '''))
254 252 text = render_with_traceback(
255 253 template,
256 254 pr_title=data['pullrequest']['title'])
257 255
258 256 return title, text
259 257
260 258 def format_pull_request_event(self, event, data):
261 259 action = {
262 260 events.PullRequestCloseEvent: 'closed',
263 261 events.PullRequestMergeEvent: 'merged',
264 262 events.PullRequestUpdateEvent: 'updated',
265 263 events.PullRequestCreateEvent: 'created',
266 264 }.get(event.__class__, str(event.__class__))
267 265
268 266 template = Template(textwrap.dedent(r'''
269 267 *${data['actor']['username']}* `${action}` pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
270 268 '''))
271 269 title = render_with_traceback(template, data=data, action=action)
272 270
273 271 template = Template(textwrap.dedent(r'''
274 272 *pull request title*: ${pr_title}
275 273 %if data['pullrequest']['commits']:
276 274 *commits*: ${len(data['pullrequest']['commits'])}
277 275 %endif
278 276 '''))
279 277 text = render_with_traceback(
280 278 template,
281 279 pr_title=data['pullrequest']['title'],
282 280 data=data)
283 281
284 282 return title, text
285 283
286 284 def format_repo_push_event(self, data):
287 285 branches_commits = self.aggregate_branch_data(
288 286 data['push']['branches'], data['push']['commits'])
289 287
290 288 template = Template(r'''
291 289 *${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
292 290 ''')
293 291 title = render_with_traceback(template, data=data)
294 292
295 293 text = render_with_traceback(
296 294 REPO_PUSH_TEMPLATE,
297 295 data=data,
298 296 branches_commits=branches_commits,
299 297 html_to_slack_links=html_to_slack_links,
300 298 )
301 299
302 300 return title, text
303 301
304 302 def format_repo_create_event(self, data):
305 303 template = Template(r'''
306 304 *${data['actor']['username']}* created new repository ${data['repo']['repo_name']}:
307 305 ''')
308 306 title = render_with_traceback(template, data=data)
309 307
310 308 template = Template(textwrap.dedent(r'''
311 309 repo_url: ${data['repo']['url']}
312 310 repo_type: ${data['repo']['repo_type']}
313 311 '''))
314 312 text = render_with_traceback(template, data=data)
315 313
316 314 return title, text
317 315
318 316
319 317 @async_task(ignore_result=True, base=RequestContextTask)
320 318 def post_text_to_slack(settings, title, text, fields=None, overrides=None):
321 319 log.debug('sending %s (%s) to slack %s', title, text, settings['service'])
322 320
323 321 fields = fields or []
324 322 overrides = overrides or {}
325 323
326 324 message_data = {
327 325 "fallback": text,
328 326 "color": "#427cc9",
329 327 "pretext": title,
330 328 #"author_name": "Bobby Tables",
331 329 #"author_link": "http://flickr.com/bobby/",
332 330 #"author_icon": "http://flickr.com/icons/bobby.jpg",
333 331 #"title": "Slack API Documentation",
334 332 #"title_link": "https://api.slack.com/",
335 333 "text": text,
336 334 "fields": fields,
337 335 #"image_url": "http://my-website.com/path/to/image.jpg",
338 336 #"thumb_url": "http://example.com/path/to/thumb.png",
339 337 "footer": "RhodeCode",
340 338 #"footer_icon": "",
341 339 "ts": time.time(),
342 340 "mrkdwn_in": ["pretext", "text"]
343 341 }
344 342 message_data.update(overrides)
345 343 json_message = {
346 344 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:'),
347 345 "channel": settings.get('channel', ''),
348 346 "username": settings.get('username', 'Rhodecode'),
349 347 "attachments": [message_data]
350 348 }
351 349 req_session = requests_retry_call()
352 350 resp = req_session.post(settings['service'], json=json_message, timeout=60)
353 351 resp.raise_for_status() # raise exception on a failed request
@@ -1,265 +1,264 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 from __future__ import unicode_literals
22 22
23 import deform
24 23 import deform.widget
25 24 import logging
26 25 import colander
27 26
28 27 import rhodecode
29 28 from rhodecode import events
29 from rhodecode.lib.colander_utils import strip_whitespace
30 from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc
30 31 from rhodecode.translation import _
31 32 from rhodecode.integrations.types.base import (
32 33 IntegrationTypeBase, get_auth, get_web_token, get_url_vars,
33 34 WebhookDataHandler, WEBHOOK_URL_VARS, requests_retry_call)
34 35 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
35 36 from rhodecode.model.validation_schema import widgets
36 37
37 38 log = logging.getLogger(__name__)
38 39
39 40
40 41 # updating this required to update the `common_vars` passed in url calling func
41 42
42 43 URL_VARS = get_url_vars(WEBHOOK_URL_VARS)
43 44
44 45
45 46 class WebhookSettingsSchema(colander.Schema):
46 47 url = colander.SchemaNode(
47 48 colander.String(),
48 49 title=_('Webhook URL'),
49 50 description=
50 51 _('URL to which Webhook should submit data. If used some of the '
51 52 'variables would trigger multiple calls, like ${branch} or '
52 53 '${commit_id}. Webhook will be called as many times as unique '
53 54 'objects in data in such cases.'),
54 55 missing=colander.required,
55 56 required=True,
57 preparer=strip_whitespace,
56 58 validator=colander.url,
57 59 widget=widgets.CodeMirrorWidget(
58 60 help_block_collapsable_name='Show url variables',
59 61 help_block_collapsable=(
60 'E.g http://my-serv/trigger_job/${{event_name}}'
62 'E.g http://my-serv.com/trigger_job/${{event_name}}'
61 63 '?PR_ID=${{pull_request_id}}'
62 64 '\nFull list of vars:\n{}'.format(URL_VARS)),
63 65 codemirror_mode='text',
64 66 codemirror_options='{"lineNumbers": false, "lineWrapping": true}'),
65 67 )
66 68 secret_token = colander.SchemaNode(
67 69 colander.String(),
68 70 title=_('Secret Token'),
69 71 description=_('Optional string used to validate received payloads. '
70 72 'It will be sent together with event data in JSON'),
71 73 default='',
72 74 missing='',
73 75 widget=deform.widget.TextInputWidget(
74 76 placeholder='e.g. secret_token'
75 77 ),
76 78 )
77 79 username = colander.SchemaNode(
78 80 colander.String(),
79 81 title=_('Username'),
80 82 description=_('Optional username to authenticate the call.'),
81 83 default='',
82 84 missing='',
83 85 widget=deform.widget.TextInputWidget(
84 86 placeholder='e.g. admin'
85 87 ),
86 88 )
87 89 password = colander.SchemaNode(
88 90 colander.String(),
89 91 title=_('Password'),
90 92 description=_('Optional password to authenticate the call.'),
91 93 default='',
92 94 missing='',
93 95 widget=deform.widget.PasswordWidget(
94 96 placeholder='e.g. secret.',
95 97 redisplay=True,
96 98 ),
97 99 )
98 100 custom_header_key = colander.SchemaNode(
99 101 colander.String(),
100 102 title=_('Custom Header Key'),
101 103 description=_('Custom Header name to be set when calling endpoint.'),
102 104 default='',
103 105 missing='',
104 106 widget=deform.widget.TextInputWidget(
105 107 placeholder='e.g: Authorization'
106 108 ),
107 109 )
108 110 custom_header_val = colander.SchemaNode(
109 111 colander.String(),
110 112 title=_('Custom Header Value'),
111 113 description=_('Custom Header value to be set when calling endpoint.'),
112 114 default='',
113 115 missing='',
114 116 widget=deform.widget.TextInputWidget(
115 117 placeholder='e.g. Basic XxXxXx'
116 118 ),
117 119 )
118 120 method_type = colander.SchemaNode(
119 121 colander.String(),
120 122 title=_('Call Method'),
121 123 description=_('Select a HTTP method to use when calling the Webhook.'),
122 124 default='post',
123 125 missing='',
124 126 widget=deform.widget.RadioChoiceWidget(
125 127 values=[('get', 'GET'), ('post', 'POST'), ('put', 'PUT')],
126 128 inline=True
127 129 ),
128 130 )
129 131
130 132
131 133 class WebhookIntegrationType(IntegrationTypeBase):
132 134 key = 'webhook'
133 135 display_name = _('Webhook')
134 136 description = _('send JSON data to a url endpoint')
135 137
136 138 @classmethod
137 139 def icon(cls):
138 140 return '''<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg viewBox="0 0 256 239" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid"><g><path d="M119.540432,100.502743 C108.930124,118.338815 98.7646301,135.611455 88.3876025,152.753617 C85.7226696,157.154315 84.4040417,160.738531 86.5332204,166.333309 C92.4107024,181.787152 84.1193605,196.825836 68.5350381,200.908244 C53.8383677,204.759349 39.5192953,195.099955 36.6032893,179.365384 C34.0194114,165.437749 44.8274148,151.78491 60.1824106,149.608284 C61.4694072,149.424428 62.7821041,149.402681 64.944891,149.240571 C72.469175,136.623655 80.1773157,123.700312 88.3025935,110.073173 C73.611854,95.4654658 64.8677898,78.3885437 66.803227,57.2292132 C68.1712787,42.2715849 74.0527146,29.3462646 84.8033863,18.7517722 C105.393354,-1.53572199 136.805164,-4.82141828 161.048542,10.7510424 C184.333097,25.7086706 194.996783,54.8450075 185.906752,79.7822957 C179.052655,77.9239597 172.151111,76.049808 164.563565,73.9917997 C167.418285,60.1274266 165.306899,47.6765751 155.95591,37.0109123 C149.777932,29.9690049 141.850349,26.2780332 132.835442,24.9178894 C114.764113,22.1877169 97.0209573,33.7983633 91.7563309,51.5355878 C85.7800012,71.6669027 94.8245623,88.1111998 119.540432,100.502743 L119.540432,100.502743 Z" fill="#C73A63"></path><path d="M149.841194,79.4106285 C157.316054,92.5969067 164.905578,105.982857 172.427885,119.246236 C210.44865,107.483365 239.114472,128.530009 249.398582,151.063322 C261.81978,178.282014 253.328765,210.520191 228.933162,227.312431 C203.893073,244.551464 172.226236,241.605803 150.040866,219.46195 C155.694953,214.729124 161.376716,209.974552 167.44794,204.895759 C189.360489,219.088306 208.525074,218.420096 222.753207,201.614016 C234.885769,187.277151 234.622834,165.900356 222.138374,151.863988 C207.730339,135.66681 188.431321,135.172572 165.103273,150.721309 C155.426087,133.553447 145.58086,116.521995 136.210101,99.2295848 C133.05093,93.4015266 129.561608,90.0209366 122.440622,88.7873178 C110.547271,86.7253555 102.868785,76.5124151 102.408155,65.0698097 C101.955433,53.7537294 108.621719,43.5249733 119.04224,39.5394355 C129.363912,35.5914599 141.476705,38.7783085 148.419765,47.554004 C154.093621,54.7244134 155.896602,62.7943365 152.911402,71.6372484 C152.081082,74.1025091 151.00562,76.4886916 149.841194,79.4106285 L149.841194,79.4106285 Z" fill="#4B4B4B"></path><path d="M167.706921,187.209935 L121.936499,187.209935 C117.54964,205.253587 108.074103,219.821756 91.7464461,229.085759 C79.0544063,236.285822 65.3738898,238.72736 50.8136292,236.376762 C24.0061432,232.053165 2.08568567,207.920497 0.156179306,180.745298 C-2.02835403,149.962159 19.1309765,122.599149 47.3341915,116.452801 C49.2814904,123.524363 51.2485589,130.663141 53.1958579,137.716911 C27.3195169,150.919004 18.3639187,167.553089 25.6054984,188.352614 C31.9811726,206.657224 50.0900643,216.690262 69.7528413,212.809503 C89.8327554,208.847688 99.9567329,192.160226 98.7211371,165.37844 C117.75722,165.37844 136.809118,165.180745 155.847178,165.475311 C163.280522,165.591951 169.019617,164.820939 174.620326,158.267339 C183.840836,147.48306 200.811003,148.455721 210.741239,158.640984 C220.88894,169.049642 220.402609,185.79839 209.663799,195.768166 C199.302587,205.38802 182.933414,204.874012 173.240413,194.508846 C171.247644,192.37176 169.677943,189.835329 167.706921,187.209935 L167.706921,187.209935 Z" fill="#4A4A4A"></path></g></svg>'''
139 141
140 142 valid_events = [
141 143 events.PullRequestCloseEvent,
142 144 events.PullRequestMergeEvent,
143 145 events.PullRequestUpdateEvent,
144 146 events.PullRequestCommentEvent,
145 147 events.PullRequestReviewEvent,
146 148 events.PullRequestCreateEvent,
147 149 events.RepoPushEvent,
148 150 events.RepoCreateEvent,
151 events.RepoCommitCommentEvent,
149 152 ]
150 153
151 154 def settings_schema(self):
152 155 schema = WebhookSettingsSchema()
153 156 schema.add(colander.SchemaNode(
154 157 colander.Set(),
155 widget=deform.widget.CheckboxChoiceWidget(
158 widget=CheckboxChoiceWidgetDesc(
156 159 values=sorted(
157 [(e.name, e.display_name) for e in self.valid_events]
158 )
160 [(e.name, e.display_name, e.description) for e in self.valid_events]
161 ),
159 162 ),
160 description="Events activated for this integration",
163 description="List of events activated for this integration",
161 164 name='events'
162 165 ))
163 166 return schema
164 167
165 168 def send_event(self, event):
166 log.debug(
167 'handling event %s with Webhook integration %s', event.name, self)
169 log.debug('handling event %s with integration %s', event.name, self)
168 170
169 171 if event.__class__ not in self.valid_events:
170 log.debug('event not valid: %r', event)
172 log.debug('event %r not present in valid event list (%s)', event, self.valid_events)
171 173 return
172 174
173 allowed_events = self.settings['events']
174 if event.name not in allowed_events:
175 log.debug('event ignored: %r event %s not in allowed events %s',
176 event, event.name, allowed_events)
175 if not self.event_enabled(event):
177 176 return
178 177
179 178 data = event.as_dict()
180 179 template_url = self.settings['url']
181 180
182 181 headers = {}
183 182 head_key = self.settings.get('custom_header_key')
184 183 head_val = self.settings.get('custom_header_val')
185 184 if head_key and head_val:
186 185 headers = {head_key: head_val}
187 186
188 187 handler = WebhookDataHandler(template_url, headers)
189 188
190 189 url_calls = handler(event, data)
191 190 log.debug('Webhook: calling following urls: %s', [x[0] for x in url_calls])
192 191
193 192 run_task(post_to_webhook, url_calls, self.settings)
194 193
195 194
196 195 @async_task(ignore_result=True, base=RequestContextTask)
197 196 def post_to_webhook(url_calls, settings):
198 197 """
199 198 Example data::
200 199
201 200 {'actor': {'user_id': 2, 'username': u'admin'},
202 201 'actor_ip': u'192.168.157.1',
203 202 'name': 'repo-push',
204 203 'push': {'branches': [{'name': u'default',
205 204 'url': 'http://rc.local:8080/hg-repo/changelog?branch=default'}],
206 205 'commits': [{'author': u'Marcin Kuzminski <marcin@rhodecode.com>',
207 206 'branch': u'default',
208 207 'date': datetime.datetime(2017, 11, 30, 12, 59, 48),
209 208 'issues': [],
210 209 'mentions': [],
211 210 'message': u'commit Thu 30 Nov 2017 13:59:48 CET',
212 211 'message_html': u'commit Thu 30 Nov 2017 13:59:48 CET',
213 212 'message_html_title': u'commit Thu 30 Nov 2017 13:59:48 CET',
214 213 'parents': [{'raw_id': '431b772a5353dad9974b810dd3707d79e3a7f6e0'}],
215 214 'permalink_url': u'http://rc.local:8080/_7/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
216 215 'raw_id': 'a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
217 216 'refs': {'bookmarks': [],
218 217 'branches': [u'default'],
219 218 'tags': [u'tip']},
220 219 'reviewers': [],
221 220 'revision': 9L,
222 221 'short_id': 'a815cc738b96',
223 222 'url': u'http://rc.local:8080/hg-repo/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf'}],
224 223 'issues': {}},
225 224 'repo': {'extra_fields': '',
226 225 'permalink_url': u'http://rc.local:8080/_7',
227 226 'repo_id': 7,
228 227 'repo_name': u'hg-repo',
229 228 'repo_type': u'hg',
230 229 'url': u'http://rc.local:8080/hg-repo'},
231 230 'server_url': u'http://rc.local:8080',
232 231 'utc_timestamp': datetime.datetime(2017, 11, 30, 13, 0, 1, 569276)
233 232 }
234 233 """
235 234
236 235 call_headers = {
237 236 'User-Agent': 'RhodeCode-webhook-caller/{}'.format(rhodecode.__version__)
238 237 } # updated below with custom ones, allows override
239 238
240 239 auth = get_auth(settings)
241 240 token = get_web_token(settings)
242 241
243 242 for url, headers, data in url_calls:
244 243 req_session = requests_retry_call()
245 244
246 245 method = settings.get('method_type') or 'post'
247 246 call_method = getattr(req_session, method)
248 247
249 248 headers = headers or {}
250 249 call_headers.update(headers)
251 250
252 251 log.debug('calling Webhook with method: %s, and auth:%s', call_method, auth)
253 252 if settings.get('log_data'):
254 253 log.debug('calling webhook with data: %s', data)
255 254 resp = call_method(url, json={
256 255 'token': token,
257 256 'event': data
258 257 }, headers=call_headers, auth=auth, timeout=60)
259 258 log.debug('Got Webhook response: %s', resp)
260 259
261 260 try:
262 261 resp.raise_for_status() # raise exception on a failed request
263 262 except Exception:
264 263 log.error(resp.text)
265 264 raise
@@ -1,32 +1,59 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22
23 import deform
24 23 import deform.widget
24 from deform.widget import null, OptGroup, string_types
25
26 log = logging.getLogger(__name__)
25 27
26 28
27 log = logging.getLogger(__name__)
29 def _normalize_choices(values):
30 result = []
31 for item in values:
32 if isinstance(item, OptGroup):
33 normalized_options = _normalize_choices(item.options)
34 result.append(OptGroup(item.label, *normalized_options))
35 else:
36 value, description, help_block = item
37 if not isinstance(value, string_types):
38 value = str(value)
39 result.append((value, description, help_block))
40 return result
28 41
29 42
30 43 class CodeMirrorWidget(deform.widget.TextAreaWidget):
31 44 template = 'codemirror'
32 45 requirements = (('deform', None), ('codemirror', None))
46
47
48 class CheckboxChoiceWidgetDesc(deform.widget.CheckboxChoiceWidget):
49 template = "checkbox_choice_desc"
50
51 def serialize(self, field, cstruct, **kw):
52 if cstruct in (null, None):
53 cstruct = ()
54 readonly = kw.get("readonly", self.readonly)
55 values = kw.get("values", self.values)
56 kw["values"] = _normalize_choices(values)
57 template = readonly and self.readonly_template or self.template
58 tmpl_values = self.get_template_values(field, cstruct, kw)
59 return field.renderer(template, **tmpl_values)
@@ -1,25 +1,25 b''
1 1 <div tal:define="css_class css_class|field.widget.css_class;
2 2 style style|field.widget.style;
3 3 oid oid|field.oid;
4 4 inline getattr(field.widget, 'inline', False)"
5 5 tal:omit-tag="not inline">
6 6 ${field.start_sequence()}
7 7 <div tal:repeat="choice values | field.widget.values"
8 8 tal:omit-tag="inline"
9 9 class="checkbox">
10 10 <div tal:define="(value, title) choice">
11 11 <input tal:attributes="checked value in cstruct;
12 12 class css_class;
13 13 style style"
14 14 type="checkbox"
15 15 name="checkbox"
16 16 value="${value}"
17 17 id="${oid}-${repeat.choice.index}"/>
18 18 <label for="${oid}-${repeat.choice.index}"
19 19 tal:attributes="class inline and 'checkbox-inline'">
20 20 ${title}
21 21 </label>
22 22 </div>
23 23 </div>
24 24 ${field.end_sequence()}
25 </div> No newline at end of file
25 </div>
@@ -1,100 +1,100 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import pytest
22 22
23 23 from rhodecode.tests.events.conftest import EventCatcher
24 24
25 25 from rhodecode.model.comment import CommentsModel
26 26 from rhodecode.model.pull_request import PullRequestModel
27 27 from rhodecode.events import (
28 28 PullRequestCreateEvent,
29 29 PullRequestUpdateEvent,
30 30 PullRequestCommentEvent,
31 31 PullRequestReviewEvent,
32 32 PullRequestMergeEvent,
33 33 PullRequestCloseEvent,
34 34 )
35 35
36 36 # TODO: dan: make the serialization tests complete json comparisons
37 37 @pytest.mark.backends("git", "hg")
38 38 @pytest.mark.parametrize('EventClass', [
39 39 PullRequestCreateEvent,
40 40 PullRequestUpdateEvent,
41 41 PullRequestReviewEvent,
42 42 PullRequestMergeEvent,
43 PullRequestCloseEvent,
43 PullRequestCloseEvent
44 44 ])
45 45 def test_pullrequest_events_serialized(EventClass, pr_util, config_stub):
46 46 pr = pr_util.create_pull_request()
47 47 if EventClass == PullRequestReviewEvent:
48 48 event = EventClass(pr, 'approved')
49 49 else:
50 50 event = EventClass(pr)
51 51 data = event.as_dict()
52 52 assert data['name'] == EventClass.name
53 53 assert data['repo']['repo_name'] == pr.target_repo.repo_name
54 54 assert data['pullrequest']['pull_request_id'] == pr.pull_request_id
55 55 assert data['pullrequest']['url']
56 56 assert data['pullrequest']['permalink_url']
57 57
58 58
59 59 @pytest.mark.backends("git", "hg")
60 60 def test_create_pull_request_events(pr_util, config_stub):
61 61 with EventCatcher() as event_catcher:
62 62 pr_util.create_pull_request()
63 63
64 64 assert PullRequestCreateEvent in event_catcher.events_types
65 65
66 66
67 67 @pytest.mark.backends("git", "hg")
68 68 def test_pullrequest_comment_events_serialized(pr_util, config_stub):
69 69 pr = pr_util.create_pull_request()
70 70 comment = CommentsModel().get_comments(
71 71 pr.target_repo.repo_id, pull_request=pr)[0]
72 72 event = PullRequestCommentEvent(pr, comment)
73 73 data = event.as_dict()
74 74 assert data['name'] == PullRequestCommentEvent.name
75 75 assert data['repo']['repo_name'] == pr.target_repo.repo_name
76 76 assert data['pullrequest']['pull_request_id'] == pr.pull_request_id
77 77 assert data['pullrequest']['url']
78 78 assert data['pullrequest']['permalink_url']
79 79 assert data['comment']['text'] == comment.text
80 80
81 81
82 82 @pytest.mark.backends("git", "hg")
83 83 def test_close_pull_request_events(pr_util, user_admin, config_stub):
84 84 pr = pr_util.create_pull_request()
85 85
86 86 with EventCatcher() as event_catcher:
87 87 PullRequestModel().close_pull_request(pr, user_admin)
88 88
89 89 assert PullRequestCloseEvent in event_catcher.events_types
90 90
91 91
92 92 @pytest.mark.backends("git", "hg")
93 93 def test_close_pull_request_with_comment_events(pr_util, user_admin, config_stub):
94 94 pr = pr_util.create_pull_request()
95 95
96 96 with EventCatcher() as event_catcher:
97 97 PullRequestModel().close_pull_request_with_comment(
98 98 pr, user_admin, pr.target_repo)
99 99
100 100 assert PullRequestCloseEvent in event_catcher.events_types
@@ -1,123 +1,145 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import pytest
22 22
23 from rhodecode.lib.utils2 import StrictAttributeDict
23 24 from rhodecode.tests.events.conftest import EventCatcher
24 25
25 26 from rhodecode.lib import hooks_base, utils2
26 27 from rhodecode.model.repo import RepoModel
27 28 from rhodecode.events.repo import (
28 29 RepoPrePullEvent, RepoPullEvent,
29 30 RepoPrePushEvent, RepoPushEvent,
30 31 RepoPreCreateEvent, RepoCreateEvent,
31 RepoPreDeleteEvent, RepoDeleteEvent,
32 RepoPreDeleteEvent, RepoDeleteEvent, RepoCommitCommentEvent,
32 33 )
33 34
34 35
35 36 @pytest.fixture()
36 37 def scm_extras(user_regular, repo_stub):
37 38 extras = utils2.AttributeDict({
38 39 'ip': '127.0.0.1',
39 40 'username': user_regular.username,
40 41 'user_id': user_regular.user_id,
41 42 'action': '',
42 43 'repository': repo_stub.repo_name,
43 44 'scm': repo_stub.scm_instance().alias,
44 45 'config': '',
45 46 'repo_store': '',
46 47 'server_url': 'http://example.com',
47 48 'make_lock': None,
48 49 'user_agent': 'some-client',
49 50 'locked_by': [None],
50 51 'commit_ids': ['a' * 40] * 3,
51 52 'hook_type': 'scm_extras_test',
52 53 'is_shadow_repo': False,
53 54 })
54 55 return extras
55 56
56 57
57 58 # TODO: dan: make the serialization tests complete json comparisons
58 59 @pytest.mark.parametrize('EventClass', [
59 60 RepoPreCreateEvent, RepoCreateEvent,
60 61 RepoPreDeleteEvent, RepoDeleteEvent,
61 62 ])
62 63 def test_repo_events_serialized(config_stub, repo_stub, EventClass):
63 64 event = EventClass(repo_stub)
64 65 data = event.as_dict()
65 66 assert data['name'] == EventClass.name
66 67 assert data['repo']['repo_name'] == repo_stub.repo_name
67 68 assert data['repo']['url']
68 69 assert data['repo']['permalink_url']
69 70
70 71
71 72 @pytest.mark.parametrize('EventClass', [
72 73 RepoPrePullEvent, RepoPullEvent, RepoPrePushEvent
73 74 ])
74 75 def test_vcs_repo_events_serialize(config_stub, repo_stub, scm_extras, EventClass):
75 76 event = EventClass(repo_name=repo_stub.repo_name, extras=scm_extras)
76 77 data = event.as_dict()
77 78 assert data['name'] == EventClass.name
78 79 assert data['repo']['repo_name'] == repo_stub.repo_name
79 80 assert data['repo']['url']
80 81 assert data['repo']['permalink_url']
81 82
82 83
83 84 @pytest.mark.parametrize('EventClass', [RepoPushEvent])
84 85 def test_vcs_repo_push_event_serialize(config_stub, repo_stub, scm_extras, EventClass):
85 86 event = EventClass(repo_name=repo_stub.repo_name,
86 87 pushed_commit_ids=scm_extras['commit_ids'],
87 88 extras=scm_extras)
88 89 data = event.as_dict()
89 90 assert data['name'] == EventClass.name
90 91 assert data['repo']['repo_name'] == repo_stub.repo_name
91 92 assert data['repo']['url']
92 93 assert data['repo']['permalink_url']
93 94
94 95
95 96 def test_create_delete_repo_fires_events(backend):
96 97 with EventCatcher() as event_catcher:
97 98 repo = backend.create_repo()
98 99 assert event_catcher.events_types == [RepoPreCreateEvent, RepoCreateEvent]
99 100
100 101 with EventCatcher() as event_catcher:
101 102 RepoModel().delete(repo)
102 103 assert event_catcher.events_types == [RepoPreDeleteEvent, RepoDeleteEvent]
103 104
104 105
105 106 def test_pull_fires_events(scm_extras):
106 107 with EventCatcher() as event_catcher:
107 108 hooks_base.pre_push(scm_extras)
108 109 assert event_catcher.events_types == [RepoPrePushEvent]
109 110
110 111 with EventCatcher() as event_catcher:
111 112 hooks_base.post_push(scm_extras)
112 113 assert event_catcher.events_types == [RepoPushEvent]
113 114
114 115
115 116 def test_push_fires_events(scm_extras):
116 117 with EventCatcher() as event_catcher:
117 118 hooks_base.pre_pull(scm_extras)
118 119 assert event_catcher.events_types == [RepoPrePullEvent]
119 120
120 121 with EventCatcher() as event_catcher:
121 122 hooks_base.post_pull(scm_extras)
122 123 assert event_catcher.events_types == [RepoPullEvent]
123 124
125
126 @pytest.mark.parametrize('EventClass', [RepoCommitCommentEvent])
127 def test_repo_commit_event(config_stub, repo_stub, EventClass):
128
129 commit = StrictAttributeDict({
130 'raw_id': 'raw_id',
131 'message': 'message',
132 'branch': 'branch',
133 })
134
135 comment = StrictAttributeDict({
136 'comment_id': 'comment_id',
137 'text': 'text',
138 'comment_type': 'comment_type',
139 'f_path': 'f_path',
140 'line_no': 'line_no',
141 })
142 event = EventClass(repo=repo_stub, commit=commit, comment=comment)
143 data = event.as_dict()
144 assert data['commit']['commit_id']
145 assert data['comment']['comment_id']
@@ -1,224 +1,225 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import time
22 22 import pytest
23 23
24 24 from rhodecode import events
25 25 from rhodecode.tests.fixture import Fixture
26 26 from rhodecode.model.db import Session, Integration
27 27 from rhodecode.model.integration import IntegrationModel
28 28
29 29
30 30 class TestDeleteScopesDeletesIntegrations(object):
31 31 def test_delete_repo_with_integration_deletes_integration(
32 32 self, repo_integration_stub):
33 33
34 34 Session().delete(repo_integration_stub.repo)
35 35 Session().commit()
36 36 Session().expire_all()
37 37 integration = Integration.get(repo_integration_stub.integration_id)
38 38 assert integration is None
39 39
40 40 def test_delete_repo_group_with_integration_deletes_integration(
41 41 self, repogroup_integration_stub):
42 42
43 43 Session().delete(repogroup_integration_stub.repo_group)
44 44 Session().commit()
45 45 Session().expire_all()
46 46 integration = Integration.get(repogroup_integration_stub.integration_id)
47 47 assert integration is None
48 48
49 49
50 50 count = 1
51 51
52
52 53 def counter():
53 54 global count
54 55 val = count
55 56 count += 1
56 57 return '{}_{}'.format(val, time.time())
57 58
58 59
59 60 @pytest.fixture()
60 61 def integration_repos(request, StubIntegrationType, stub_integration_settings):
61 62 """
62 63 Create repositories and integrations for testing, and destroy them after
63 64
64 65 Structure:
65 66 root_repo
66 67 parent_group/
67 68 parent_repo
68 69 child_group/
69 70 child_repo
70 71 other_group/
71 72 other_repo
72 73 """
73 74 fixture = Fixture()
74 75
75 76 parent_group_id = 'int_test_parent_group_{}'.format(counter())
76 77 parent_group = fixture.create_repo_group(parent_group_id)
77 78
78 79 other_group_id = 'int_test_other_group_{}'.format(counter())
79 80 other_group = fixture.create_repo_group(other_group_id)
80 81
81 82 child_group_id = (
82 83 parent_group_id + '/' + 'int_test_child_group_{}'.format(counter()))
83 84 child_group = fixture.create_repo_group(child_group_id)
84 85
85 86 parent_repo_id = 'int_test_parent_repo_{}'.format(counter())
86 87 parent_repo = fixture.create_repo(parent_repo_id, repo_group=parent_group)
87 88
88 89 child_repo_id = 'int_test_child_repo_{}'.format(counter())
89 90 child_repo = fixture.create_repo(child_repo_id, repo_group=child_group)
90 91
91 92 other_repo_id = 'int_test_other_repo_{}'.format(counter())
92 93 other_repo = fixture.create_repo(other_repo_id, repo_group=other_group)
93 94
94 95 root_repo_id = 'int_test_repo_root_{}'.format(counter())
95 96 root_repo = fixture.create_repo(root_repo_id)
96 97
97 98 integrations = {}
98 99 for name, repo, repo_group, child_repos_only in [
99 100 ('global', None, None, None),
100 101 ('root_repos', None, None, True),
101 102 ('parent_repo', parent_repo, None, None),
102 103 ('child_repo', child_repo, None, None),
103 104 ('other_repo', other_repo, None, None),
104 105 ('root_repo', root_repo, None, None),
105 106 ('parent_group', None, parent_group, True),
106 107 ('parent_group_recursive', None, parent_group, False),
107 108 ('child_group', None, child_group, True),
108 109 ('child_group_recursive', None, child_group, False),
109 110 ('other_group', None, other_group, True),
110 111 ('other_group_recursive', None, other_group, False),
111 112 ]:
112 113 integrations[name] = IntegrationModel().create(
113 114 StubIntegrationType, settings=stub_integration_settings,
114 115 enabled=True, name='test %s integration' % name,
115 116 repo=repo, repo_group=repo_group, child_repos_only=child_repos_only)
116 117
117 118 Session().commit()
118 119
119 120 def _cleanup():
120 121 for integration in integrations.values():
121 122 Session.delete(integration)
122 123
123 124 fixture.destroy_repo(root_repo)
124 125 fixture.destroy_repo(child_repo)
125 126 fixture.destroy_repo(parent_repo)
126 127 fixture.destroy_repo(other_repo)
127 128 fixture.destroy_repo_group(child_group)
128 129 fixture.destroy_repo_group(parent_group)
129 130 fixture.destroy_repo_group(other_group)
130 131
131 132 request.addfinalizer(_cleanup)
132 133
133 134 return {
134 135 'integrations': integrations,
135 136 'repos': {
136 137 'root_repo': root_repo,
137 138 'other_repo': other_repo,
138 139 'parent_repo': parent_repo,
139 140 'child_repo': child_repo,
140 141 }
141 142 }
142 143
143 144
144 145 def test_enabled_integration_repo_scopes(integration_repos):
145 146 integrations = integration_repos['integrations']
146 147 repos = integration_repos['repos']
147 148
148 149 triggered_integrations = IntegrationModel().get_for_event(
149 150 events.RepoEvent(repos['root_repo']))
150 151
151 152 assert triggered_integrations == [
152 153 integrations['global'],
153 154 integrations['root_repos'],
154 155 integrations['root_repo'],
155 156 ]
156 157
157 158 triggered_integrations = IntegrationModel().get_for_event(
158 159 events.RepoEvent(repos['other_repo']))
159 160
160 161 assert triggered_integrations == [
161 162 integrations['global'],
162 163 integrations['other_group'],
163 164 integrations['other_group_recursive'],
164 165 integrations['other_repo'],
165 166 ]
166 167
167 168 triggered_integrations = IntegrationModel().get_for_event(
168 169 events.RepoEvent(repos['parent_repo']))
169 170
170 171 assert triggered_integrations == [
171 172 integrations['global'],
172 173 integrations['parent_group'],
173 174 integrations['parent_group_recursive'],
174 175 integrations['parent_repo'],
175 176 ]
176 177
177 178 triggered_integrations = IntegrationModel().get_for_event(
178 179 events.RepoEvent(repos['child_repo']))
179 180
180 181 assert triggered_integrations == [
181 182 integrations['global'],
182 183 integrations['child_group'],
183 184 integrations['parent_group_recursive'],
184 185 integrations['child_group_recursive'],
185 186 integrations['child_repo'],
186 187 ]
187 188
188 189
189 190 def test_disabled_integration_repo_scopes(integration_repos):
190 191 integrations = integration_repos['integrations']
191 192 repos = integration_repos['repos']
192 193
193 194 for integration in integrations.values():
194 195 integration.enabled = False
195 196 Session().commit()
196 197
197 198 triggered_integrations = IntegrationModel().get_for_event(
198 199 events.RepoEvent(repos['root_repo']))
199 200
200 201 assert triggered_integrations == []
201 202
202 203 triggered_integrations = IntegrationModel().get_for_event(
203 204 events.RepoEvent(repos['parent_repo']))
204 205
205 206 assert triggered_integrations == []
206 207
207 208 triggered_integrations = IntegrationModel().get_for_event(
208 209 events.RepoEvent(repos['child_repo']))
209 210
210 211 assert triggered_integrations == []
211 212
212 213 triggered_integrations = IntegrationModel().get_for_event(
213 214 events.RepoEvent(repos['other_repo']))
214 215
215 216 assert triggered_integrations == []
216 217
217 218
218 219 def test_enabled_non_repo_integrations(integration_repos):
219 220 integrations = integration_repos['integrations']
220 221
221 222 triggered_integrations = IntegrationModel().get_for_event(
222 223 events.UserPreCreate({}))
223 224
224 225 assert triggered_integrations == [integrations['global']]
@@ -1,135 +1,134 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import pytest
22 22
23 23 from rhodecode import events
24 24 from rhodecode.lib.utils2 import AttributeDict
25 25 from rhodecode.integrations.types.webhook import WebhookDataHandler
26 26
27 27
28 28 @pytest.fixture()
29 29 def base_data():
30 30 return {
31 31 'name': 'event',
32 32 'repo': {
33 33 'repo_name': 'foo',
34 34 'repo_type': 'hg',
35 35 'repo_id': '12',
36 36 'url': 'http://repo.url/foo',
37 37 'extra_fields': {},
38 38 },
39 39 'actor': {
40 40 'username': 'actor_name',
41 41 'user_id': 1
42 42 }
43 43 }
44 44
45 45
46 46 def test_webhook_parse_url_invalid_event():
47 47 template_url = 'http://server.com/${repo_name}/build'
48 48 handler = WebhookDataHandler(
49 49 template_url, {'exmaple-header': 'header-values'})
50 50 event = events.RepoDeleteEvent('')
51 51 with pytest.raises(ValueError) as err:
52 52 handler(event, {})
53 53
54 54 err = str(err.value)
55 assert err.startswith(
56 'event type `%s` not in supported list' % event.__class__)
55 assert err == "event type `<class 'rhodecode.events.repo.RepoDeleteEvent'>` has no handler defined"
57 56
58 57
59 58 @pytest.mark.parametrize('template,expected_urls', [
60 59 ('http://server.com/${repo_name}/build',
61 60 ['http://server.com/foo/build']),
62 61 ('http://server.com/${repo_name}/${repo_type}',
63 62 ['http://server.com/foo/hg']),
64 63 ('http://${server}.com/${repo_name}/${repo_id}',
65 64 ['http://${server}.com/foo/12']),
66 65 ('http://server.com/${branch}/build',
67 66 ['http://server.com/${branch}/build']),
68 67 ])
69 68 def test_webook_parse_url_for_create_event(base_data, template, expected_urls):
70 69 headers = {'exmaple-header': 'header-values'}
71 70 handler = WebhookDataHandler(template, headers)
72 71 urls = handler(events.RepoCreateEvent(''), base_data)
73 72 assert urls == [
74 73 (url, headers, base_data) for url in expected_urls]
75 74
76 75
77 76 @pytest.mark.parametrize('template,expected_urls', [
78 77 ('http://server.com/${repo_name}/${pull_request_id}',
79 78 ['http://server.com/foo/999']),
80 79 ('http://server.com/${repo_name}/${pull_request_url}',
81 80 ['http://server.com/foo/http%3A//pr-url.com']),
82 81 ('http://server.com/${repo_name}/${pull_request_url}/?TITLE=${pull_request_title}',
83 82 ['http://server.com/foo/http%3A//pr-url.com/?TITLE=example-pr-title%20Ticket%20%23123']),
84 83 ('http://server.com/${repo_name}/?SHADOW_URL=${pull_request_shadow_url}',
85 84 ['http://server.com/foo/?SHADOW_URL=http%3A//pr-url.com/repository']),
86 85 ])
87 86 def test_webook_parse_url_for_pull_request_event(base_data, template, expected_urls):
88 87
89 88 base_data['pullrequest'] = {
90 89 'pull_request_id': 999,
91 90 'url': 'http://pr-url.com',
92 91 'title': 'example-pr-title Ticket #123',
93 92 'commits_uid': 'abcdefg1234',
94 93 'shadow_url': 'http://pr-url.com/repository'
95 94 }
96 95 headers = {'exmaple-header': 'header-values'}
97 96 handler = WebhookDataHandler(template, headers)
98 97 urls = handler(events.PullRequestCreateEvent(
99 98 AttributeDict({'target_repo': 'foo'})), base_data)
100 99 assert urls == [
101 100 (url, headers, base_data) for url in expected_urls]
102 101
103 102
104 103 @pytest.mark.parametrize('template,expected_urls', [
105 104 ('http://server.com/${branch}/build',
106 105 ['http://server.com/stable/build',
107 106 'http://server.com/dev/build']),
108 107 ('http://server.com/${branch}/${commit_id}',
109 108 ['http://server.com/stable/stable-xxx',
110 109 'http://server.com/stable/stable-yyy',
111 110 'http://server.com/dev/dev-xxx',
112 111 'http://server.com/dev/dev-yyy']),
113 112 ('http://server.com/${branch_head}',
114 113 ['http://server.com/stable-yyy',
115 114 'http://server.com/dev-yyy']),
116 115 ('http://server.com/${commit_id}',
117 116 ['http://server.com/stable-xxx',
118 117 'http://server.com/stable-yyy',
119 118 'http://server.com/dev-xxx',
120 119 'http://server.com/dev-yyy']),
121 120 ])
122 121 def test_webook_parse_url_for_push_event(
123 122 baseapp, repo_push_event, base_data, template, expected_urls):
124 123 base_data['push'] = {
125 124 'branches': [{'name': 'stable'}, {'name': 'dev'}],
126 125 'commits': [{'branch': 'stable', 'raw_id': 'stable-xxx'},
127 126 {'branch': 'stable', 'raw_id': 'stable-yyy'},
128 127 {'branch': 'dev', 'raw_id': 'dev-xxx'},
129 128 {'branch': 'dev', 'raw_id': 'dev-yyy'}]
130 129 }
131 130 headers = {'exmaple-header': 'header-values'}
132 131 handler = WebhookDataHandler(template, headers)
133 132 urls = handler(repo_push_event, base_data)
134 133 assert urls == [
135 134 (url, headers, base_data) for url in expected_urls]
General Comments 0
You need to be logged in to leave comments. Login now