Merge ~ilasc/turnip:add-mp-url-to-git-push into turnip:master
- Git
- lp:~ilasc/turnip
- add-mp-url-to-git-push
- Merge into master
Status: | Merged |
---|---|
Approved by: | Ioana Lasc |
Approved revision: | 6913d7ff58afe4cf21afc83c5883788a12a19b3c |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | ~ilasc/turnip:add-mp-url-to-git-push |
Merge into: | turnip:master |
Diff against target: |
328 lines (+184/-9) 5 files modified
turnip/pack/hookrpc.py (+49/-1) turnip/pack/hooks/hook.py (+30/-3) turnip/pack/tests/fake_servers.py (+8/-0) turnip/pack/tests/test_hookrpc.py (+43/-0) turnip/pack/tests/test_hooks.py (+54/-5) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Approve | ||
Review via email: mp+377045@code.launchpad.net |
Commit message
Add MP URL to git push
Description of the change
Ioana Lasc (ilasc) wrote : | # |
Colin Watson (cjwatson) : | # |
- e7ad2c0... by Ioana Lasc
-
Addressed review comments.
Ioana Lasc (ilasc) wrote : | # |
Addressed most code review comments apart from moving the check for the default branch here.
Also needs Unit Tests added.
- c1238fe... by Ioana Lasc
-
Move check for default branch from LP to turnip
Ioana Lasc (ilasc) wrote : | # |
Moved the check for default branch to Turnip per our conversation.
Goes hand in hand with this on the Turnip side:
https:/
Both now ready for review.
I'm not happy with the Unit Tests elements I added here - needs more time to understand what and how we currently test - tried to replicate what we do for checkRefPermissions but in that case we store ref_permissions on the HookRPCHandler for caching purposes I assume, we're currently not doing that for the branch name, so I can't use it in a similar manner in Unit Tests and I don't think we should add it... any comment / suggestion in terms of direction on the Unit Tests would help.
Thanks Colin!
Colin Watson (cjwatson) : | # |
Colin Watson (cjwatson) : | # |
- 6c906f3... by Ioana Lasc
-
Refactor the check for default branch
Refactored the check for default branch into a standalone
method and modified unit tests to pass for current code. - 7100052... by Ioana Lasc
-
Add Unit Test for send_mp_url
- d274c3f... by Ioana Lasc
-
Cleanup with make lint
- 0721778... by Ioana Lasc
-
Refactor the check for default branch
Ioana Lasc (ilasc) wrote : | # |
Thanks Colin! I'm stuck again on the Unit Tests.
This all works when performing functional tests with real repos.
However I can't figure out how to test the refactored send_mp_url in the TestPostReceive
Current failure for commented out test_no_
File "turnip/
sock, b'get_mp_url',
NameError: global name 'sock' is not defined
I tried a number of things including using the MockHookRPCHandler with a mocked sock but went down a rabbit hole.
Colin Watson (cjwatson) wrote : | # |
OK, so the NameError is straightforward enough: your send_mp_url function has the wrong signature. It should be send_mp_url(sock, rpc_key, received_lines), and the caller should change appropriately. Without this it doesn't have the right context to be able to make an RPC call.
For the test side, yes, this gets awkward because you need to both mock is_default_branch (not get_default_branch, as your test currently has it) and allow the hook to make an RPC call; so the invokeHook strategy doesn't work easily because you can't mock something in a subprocess without a lot of extra work, and the obvious in-process strategy doesn't work easily because MockSocket is currently one-way.
I can see two approaches to dealing with this. One would be to create an actual git repository on disk with the right structure on the fly. You can change HookTestMixin.setUp to do most of this for all the hook tests: change dir to self.dir, have it call "git init" on self.dir, and then create hookrpc_sock and hooks in os.path.
If you take this approach, you should use the invokeHook helper to construct your test. You'll also need to make MockHookRPCHand
If and only if the above approach doesn't work, the other possibility would be to do everything in-process but fix it up to actually work. For this, aside from fixing the signature of send_mp_url as mentioned above, I think all you'd need to do would be to give MockSocket a send method so that it can handle both sides of the connection. I don't recommend this approach because it'll make for a less comprehensive test, but I've tried out enough of it locally to be convinced that it can work.
- 8307231... by Ioana Lasc
-
Add Unit Test for PostReceiveHook
- de10d1e... by Ioana Lasc
-
Add Test to PostReceiveHook
Test that we get an MP URL back when pushing
non default git branch.
Ioana Lasc (ilasc) wrote : | # |
Thank you Colin! Coded both approaches and committed the first approach, agreed on the fact the second approach leads to confusing tests. MP is now ready for another look.
Colin Watson (cjwatson) wrote : | # |
I think this looks like basically the right shape now, thanks! Just a few minor comments. Also note that this can't be landed until https:/
Colin Watson (cjwatson) : | # |
- d5c9cdf... by Ioana Lasc
-
Address code review comments
Added minor changes to unit tests suggested in code review
for the add-mp-url-to- git-push branch.
Ioana Lasc (ilasc) wrote : | # |
Thanks Colin, last round of updates done and noted that we don't land this until the launchpad side makes it to Prod.
Colin Watson (cjwatson) : | # |
- 0ed3642... by Ioana Lasc
-
Merge branch 'master' into add-mp-
url-to- git-push - 6913d7f... by Ioana Lasc
-
Compare full ref paths in check for default branch
Preview Diff
1 | diff --git a/turnip/pack/hookrpc.py b/turnip/pack/hookrpc.py |
2 | index c5b0d68..bcdcfd1 100644 |
3 | --- a/turnip/pack/hookrpc.py |
4 | +++ b/turnip/pack/hookrpc.py |
5 | @@ -179,7 +179,7 @@ class HookRPCHandler(object): |
6 | TurnipFaultCode.NOT_FOUND, |
7 | TurnipFaultCode.UNAUTHORIZED, |
8 | ): |
9 | - # These faults can happen with unlucky timing: a NOT_FAULT |
10 | + # These faults can happen with unlucky timing: a NOT_FOUND |
11 | # fault can happen if the repository was removed from disk |
12 | # between translatePath and checkRefPermissions (although |
13 | # that's impossible in practice with Launchpad's |
14 | @@ -246,6 +246,53 @@ class HookRPCHandler(object): |
15 | raise |
16 | log_context.log.info("notifyPush done: ref_path={path}", path=path) |
17 | |
18 | + @defer.inlineCallbacks |
19 | + def getMergeProposalURL(self, proto, args): |
20 | + log_context = HookRPCLogContext(self.auth_params[args['key']]) |
21 | + path = self.ref_paths[args['key']] |
22 | + auth_params = self.auth_params[args['key']] |
23 | + branch = args['branch'] |
24 | + log_context.log.info( |
25 | + "getMergeProposalURL request received: ref_path={path}", path=path) |
26 | + mp_url = None |
27 | + try: |
28 | + proxy = xmlrpc.Proxy(self.virtinfo_url, allowNone=True) |
29 | + mp_url = yield proxy.callRemote( |
30 | + b'getMergeProposalURL', path, branch, auth_params).addTimeout( |
31 | + self.virtinfo_timeout, self.reactor) |
32 | + except xmlrpc.Fault as e: |
33 | + code = translate_xmlrpc_fault(e.faultCode) |
34 | + if code in ( |
35 | + TurnipFaultCode.NOT_FOUND, |
36 | + TurnipFaultCode.UNAUTHORIZED, |
37 | + ): |
38 | + # These faults can happen with unlucky timing: a NOT_FOUND |
39 | + # fault can happen if the repository was removed from disk |
40 | + # between translatePath and checkRefPermissions (although |
41 | + # that's impossible in practice with Launchpad's |
42 | + # implementation); similarly, an UNAUTHORIZED fault can |
43 | + # happen if the user's access to the repository was removed |
44 | + # between translatePath and checkRefPermissions. Just |
45 | + # treat this as if the user has no permissions on any ref. |
46 | + log_context.log.info( |
47 | + "getMergeProposalURL virtinfo raised Unauthorized: " |
48 | + "auth_params={auth_params}, ref_path={ref_path}", |
49 | + auth_params=auth_params, branch=branch) |
50 | + else: |
51 | + log_context.log.info( |
52 | + "getMergeProposalURL virtinfo raised " |
53 | + "{fault_code} {fault_string}: " |
54 | + "auth_params={auth_params}, ref_path={ref_path}", |
55 | + fault_code=code.name, fault_string=e.faultString, |
56 | + auth_params=auth_params, branch=branch) |
57 | + except defer.TimeoutError: |
58 | + log_context.log.info( |
59 | + "getMergeProposalURL timed out: ref_path={path}", path=path) |
60 | + raise |
61 | + log_context.log.info("getMergeProposalURL done: ref_path={path}", |
62 | + path=path) |
63 | + defer.returnValue(mp_url) |
64 | + |
65 | |
66 | class HookRPCServerFactory(RPCServerFactory): |
67 | """A JSON netstring RPC interface to a HookRPCHandler.""" |
68 | @@ -255,4 +302,5 @@ class HookRPCServerFactory(RPCServerFactory): |
69 | self.methods = { |
70 | 'check_ref_permissions': self.hookrpc_handler.checkRefPermissions, |
71 | 'notify_push': self.hookrpc_handler.notifyPush, |
72 | + 'get_mp_url': self.hookrpc_handler.getMergeProposalURL, |
73 | } |
74 | diff --git a/turnip/pack/hooks/hook.py b/turnip/pack/hooks/hook.py |
75 | index 30189b1..e4b6886 100755 |
76 | --- a/turnip/pack/hooks/hook.py |
77 | +++ b/turnip/pack/hooks/hook.py |
78 | @@ -16,7 +16,6 @@ import socket |
79 | import subprocess |
80 | import sys |
81 | |
82 | - |
83 | # XXX twom 2018-10-23 This should be a pygit2 import, but |
84 | # that currently causes CFFI warnings to be returned to the client. |
85 | GIT_OID_HEX_ZERO = '0'*40 |
86 | @@ -32,6 +31,12 @@ def check_ancestor(old, new): |
87 | return return_code == 0 |
88 | |
89 | |
90 | +def is_default_branch(pushed_branch): |
91 | + default_branch = subprocess.check_output( |
92 | + ['git', 'symbolic-ref', 'HEAD']).rstrip(b'\n') |
93 | + return pushed_branch == default_branch |
94 | + |
95 | + |
96 | def determine_permissions_outcome(old, ref, rule_lines): |
97 | rule = rule_lines.get(ref, []) |
98 | if old == GIT_OID_HEX_ZERO: |
99 | @@ -133,13 +138,32 @@ def check_ref_permissions(sock, rpc_key, ref_paths): |
100 | for path, permissions in rule_lines.items()} |
101 | |
102 | |
103 | +def send_mp_url(received_line): |
104 | + ref = received_line.rstrip(b'\n').split(b' ', 2)[2] |
105 | + |
106 | + # check for branch ref here (we're interested in |
107 | + # heads and not tags) |
108 | + |
109 | + if ref.startswith(b'refs/heads/') and not is_default_branch(ref): |
110 | + pushed_branch = ref[len(b'refs/heads/'):] |
111 | + if not is_default_branch(pushed_branch): |
112 | + mp_url = rpc_invoke( |
113 | + sock, b'get_mp_url', |
114 | + {'key': rpc_key, 'branch': pushed_branch}) |
115 | + if mp_url is not None: |
116 | + sys.stdout.write(b' \n') |
117 | + sys.stdout.write( |
118 | + b"Create a merge proposal for '%s' on Launchpad by" |
119 | + b" visiting:\n" % pushed_branch) |
120 | + sys.stdout.write(b' %s\n' % mp_url) |
121 | + sys.stdout.write(b' \n') |
122 | + |
123 | if __name__ == '__main__': |
124 | # Connect to the RPC server, authenticating using the random key |
125 | # from the environment. |
126 | rpc_key = os.environ['TURNIP_HOOK_RPC_KEY'] |
127 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) |
128 | sock.connect(os.environ['TURNIP_HOOK_RPC_SOCK']) |
129 | - |
130 | hook = os.path.basename(sys.argv[0]) |
131 | if hook == 'pre-receive': |
132 | # Verify the proposed changes against rules from the server. |
133 | @@ -153,8 +177,11 @@ if __name__ == '__main__': |
134 | elif hook == 'post-receive': |
135 | # Notify the server about the push if there were any changes. |
136 | # Details of the changes aren't currently included. |
137 | - if sys.stdin.readlines(): |
138 | + lines = sys.stdin.readlines() |
139 | + if lines: |
140 | rpc_invoke(sock, b'notify_push', {'key': rpc_key}) |
141 | + if len(lines) == 1: |
142 | + send_mp_url(lines[0]) |
143 | sys.exit(0) |
144 | elif hook == 'update': |
145 | ref = sys.argv[1] |
146 | diff --git a/turnip/pack/tests/fake_servers.py b/turnip/pack/tests/fake_servers.py |
147 | index 0914637..c0c4015 100644 |
148 | --- a/turnip/pack/tests/fake_servers.py |
149 | +++ b/turnip/pack/tests/fake_servers.py |
150 | @@ -62,6 +62,8 @@ class FakeVirtInfoService(xmlrpc.XMLRPC): |
151 | self.translations = [] |
152 | self.authentications = [] |
153 | self.push_notifications = [] |
154 | + self.merge_proposal_url = 'http://bogus.test' |
155 | + self.merge_proposal_url_fault = None |
156 | self.ref_permissions_checks = [] |
157 | self.ref_permissions = {} |
158 | self.ref_permissions_fault = None |
159 | @@ -94,3 +96,9 @@ class FakeVirtInfoService(xmlrpc.XMLRPC): |
160 | return [ |
161 | (xmlrpc_client.Binary(ref), permissions) |
162 | for ref, permissions in self.ref_permissions.items()] |
163 | + |
164 | + def xmlrpc_getMergeProposalURL(self, path, branch, auth_params): |
165 | + if self.merge_proposal_url_fault is not None: |
166 | + raise self.merge_proposal_url_fault |
167 | + else: |
168 | + return self.merge_proposal_url |
169 | diff --git a/turnip/pack/tests/test_hookrpc.py b/turnip/pack/tests/test_hookrpc.py |
170 | index e0344f5..a4b0d19 100644 |
171 | --- a/turnip/pack/tests/test_hookrpc.py |
172 | +++ b/turnip/pack/tests/test_hookrpc.py |
173 | @@ -341,3 +341,46 @@ class TestHookRPCHandler(TestCase): |
174 | clock.advance(15) |
175 | self.assertTrue(d.called) |
176 | return assert_fails_with(d, defer.TimeoutError) |
177 | + |
178 | + @defer.inlineCallbacks |
179 | + def test_getMergeProposalURL(self): |
180 | + clock = task.Clock() |
181 | + self.hookrpc_handler = hookrpc.HookRPCHandler( |
182 | + self.virtinfo_url, 15, reactor=clock) |
183 | + |
184 | + with self.registeredKey('/translated', auth_params={'uid': 42}) as key: |
185 | + mp_url = yield self.hookrpc_handler.getMergeProposalURL( |
186 | + None, {'key': key, 'branch': 'master', 'uid': 4}) |
187 | + self.assertIsNotNone(mp_url) |
188 | + |
189 | + def test_getMergeProposalURL_timeout(self): |
190 | + clock = task.Clock() |
191 | + self.hookrpc_handler = hookrpc.HookRPCHandler( |
192 | + self.virtinfo_url, 15, reactor=clock) |
193 | + |
194 | + with self.registeredKey('/translated', auth_params={'uid': 42}) as key: |
195 | + d = self.hookrpc_handler.getMergeProposalURL( |
196 | + None, {'key': key, 'branch': 'master', 'uid': 4}) |
197 | + clock.advance(1) |
198 | + self.assertFalse(d.called) |
199 | + clock.advance(15) |
200 | + self.assertTrue(d.called) |
201 | + return assert_fails_with(d, defer.TimeoutError) |
202 | + |
203 | + @defer.inlineCallbacks |
204 | + def test_getMergeProposalURL_unauthorized(self): |
205 | + # we return None for the merge proposal URL |
206 | + # when catching and UNAUTHORIZED and NOT_FOUND exception |
207 | + self.virtinfo.merge_proposal_url_fault = xmlrpc.Fault( |
208 | + 3, 'Unauthorized') |
209 | + with self.registeredKey('/translated', auth_params={'uid': 42}) as key: |
210 | + mp_url = yield self.hookrpc_handler.getMergeProposalURL( |
211 | + None, {'key': key, 'branch': 'master', 'uid': 4}) |
212 | + self.assertIsNone(mp_url) |
213 | + |
214 | + self.virtinfo.merge_proposal_url_fault = xmlrpc.Fault( |
215 | + 1, 'NOT_FOUND') |
216 | + with self.registeredKey('/translated', auth_params={'uid': 42}) as key: |
217 | + mp_url = yield self.hookrpc_handler.getMergeProposalURL( |
218 | + None, {'key': key, 'branch': 'master', 'uid': 4}) |
219 | + self.assertIsNone(mp_url) |
220 | diff --git a/turnip/pack/tests/test_hooks.py b/turnip/pack/tests/test_hooks.py |
221 | index ce92c3a..4e793da 100644 |
222 | --- a/turnip/pack/tests/test_hooks.py |
223 | +++ b/turnip/pack/tests/test_hooks.py |
224 | @@ -10,6 +10,7 @@ from __future__ import ( |
225 | |
226 | import base64 |
227 | import os.path |
228 | +import subprocess |
229 | import uuid |
230 | |
231 | from fixtures import ( |
232 | @@ -67,6 +68,9 @@ class MockHookRPCHandler(hookrpc.HookRPCHandler): |
233 | base64.b64encode(ref).decode('UTF-8'): permissions |
234 | for ref, permissions in self.ref_permissions[args['key']].items()} |
235 | |
236 | + def getMergeProposalURL(self, proto, args): |
237 | + return 'http://mp-url.test' |
238 | + |
239 | |
240 | class MockRef(object): |
241 | |
242 | @@ -141,14 +145,16 @@ class HookTestMixin(object): |
243 | super(HookTestMixin, self).setUp() |
244 | self.hookrpc_handler = MockHookRPCHandler() |
245 | self.hookrpc = hookrpc.HookRPCServerFactory(self.hookrpc_handler) |
246 | - dir = self.useFixture(TempDir()).path |
247 | - self.hookrpc_sock_path = os.path.join(dir, 'hookrpc_sock') |
248 | + self.repo_dir = os.path.join(self.useFixture(TempDir()).path, '.git') |
249 | + os.mkdir(self.repo_dir) |
250 | + subprocess.check_output(['git', 'init', self.repo_dir]) |
251 | + self.hookrpc_sock_path = os.path.join(self.repo_dir, 'hookrpc_sock') |
252 | self.hookrpc_port = reactor.listenUNIX( |
253 | self.hookrpc_sock_path, self.hookrpc) |
254 | self.addCleanup(self.hookrpc_port.stopListening) |
255 | - hooks_dir = os.path.join(dir, 'hooks') |
256 | + hooks_dir = os.path.join(self.repo_dir, 'hooks') |
257 | os.mkdir(hooks_dir) |
258 | - ensure_hooks(dir) |
259 | + ensure_hooks(self.repo_dir) |
260 | self.hook_path = os.path.join(hooks_dir, self.hook_name) |
261 | |
262 | def encodeRefs(self, updates): |
263 | @@ -185,6 +191,16 @@ class HookTestMixin(object): |
264 | self.encodeRefs(updates), permissions) |
265 | self.assertEqual((1, message, b''), (code, out, err)) |
266 | |
267 | + @defer.inlineCallbacks |
268 | + def assertMergeProposalURLReceived(self, updates, permissions): |
269 | + mp_url_message = ( |
270 | + b"Create a merge proposal for '%s' on Launchpad by" |
271 | + b" visiting:\n" % updates[0][0].split(b'/', 2)[2]) |
272 | + code, out, err = yield self.invokeHook( |
273 | + self.encodeRefs(updates), permissions) |
274 | + self.assertEqual((0, b''), (code, err)) |
275 | + self.assertIn(mp_url_message, out) |
276 | + |
277 | |
278 | class TestPreReceiveHook(HookTestMixin, TestCase): |
279 | """Tests for the git pre-receive hook.""" |
280 | @@ -247,7 +263,7 @@ class TestPostReceiveHook(HookTestMixin, TestCase): |
281 | @defer.inlineCallbacks |
282 | def test_notifies(self): |
283 | # The notification callback is invoked with the storage path. |
284 | - yield self.assertAccepted( |
285 | + yield self.assertMergeProposalURLReceived( |
286 | [(b'refs/heads/foo', self.old_sha1, self.new_sha1)], []) |
287 | self.assertEqual(['/translated'], self.hookrpc_handler.notifications) |
288 | |
289 | @@ -257,6 +273,39 @@ class TestPostReceiveHook(HookTestMixin, TestCase): |
290 | yield self.assertAccepted([], []) |
291 | self.assertEqual([], self.hookrpc_handler.notifications) |
292 | |
293 | + @defer.inlineCallbacks |
294 | + def test_merge_proposal_URL(self): |
295 | + # MP URL received for push of non-default branch |
296 | + curdir = os.getcwd() |
297 | + try: |
298 | + os.chdir(self.repo_dir) |
299 | + default_branch = subprocess.check_output( |
300 | + ['git', 'symbolic-ref', 'HEAD'] |
301 | + ).rstrip(b'\n') |
302 | + pushed_branch = str(default_branch + 'notdefault') |
303 | + yield self.assertMergeProposalURLReceived([( |
304 | + b'%s' % pushed_branch, |
305 | + self.old_sha1, self.new_sha1)], |
306 | + {b'%s' % pushed_branch: ['push']}) |
307 | + finally: |
308 | + os.chdir(curdir) |
309 | + |
310 | + @defer.inlineCallbacks |
311 | + def test_no_merge_proposal_URL(self): |
312 | + # No MP URL received for push of default branch |
313 | + curdir = os.getcwd() |
314 | + try: |
315 | + os.chdir(self.repo_dir) |
316 | + default_branch = subprocess.check_output( |
317 | + ['git', 'symbolic-ref', 'HEAD'] |
318 | + ).rstrip(b'\n') |
319 | + yield self.assertAccepted([( |
320 | + b'%s' % default_branch, |
321 | + self.old_sha1, self.new_sha1)], |
322 | + {b'%s' % default_branch: ['push']}) |
323 | + finally: |
324 | + os.chdir(curdir) |
325 | + |
326 | |
327 | class TestUpdateHook(TestCase): |
328 | """Tests for the git update hook""" |
This still needs Unit Tests.
A sample output from local envs looks like this:
ilasc@thinkpad: ~/repo$ git push /code.launchpad .test/~ ilasc/+ git/repo/ +ref/branch1/ +register- merge //git.launchpad .test:9422/ ~ilasc/ +git/repo ~/repo$
Counting objects: 3, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 343 bytes | 343.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0)
remote:
remote: Create a merge proposal for branch 'branch1' on Launchpad by visiting:
remote: https:/
remote:
To git+ssh:
1715591..38a497b branch1 -> branch1
ilasc@thinkpad: