Merge ~ilasc/turnip:add-mp-url-to-git-push into turnip:master

Proposed by Ioana Lasc
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)
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

To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) wrote :

This still needs Unit Tests.

A sample output from local envs looks like this:

ilasc@thinkpad:~/repo$ git push
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://code.launchpad.test/~ilasc/+git/repo/+ref/branch1/+register-merge
remote:
To git+ssh://git.launchpad.test:9422/~ilasc/+git/repo
   1715591..38a497b branch1 -> branch1
ilasc@thinkpad:~/repo$

Revision history for this message
Colin Watson (cjwatson) :
~ilasc/turnip:add-mp-url-to-git-push updated
e7ad2c0... by Ioana Lasc

Addressed review comments.

Revision history for this message
Ioana Lasc (ilasc) wrote :

Addressed most code review comments apart from moving the check for the default branch here.

Also needs Unit Tests added.

~ilasc/turnip:add-mp-url-to-git-push updated
c1238fe... by Ioana Lasc

Move check for default branch from LP to turnip

Revision history for this message
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://code.launchpad.net/~ilasc/launchpad/+git/launchpad/+merge/377044

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!

Revision history for this message
Colin Watson (cjwatson) :
review: Needs Fixing
Revision history for this message
Colin Watson (cjwatson) :
~ilasc/turnip:add-mp-url-to-git-push updated
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

Revision history for this message
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 TestPostReceiveHook, the test method "test_no_merge_proposal_URL" at the bottom is commented out. It's not similar to match_update_rules in that it invokes and rpc call.

Current failure for commented out test_no_merge_proposal_URL is:

File "turnip/pack/hooks/hook.py", line 154, in send_mp_url
    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.

Revision history for this message
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.join(self.dir, '.git') rather than directly in self.dir. Individual tests can fiddle with self.dir as needed. Then you don't need to mock is_default_branch; you can let the hook subprocess call it for real. This might actually be the best approach, because it allows the tests to be more realistic and robust: for instance, it would catch any implementation mistakes in is_default_branch, which would otherwise be difficult to find.

If you take this approach, you should use the invokeHook helper to construct your test. You'll also need to make MockHookRPCHandler.getMergeProposalURL be a more useful mock: in particular, it'll need to actually return (or raise) something so that the hook will print it.

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.

~ilasc/turnip:add-mp-url-to-git-push updated
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.

Revision history for this message
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.

Revision history for this message
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://code.launchpad.net/~ilasc/launchpad/+git/launchpad/+merge/377044 has been deployed to production, in order to avoid a situation where we have undeployable commits on turnip's master branch.

review: Approve
Revision history for this message
Colin Watson (cjwatson) :
~ilasc/turnip:add-mp-url-to-git-push updated
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.

Revision history for this message
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.

Revision history for this message
Colin Watson (cjwatson) :
~ilasc/turnip:add-mp-url-to-git-push updated
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

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/turnip/pack/hookrpc.py b/turnip/pack/hookrpc.py
2index 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 }
74diff --git a/turnip/pack/hooks/hook.py b/turnip/pack/hooks/hook.py
75index 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]
146diff --git a/turnip/pack/tests/fake_servers.py b/turnip/pack/tests/fake_servers.py
147index 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
169diff --git a/turnip/pack/tests/test_hookrpc.py b/turnip/pack/tests/test_hookrpc.py
170index 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)
220diff --git a/turnip/pack/tests/test_hooks.py b/turnip/pack/tests/test_hooks.py
221index 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"""

Subscribers

People subscribed via source and target branches