##// END OF EJS Templates
integrations: restructure code and added better coverage.
super-admin -
r5123:1d3bc909 default
parent child Browse files
Show More
@@ -0,0 +1,17 b''
1 # Copyright (C) 2012-2023 RhodeCode GmbH
2 #
3 # This program is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU Affero General Public License, version 3
5 # (only), as published by the Free Software Foundation.
6 #
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
11 #
12 # You should have received a copy of the GNU Affero General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 #
15 # This program is dual-licensed. If you wish to learn more about the
16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
@@ -0,0 +1,242 b''
1 # Copyright (C) 2012-2023 RhodeCode GmbH
2 #
3 # This program is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU Affero General Public License, version 3
5 # (only), as published by the Free Software Foundation.
6 #
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
11 #
12 # You should have received a copy of the GNU Affero General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 #
15 # This program is dual-licensed. If you wish to learn more about the
16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 import re
19 import textwrap
20 import dataclasses
21 import logging
22 import typing
23
24 from mako.template import Template
25
26 from rhodecode import events
27 from rhodecode.integrations.types.base import CommitParsingDataHandler, render_with_traceback
28
29 log = logging.getLogger(__name__)
30
31
32 @dataclasses.dataclass
33 class SlackData:
34 title: str
35 text: str
36 fields: list[dict] | None = None
37 overrides: dict | None = None
38
39
40 def html_to_slack_links(message):
41 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(r'<\1|\2>', message)
42
43
44 REPO_PUSH_TEMPLATE = Template('''
45 <%
46 def branch_text(branch):
47 if branch:
48 return 'on branch: <{}|{}>'.format(branch_commits['branch']['url'], branch_commits['branch']['name'])
49 else:
50 ## case for SVN no branch push...
51 return 'to trunk'
52 %> \
53
54 % for branch, branch_commits in branches_commits.items():
55 ${len(branch_commits['commits'])} ${'commit' if len(branch_commits['commits']) == 1 else 'commits'} ${branch_text(branch)}
56 % for commit in branch_commits['commits']:
57 `<${commit['url']}|${commit['short_id']}>` - ${commit['message_html']|html_to_slack_links}
58 % endfor
59 % endfor
60 ''')
61
62
63 class SlackDataHandler(CommitParsingDataHandler):
64 name = 'slack'
65
66 def __init__(self):
67 pass
68
69 def __call__(self, event: events.RhodecodeEvent, data):
70 if not isinstance(event, events.RhodecodeEvent):
71 raise TypeError(f"event {event} is not subtype of events.RhodecodeEvent")
72
73 actor = data["actor"]["username"]
74 default_title = f'*{actor}* caused a *{event.name}* event'
75 default_text = f'*{actor}* caused a *{event.name}* event'
76
77 default_slack_data = SlackData(title=default_title, text=default_text)
78
79 if isinstance(event, events.PullRequestCommentEvent):
80 return self.format_pull_request_comment_event(
81 event, data, default_slack_data
82 )
83 elif isinstance(event, events.PullRequestCommentEditEvent):
84 return self.format_pull_request_comment_event(
85 event, data, default_slack_data
86 )
87 elif isinstance(event, events.PullRequestReviewEvent):
88 return self.format_pull_request_review_event(event, data, default_slack_data)
89 elif isinstance(event, events.PullRequestEvent):
90 return self.format_pull_request_event(event, data, default_slack_data)
91 elif isinstance(event, events.RepoPushEvent):
92 return self.format_repo_push_event(event, data, default_slack_data)
93 elif isinstance(event, events.RepoCreateEvent):
94 return self.format_repo_create_event(event, data, default_slack_data)
95 else:
96 raise ValueError(
97 f'event type `{event.__class__}` has no handler defined')
98
99 def format_pull_request_comment_event(self, event, data, slack_data):
100 comment_text = data['comment']['text']
101 if len(comment_text) > 200:
102 comment_text = '<{comment_url}|{comment_text}...>'.format(
103 comment_text=comment_text[:200],
104 comment_url=data['comment']['url'],
105 )
106
107 fields = None
108 overrides = None
109 status_text = None
110
111 if data['comment']['status']:
112 status_color = {
113 'approved': '#0ac878',
114 'rejected': '#e85e4d'}.get(data['comment']['status'])
115
116 if status_color:
117 overrides = {"color": status_color}
118
119 status_text = data['comment']['status']
120
121 if data['comment']['file']:
122 fields = [
123 {
124 "title": "file",
125 "value": data['comment']['file']
126 },
127 {
128 "title": "line",
129 "value": data['comment']['line']
130 }
131 ]
132
133 template = Template(textwrap.dedent(r'''
134 *${data['actor']['username']}* left ${data['comment']['type']} on pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
135 '''))
136 title = render_with_traceback(
137 template, data=data, comment=event.comment)
138
139 template = Template(textwrap.dedent(r'''
140 *pull request title*: ${pr_title}
141 % if status_text:
142 *submitted status*: `${status_text}`
143 % endif
144 >>> ${comment_text}
145 '''))
146 text = render_with_traceback(
147 template,
148 comment_text=comment_text,
149 pr_title=data['pullrequest']['title'],
150 status_text=status_text)
151
152 slack_data.title = title
153 slack_data.text = text
154 slack_data.fields = fields
155 slack_data.overrides = overrides
156
157 return slack_data
158
159 def format_pull_request_review_event(self, event, data, slack_data) -> SlackData:
160 template = Template(textwrap.dedent(r'''
161 *${data['actor']['username']}* changed status of pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']} to `${data['pullrequest']['status']}`>:
162 '''))
163 title = render_with_traceback(template, data=data)
164
165 template = Template(textwrap.dedent(r'''
166 *pull request title*: ${pr_title}
167 '''))
168 text = render_with_traceback(
169 template,
170 pr_title=data['pullrequest']['title'])
171
172 slack_data.title = title
173 slack_data.text = text
174
175 return slack_data
176
177 def format_pull_request_event(self, event, data, slack_data) -> SlackData:
178 action = {
179 events.PullRequestCloseEvent: 'closed',
180 events.PullRequestMergeEvent: 'merged',
181 events.PullRequestUpdateEvent: 'updated',
182 events.PullRequestCreateEvent: 'created',
183 }.get(event.__class__, str(event.__class__))
184
185 template = Template(textwrap.dedent(r'''
186 *${data['actor']['username']}* `${action}` pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
187 '''))
188 title = render_with_traceback(template, data=data, action=action)
189
190 template = Template(textwrap.dedent(r'''
191 *pull request title*: ${pr_title}
192 %if data['pullrequest']['commits']:
193 *commits*: ${len(data['pullrequest']['commits'])}
194 %endif
195 '''))
196 text = render_with_traceback(
197 template,
198 pr_title=data['pullrequest']['title'],
199 data=data)
200
201 slack_data.title = title
202 slack_data.text = text
203
204 return slack_data
205
206 def format_repo_push_event(self, event, data, slack_data) -> SlackData:
207 branches_commits = self.aggregate_branch_data(
208 data['push']['branches'], data['push']['commits'])
209
210 template = Template(r'''
211 *${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
212 ''')
213 title = render_with_traceback(template, data=data)
214
215 text = render_with_traceback(
216 REPO_PUSH_TEMPLATE,
217 data=data,
218 branches_commits=branches_commits,
219 html_to_slack_links=html_to_slack_links,
220 )
221
222 slack_data.title = title
223 slack_data.text = text
224
225 return slack_data
226
227 def format_repo_create_event(self, event, data, slack_data) -> SlackData:
228 template = Template(r'''
229 *${data['actor']['username']}* created new repository ${data['repo']['repo_name']}:
230 ''')
231 title = render_with_traceback(template, data=data)
232
233 template = Template(textwrap.dedent(r'''
234 repo_url: ${data['repo']['url']}
235 repo_type: ${data['repo']['repo_type']}
236 '''))
237 text = render_with_traceback(template, data=data)
238
239 slack_data.title = title
240 slack_data.text = text
241
242 return slack_data No newline at end of file
@@ -0,0 +1,175 b''
1 # Copyright (C) 2012-2023 RhodeCode GmbH
2 #
3 # This program is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU Affero General Public License, version 3
5 # (only), as published by the Free Software Foundation.
6 #
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
11 #
12 # You should have received a copy of the GNU Affero General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 #
15 # This program is dual-licensed. If you wish to learn more about the
16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18
19 import logging
20
21 from rhodecode import events
22 from rhodecode.integrations.types.base import CommitParsingDataHandler, UrlTmpl
23
24
25 log = logging.getLogger(__name__)
26
27
28 class WebhookDataHandler(CommitParsingDataHandler):
29 name = 'webhook'
30
31 def __init__(self, template_url, headers):
32 self.template_url = template_url
33 self.headers = headers
34
35 def get_base_parsed_template(self, data):
36 """
37 initially parses the passed in template with some common variables
38 available on ALL calls
39 """
40 # note: make sure to update the `WEBHOOK_URL_VARS` if this changes
41 common_vars = {
42 'repo_name': data['repo']['repo_name'],
43 'repo_type': data['repo']['repo_type'],
44 'repo_id': data['repo']['repo_id'],
45 'repo_url': data['repo']['url'],
46 'username': data['actor']['username'],
47 'user_id': data['actor']['user_id'],
48 'event_name': data['name']
49 }
50
51 extra_vars = {}
52 for extra_key, extra_val in data['repo']['extra_fields'].items():
53 extra_vars[f'extra__{extra_key}'] = extra_val
54 common_vars.update(extra_vars)
55
56 template_url = self.template_url.replace('${extra:', '${extra__')
57 for k, v in common_vars.items():
58 template_url = UrlTmpl(template_url).safe_substitute(**{k: v})
59 return template_url
60
61 def repo_push_event_handler(self, event, data):
62 url = self.get_base_parsed_template(data)
63 url_calls = []
64
65 branches_commits = self.aggregate_branch_data(
66 data['push']['branches'], data['push']['commits'])
67 if '${branch}' in url or '${branch_head}' in url or '${commit_id}' in url:
68 # call it multiple times, for each branch if used in variables
69 for branch, commit_ids in branches_commits.items():
70 branch_url = UrlTmpl(url).safe_substitute(branch=branch)
71
72 if '${branch_head}' in branch_url:
73 # last commit in the aggregate is the head of the branch
74 branch_head = commit_ids['branch_head']
75 branch_url = UrlTmpl(branch_url).safe_substitute(branch_head=branch_head)
76
77 # call further down for each commit if used
78 if '${commit_id}' in branch_url:
79 for commit_data in commit_ids['commits']:
80 commit_id = commit_data['raw_id']
81 commit_url = UrlTmpl(branch_url).safe_substitute(commit_id=commit_id)
82 # register per-commit call
83 log.debug(
84 'register %s call(%s) to url %s',
85 self.name, event, commit_url)
86 url_calls.append(
87 (commit_url, self.headers, data))
88
89 else:
90 # register per-branch call
91 log.debug('register %s call(%s) to url %s',
92 self.name, event, branch_url)
93 url_calls.append((branch_url, self.headers, data))
94
95 else:
96 log.debug('register %s call(%s) to url %s', self.name, event, url)
97 url_calls.append((url, self.headers, data))
98
99 return url_calls
100
101 def repo_commit_comment_handler(self, event, data):
102 url = self.get_base_parsed_template(data)
103 log.debug('register %s call(%s) to url %s', self.name, event, url)
104 comment_vars = [
105 ('commit_comment_id', data['comment']['comment_id']),
106 ('commit_comment_text', data['comment']['comment_text']),
107 ('commit_comment_type', data['comment']['comment_type']),
108
109 ('commit_comment_f_path', data['comment']['comment_f_path']),
110 ('commit_comment_line_no', data['comment']['comment_line_no']),
111
112 ('commit_comment_commit_id', data['commit']['commit_id']),
113 ('commit_comment_commit_branch', data['commit']['commit_branch']),
114 ('commit_comment_commit_message', data['commit']['commit_message']),
115 ]
116 for k, v in comment_vars:
117 url = UrlTmpl(url).safe_substitute(**{k: v})
118
119 return [(url, self.headers, data)]
120
121 def repo_commit_comment_edit_handler(self, event, data):
122 url = self.get_base_parsed_template(data)
123 log.debug('register %s call(%s) to url %s', self.name, event, url)
124 comment_vars = [
125 ('commit_comment_id', data['comment']['comment_id']),
126 ('commit_comment_text', data['comment']['comment_text']),
127 ('commit_comment_type', data['comment']['comment_type']),
128
129 ('commit_comment_f_path', data['comment']['comment_f_path']),
130 ('commit_comment_line_no', data['comment']['comment_line_no']),
131
132 ('commit_comment_commit_id', data['commit']['commit_id']),
133 ('commit_comment_commit_branch', data['commit']['commit_branch']),
134 ('commit_comment_commit_message', data['commit']['commit_message']),
135 ]
136 for k, v in comment_vars:
137 url = UrlTmpl(url).safe_substitute(**{k: v})
138
139 return [(url, self.headers, data)]
140
141 def repo_create_event_handler(self, event, data):
142 url = self.get_base_parsed_template(data)
143 log.debug('register %s call(%s) to url %s', self.name, event, url)
144 return [(url, self.headers, data)]
145
146 def pull_request_event_handler(self, event, data):
147 url = self.get_base_parsed_template(data)
148 log.debug('register %s call(%s) to url %s', self.name, event, url)
149 pr_vars = [
150 ('pull_request_id', data['pullrequest']['pull_request_id']),
151 ('pull_request_title', data['pullrequest']['title']),
152 ('pull_request_url', data['pullrequest']['url']),
153 ('pull_request_shadow_url', data['pullrequest']['shadow_url']),
154 ('pull_request_commits_uid', data['pullrequest']['commits_uid']),
155 ]
156 for k, v in pr_vars:
157 url = UrlTmpl(url).safe_substitute(**{k: v})
158
159 return [(url, self.headers, data)]
160
161 def __call__(self, event, data):
162
163 if isinstance(event, events.RepoPushEvent):
164 return self.repo_push_event_handler(event, data)
165 elif isinstance(event, events.RepoCreateEvent):
166 return self.repo_create_event_handler(event, data)
167 elif isinstance(event, events.RepoCommitCommentEvent):
168 return self.repo_commit_comment_handler(event, data)
169 elif isinstance(event, events.RepoCommitCommentEditEvent):
170 return self.repo_commit_comment_edit_handler(event, data)
171 elif isinstance(event, events.PullRequestEvent):
172 return self.pull_request_event_handler(event, data)
173 else:
174 raise ValueError(
175 f'event type `{event.__class__}` has no handler defined')
@@ -1,85 +1,88 b''
1 1 # Copyright (C) 2016-2023 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 from rhodecode.events.base import RhodeCodeIntegrationEvent
21 from rhodecode.events.base import (
22 RhodeCodeIntegrationEvent,
23 RhodecodeEvent
24 )
22 25
23 26 from rhodecode.events.base import ( # pragma: no cover
24 27 FtsBuild
25 28 )
26 29
27 30 from rhodecode.events.user import ( # pragma: no cover
28 31 UserPreCreate,
29 32 UserPostCreate,
30 33 UserPreUpdate,
31 34 UserRegistered,
32 35 UserPermissionsChange,
33 36 )
34 37
35 38 from rhodecode.events.repo import ( # pragma: no cover
36 39 RepoEvent,
37 40 RepoCommitCommentEvent, RepoCommitCommentEditEvent,
38 41 RepoPreCreateEvent, RepoCreateEvent,
39 42 RepoPreDeleteEvent, RepoDeleteEvent,
40 43 RepoPrePushEvent, RepoPushEvent,
41 44 RepoPrePullEvent, RepoPullEvent,
42 45 )
43 46
44 47 from rhodecode.events.repo_group import ( # pragma: no cover
45 48 RepoGroupEvent,
46 49 RepoGroupCreateEvent,
47 50 RepoGroupUpdateEvent,
48 51 RepoGroupDeleteEvent,
49 52 )
50 53
51 54 from rhodecode.events.pullrequest import ( # pragma: no cover
52 55 PullRequestEvent,
53 56 PullRequestCreateEvent,
54 57 PullRequestUpdateEvent,
55 58 PullRequestCommentEvent,
56 59 PullRequestCommentEditEvent,
57 60 PullRequestReviewEvent,
58 61 PullRequestMergeEvent,
59 62 PullRequestCloseEvent,
60 63 )
61 64
62 65
63 66 log = logging.getLogger(__name__)
64 67
65 68
66 69 def trigger(event, registry=None):
67 70 """
68 71 Helper method to send an event. This wraps the pyramid logic to send an
69 72 event.
70 73 """
71 74 # For the first step we are using pyramids thread locals here. If the
72 75 # event mechanism works out as a good solution we should think about
73 76 # passing the registry as an argument to get rid of it.
74 77 from pyramid.threadlocal import get_current_registry
75 78
76 79 event_name = event.__class__
77 80 log.debug('event %s sent for execution', event_name)
78 81 registry = registry or get_current_registry()
79 82 registry.notify(event)
80 83 log.debug('event %s triggered using registry %s', event_name, registry)
81 84
82 85 # Send the events to integrations directly
83 86 from rhodecode.integrations import integrations_event_handler
84 87 if isinstance(event, RhodeCodeIntegrationEvent):
85 88 integrations_event_handler(event)
@@ -1,130 +1,131 b''
1 1 # Copyright (C) 2016-2023 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 import typing
20 21
21 22 from zope.cachedescriptors.property import Lazy as LazyProperty
22 23 from pyramid.threadlocal import get_current_request
23 24
24 25 from rhodecode.lib.utils2 import AttributeDict
25 26
26 27
27 28 # this is a user object to be used for events caused by the system (eg. shell)
28 29 SYSTEM_USER = AttributeDict(dict(
29 30 username='__SYSTEM__',
30 31 user_id='__SYSTEM_ID__'
31 32 ))
32 33
33 34 log = logging.getLogger(__name__)
34 35
35 36
36 37 class RhodecodeEvent(object):
37 38 """
38 39 Base event class for all RhodeCode events
39 40 """
40 41 name = "RhodeCodeEvent"
41 42 no_url_set = '<no server_url available>'
42 43
43 44 def __init__(self, request=None):
44 45 self._request = request
45 46 self.utc_timestamp = datetime.datetime.utcnow()
46 47
47 48 def __repr__(self):
48 49 return '<{}:({})>'.format(self.__class__.__name__, self.name)
49 50
50 51 def get_request(self):
51 52 if self._request:
52 53 return self._request
53 54 return get_current_request()
54 55
55 56 @LazyProperty
56 57 def request(self):
57 58 return self.get_request()
58 59
59 60 @property
60 61 def auth_user(self):
61 62 if not self.request:
62 63 return
63 64
64 65 user = getattr(self.request, 'user', None)
65 66 if user:
66 67 return user
67 68
68 69 api_user = getattr(self.request, 'rpc_user', None)
69 70 if api_user:
70 71 return api_user
71 72
72 73 @property
73 74 def actor(self):
74 75 auth_user = self.auth_user
75 76 if auth_user:
76 77 instance = auth_user.get_instance()
77 78 if not instance:
78 79 return AttributeDict(dict(
79 80 username=auth_user.username,
80 81 user_id=auth_user.user_id,
81 82 ))
82 83 return instance
83 84
84 85 return SYSTEM_USER
85 86
86 87 @property
87 88 def actor_ip(self):
88 89 auth_user = self.auth_user
89 90 if auth_user:
90 91 return auth_user.ip_addr
91 92 return '<no ip available>'
92 93
93 94 @property
94 95 def server_url(self):
95 96 if self.request:
96 97 try:
97 98 return self.request.route_url('home')
98 99 except Exception:
99 100 log.exception('Failed to fetch URL for server')
100 101 return self.no_url_set
101 102
102 103 return self.no_url_set
103 104
104 105 def as_dict(self):
105 106 data = {
106 107 'name': self.name,
107 108 'utc_timestamp': self.utc_timestamp,
108 109 'actor_ip': self.actor_ip,
109 110 'actor': {
110 111 'username': self.actor.username,
111 112 'user_id': self.actor.user_id
112 113 },
113 114 'server_url': self.server_url
114 115 }
115 116 return data
116 117
117 118
118 119 class RhodeCodeIntegrationEvent(RhodecodeEvent):
119 120 """
120 121 Special subclass for Integration events
121 122 """
122 123 description = ''
123 124
124 125
125 126 class FtsBuild(RhodecodeEvent):
126 127 """
127 128 This event will be triggered when FTS Build is triggered
128 129 """
129 130 name = 'fts-build'
130 131 display_name = 'Start FTS Build'
@@ -1,72 +1,70 b''
1 1 # Copyright (C) 2012-2023 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 sys
19 19 import logging
20 20
21 21 from rhodecode.integrations.registry import IntegrationTypeRegistry
22 from rhodecode.integrations.types import webhook, slack, hipchat, email, base
22 from rhodecode.integrations.types import webhook, slack, email, base
23 23 from rhodecode.lib.exc_tracking import store_exception
24 24
25 25 log = logging.getLogger(__name__)
26 26
27 27
28 28 # TODO: dan: This is currently global until we figure out what to do about
29 29 # VCS's not having a pyramid context - move it to pyramid app configuration
30 30 # includeme level later to allow per instance integration setup
31 31 integration_type_registry = IntegrationTypeRegistry()
32 32
33 33 integration_type_registry.register_integration_type(
34 34 webhook.WebhookIntegrationType)
35 35 integration_type_registry.register_integration_type(
36 36 slack.SlackIntegrationType)
37 37 integration_type_registry.register_integration_type(
38 hipchat.HipchatIntegrationType)
39 integration_type_registry.register_integration_type(
40 38 email.EmailIntegrationType)
41 39
42 40
43 41 # dummy EE integration to show users what we have in EE edition
44 42 integration_type_registry.register_integration_type(
45 43 base.EEIntegration('Jira Issues integration', 'jira'))
46 44 integration_type_registry.register_integration_type(
47 45 base.EEIntegration('Redmine Tracker integration', 'redmine'))
48 46 integration_type_registry.register_integration_type(
49 47 base.EEIntegration('Jenkins CI integration', 'jenkins'))
50 48
51 49
52 50 def integrations_event_handler(event):
53 51 """
54 52 Takes an event and passes it to all enabled integrations
55 53 """
56 54 from rhodecode.model.integration import IntegrationModel
57 55
58 56 integration_model = IntegrationModel()
59 57 integrations = integration_model.get_for_event(event)
60 58 for integration in integrations:
61 59 try:
62 60 integration_model.send_event(integration, event)
63 61 except Exception:
64 62 exc_info = sys.exc_info()
65 63 store_exception(id(exc_info), exc_info)
66 64 log.exception(
67 65 'failure occurred when sending event %s to integration %s',
68 66 event, integration)
69 67
70 68
71 69 def includeme(config):
72 70 config.include('rhodecode.integrations.routes')
@@ -1,450 +1,300 b''
1 1 # Copyright (C) 2012-2023 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 colander
20 20 import string
21 21 import collections
22 22 import logging
23
23 24 import requests
24 25 import urllib.request
25 26 import urllib.parse
26 27 import urllib.error
27 28 from requests.adapters import HTTPAdapter
28 from requests.packages.urllib3.util.retry import Retry
29 from requests.packages.urllib3.util import Retry
29 30
30 31 from mako import exceptions
31 32
32 from rhodecode.lib.utils2 import safe_str
33 from rhodecode.translation import _
33
34 from rhodecode.lib.str_utils import safe_str
34 35
35 36
36 37 log = logging.getLogger(__name__)
37 38
38 39
39 40 class UrlTmpl(string.Template):
40 41
41 42 def safe_substitute(self, **kws):
42 43 # url encode the kw for usage in url
43 44 kws = {k: urllib.parse.quote(safe_str(v)) for k, v in kws.items()}
44 45 return super().safe_substitute(**kws)
45 46
46 47
47 48 class IntegrationTypeBase(object):
48 49 """ Base class for IntegrationType plugins """
49 50 is_dummy = False
50 51 description = ''
51 52
52 53 @classmethod
53 54 def icon(cls):
54 55 return '''
55 56 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
56 57 <svg
57 58 xmlns:dc="http://purl.org/dc/elements/1.1/"
58 59 xmlns:cc="http://creativecommons.org/ns#"
59 60 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
60 61 xmlns:svg="http://www.w3.org/2000/svg"
61 62 xmlns="http://www.w3.org/2000/svg"
62 63 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
63 64 xmlns:inkscape="http://setwww.inkscape.org/namespaces/inkscape"
64 65 viewBox="0 -256 1792 1792"
65 66 id="svg3025"
66 67 version="1.1"
67 68 inkscape:version="0.48.3.1 r9886"
68 69 width="100%"
69 70 height="100%"
70 71 sodipodi:docname="cog_font_awesome.svg">
71 72 <metadata
72 73 id="metadata3035">
73 74 <rdf:RDF>
74 75 <cc:Work
75 76 rdf:about="">
76 77 <dc:format>image/svg+xml</dc:format>
77 78 <dc:type
78 79 rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
79 80 </cc:Work>
80 81 </rdf:RDF>
81 82 </metadata>
82 83 <defs
83 84 id="defs3033" />
84 85 <sodipodi:namedview
85 86 pagecolor="#ffffff"
86 87 bordercolor="#666666"
87 88 borderopacity="1"
88 89 objecttolerance="10"
89 90 gridtolerance="10"
90 91 guidetolerance="10"
91 92 inkscape:pageopacity="0"
92 93 inkscape:pageshadow="2"
93 94 inkscape:window-width="640"
94 95 inkscape:window-height="480"
95 96 id="namedview3031"
96 97 showgrid="false"
97 98 inkscape:zoom="0.13169643"
98 99 inkscape:cx="896"
99 100 inkscape:cy="896"
100 101 inkscape:window-x="0"
101 102 inkscape:window-y="25"
102 103 inkscape:window-maximized="0"
103 104 inkscape:current-layer="svg3025" />
104 105 <g
105 106 transform="matrix(1,0,0,-1,121.49153,1285.4237)"
106 107 id="g3027">
107 108 <path
108 109 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 110 id="path3029"
110 111 inkscape:connector-curvature="0"
111 112 style="fill:currentColor" />
112 113 </g>
113 114 </svg>
114 115 '''
115 116
116 117 def __init__(self, settings):
117 118 """
118 119 :param settings: dict of settings to be used for the integration
119 120 """
120 121 self.settings = settings
121 122
122 123 def settings_schema(self):
123 124 """
124 125 A colander schema of settings for the integration type
125 126 """
126 127 return colander.Schema()
127 128
128 129 def event_enabled(self, event):
129 130 """
130 131 Checks if submitted event is enabled based on the plugin settings
131 132 :param event:
132 133 :return: bool
133 134 """
134 135 allowed_events = self.settings.get('events') or []
135 136 if event.name not in allowed_events:
136 137 log.debug('event ignored: %r event %s not in allowed set of events %s',
137 138 event, event.name, allowed_events)
138 139 return False
139 140 return True
140 141
141 142
142 143 class EEIntegration(IntegrationTypeBase):
143 144 description = 'Integration available in RhodeCode EE edition.'
144 145 is_dummy = True
145 146
146 147 def __init__(self, name, key, settings=None):
147 148 self.display_name = name
148 149 self.key = key
149 150 super(EEIntegration, self).__init__(settings)
150 151
151 152
152 153 # Helpers #
153 154 # updating this required to update the `common_vars` as well.
154 155 WEBHOOK_URL_VARS = [
155 156 # GENERAL
156 157 ('General', [
157 158 ('event_name', 'Unique name of the event type, e.g pullrequest-update'),
158 159 ('repo_name', 'Full name of the repository'),
159 160 ('repo_type', 'VCS type of repository'),
160 161 ('repo_id', 'Unique id of repository'),
161 162 ('repo_url', 'Repository url'),
162 163 ]
163 164 ),
164 165 # extra repo fields
165 166 ('Repository', [
166 167 ('extra:<extra_key_name>', 'Extra repo variables, read from its settings.'),
167 168 ]
168 169 ),
169 170 # special attrs below that we handle, using multi-call
170 171 ('Commit push - Multicalls', [
171 172 ('branch', 'Name of each branch submitted, if any.'),
172 173 ('branch_head', 'Head ID of pushed branch (full sha of last commit), if any.'),
173 174 ('commit_id', 'ID (full sha) of each commit submitted, if any.'),
174 175 ]
175 176 ),
176 177 # pr events vars
177 178 ('Pull request', [
178 179 ('pull_request_id', 'Unique ID of the pull request.'),
179 180 ('pull_request_title', 'Title of the pull request.'),
180 181 ('pull_request_url', 'Pull request url.'),
181 182 ('pull_request_shadow_url', 'Pull request shadow repo clone url.'),
182 183 ('pull_request_commits_uid', 'Calculated UID of all commits inside the PR. '
183 184 'Changes after PR update'),
184 185 ]
185 186 ),
186 187 # commit comment event vars
187 188 ('Commit comment', [
188 189 ('commit_comment_id', 'Unique ID of the comment made on a commit.'),
189 190 ('commit_comment_text', 'Text of commit comment.'),
190 191 ('commit_comment_type', 'Type of comment, e.g note/todo.'),
191 192
192 193 ('commit_comment_f_path', 'Optionally path of file for inline comments.'),
193 194 ('commit_comment_line_no', 'Line number of the file: eg o10, or n200'),
194 195
195 196 ('commit_comment_commit_id', 'Commit id that comment was left at.'),
196 197 ('commit_comment_commit_branch', 'Commit branch that comment was left at'),
197 198 ('commit_comment_commit_message', 'Commit message that comment was left at'),
198 199 ]
199 200 ),
200 201 # user who triggers the call
201 202 ('Caller', [
202 203 ('username', 'User who triggered the call.'),
203 204 ('user_id', 'User id who triggered the call.'),
204 205 ]
205 206 ),
206 207 ]
207 208
208 209 # common vars for url template used for CI plugins. Shared with webhook
209 210 CI_URL_VARS = WEBHOOK_URL_VARS
210 211
211 212
212 213 class CommitParsingDataHandler(object):
213 214
214 215 def aggregate_branch_data(self, branches, commits):
215 216 branch_data = collections.OrderedDict()
216 217 for obj in branches:
217 218 branch_data[obj['name']] = obj
218 219
219 220 branches_commits = collections.OrderedDict()
220 221 for commit in commits:
221 222 if commit.get('git_ref_change'):
222 223 # special case for GIT that allows creating tags,
223 224 # deleting branches without associated commit
224 225 continue
225 226 commit_branch = commit['branch']
226 227
227 228 if commit_branch not in branches_commits:
228 229 _branch = branch_data[commit_branch] \
229 230 if commit_branch else commit_branch
230 231 branch_commits = {'branch': _branch,
231 232 'branch_head': '',
232 233 'commits': []}
233 234 branches_commits[commit_branch] = branch_commits
234 235
235 236 branch_commits = branches_commits[commit_branch]
236 237 branch_commits['commits'].append(commit)
237 238 branch_commits['branch_head'] = commit['raw_id']
238 239 return branches_commits
239 240
240 241
241 class WebhookDataHandler(CommitParsingDataHandler):
242 name = 'webhook'
243
244 def __init__(self, template_url, headers):
245 self.template_url = template_url
246 self.headers = headers
247
248 def get_base_parsed_template(self, data):
249 """
250 initially parses the passed in template with some common variables
251 available on ALL calls
252 """
253 # note: make sure to update the `WEBHOOK_URL_VARS` if this changes
254 common_vars = {
255 'repo_name': data['repo']['repo_name'],
256 'repo_type': data['repo']['repo_type'],
257 'repo_id': data['repo']['repo_id'],
258 'repo_url': data['repo']['url'],
259 'username': data['actor']['username'],
260 'user_id': data['actor']['user_id'],
261 'event_name': data['name']
262 }
263
264 extra_vars = {}
265 for extra_key, extra_val in data['repo']['extra_fields'].items():
266 extra_vars[f'extra__{extra_key}'] = extra_val
267 common_vars.update(extra_vars)
268
269 template_url = self.template_url.replace('${extra:', '${extra__')
270 for k, v in common_vars.items():
271 template_url = UrlTmpl(template_url).safe_substitute(**{k: v})
272 return template_url
273
274 def repo_push_event_handler(self, event, data):
275 url = self.get_base_parsed_template(data)
276 url_calls = []
277
278 branches_commits = self.aggregate_branch_data(
279 data['push']['branches'], data['push']['commits'])
280 if '${branch}' in url or '${branch_head}' in url or '${commit_id}' in url:
281 # call it multiple times, for each branch if used in variables
282 for branch, commit_ids in branches_commits.items():
283 branch_url = UrlTmpl(url).safe_substitute(branch=branch)
284
285 if '${branch_head}' in branch_url:
286 # last commit in the aggregate is the head of the branch
287 branch_head = commit_ids['branch_head']
288 branch_url = UrlTmpl(branch_url).safe_substitute(branch_head=branch_head)
289
290 # call further down for each commit if used
291 if '${commit_id}' in branch_url:
292 for commit_data in commit_ids['commits']:
293 commit_id = commit_data['raw_id']
294 commit_url = UrlTmpl(branch_url).safe_substitute(commit_id=commit_id)
295 # register per-commit call
296 log.debug(
297 'register %s call(%s) to url %s',
298 self.name, event, commit_url)
299 url_calls.append(
300 (commit_url, self.headers, data))
301
302 else:
303 # register per-branch call
304 log.debug('register %s call(%s) to url %s',
305 self.name, event, branch_url)
306 url_calls.append((branch_url, self.headers, data))
307
308 else:
309 log.debug('register %s call(%s) to url %s', self.name, event, url)
310 url_calls.append((url, self.headers, data))
311
312 return url_calls
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
334 def repo_commit_comment_edit_handler(self, event, data):
335 url = self.get_base_parsed_template(data)
336 log.debug('register %s call(%s) to url %s', self.name, event, url)
337 comment_vars = [
338 ('commit_comment_id', data['comment']['comment_id']),
339 ('commit_comment_text', data['comment']['comment_text']),
340 ('commit_comment_type', data['comment']['comment_type']),
341
342 ('commit_comment_f_path', data['comment']['comment_f_path']),
343 ('commit_comment_line_no', data['comment']['comment_line_no']),
344
345 ('commit_comment_commit_id', data['commit']['commit_id']),
346 ('commit_comment_commit_branch', data['commit']['commit_branch']),
347 ('commit_comment_commit_message', data['commit']['commit_message']),
348 ]
349 for k, v in comment_vars:
350 url = UrlTmpl(url).safe_substitute(**{k: v})
351
352 return [(url, self.headers, data)]
353
354 def repo_create_event_handler(self, event, data):
355 url = self.get_base_parsed_template(data)
356 log.debug('register %s call(%s) to url %s', self.name, event, url)
357 return [(url, self.headers, data)]
358
359 def pull_request_event_handler(self, event, data):
360 url = self.get_base_parsed_template(data)
361 log.debug('register %s call(%s) to url %s', self.name, event, url)
362 pr_vars = [
363 ('pull_request_id', data['pullrequest']['pull_request_id']),
364 ('pull_request_title', data['pullrequest']['title']),
365 ('pull_request_url', data['pullrequest']['url']),
366 ('pull_request_shadow_url', data['pullrequest']['shadow_url']),
367 ('pull_request_commits_uid', data['pullrequest']['commits_uid']),
368 ]
369 for k, v in pr_vars:
370 url = UrlTmpl(url).safe_substitute(**{k: v})
371
372 return [(url, self.headers, data)]
373
374 def __call__(self, event, data):
375 from rhodecode import events
376
377 if isinstance(event, events.RepoPushEvent):
378 return self.repo_push_event_handler(event, data)
379 elif isinstance(event, events.RepoCreateEvent):
380 return self.repo_create_event_handler(event, data)
381 elif isinstance(event, events.RepoCommitCommentEvent):
382 return self.repo_commit_comment_handler(event, data)
383 elif isinstance(event, events.RepoCommitCommentEditEvent):
384 return self.repo_commit_comment_edit_handler(event, data)
385 elif isinstance(event, events.PullRequestEvent):
386 return self.pull_request_event_handler(event, data)
387 else:
388 raise ValueError(
389 f'event type `{event.__class__}` has no handler defined')
390
391
392 242 def get_auth(settings):
393 243 from requests.auth import HTTPBasicAuth
394 244 username = settings.get('username')
395 245 password = settings.get('password')
396 246 if username and password:
397 247 return HTTPBasicAuth(username, password)
398 248 return None
399 249
400 250
401 251 def get_web_token(settings):
402 252 return settings['secret_token']
403 253
404 254
405 255 def get_url_vars(url_vars):
406 256 items = []
407 257
408 258 for section, section_items in url_vars:
409 259 items.append(f'\n*{section}*')
410 260 for key, explanation in section_items:
411 261 items.append(' {} - {}'.format('${' + key + '}', explanation))
412 262 return '\n'.join(items)
413 263
414 264
415 265 def render_with_traceback(template, *args, **kwargs):
416 266 try:
417 267 return template.render(*args, **kwargs)
418 268 except Exception:
419 269 log.error(exceptions.text_error_template().render())
420 270 raise
421 271
422 272
423 273 STATUS_400 = (400, 401, 403)
424 274 STATUS_500 = (500, 502, 504)
425 275
426 276
427 277 def requests_retry_call(
428 278 retries=3, backoff_factor=0.3, status_forcelist=STATUS_400+STATUS_500,
429 279 session=None):
430 280 """
431 281 session = requests_retry_session()
432 282 response = session.get('http://example.com')
433 283
434 284 :param retries:
435 285 :param backoff_factor:
436 286 :param status_forcelist:
437 287 :param session:
438 288 """
439 289 session = session or requests.Session()
440 290 retry = Retry(
441 291 total=retries,
442 292 read=retries,
443 293 connect=retries,
444 294 backoff_factor=backoff_factor,
445 295 status_forcelist=status_forcelist,
446 296 )
447 297 adapter = HTTPAdapter(max_retries=retry)
448 298 session.mount('http://', adapter)
449 299 session.mount('https://', adapter)
450 300 return session
@@ -1,352 +1,185 b''
1 1 # Copyright (C) 2012-2023 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
20 import re
20
21 21 import time
22 import textwrap
23 22 import logging
24 23
25 import deform
26 import requests
24 import deform # noqa
25 import deform.widget
27 26 import colander
28 from mako.template import Template
29 27
30 28 from rhodecode import events
31 29 from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc
32 30 from rhodecode.translation import _
33 31 from rhodecode.lib import helpers as h
34 32 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
35 33 from rhodecode.lib.colander_utils import strip_whitespace
36 34 from rhodecode.integrations.types.base import (
37 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback,
38 requests_retry_call)
39
40 log = logging.getLogger(__name__)
35 IntegrationTypeBase,
36 requests_retry_call,
37 )
41 38
42
43 def html_to_slack_links(message):
44 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
45 r'<\1|\2>', message)
39 from rhodecode.integrations.types.handlers.slack import SlackDataHandler, SlackData
46 40
47 41
48 REPO_PUSH_TEMPLATE = Template('''
49 <%
50 def branch_text(branch):
51 if branch:
52 return 'on branch: <{}|{}>'.format(branch_commits['branch']['url'], branch_commits['branch']['name'])
53 else:
54 ## case for SVN no branch push...
55 return 'to trunk'
56 %> \
57
58 % for branch, branch_commits in branches_commits.items():
59 ${len(branch_commits['commits'])} ${'commit' if len(branch_commits['commits']) == 1 else 'commits'} ${branch_text(branch)}
60 % for commit in branch_commits['commits']:
61 `<${commit['url']}|${commit['short_id']}>` - ${commit['message_html']|html_to_slack_links}
62 % endfor
63 % endfor
64 ''')
42 log = logging.getLogger(__name__)
65 43
66 44
67 45 class SlackSettingsSchema(colander.Schema):
68 46 service = colander.SchemaNode(
69 47 colander.String(),
70 48 title=_('Slack service URL'),
71 49 description=h.literal(_(
72 50 'This can be setup at the '
73 51 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
74 52 'slack app manager</a>')),
75 53 default='',
76 54 preparer=strip_whitespace,
77 55 validator=colander.url,
78 56 widget=deform.widget.TextInputWidget(
79 57 placeholder='https://hooks.slack.com/services/...',
80 58 ),
81 59 )
82 60 username = colander.SchemaNode(
83 61 colander.String(),
84 62 title=_('Username'),
85 63 description=_('Username to show notifications coming from.'),
86 64 missing='Rhodecode',
87 65 preparer=strip_whitespace,
88 66 widget=deform.widget.TextInputWidget(
89 67 placeholder='Rhodecode'
90 68 ),
91 69 )
92 70 channel = colander.SchemaNode(
93 71 colander.String(),
94 72 title=_('Channel'),
95 73 description=_('Channel to send notifications to.'),
96 74 missing='',
97 75 preparer=strip_whitespace,
98 76 widget=deform.widget.TextInputWidget(
99 77 placeholder='#general'
100 78 ),
101 79 )
102 80 icon_emoji = colander.SchemaNode(
103 81 colander.String(),
104 82 title=_('Emoji'),
105 83 description=_('Emoji to use eg. :studio_microphone:'),
106 84 missing='',
107 85 preparer=strip_whitespace,
108 86 widget=deform.widget.TextInputWidget(
109 87 placeholder=':studio_microphone:'
110 88 ),
111 89 )
112 90
113 91
114 class SlackIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
92 class SlackIntegrationType(IntegrationTypeBase):
115 93 key = 'slack'
116 94 display_name = _('Slack')
117 95 description = _('Send events such as repo pushes and pull requests to '
118 96 'your slack channel.')
119 97
120 98 @classmethod
121 99 def icon(cls):
122 100 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>'''
123 101
124 102 valid_events = [
125 103 events.PullRequestCloseEvent,
126 104 events.PullRequestMergeEvent,
127 105 events.PullRequestUpdateEvent,
128 106 events.PullRequestCommentEvent,
129 107 events.PullRequestReviewEvent,
130 108 events.PullRequestCreateEvent,
131 109 events.RepoPushEvent,
132 110 events.RepoCreateEvent,
133 111 ]
134 112
135 def send_event(self, event):
136 log.debug('handling event %s with integration %s', event.name, self)
137
138 if event.__class__ not in self.valid_events:
139 log.debug('event %r not present in valid event list (%s)', event, self.valid_events)
140 return
141
142 if not self.event_enabled(event):
143 return
144
145 data = event.as_dict()
146
147 # defaults
148 title = '*%s* caused a *%s* event' % (
149 data['actor']['username'], event.name)
150 text = '*%s* caused a *%s* event' % (
151 data['actor']['username'], event.name)
152 fields = None
153 overrides = None
154
155 if isinstance(event, events.PullRequestCommentEvent):
156 (title, text, fields, overrides) \
157 = self.format_pull_request_comment_event(event, data)
158 elif isinstance(event, events.PullRequestCommentEditEvent):
159 (title, text, fields, overrides) \
160 = self.format_pull_request_comment_event(event, data)
161 elif isinstance(event, events.PullRequestReviewEvent):
162 title, text = self.format_pull_request_review_event(event, data)
163 elif isinstance(event, events.PullRequestEvent):
164 title, text = self.format_pull_request_event(event, data)
165 elif isinstance(event, events.RepoPushEvent):
166 title, text = self.format_repo_push_event(data)
167 elif isinstance(event, events.RepoCreateEvent):
168 title, text = self.format_repo_create_event(data)
169 else:
170 log.error('unhandled event type: %r', event)
171
172 run_task(post_text_to_slack, self.settings, title, text, fields, overrides)
173
174 113 def settings_schema(self):
175 114 schema = SlackSettingsSchema()
176 115 schema.add(colander.SchemaNode(
177 116 colander.Set(),
178 117 widget=CheckboxChoiceWidgetDesc(
179 118 values=sorted(
180 119 [(e.name, e.display_name, e.description) for e in self.valid_events]
181 120 ),
182 121 ),
183 122 description="List of events activated for this integration",
184 123 name='events'
185 124 ))
186 125
187 126 return schema
188 127
189 def format_pull_request_comment_event(self, event, data):
190 comment_text = data['comment']['text']
191 if len(comment_text) > 200:
192 comment_text = '<{comment_url}|{comment_text}...>'.format(
193 comment_text=comment_text[:200],
194 comment_url=data['comment']['url'],
195 )
196
197 fields = None
198 overrides = None
199 status_text = None
200
201 if data['comment']['status']:
202 status_color = {
203 'approved': '#0ac878',
204 'rejected': '#e85e4d'}.get(data['comment']['status'])
205
206 if status_color:
207 overrides = {"color": status_color}
208
209 status_text = data['comment']['status']
128 def send_event(self, event):
129 log.debug('handling event %s with integration %s', event.name, self)
210 130
211 if data['comment']['file']:
212 fields = [
213 {
214 "title": "file",
215 "value": data['comment']['file']
216 },
217 {
218 "title": "line",
219 "value": data['comment']['line']
220 }
221 ]
222
223 template = Template(textwrap.dedent(r'''
224 *${data['actor']['username']}* left ${data['comment']['type']} on pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
225 '''))
226 title = render_with_traceback(
227 template, data=data, comment=event.comment)
228
229 template = Template(textwrap.dedent(r'''
230 *pull request title*: ${pr_title}
231 % if status_text:
232 *submitted status*: `${status_text}`
233 % endif
234 >>> ${comment_text}
235 '''))
236 text = render_with_traceback(
237 template,
238 comment_text=comment_text,
239 pr_title=data['pullrequest']['title'],
240 status_text=status_text)
241
242 return title, text, fields, overrides
243
244 def format_pull_request_review_event(self, event, data):
245 template = Template(textwrap.dedent(r'''
246 *${data['actor']['username']}* changed status of pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']} to `${data['pullrequest']['status']}`>:
247 '''))
248 title = render_with_traceback(template, data=data)
131 if event.__class__ not in self.valid_events:
132 log.debug('event %r not present in valid event list (%s)', event, self.valid_events)
133 return
249 134
250 template = Template(textwrap.dedent(r'''
251 *pull request title*: ${pr_title}
252 '''))
253 text = render_with_traceback(
254 template,
255 pr_title=data['pullrequest']['title'])
256
257 return title, text
135 if not self.event_enabled(event):
136 return
258 137
259 def format_pull_request_event(self, event, data):
260 action = {
261 events.PullRequestCloseEvent: 'closed',
262 events.PullRequestMergeEvent: 'merged',
263 events.PullRequestUpdateEvent: 'updated',
264 events.PullRequestCreateEvent: 'created',
265 }.get(event.__class__, str(event.__class__))
266
267 template = Template(textwrap.dedent(r'''
268 *${data['actor']['username']}* `${action}` pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
269 '''))
270 title = render_with_traceback(template, data=data, action=action)
271
272 template = Template(textwrap.dedent(r'''
273 *pull request title*: ${pr_title}
274 %if data['pullrequest']['commits']:
275 *commits*: ${len(data['pullrequest']['commits'])}
276 %endif
277 '''))
278 text = render_with_traceback(
279 template,
280 pr_title=data['pullrequest']['title'],
281 data=data)
138 data = event.as_dict()
282 139
283 return title, text
284
285 def format_repo_push_event(self, data):
286 branches_commits = self.aggregate_branch_data(
287 data['push']['branches'], data['push']['commits'])
288
289 template = Template(r'''
290 *${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
291 ''')
292 title = render_with_traceback(template, data=data)
293
294 text = render_with_traceback(
295 REPO_PUSH_TEMPLATE,
296 data=data,
297 branches_commits=branches_commits,
298 html_to_slack_links=html_to_slack_links,
299 )
300
301 return title, text
302
303 def format_repo_create_event(self, data):
304 template = Template(r'''
305 *${data['actor']['username']}* created new repository ${data['repo']['repo_name']}:
306 ''')
307 title = render_with_traceback(template, data=data)
308
309 template = Template(textwrap.dedent(r'''
310 repo_url: ${data['repo']['url']}
311 repo_type: ${data['repo']['repo_type']}
312 '''))
313 text = render_with_traceback(template, data=data)
314
315 return title, text
140 handler = SlackDataHandler()
141 slack_data = handler(event, data)
142 # title, text, fields, overrides
143 run_task(post_text_to_slack, self.settings, slack_data)
316 144
317 145
318 146 @async_task(ignore_result=True, base=RequestContextTask)
319 def post_text_to_slack(settings, title, text, fields=None, overrides=None):
147 def post_text_to_slack(settings, slack_data: SlackData):
148 title = slack_data.title
149 text = slack_data.text
150 fields = slack_data.fields
151 overrides = slack_data.overrides
152
320 153 log.debug('sending %s (%s) to slack %s', title, text, settings['service'])
321 154
322 155 fields = fields or []
323 156 overrides = overrides or {}
324 157
325 158 message_data = {
326 159 "fallback": text,
327 160 "color": "#427cc9",
328 161 "pretext": title,
329 162 #"author_name": "Bobby Tables",
330 163 #"author_link": "http://flickr.com/bobby/",
331 164 #"author_icon": "http://flickr.com/icons/bobby.jpg",
332 165 #"title": "Slack API Documentation",
333 166 #"title_link": "https://api.slack.com/",
334 167 "text": text,
335 168 "fields": fields,
336 169 #"image_url": "http://my-website.com/path/to/image.jpg",
337 170 #"thumb_url": "http://example.com/path/to/thumb.png",
338 171 "footer": "RhodeCode",
339 172 #"footer_icon": "",
340 173 "ts": time.time(),
341 174 "mrkdwn_in": ["pretext", "text"]
342 175 }
343 176 message_data.update(overrides)
344 177 json_message = {
345 178 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:'),
346 179 "channel": settings.get('channel', ''),
347 180 "username": settings.get('username', 'Rhodecode'),
348 181 "attachments": [message_data]
349 182 }
350 183 req_session = requests_retry_call()
351 184 resp = req_session.post(settings['service'], json=json_message, timeout=60)
352 185 resp.raise_for_status() # raise exception on a failed request
@@ -1,264 +1,270 b''
1 1 # Copyright (C) 2012-2023 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
20
20 import deform # noqa
21 21 import deform.widget
22 22 import logging
23 23 import colander
24 24
25 25 import rhodecode
26 26 from rhodecode import events
27 27 from rhodecode.lib.colander_utils import strip_whitespace
28 28 from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc
29 29 from rhodecode.translation import _
30 30 from rhodecode.integrations.types.base import (
31 IntegrationTypeBase, get_auth, get_web_token, get_url_vars,
32 WebhookDataHandler, WEBHOOK_URL_VARS, requests_retry_call)
31 IntegrationTypeBase,
32 get_auth,
33 get_web_token,
34 get_url_vars,
35 WEBHOOK_URL_VARS,
36 requests_retry_call,
37 )
38 from rhodecode.integrations.types.handlers.webhook import WebhookDataHandler
33 39 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
34 40 from rhodecode.model.validation_schema import widgets
35 41
36 42 log = logging.getLogger(__name__)
37 43
38 44
39 45 # updating this required to update the `common_vars` passed in url calling func
40 46
41 47 URL_VARS = get_url_vars(WEBHOOK_URL_VARS)
42 48
43 49
44 50 class WebhookSettingsSchema(colander.Schema):
45 51 url = colander.SchemaNode(
46 52 colander.String(),
47 53 title=_('Webhook URL'),
48 54 description=
49 55 _('URL to which Webhook should submit data. If used some of the '
50 56 'variables would trigger multiple calls, like ${branch} or '
51 57 '${commit_id}. Webhook will be called as many times as unique '
52 58 'objects in data in such cases.'),
53 59 missing=colander.required,
54 60 required=True,
55 61 preparer=strip_whitespace,
56 62 validator=colander.url,
57 63 widget=widgets.CodeMirrorWidget(
58 64 help_block_collapsable_name='Show url variables',
59 65 help_block_collapsable=(
60 'E.g http://my-serv.com/trigger_job/${{event_name}}'
66 'E.g https://my-serv.com/trigger_job/${{event_name}}'
61 67 '?PR_ID=${{pull_request_id}}'
62 68 '\nFull list of vars:\n{}'.format(URL_VARS)),
63 69 codemirror_mode='text',
64 70 codemirror_options='{"lineNumbers": false, "lineWrapping": true}'),
65 71 )
66 72 secret_token = colander.SchemaNode(
67 73 colander.String(),
68 74 title=_('Secret Token'),
69 75 description=_('Optional string used to validate received payloads. '
70 76 'It will be sent together with event data in JSON'),
71 77 default='',
72 78 missing='',
73 79 widget=deform.widget.TextInputWidget(
74 80 placeholder='e.g. secret_token'
75 81 ),
76 82 )
77 83 username = colander.SchemaNode(
78 84 colander.String(),
79 85 title=_('Username'),
80 86 description=_('Optional username to authenticate the call.'),
81 87 default='',
82 88 missing='',
83 89 widget=deform.widget.TextInputWidget(
84 90 placeholder='e.g. admin'
85 91 ),
86 92 )
87 93 password = colander.SchemaNode(
88 94 colander.String(),
89 95 title=_('Password'),
90 96 description=_('Optional password to authenticate the call.'),
91 97 default='',
92 98 missing='',
93 99 widget=deform.widget.PasswordWidget(
94 100 placeholder='e.g. secret.',
95 101 redisplay=True,
96 102 ),
97 103 )
98 104 custom_header_key = colander.SchemaNode(
99 105 colander.String(),
100 106 title=_('Custom Header Key'),
101 107 description=_('Custom Header name to be set when calling endpoint.'),
102 108 default='',
103 109 missing='',
104 110 widget=deform.widget.TextInputWidget(
105 111 placeholder='e.g: Authorization'
106 112 ),
107 113 )
108 114 custom_header_val = colander.SchemaNode(
109 115 colander.String(),
110 116 title=_('Custom Header Value'),
111 117 description=_('Custom Header value to be set when calling endpoint.'),
112 118 default='',
113 119 missing='',
114 120 widget=deform.widget.TextInputWidget(
115 121 placeholder='e.g. Basic XxXxXx'
116 122 ),
117 123 )
118 124 method_type = colander.SchemaNode(
119 125 colander.String(),
120 126 title=_('Call Method'),
121 127 description=_('Select a HTTP method to use when calling the Webhook.'),
122 128 default='post',
123 129 missing='',
124 130 widget=deform.widget.RadioChoiceWidget(
125 131 values=[('get', 'GET'), ('post', 'POST'), ('put', 'PUT')],
126 132 inline=True
127 133 ),
128 134 )
129 135
130 136
131 137 class WebhookIntegrationType(IntegrationTypeBase):
132 138 key = 'webhook'
133 139 display_name = _('Webhook')
134 140 description = _('send JSON data to a url endpoint')
135 141
136 142 @classmethod
137 143 def icon(cls):
138 144 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 145
140 146 valid_events = [
141 147 events.PullRequestCloseEvent,
142 148 events.PullRequestMergeEvent,
143 149 events.PullRequestUpdateEvent,
144 150 events.PullRequestCommentEvent,
145 151 events.PullRequestCommentEditEvent,
146 152 events.PullRequestReviewEvent,
147 153 events.PullRequestCreateEvent,
148 154 events.RepoPushEvent,
149 155 events.RepoCreateEvent,
150 156 events.RepoCommitCommentEvent,
151 157 events.RepoCommitCommentEditEvent,
152 158 ]
153 159
154 160 def settings_schema(self):
155 161 schema = WebhookSettingsSchema()
156 162 schema.add(colander.SchemaNode(
157 163 colander.Set(),
158 164 widget=CheckboxChoiceWidgetDesc(
159 165 values=sorted(
160 166 [(e.name, e.display_name, e.description) for e in self.valid_events]
161 167 ),
162 168 ),
163 169 description="List of events activated for this integration",
164 170 name='events'
165 171 ))
166 172 return schema
167 173
168 174 def send_event(self, event):
169 175 log.debug('handling event %s with integration %s', event.name, self)
170 176
171 177 if event.__class__ not in self.valid_events:
172 178 log.debug('event %r not present in valid event list (%s)', event, self.valid_events)
173 179 return
174 180
175 181 if not self.event_enabled(event):
176 182 return
177 183
178 184 data = event.as_dict()
179 185 template_url = self.settings['url']
180 186
181 187 headers = {}
182 188 head_key = self.settings.get('custom_header_key')
183 189 head_val = self.settings.get('custom_header_val')
184 190 if head_key and head_val:
185 191 headers = {head_key: head_val}
186 192
187 193 handler = WebhookDataHandler(template_url, headers)
188 194
189 195 url_calls = handler(event, data)
190 196 log.debug('Webhook: calling following urls: %s', [x[0] for x in url_calls])
191 197
192 run_task(post_to_webhook, url_calls, self.settings)
198 run_task(post_to_webhook, self.settings, url_calls)
193 199
194 200
195 201 @async_task(ignore_result=True, base=RequestContextTask)
196 def post_to_webhook(url_calls, settings):
202 def post_to_webhook(settings, url_calls):
197 203 """
198 204 Example data::
199 205
200 206 {'actor': {'user_id': 2, 'username': u'admin'},
201 207 'actor_ip': u'192.168.157.1',
202 208 'name': 'repo-push',
203 209 'push': {'branches': [{'name': u'default',
204 210 'url': 'http://rc.local:8080/hg-repo/changelog?branch=default'}],
205 211 'commits': [{'author': u'Marcin Kuzminski <marcin@rhodecode.com>',
206 212 'branch': u'default',
207 213 'date': datetime.datetime(2017, 11, 30, 12, 59, 48),
208 214 'issues': [],
209 215 'mentions': [],
210 216 'message': u'commit Thu 30 Nov 2017 13:59:48 CET',
211 217 'message_html': u'commit Thu 30 Nov 2017 13:59:48 CET',
212 218 'message_html_title': u'commit Thu 30 Nov 2017 13:59:48 CET',
213 219 'parents': [{'raw_id': '431b772a5353dad9974b810dd3707d79e3a7f6e0'}],
214 220 'permalink_url': u'http://rc.local:8080/_7/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
215 221 'raw_id': 'a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
216 222 'refs': {'bookmarks': [],
217 223 'branches': [u'default'],
218 224 'tags': [u'tip']},
219 225 'reviewers': [],
220 226 'revision': 9L,
221 227 'short_id': 'a815cc738b96',
222 228 'url': u'http://rc.local:8080/hg-repo/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf'}],
223 229 'issues': {}},
224 230 'repo': {'extra_fields': '',
225 231 'permalink_url': u'http://rc.local:8080/_7',
226 232 'repo_id': 7,
227 233 'repo_name': u'hg-repo',
228 234 'repo_type': u'hg',
229 235 'url': u'http://rc.local:8080/hg-repo'},
230 236 'server_url': u'http://rc.local:8080',
231 237 'utc_timestamp': datetime.datetime(2017, 11, 30, 13, 0, 1, 569276)
232 238 }
233 239 """
234 240
235 241 call_headers = {
236 242 'User-Agent': f'RhodeCode-webhook-caller/{rhodecode.__version__}'
237 243 } # updated below with custom ones, allows override
238 244
239 245 auth = get_auth(settings)
240 246 token = get_web_token(settings)
241 247
242 248 for url, headers, data in url_calls:
243 249 req_session = requests_retry_call()
244 250
245 251 method = settings.get('method_type') or 'post'
246 252 call_method = getattr(req_session, method)
247 253
248 254 headers = headers or {}
249 255 call_headers.update(headers)
250 256
251 257 log.debug('calling Webhook with method: %s, and auth:%s', call_method, auth)
252 258 if settings.get('log_data'):
253 259 log.debug('calling webhook with data: %s', data)
254 260 resp = call_method(url, json={
255 261 'token': token,
256 262 'event': data
257 263 }, headers=call_headers, auth=auth, timeout=60)
258 264 log.debug('Got Webhook response: %s', resp)
259 265
260 266 try:
261 267 resp.raise_for_status() # raise exception on a failed request
262 268 except Exception:
263 269 log.error(resp.text)
264 270 raise
@@ -1,54 +1,58 b''
1 1
2 2 # Copyright (C) 2010-2023 RhodeCode GmbH
3 3 #
4 4 # This program is free software: you can redistribute it and/or modify
5 5 # it under the terms of the GNU Affero General Public License, version 3
6 6 # (only), as published by the Free Software Foundation.
7 7 #
8 8 # This program is distributed in the hope that it will be useful,
9 9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 11 # GNU General Public License for more details.
12 12 #
13 13 # You should have received a copy of the GNU Affero General Public License
14 14 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 15 #
16 16 # This program is dual-licensed. If you wish to learn more about the
17 17 # RhodeCode Enterprise Edition, including its added features, Support services,
18 18 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 19
20 20
21 21 import pytest
22 22 from rhodecode import events
23 23 from rhodecode.lib.utils2 import AttributeDict
24 24
25 25
26 26 @pytest.fixture()
27 27 def repo_push_event(backend, user_regular):
28 28 commits = [
29 29 {'message': 'ancestor commit fixes #15'},
30 30 {'message': 'quick fixes'},
31 31 {'message': 'change that fixes #41, #2'},
32 32 {'message': 'this is because 5b23c3532 broke stuff'},
33 33 {'message': 'last commit'},
34 34 ]
35 commit_ids = backend.create_master_repo(commits).values()
36 repo = backend.create_repo()
35 r = backend.create_repo(commits)
36
37 commit_ids = list(backend.commit_ids.values())
38 repo_name = backend.repo_name
39 alias = backend.alias
40
37 41 scm_extras = AttributeDict({
38 42 'ip': '127.0.0.1',
39 43 'username': user_regular.username,
40 44 'user_id': user_regular.user_id,
41 45 'action': '',
42 'repository': repo.repo_name,
43 'scm': repo.scm_instance().alias,
46 'repository': repo_name,
47 'scm': alias,
44 48 'config': '',
45 49 'repo_store': '',
46 'server_url': 'http://example.com',
50 'server_url': 'http://httpbin:9090',
47 51 'make_lock': None,
48 52 'locked_by': [None],
49 53 'commit_ids': commit_ids,
50 54 })
51 55
52 return events.RepoPushEvent(repo_name=repo.repo_name,
56 return events.RepoPushEvent(repo_name=repo_name,
53 57 pushed_commit_ids=commit_ids,
54 58 extras=scm_extras)
@@ -1,224 +1,223 b''
1
2 1 # Copyright (C) 2010-2023 RhodeCode GmbH
3 2 #
4 3 # This program is free software: you can redistribute it and/or modify
5 4 # it under the terms of the GNU Affero General Public License, version 3
6 5 # (only), as published by the Free Software Foundation.
7 6 #
8 7 # This program is distributed in the hope that it will be useful,
9 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 10 # GNU General Public License for more details.
12 11 #
13 12 # You should have received a copy of the GNU Affero General Public License
14 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 14 #
16 15 # This program is dual-licensed. If you wish to learn more about the
17 16 # RhodeCode Enterprise Edition, including its added features, Support services,
18 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 18
20 19 import time
21 20 import pytest
22 21
23 22 from rhodecode import events
24 23 from rhodecode.tests.fixture import Fixture
25 24 from rhodecode.model.db import Session, Integration
26 25 from rhodecode.model.integration import IntegrationModel
27 26
28 27
29 28 class TestDeleteScopesDeletesIntegrations(object):
30 29 def test_delete_repo_with_integration_deletes_integration(
31 30 self, repo_integration_stub):
32 31
33 32 Session().delete(repo_integration_stub.repo)
34 33 Session().commit()
35 34 Session().expire_all()
36 35 integration = Integration.get(repo_integration_stub.integration_id)
37 36 assert integration is None
38 37
39 38 def test_delete_repo_group_with_integration_deletes_integration(
40 39 self, repogroup_integration_stub):
41 40
42 41 Session().delete(repogroup_integration_stub.repo_group)
43 42 Session().commit()
44 43 Session().expire_all()
45 44 integration = Integration.get(repogroup_integration_stub.integration_id)
46 45 assert integration is None
47 46
48 47
49 48 count = 1
50 49
51 50
52 51 def counter():
53 52 global count
54 53 val = count
55 54 count += 1
56 55 return '{}_{}'.format(val, time.time())
57 56
58 57
59 58 @pytest.fixture()
60 59 def integration_repos(request, StubIntegrationType, stub_integration_settings):
61 60 """
62 61 Create repositories and integrations for testing, and destroy them after
63 62
64 63 Structure:
65 64 root_repo
66 65 parent_group/
67 66 parent_repo
68 67 child_group/
69 68 child_repo
70 69 other_group/
71 70 other_repo
72 71 """
73 72 fixture = Fixture()
74 73
75 74 parent_group_id = 'int_test_parent_group_{}'.format(counter())
76 75 parent_group = fixture.create_repo_group(parent_group_id)
77 76
78 77 other_group_id = 'int_test_other_group_{}'.format(counter())
79 78 other_group = fixture.create_repo_group(other_group_id)
80 79
81 80 child_group_id = (
82 81 parent_group_id + '/' + 'int_test_child_group_{}'.format(counter()))
83 82 child_group = fixture.create_repo_group(child_group_id)
84 83
85 84 parent_repo_id = 'int_test_parent_repo_{}'.format(counter())
86 85 parent_repo = fixture.create_repo(parent_repo_id, repo_group=parent_group)
87 86
88 87 child_repo_id = 'int_test_child_repo_{}'.format(counter())
89 88 child_repo = fixture.create_repo(child_repo_id, repo_group=child_group)
90 89
91 90 other_repo_id = 'int_test_other_repo_{}'.format(counter())
92 91 other_repo = fixture.create_repo(other_repo_id, repo_group=other_group)
93 92
94 93 root_repo_id = 'int_test_repo_root_{}'.format(counter())
95 94 root_repo = fixture.create_repo(root_repo_id)
96 95
97 96 integrations = {}
98 97 for name, repo, repo_group, child_repos_only in [
99 98 ('global', None, None, None),
100 99 ('root_repos', None, None, True),
101 100 ('parent_repo', parent_repo, None, None),
102 101 ('child_repo', child_repo, None, None),
103 102 ('other_repo', other_repo, None, None),
104 103 ('root_repo', root_repo, None, None),
105 104 ('parent_group', None, parent_group, True),
106 105 ('parent_group_recursive', None, parent_group, False),
107 106 ('child_group', None, child_group, True),
108 107 ('child_group_recursive', None, child_group, False),
109 108 ('other_group', None, other_group, True),
110 109 ('other_group_recursive', None, other_group, False),
111 110 ]:
112 111 integrations[name] = IntegrationModel().create(
113 112 StubIntegrationType, settings=stub_integration_settings,
114 113 enabled=True, name='test %s integration' % name,
115 114 repo=repo, repo_group=repo_group, child_repos_only=child_repos_only)
116 115
117 116 Session().commit()
118 117
119 118 def _cleanup():
120 119 for integration in integrations.values():
121 120 Session.delete(integration)
122 121
123 122 fixture.destroy_repo(root_repo)
124 123 fixture.destroy_repo(child_repo)
125 124 fixture.destroy_repo(parent_repo)
126 125 fixture.destroy_repo(other_repo)
127 126 fixture.destroy_repo_group(child_group)
128 127 fixture.destroy_repo_group(parent_group)
129 128 fixture.destroy_repo_group(other_group)
130 129
131 130 request.addfinalizer(_cleanup)
132 131
133 132 return {
134 133 'integrations': integrations,
135 134 'repos': {
136 135 'root_repo': root_repo,
137 136 'other_repo': other_repo,
138 137 'parent_repo': parent_repo,
139 138 'child_repo': child_repo,
140 139 }
141 140 }
142 141
143 142
144 143 def test_enabled_integration_repo_scopes(integration_repos):
145 144 integrations = integration_repos['integrations']
146 145 repos = integration_repos['repos']
147 146
148 147 triggered_integrations = IntegrationModel().get_for_event(
149 148 events.RepoEvent(repos['root_repo']))
150 149
151 150 assert triggered_integrations == [
152 151 integrations['global'],
153 152 integrations['root_repos'],
154 153 integrations['root_repo'],
155 154 ]
156 155
157 156 triggered_integrations = IntegrationModel().get_for_event(
158 157 events.RepoEvent(repos['other_repo']))
159 158
160 159 assert triggered_integrations == [
161 160 integrations['global'],
162 161 integrations['other_group'],
163 162 integrations['other_group_recursive'],
164 163 integrations['other_repo'],
165 164 ]
166 165
167 166 triggered_integrations = IntegrationModel().get_for_event(
168 167 events.RepoEvent(repos['parent_repo']))
169 168
170 169 assert triggered_integrations == [
171 170 integrations['global'],
172 171 integrations['parent_group'],
173 172 integrations['parent_group_recursive'],
174 173 integrations['parent_repo'],
175 174 ]
176 175
177 176 triggered_integrations = IntegrationModel().get_for_event(
178 177 events.RepoEvent(repos['child_repo']))
179 178
180 179 assert triggered_integrations == [
181 180 integrations['global'],
182 181 integrations['child_group'],
183 182 integrations['parent_group_recursive'],
184 183 integrations['child_group_recursive'],
185 184 integrations['child_repo'],
186 185 ]
187 186
188 187
189 188 def test_disabled_integration_repo_scopes(integration_repos):
190 189 integrations = integration_repos['integrations']
191 190 repos = integration_repos['repos']
192 191
193 192 for integration in integrations.values():
194 193 integration.enabled = False
195 194 Session().commit()
196 195
197 196 triggered_integrations = IntegrationModel().get_for_event(
198 197 events.RepoEvent(repos['root_repo']))
199 198
200 199 assert triggered_integrations == []
201 200
202 201 triggered_integrations = IntegrationModel().get_for_event(
203 202 events.RepoEvent(repos['parent_repo']))
204 203
205 204 assert triggered_integrations == []
206 205
207 206 triggered_integrations = IntegrationModel().get_for_event(
208 207 events.RepoEvent(repos['child_repo']))
209 208
210 209 assert triggered_integrations == []
211 210
212 211 triggered_integrations = IntegrationModel().get_for_event(
213 212 events.RepoEvent(repos['other_repo']))
214 213
215 214 assert triggered_integrations == []
216 215
217 216
218 217 def test_enabled_non_repo_integrations(integration_repos):
219 218 integrations = integration_repos['integrations']
220 219
221 220 triggered_integrations = IntegrationModel().get_for_event(
222 221 events.UserPreCreate({}))
223 222
224 223 assert triggered_integrations == [integrations['global']]
@@ -1,65 +1,148 b''
1
2 1 # Copyright (C) 2010-2023 RhodeCode GmbH
3 2 #
4 3 # This program is free software: you can redistribute it and/or modify
5 4 # it under the terms of the GNU Affero General Public License, version 3
6 5 # (only), as published by the Free Software Foundation.
7 6 #
8 7 # This program is distributed in the hope that it will be useful,
9 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 10 # GNU General Public License for more details.
12 11 #
13 12 # You should have received a copy of the GNU Affero General Public License
14 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 14 #
16 15 # This program is dual-licensed. If you wish to learn more about the
17 16 # RhodeCode Enterprise Edition, including its added features, Support services,
18 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 18
20 19 import pytest
20 import mock
21 21 from mock import patch
22 22
23 23 from rhodecode import events
24 from rhodecode.integrations.types.handlers.slack import SlackDataHandler
24 25 from rhodecode.model.db import Session, Integration
25 26 from rhodecode.integrations.types.slack import SlackIntegrationType
27 from rhodecode.tests import GIT_REPO
28
29
30 @pytest.fixture()
31 def base_slack_data():
32 return {
33 "pullrequest": {
34 "url": "https://example.com/pr1",
35 "pull_request_id": "1",
36 "title": "started pr",
37 "status": "new",
38 "commits": ["1", "2"],
39 "shadow_url": "http://shadow-url",
40 },
41 "actor": {"username": "foo-user"},
42 "comment": {
43 "comment_id": 1,
44 "text": "test-comment",
45 "status": "approved",
46 "file": "text.py",
47 "line": "1",
48 "type": "note",
49 },
50 "push": {"branches": "", "commits": []},
51 "repo": {
52 "url": "https://example.com/repo1",
53 "repo_name": GIT_REPO,
54 "repo_type": "git",
55 },
56 }
26 57
27 58
28 59 @pytest.fixture()
29 60 def slack_settings():
30 61 return {
31 62 "service": "mock://slackintegration",
32 63 "events": [
33 64 "pullrequest-create",
34 65 "repo-push",
35 66 ],
36 67 "channel": "#testing",
37 68 "icon_emoji": ":recycle:",
38 69 "username": "rhodecode-test"
39 70 }
40 71
41 72
42 73 @pytest.fixture()
43 74 def slack_integration(request, app, slack_settings):
44 75 integration = Integration()
45 76 integration.name = 'test slack integration'
46 77 integration.enabled = True
47 78 integration.integration_type = SlackIntegrationType.key
48 79 integration.settings = slack_settings
49 80 Session().add(integration)
50 81 Session().commit()
51 82 request.addfinalizer(lambda: Session().delete(integration))
52 83 return integration
53 84
54 85
86 @pytest.fixture()
87 def slack_integration_empty(request, app, slack_settings):
88 slack_settings['events'] = []
89 integration = Integration()
90 integration.name = 'test slack integration'
91 integration.enabled = True
92 integration.integration_type = SlackIntegrationType.key
93 integration.settings = slack_settings
94 Session().add(integration)
95 Session().commit()
96 request.addfinalizer(lambda: Session().delete(integration))
97 return integration
98
99
55 100 def test_slack_push(slack_integration, repo_push_event):
101
56 102 with patch('rhodecode.integrations.types.slack.post_text_to_slack') as call:
57 103 events.trigger(repo_push_event)
58 assert 'pushed to' in call.call_args[0][1]
104
105 assert 'pushed to' in call.call_args[0][1].title
106 # specific commit was parsed and serialized
107 assert 'change that fixes #41' in call.call_args[0][1].text
59 108
60 slack_integration.settings['events'] = []
61 Session().commit()
109
110 def test_slack_push_no_events(slack_integration_empty, repo_push_event):
111
112 assert Integration.get(slack_integration_empty.integration_id).settings['events'] == []
62 113
63 114 with patch('rhodecode.integrations.types.slack.post_text_to_slack') as call:
64 115 events.trigger(repo_push_event)
65 116 assert not call.call_args
117
118
119 def test_slack_data_handler_wrong_event():
120 handler = SlackDataHandler()
121 data = {"actor": {"username": "foo-user"}}
122 with pytest.raises(ValueError):
123 handler(events.RhodecodeEvent(), data)
124
125
126 @pytest.mark.parametrize("event_type, args", [
127 (
128 events.PullRequestCommentEvent,
129 (mock.MagicMock(name="pull-request"), mock.MagicMock(name="comment")),
130 ),
131 (
132 events.PullRequestCommentEditEvent,
133 (mock.MagicMock(name="pull-request"), mock.MagicMock(name="comment")),
134 ),
135 (
136 events.PullRequestReviewEvent,
137 (mock.MagicMock(name="pull-request"), mock.MagicMock(name="status")),
138 ),
139 (
140 events.RepoPushEvent,
141 (GIT_REPO, mock.MagicMock(name="pushed_commit_ids"), mock.MagicMock(name="extras")),
142 ),
143 (events.PullRequestEvent, (mock.MagicMock(), )),
144 (events.RepoCreateEvent, (mock.MagicMock(), )),
145 ])
146 def test_slack_data_handler(app, event_type: events.RhodecodeEvent, args, base_slack_data):
147 handler = SlackDataHandler()
148 handler(event_type(*args), base_slack_data)
@@ -1,133 +1,196 b''
1 1
2 2 # Copyright (C) 2010-2023 RhodeCode GmbH
3 3 #
4 4 # This program is free software: you can redistribute it and/or modify
5 5 # it under the terms of the GNU Affero General Public License, version 3
6 6 # (only), as published by the Free Software Foundation.
7 7 #
8 8 # This program is distributed in the hope that it will be useful,
9 9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 11 # GNU General Public License for more details.
12 12 #
13 13 # You should have received a copy of the GNU Affero General Public License
14 14 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 15 #
16 16 # This program is dual-licensed. If you wish to learn more about the
17 17 # RhodeCode Enterprise Edition, including its added features, Support services,
18 18 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 19
20 20 import pytest
21 import mock
22 from mock import patch
21 23
22 24 from rhodecode import events
23 25 from rhodecode.lib.utils2 import AttributeDict
24 26 from rhodecode.integrations.types.webhook import WebhookDataHandler
27 from rhodecode.tests import GIT_REPO
25 28
26 29
27 30 @pytest.fixture()
28 31 def base_data():
29 32 return {
30 33 'name': 'event',
31 34 'repo': {
32 35 'repo_name': 'foo',
33 36 'repo_type': 'hg',
34 37 'repo_id': '12',
35 38 'url': 'http://repo.url/foo',
36 39 'extra_fields': {},
37 40 },
38 41 'actor': {
39 42 'username': 'actor_name',
40 43 'user_id': 1
41 }
44 },
45 "pullrequest": {
46 "url": "https://example.com/pr1",
47 "pull_request_id": "1",
48 "title": "started pr",
49 "status": "new",
50 "commits_uid": ["1", "2"],
51 "shadow_url": "http://shadow-url",
52 },
53 "comment": {
54 "comment_id": 1,
55 "comment_text": "test-comment",
56 "status": "approved",
57 "comment_f_path": "text.py",
58 "comment_line_no": "1",
59 "comment_type": "note",
60 },
61 "commit": {
62 "commit_id": "efefef",
63 "commit_branch": "master",
64 "commit_message": "changed foo"
65 },
66 "push": {
67 "branches": "",
68 "commits": []
69 },
70
42 71 }
43 72
44 73
45 74 def test_webhook_parse_url_invalid_event():
46 75 template_url = 'http://server.com/${repo_name}/build'
47 76 handler = WebhookDataHandler(
48 77 template_url, {'exmaple-header': 'header-values'})
49 78 event = events.RepoDeleteEvent('')
50 79 with pytest.raises(ValueError) as err:
51 80 handler(event, {})
52 81
53 82 err = str(err.value)
54 83 assert err == "event type `<class 'rhodecode.events.repo.RepoDeleteEvent'>` has no handler defined"
55 84
56 85
57 86 @pytest.mark.parametrize('template,expected_urls', [
58 87 ('http://server.com/${repo_name}/build',
59 88 ['http://server.com/foo/build']),
60 89 ('http://server.com/${repo_name}/${repo_type}',
61 90 ['http://server.com/foo/hg']),
62 91 ('http://${server}.com/${repo_name}/${repo_id}',
63 92 ['http://${server}.com/foo/12']),
64 93 ('http://server.com/${branch}/build',
65 94 ['http://server.com/${branch}/build']),
66 95 ])
67 96 def test_webook_parse_url_for_create_event(base_data, template, expected_urls):
68 97 headers = {'exmaple-header': 'header-values'}
69 98 handler = WebhookDataHandler(template, headers)
70 99 urls = handler(events.RepoCreateEvent(''), base_data)
71 100 assert urls == [
72 101 (url, headers, base_data) for url in expected_urls]
73 102
74 103
75 104 @pytest.mark.parametrize('template,expected_urls', [
76 105 ('http://server.com/${repo_name}/${pull_request_id}',
77 106 ['http://server.com/foo/999']),
78 107 ('http://server.com/${repo_name}/${pull_request_url}',
79 108 ['http://server.com/foo/http%3A//pr-url.com']),
80 109 ('http://server.com/${repo_name}/${pull_request_url}/?TITLE=${pull_request_title}',
81 110 ['http://server.com/foo/http%3A//pr-url.com/?TITLE=example-pr-title%20Ticket%20%23123']),
82 111 ('http://server.com/${repo_name}/?SHADOW_URL=${pull_request_shadow_url}',
83 112 ['http://server.com/foo/?SHADOW_URL=http%3A//pr-url.com/repository']),
84 113 ])
85 114 def test_webook_parse_url_for_pull_request_event(base_data, template, expected_urls):
86 115
87 116 base_data['pullrequest'] = {
88 117 'pull_request_id': 999,
89 118 'url': 'http://pr-url.com',
90 119 'title': 'example-pr-title Ticket #123',
91 120 'commits_uid': 'abcdefg1234',
92 121 'shadow_url': 'http://pr-url.com/repository'
93 122 }
94 123 headers = {'exmaple-header': 'header-values'}
95 124 handler = WebhookDataHandler(template, headers)
96 125 urls = handler(events.PullRequestCreateEvent(
97 126 AttributeDict({'target_repo': 'foo'})), base_data)
98 127 assert urls == [
99 128 (url, headers, base_data) for url in expected_urls]
100 129
101 130
102 131 @pytest.mark.parametrize('template,expected_urls', [
103 132 ('http://server.com/${branch}/build',
104 133 ['http://server.com/stable/build',
105 134 'http://server.com/dev/build']),
106 135 ('http://server.com/${branch}/${commit_id}',
107 136 ['http://server.com/stable/stable-xxx',
108 137 'http://server.com/stable/stable-yyy',
109 138 'http://server.com/dev/dev-xxx',
110 139 'http://server.com/dev/dev-yyy']),
111 140 ('http://server.com/${branch_head}',
112 141 ['http://server.com/stable-yyy',
113 142 'http://server.com/dev-yyy']),
114 143 ('http://server.com/${commit_id}',
115 144 ['http://server.com/stable-xxx',
116 145 'http://server.com/stable-yyy',
117 146 'http://server.com/dev-xxx',
118 147 'http://server.com/dev-yyy']),
119 148 ])
120 149 def test_webook_parse_url_for_push_event(
121 150 baseapp, repo_push_event, base_data, template, expected_urls):
122 151 base_data['push'] = {
123 152 'branches': [{'name': 'stable'}, {'name': 'dev'}],
124 153 'commits': [{'branch': 'stable', 'raw_id': 'stable-xxx'},
125 154 {'branch': 'stable', 'raw_id': 'stable-yyy'},
126 155 {'branch': 'dev', 'raw_id': 'dev-xxx'},
127 156 {'branch': 'dev', 'raw_id': 'dev-yyy'}]
128 157 }
129 158 headers = {'exmaple-header': 'header-values'}
130 159 handler = WebhookDataHandler(template, headers)
131 160 urls = handler(repo_push_event, base_data)
132 161 assert urls == [
133 162 (url, headers, base_data) for url in expected_urls]
163
164
165 @pytest.mark.parametrize("event_type, args", [
166 (
167 events.RepoPushEvent,
168 (GIT_REPO, mock.MagicMock(name="pushed_commit_ids"), mock.MagicMock(name="extras")),
169 ),
170 (
171 events.RepoCreateEvent,
172 (GIT_REPO,),
173 ),
174 (
175 events.RepoCommitCommentEvent,
176 (GIT_REPO, mock.MagicMock(name="commit"), mock.MagicMock(name="comment")),
177 ),
178 (
179 events.RepoCommitCommentEditEvent,
180 (GIT_REPO, mock.MagicMock(name="commit"), mock.MagicMock(name="comment")),
181 ),
182 (
183 events.PullRequestEvent,
184 (mock.MagicMock(), ),
185 ),
186 ])
187 def test_webhook_data_handler(app, event_type: events.RhodecodeEvent, args, base_data):
188 handler = WebhookDataHandler(
189 template_url='http://server.com/${branch}/${commit_id}',
190 headers={'exmaple-header': 'header-values'}
191 )
192 handler(event_type(*args), base_data)
193
194
195
196
@@ -1,591 +1,591 b''
1 1
2 2 # Copyright (C) 2010-2023 RhodeCode GmbH
3 3 #
4 4 # This program is free software: you can redistribute it and/or modify
5 5 # it under the terms of the GNU Affero General Public License, version 3
6 6 # (only), as published by the Free Software Foundation.
7 7 #
8 8 # This program is distributed in the hope that it will be useful,
9 9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 11 # GNU General Public License for more details.
12 12 #
13 13 # You should have received a copy of the GNU Affero General Public License
14 14 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 15 #
16 16 # This program is dual-licensed. If you wish to learn more about the
17 17 # RhodeCode Enterprise Edition, including its added features, Support services,
18 18 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 19
20 20 import datetime
21 21 import time
22 22
23 23 import pytest
24 24
25 25 from rhodecode.lib.str_utils import safe_bytes
26 26 from rhodecode.lib.vcs.backends.base import (
27 27 CollectionGenerator, FILEMODE_DEFAULT, EmptyCommit)
28 28 from rhodecode.lib.vcs.exceptions import (
29 29 BranchDoesNotExistError, CommitDoesNotExistError,
30 30 RepositoryError, EmptyRepositoryError)
31 31 from rhodecode.lib.vcs.nodes import (
32 32 FileNode, AddedFileNodesGenerator,
33 33 ChangedFileNodesGenerator, RemovedFileNodesGenerator)
34 34 from rhodecode.tests import get_new_dir
35 35 from rhodecode.tests.vcs.conftest import BackendTestMixin
36 36
37 37
38 38 class TestBaseChangeset(object):
39 39
40 40 def test_is_deprecated(self):
41 41 from rhodecode.lib.vcs.backends.base import BaseChangeset
42 42 pytest.deprecated_call(BaseChangeset)
43 43
44 44
45 45 class TestEmptyCommit(object):
46 46
47 47 def test_branch_without_alias_returns_none(self):
48 48 commit = EmptyCommit()
49 49 assert commit.branch is None
50 50
51 51
52 52 @pytest.mark.usefixtures("vcs_repository_support")
53 53 class TestCommitsInNonEmptyRepo(BackendTestMixin):
54 54 recreate_repo_per_test = True
55 55
56 56 @classmethod
57 57 def _get_commits(cls):
58 58 start_date = datetime.datetime(2010, 1, 1, 20)
59 59 for x in range(5):
60 60 yield {
61 61 'message': 'Commit %d' % x,
62 62 'author': 'Joe Doe <joe.doe@example.com>',
63 63 'date': start_date + datetime.timedelta(hours=12 * x),
64 64 'added': [
65 65 FileNode(b'file_%d.txt' % x,
66 66 content=b'Foobar %d' % x),
67 67 ],
68 68 }
69 69
70 70 def test_walk_returns_empty_list_in_case_of_file(self):
71 71 result = list(self.tip.walk('file_0.txt'))
72 72 assert result == []
73 73
74 74 @pytest.mark.backends("git", "hg")
75 75 def test_new_branch(self):
76 76 self.imc.add(FileNode(b'docs/index.txt', content=b'Documentation\n'))
77 77 foobar_tip = self.imc.commit(
78 78 message='New branch: foobar',
79 79 author='joe <joe@rhodecode.com>',
80 80 branch='foobar',
81 81 )
82 82 assert 'foobar' in self.repo.branches
83 83 assert foobar_tip.branch == 'foobar'
84 84 # 'foobar' should be the only branch that contains the new commit
85 85 branch = list(self.repo.branches.values())
86 86 assert branch[0] != branch[1]
87 87
88 88 @pytest.mark.backends("git", "hg")
89 89 def test_new_head_in_default_branch(self):
90 90 tip = self.repo.get_commit()
91 91 self.imc.add(FileNode(b'docs/index.txt', content=b'Documentation\n'))
92 92 foobar_tip = self.imc.commit(
93 93 message='New branch: foobar',
94 94 author='joe <joe@rhodecode.com>',
95 95 branch='foobar',
96 96 parents=[tip],
97 97 )
98 98 self.imc.change(FileNode(b'docs/index.txt', content=b'Documentation\nand more...\n'))
99 99 newtip = self.imc.commit(
100 100 message='At default branch',
101 101 author='joe <joe@rhodecode.com>',
102 102 branch=foobar_tip.branch,
103 103 parents=[foobar_tip],
104 104 )
105 105
106 106 newest_tip = self.imc.commit(
107 107 message='Merged with %s' % foobar_tip.raw_id,
108 108 author='joe <joe@rhodecode.com>',
109 109 branch=self.backend_class.DEFAULT_BRANCH_NAME,
110 110 parents=[newtip, foobar_tip],
111 111 )
112 112
113 113 assert newest_tip.branch == self.backend_class.DEFAULT_BRANCH_NAME
114 114
115 115 @pytest.mark.backends("git", "hg")
116 116 def test_get_commits_respects_branch_name(self):
117 117 """
118 118 * e1930d0 (HEAD, master) Back in default branch
119 119 | * e1930d0 (docs) New Branch: docs2
120 120 | * dcc14fa New branch: docs
121 121 |/
122 122 * e63c41a Initial commit
123 123 ...
124 124 * 624d3db Commit 0
125 125
126 126 :return:
127 127 """
128 128 DEFAULT_BRANCH = self.repo.DEFAULT_BRANCH_NAME
129 129 TEST_BRANCH = 'docs'
130 130 org_tip = self.repo.get_commit()
131 131
132 132 self.imc.add(FileNode(b'readme.txt', content=b'Document\n'))
133 133 initial = self.imc.commit(
134 134 message='Initial commit',
135 135 author='joe <joe@rhodecode.com>',
136 136 parents=[org_tip],
137 137 branch=DEFAULT_BRANCH,)
138 138
139 139 self.imc.add(FileNode(b'newdoc.txt', content=b'foobar\n'))
140 140 docs_branch_commit1 = self.imc.commit(
141 141 message='New branch: docs',
142 142 author='joe <joe@rhodecode.com>',
143 143 parents=[initial],
144 144 branch=TEST_BRANCH,)
145 145
146 146 self.imc.add(FileNode(b'newdoc2.txt', content=b'foobar2\n'))
147 147 docs_branch_commit2 = self.imc.commit(
148 148 message='New branch: docs2',
149 149 author='joe <joe@rhodecode.com>',
150 150 parents=[docs_branch_commit1],
151 151 branch=TEST_BRANCH,)
152 152
153 153 self.imc.add(FileNode(b'newfile', content=b'hello world\n'))
154 154 self.imc.commit(
155 155 message='Back in default branch',
156 156 author='joe <joe@rhodecode.com>',
157 157 parents=[initial],
158 158 branch=DEFAULT_BRANCH,)
159 159
160 160 default_branch_commits = self.repo.get_commits(branch_name=DEFAULT_BRANCH)
161 161 assert docs_branch_commit1 not in list(default_branch_commits)
162 162 assert docs_branch_commit2 not in list(default_branch_commits)
163 163
164 164 docs_branch_commits = self.repo.get_commits(
165 165 start_id=self.repo.commit_ids[0], end_id=self.repo.commit_ids[-1],
166 166 branch_name=TEST_BRANCH)
167 167 assert docs_branch_commit1 in list(docs_branch_commits)
168 168 assert docs_branch_commit2 in list(docs_branch_commits)
169 169
170 170 @pytest.mark.backends("svn")
171 171 def test_get_commits_respects_branch_name_svn(self, vcsbackend_svn):
172 172 repo = vcsbackend_svn['svn-simple-layout']
173 173 commits = repo.get_commits(branch_name='trunk')
174 174 commit_indexes = [c.idx for c in commits]
175 175 assert commit_indexes == [1, 2, 3, 7, 12, 15]
176 176
177 177 def test_get_commit_by_index(self):
178 178 for idx in [1, 2, 3, 4]:
179 179 assert idx == self.repo.get_commit(commit_idx=idx).idx
180 180
181 181 def test_get_commit_by_branch(self):
182 182 for branch, commit_id in self.repo.branches.items():
183 183 assert commit_id == self.repo.get_commit(branch).raw_id
184 184
185 185 def test_get_commit_by_tag(self):
186 186 for tag, commit_id in self.repo.tags.items():
187 187 assert commit_id == self.repo.get_commit(tag).raw_id
188 188
189 189 def test_get_commit_parents(self):
190 190 repo = self.repo
191 191 for test_idx in [1, 2, 3]:
192 192 commit = repo.get_commit(commit_idx=test_idx - 1)
193 193 assert [commit] == repo.get_commit(commit_idx=test_idx).parents
194 194
195 195 def test_get_commit_children(self):
196 196 repo = self.repo
197 197 for test_idx in [1, 2, 3]:
198 198 commit = repo.get_commit(commit_idx=test_idx + 1)
199 199 assert [commit] == repo.get_commit(commit_idx=test_idx).children
200 200
201 201
202 202 @pytest.mark.usefixtures("vcs_repository_support")
203 203 class TestCommits(BackendTestMixin):
204 204 recreate_repo_per_test = False
205 205
206 206 @classmethod
207 207 def _get_commits(cls):
208 208 start_date = datetime.datetime(2010, 1, 1, 20)
209 209 for x in range(5):
210 210 yield {
211 211 'message': 'Commit %d' % x,
212 212 'author': 'Joe Doe <joe.doe@example.com>',
213 213 'date': start_date + datetime.timedelta(hours=12 * x),
214 214 'added': [
215 215 FileNode(b'file_%d.txt' % x,
216 216 content=b'Foobar %d' % x)
217 217 ],
218 218 }
219 219
220 220 def test_simple(self):
221 221 tip = self.repo.get_commit()
222 222 assert tip.date, datetime.datetime(2010, 1, 3 == 20)
223 223
224 224 def test_simple_serialized_commit(self):
225 225 tip = self.repo.get_commit()
226 226 # json.dumps(tip) uses .__json__() method
227 227 data = tip.__json__()
228 228 assert 'branch' in data
229 229 assert data['revision']
230 230
231 231 def test_retrieve_tip(self):
232 232 tip = self.repo.get_commit('tip')
233 233 assert tip == self.repo.get_commit()
234 234
235 235 def test_invalid(self):
236 236 with pytest.raises(CommitDoesNotExistError):
237 237 self.repo.get_commit(commit_idx=123456789)
238 238
239 239 def test_idx(self):
240 240 commit = self.repo[0]
241 241 assert commit.idx == 0
242 242
243 243 def test_negative_idx(self):
244 244 commit = self.repo.get_commit(commit_idx=-1)
245 245 assert commit.idx >= 0
246 246
247 247 def test_revision_is_deprecated(self):
248 248 def get_revision(commit):
249 249 return commit.revision
250 250
251 251 commit = self.repo[0]
252 252 pytest.deprecated_call(get_revision, commit)
253 253
254 254 def test_size(self):
255 255 tip = self.repo.get_commit()
256 256 size = 5 * len('Foobar N') # Size of 5 files
257 257 assert tip.size == size
258 258
259 259 def test_size_at_commit(self):
260 260 tip = self.repo.get_commit()
261 261 size = 5 * len('Foobar N') # Size of 5 files
262 262 assert self.repo.size_at_commit(tip.raw_id) == size
263 263
264 264 def test_size_at_first_commit(self):
265 265 commit = self.repo[0]
266 266 size = len('Foobar N') # Size of 1 file
267 267 assert self.repo.size_at_commit(commit.raw_id) == size
268 268
269 269 def test_author(self):
270 270 tip = self.repo.get_commit()
271 271 assert_text_equal(tip.author, 'Joe Doe <joe.doe@example.com>')
272 272
273 273 def test_author_name(self):
274 274 tip = self.repo.get_commit()
275 275 assert_text_equal(tip.author_name, 'Joe Doe')
276 276
277 277 def test_author_email(self):
278 278 tip = self.repo.get_commit()
279 279 assert_text_equal(tip.author_email, 'joe.doe@example.com')
280 280
281 281 def test_message(self):
282 282 tip = self.repo.get_commit()
283 283 assert_text_equal(tip.message, 'Commit 4')
284 284
285 285 def test_diff(self):
286 286 tip = self.repo.get_commit()
287 287 diff = tip.diff()
288 288 assert b"+Foobar 4" in diff.raw.tobytes()
289 289
290 290 def test_prev(self):
291 291 tip = self.repo.get_commit()
292 292 prev_commit = tip.prev()
293 293 assert prev_commit.message == 'Commit 3'
294 294
295 295 def test_prev_raises_on_first_commit(self):
296 296 commit = self.repo.get_commit(commit_idx=0)
297 297 with pytest.raises(CommitDoesNotExistError):
298 298 commit.prev()
299 299
300 300 def test_prev_works_on_second_commit_issue_183(self):
301 301 commit = self.repo.get_commit(commit_idx=1)
302 302 prev_commit = commit.prev()
303 303 assert prev_commit.idx == 0
304 304
305 305 def test_next(self):
306 306 commit = self.repo.get_commit(commit_idx=2)
307 307 next_commit = commit.next()
308 308 assert next_commit.message == 'Commit 3'
309 309
310 310 def test_next_raises_on_tip(self):
311 311 commit = self.repo.get_commit()
312 312 with pytest.raises(CommitDoesNotExistError):
313 313 commit.next()
314 314
315 315 def test_get_path_commit(self):
316 316 commit = self.repo.get_commit()
317 317 commit.get_path_commit('file_4.txt')
318 318 assert commit.message == 'Commit 4'
319 319
320 320 def test_get_filenodes_generator(self):
321 321 tip = self.repo.get_commit()
322 322 filepaths = [node.path for node in tip.get_filenodes_generator()]
323 323 assert filepaths == ['file_%d.txt' % x for x in range(5)]
324 324
325 325 def test_get_file_annotate(self):
326 326 file_added_commit = self.repo.get_commit(commit_idx=3)
327 327 annotations = list(file_added_commit.get_file_annotate('file_3.txt'))
328 328
329 329 line_no, commit_id, commit_loader, line = annotations[0]
330 330
331 331 assert line_no == 1
332 332 assert commit_id == file_added_commit.raw_id
333 333 assert commit_loader() == file_added_commit
334 assert 'Foobar 3' in line
334 assert b'Foobar 3' in line
335 335
336 336 def test_get_file_annotate_does_not_exist(self):
337 337 file_added_commit = self.repo.get_commit(commit_idx=2)
338 338 # TODO: Should use a specific exception class here?
339 339 with pytest.raises(Exception):
340 340 list(file_added_commit.get_file_annotate('file_3.txt'))
341 341
342 342 def test_get_file_annotate_tip(self):
343 343 tip = self.repo.get_commit()
344 344 commit = self.repo.get_commit(commit_idx=3)
345 345 expected_values = list(commit.get_file_annotate('file_3.txt'))
346 346 annotations = list(tip.get_file_annotate('file_3.txt'))
347 347
348 348 # Note: Skip index 2 because the loader function is not the same
349 349 for idx in (0, 1, 3):
350 350 assert annotations[0][idx] == expected_values[0][idx]
351 351
352 352 def test_get_commits_is_ordered_by_date(self):
353 353 commits = self.repo.get_commits()
354 354 assert isinstance(commits, CollectionGenerator)
355 355 assert len(commits) == 0 or len(commits) != 0
356 356 commits = list(commits)
357 357 ordered_by_date = sorted(commits, key=lambda commit: commit.date)
358 358 assert commits == ordered_by_date
359 359
360 360 def test_get_commits_respects_start(self):
361 361 second_id = self.repo.commit_ids[1]
362 362 commits = self.repo.get_commits(start_id=second_id)
363 363 assert isinstance(commits, CollectionGenerator)
364 364 commits = list(commits)
365 365 assert len(commits) == 4
366 366
367 367 def test_get_commits_includes_start_commit(self):
368 368 second_id = self.repo.commit_ids[1]
369 369 commits = self.repo.get_commits(start_id=second_id)
370 370 assert isinstance(commits, CollectionGenerator)
371 371 commits = list(commits)
372 372 assert commits[0].raw_id == second_id
373 373
374 374 def test_get_commits_respects_end(self):
375 375 second_id = self.repo.commit_ids[1]
376 376 commits = self.repo.get_commits(end_id=second_id)
377 377 assert isinstance(commits, CollectionGenerator)
378 378 commits = list(commits)
379 379 assert commits[-1].raw_id == second_id
380 380 assert len(commits) == 2
381 381
382 382 def test_get_commits_respects_both_start_and_end(self):
383 383 second_id = self.repo.commit_ids[1]
384 384 third_id = self.repo.commit_ids[2]
385 385 commits = self.repo.get_commits(start_id=second_id, end_id=third_id)
386 386 assert isinstance(commits, CollectionGenerator)
387 387 commits = list(commits)
388 388 assert len(commits) == 2
389 389
390 390 def test_get_commits_on_empty_repo_raises_EmptyRepository_error(self):
391 391 repo_path = get_new_dir(str(time.time()))
392 392 repo = self.Backend(repo_path, create=True)
393 393
394 394 with pytest.raises(EmptyRepositoryError):
395 395 list(repo.get_commits(start_id='foobar'))
396 396
397 397 def test_get_commits_respects_hidden(self):
398 398 commits = self.repo.get_commits(show_hidden=True)
399 399 assert isinstance(commits, CollectionGenerator)
400 400 assert len(commits) == 5
401 401
402 402 def test_get_commits_includes_end_commit(self):
403 403 second_id = self.repo.commit_ids[1]
404 404 commits = self.repo.get_commits(end_id=second_id)
405 405 assert isinstance(commits, CollectionGenerator)
406 406 assert len(commits) == 2
407 407 commits = list(commits)
408 408 assert commits[-1].raw_id == second_id
409 409
410 410 def test_get_commits_respects_start_date(self):
411 411 start_date = datetime.datetime(2010, 1, 2)
412 412 commits = self.repo.get_commits(start_date=start_date)
413 413 assert isinstance(commits, CollectionGenerator)
414 414 # Should be 4 commits after 2010-01-02 00:00:00
415 415 assert len(commits) == 4
416 416 for c in commits:
417 417 assert c.date >= start_date
418 418
419 419 def test_get_commits_respects_start_date_with_branch(self):
420 420 start_date = datetime.datetime(2010, 1, 2)
421 421 commits = self.repo.get_commits(
422 422 start_date=start_date, branch_name=self.repo.DEFAULT_BRANCH_NAME)
423 423 assert isinstance(commits, CollectionGenerator)
424 424 # Should be 4 commits after 2010-01-02 00:00:00
425 425 assert len(commits) == 4
426 426 for c in commits:
427 427 assert c.date >= start_date
428 428
429 429 def test_get_commits_respects_start_date_and_end_date(self):
430 430 start_date = datetime.datetime(2010, 1, 2)
431 431 end_date = datetime.datetime(2010, 1, 3)
432 432 commits = self.repo.get_commits(start_date=start_date,
433 433 end_date=end_date)
434 434 assert isinstance(commits, CollectionGenerator)
435 435 assert len(commits) == 2
436 436 for c in commits:
437 437 assert c.date >= start_date
438 438 assert c.date <= end_date
439 439
440 440 def test_get_commits_respects_end_date(self):
441 441 end_date = datetime.datetime(2010, 1, 2)
442 442 commits = self.repo.get_commits(end_date=end_date)
443 443 assert isinstance(commits, CollectionGenerator)
444 444 assert len(commits) == 1
445 445 for c in commits:
446 446 assert c.date <= end_date
447 447
448 448 def test_get_commits_respects_reverse(self):
449 449 commits = self.repo.get_commits() # no longer reverse support
450 450 assert isinstance(commits, CollectionGenerator)
451 451 assert len(commits) == 5
452 452 commit_ids = reversed([c.raw_id for c in commits])
453 453 assert list(commit_ids) == list(reversed(self.repo.commit_ids))
454 454
455 455 def test_get_commits_slice_generator(self):
456 456 commits = self.repo.get_commits(
457 457 branch_name=self.repo.DEFAULT_BRANCH_NAME)
458 458 assert isinstance(commits, CollectionGenerator)
459 459 commit_slice = list(commits[1:3])
460 460 assert len(commit_slice) == 2
461 461
462 462 def test_get_commits_raise_commitdoesnotexist_for_wrong_start(self):
463 463 with pytest.raises(CommitDoesNotExistError):
464 464 list(self.repo.get_commits(start_id='foobar'))
465 465
466 466 def test_get_commits_raise_commitdoesnotexist_for_wrong_end(self):
467 467 with pytest.raises(CommitDoesNotExistError):
468 468 list(self.repo.get_commits(end_id='foobar'))
469 469
470 470 def test_get_commits_raise_branchdoesnotexist_for_wrong_branch_name(self):
471 471 with pytest.raises(BranchDoesNotExistError):
472 472 list(self.repo.get_commits(branch_name='foobar'))
473 473
474 474 def test_get_commits_raise_repositoryerror_for_wrong_start_end(self):
475 475 start_id = self.repo.commit_ids[-1]
476 476 end_id = self.repo.commit_ids[0]
477 477 with pytest.raises(RepositoryError):
478 478 list(self.repo.get_commits(start_id=start_id, end_id=end_id))
479 479
480 480 def test_get_commits_raises_for_numerical_ids(self):
481 481 with pytest.raises(TypeError):
482 482 self.repo.get_commits(start_id=1, end_id=2)
483 483
484 484 def test_commit_equality(self):
485 485 commit1 = self.repo.get_commit(self.repo.commit_ids[0])
486 486 commit2 = self.repo.get_commit(self.repo.commit_ids[1])
487 487
488 488 assert commit1 == commit1
489 489 assert commit2 == commit2
490 490 assert commit1 != commit2
491 491 assert commit2 != commit1
492 492 assert commit1 is not None
493 493 assert commit2 is not None
494 494 assert 1 != commit1
495 495 assert 'string' != commit1
496 496
497 497
498 498 @pytest.mark.parametrize("filename, expected", [
499 499 ("README.rst", False),
500 500 ("README", True),
501 501 ])
502 502 def test_commit_is_link(vcsbackend, filename, expected):
503 503 commit = vcsbackend.repo.get_commit()
504 504 link_status = commit.is_link(filename)
505 505 assert link_status is expected
506 506
507 507
508 508 @pytest.mark.usefixtures("vcs_repository_support")
509 509 class TestCommitsChanges(BackendTestMixin):
510 510 recreate_repo_per_test = False
511 511
512 512 @classmethod
513 513 def _get_commits(cls):
514 514 return [
515 515 {
516 516 'message': 'Initial',
517 517 'author': 'Joe Doe <joe.doe@example.com>',
518 518 'date': datetime.datetime(2010, 1, 1, 20),
519 519 'added': [
520 520 FileNode(b'foo/bar', content=b'foo'),
521 521 FileNode(safe_bytes('foo/bał'), content=b'foo'),
522 522 FileNode(b'foobar', content=b'foo'),
523 523 FileNode(b'qwe', content=b'foo'),
524 524 ],
525 525 },
526 526 {
527 527 'message': 'Massive changes',
528 528 'author': 'Joe Doe <joe.doe@example.com>',
529 529 'date': datetime.datetime(2010, 1, 1, 22),
530 530 'added': [FileNode(b'fallout', content=b'War never changes')],
531 531 'changed': [
532 532 FileNode(b'foo/bar', content=b'baz'),
533 533 FileNode(b'foobar', content=b'baz'),
534 534 ],
535 535 'removed': [FileNode(b'qwe')],
536 536 },
537 537 ]
538 538
539 539 def test_initial_commit(self, local_dt_to_utc):
540 540 commit = self.repo.get_commit(commit_idx=0)
541 541 assert set(commit.added) == {
542 542 commit.get_node('foo/bar'),
543 543 commit.get_node('foo/bał'),
544 544 commit.get_node('foobar'),
545 545 commit.get_node('qwe')
546 546 }
547 547 assert set(commit.changed) == set()
548 548 assert set(commit.removed) == set()
549 549 assert set(commit.affected_files) == {'foo/bar', 'foo/bał', 'foobar', 'qwe'}
550 550 assert commit.date == local_dt_to_utc(
551 551 datetime.datetime(2010, 1, 1, 20, 0))
552 552
553 553 def test_head_added(self):
554 554 commit = self.repo.get_commit()
555 555 assert isinstance(commit.added, AddedFileNodesGenerator)
556 556 assert set(commit.added) == {commit.get_node('fallout')}
557 557 assert isinstance(commit.changed, ChangedFileNodesGenerator)
558 558 assert set(commit.changed) == {commit.get_node('foo/bar'), commit.get_node('foobar')}
559 559 assert isinstance(commit.removed, RemovedFileNodesGenerator)
560 560 assert len(commit.removed) == 1
561 561 assert list(commit.removed)[0].path == 'qwe'
562 562
563 563 def test_get_filemode(self):
564 564 commit = self.repo.get_commit()
565 565 assert FILEMODE_DEFAULT == commit.get_file_mode('foo/bar')
566 566
567 567 def test_get_filemode_non_ascii(self):
568 568 commit = self.repo.get_commit()
569 569 assert FILEMODE_DEFAULT == commit.get_file_mode('foo/bał')
570 570 assert FILEMODE_DEFAULT == commit.get_file_mode('foo/bał')
571 571
572 572 def test_get_path_history(self):
573 573 commit = self.repo.get_commit()
574 574 history = commit.get_path_history('foo/bar')
575 575 assert len(history) == 2
576 576
577 577 def test_get_path_history_with_limit(self):
578 578 commit = self.repo.get_commit()
579 579 history = commit.get_path_history('foo/bar', limit=1)
580 580 assert len(history) == 1
581 581
582 582 def test_get_path_history_first_commit(self):
583 583 commit = self.repo[0]
584 584 history = commit.get_path_history('foo/bar')
585 585 assert len(history) == 1
586 586
587 587
588 588 def assert_text_equal(expected, given):
589 589 assert expected == given
590 590 assert isinstance(expected, str)
591 591 assert isinstance(given, str)
General Comments 0
You need to be logged in to leave comments. Login now