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