Merge lp:~frankban/charms/precise/juju-gui/guiserver-bundles-base-helpers into lp:~juju-gui/charms/precise/juju-gui/trunk

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 94
Proposed branch: lp:~frankban/charms/precise/juju-gui/guiserver-bundles-base-helpers
Merge into: lp:~juju-gui/charms/precise/juju-gui/trunk
Diff against target: 537 lines (+399/-11)
6 files modified
revision (+1/-1)
server/guiserver/bundles/__init__.py (+18/-7)
server/guiserver/bundles/utils.py (+85/-1)
server/guiserver/tests/bundles/test_utils.py (+170/-1)
server/guiserver/tests/test_utils.py (+98/-1)
server/guiserver/utils.py (+27/-0)
To merge this branch: bzr merge lp:~frankban/charms/precise/juju-gui/guiserver-bundles-base-helpers
Reviewer Review Type Date Requested Status
charmers Pending
Review via email: mp+181269@code.launchpad.net

Description of the change

GUI server: base deployer helpers.

This branch includes some helper functions
and objects that are required to implement
the Deployer and DeployMiddleware classes.

To avoid this branch to include too many
changes, the base classes above will be
implemented in a separate branch.

Tests: `make unittest` from the branch root.

https://codereview.appspot.com/13153043/

To post a comment you must log in.
Revision history for this message
Francesco Banconi (frankban) wrote :

Reviewers: mp+181269_code.launchpad.net,

Message:
Please take a look.

Description:
GUI server: base deployer helpers.

This branch includes some helper functions
and objects that are required to implement
the Deployer and DeployMiddleware classes.

To avoid this branch to include too many
changes, the base classes above will be
implemented in a separate branch.

Tests: `make unittest` from the branch root.

https://code.launchpad.net/~frankban/charms/precise/juju-gui/guiserver-bundles-base-helpers/+merge/181269

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/13153043/

Affected files:
   A [revision details]
   M revision
   M server/guiserver/bundles/__init__.py
   M server/guiserver/bundles/utils.py
   M server/guiserver/tests/bundles/test_utils.py
   M server/guiserver/tests/test_utils.py
   M server/guiserver/utils.py

Revision history for this message
Brad Crittenden (bac) wrote :

LGTM, thanks.

https://codereview.appspot.com/13153043/diff/1/server/guiserver/bundles/utils.py
File server/guiserver/bundles/utils.py (right):

https://codereview.appspot.com/13153043/diff/1/server/guiserver/bundles/utils.py#newcode41
server/guiserver/bundles/utils.py:41: - Time: the time in seconds since
the epoch as an int.
Would be nice to document the optional fields.

https://codereview.appspot.com/13153043/diff/1/server/guiserver/tests/bundles/test_utils.py
File server/guiserver/tests/bundles/test_utils.py (right):

https://codereview.appspot.com/13153043/diff/1/server/guiserver/tests/bundles/test_utils.py#newcode143
server/guiserver/tests/bundles/test_utils.py:143: def
test_notify_position(self):
A test exercising showing the STARTED state would be nice.

https://codereview.appspot.com/13153043/

118. By Francesco Banconi

Changes as per review.

Revision history for this message
Francesco Banconi (frankban) wrote :

*** Submitted:

GUI server: base deployer helpers.

This branch includes some helper functions
and objects that are required to implement
the Deployer and DeployMiddleware classes.

To avoid this branch to include too many
changes, the base classes above will be
implemented in a separate branch.

Tests: `make unittest` from the branch root.

R=benji, bac
CC=
https://codereview.appspot.com/13153043

https://codereview.appspot.com/13153043/diff/1/server/guiserver/bundles/utils.py
File server/guiserver/bundles/utils.py (right):

https://codereview.appspot.com/13153043/diff/1/server/guiserver/bundles/utils.py#newcode41
server/guiserver/bundles/utils.py:41: - Time: the time in seconds since
the epoch as an int.
On 2013/08/21 14:19:18, bac wrote:
> Would be nice to document the optional fields.

Done.

https://codereview.appspot.com/13153043/diff/1/server/guiserver/bundles/utils.py#newcode88
server/guiserver/bundles/utils.py:88: """Add a change to the deployment
watcher notifying a new position."""
On 2013/08/21 13:18:10, benji wrote:
> I think I see that "position" is the position of the deployment in the
queue but
> I don't quite see how bool(position) == False implies that the
deployment has
> been started. Maybe some more text in a comment the docstring would
help.

Done.

https://codereview.appspot.com/13153043/diff/1/server/guiserver/tests/bundles/test_utils.py
File server/guiserver/tests/bundles/test_utils.py (right):

https://codereview.appspot.com/13153043/diff/1/server/guiserver/tests/bundles/test_utils.py#newcode143
server/guiserver/tests/bundles/test_utils.py:143: def
test_notify_position(self):
On 2013/08/21 14:19:18, bac wrote:
> A test exercising showing the STARTED state would be nice.

You are right! Missed it, thank you.

https://codereview.appspot.com/13153043/

Revision history for this message
Francesco Banconi (frankban) wrote :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'revision'
2--- revision 2013-08-20 08:02:05 +0000
3+++ revision 2013-08-21 16:12:55 +0000
4@@ -1,1 +1,1 @@
5-72
6+73
7
8=== modified file 'server/guiserver/bundles/__init__.py'
9--- server/guiserver/bundles/__init__.py 2013-08-20 08:00:13 +0000
10+++ server/guiserver/bundles/__init__.py 2013-08-21 16:12:55 +0000
11@@ -178,9 +178,12 @@
12 'RequestId': 3,
13 'Response': {
14 'Changes': [
15- {'DeploymentId': 42, 'Status': 'scheduled', 'Queue': 2},
16- {'DeploymentId': 42, 'Status': 'scheduled', 'Queue': 1},
17- {'DeploymentId': 42, 'Status': 'started', 'Queue': 0},
18+ {'DeploymentId': 42, 'Status': 'scheduled', 'Time': 1377080066,
19+ 'Queue': 2},
20+ {'DeploymentId': 42, 'Status': 'scheduled', 'Time': 1377080062,
21+ 'Queue': 1},
22+ {'DeploymentId': 42, 'Status': 'started', 'Time': 1377080000,
23+ 'Queue': 0},
24 ],
25 },
26 }
27@@ -192,6 +195,9 @@
28
29 The Status can be one of the following: 'scheduled', 'started' and 'completed'.
30
31+The Time field indicates the number of seconds since the epoch at the time of
32+the change.
33+
34 The Next request can be performed as many times as required by the API clients
35 after receiving a response from a previous one. However, if the Status of the
36 last deployment change is 'completed', no further changes will be notified, and
37@@ -204,6 +210,7 @@
38 {
39 'DeploymentId': 42,
40 'Status': 'completed',
41+ 'Time': 1377080000,
42 'Error': 'this field is only present if an error occurred',
43 },
44 ],
45@@ -238,10 +245,14 @@
46 'RequestId': 5,
47 'Response': {
48 'LastChanges': [
49- {'DeploymentId': 42, 'Status': 'completed', 'Error': 'error'},
50- {'DeploymentId': 43, 'Status': 'completed'},
51- {'DeploymentId': 44, 'Status': 'started', 'Queue': 0},
52- {'DeploymentId': 45, 'Status': 'scheduled', 'Queue': 1},
53+ {'DeploymentId': 42, 'Status': 'completed', 'Time': 1377080001,
54+ 'Error': 'error'},
55+ {'DeploymentId': 43, 'Status': 'completed',
56+ 'Time': 1377080002},
57+ {'DeploymentId': 44, 'Status': 'started', 'Time': 1377080003,
58+ 'Queue': 0},
59+ {'DeploymentId': 45, 'Status': 'scheduled', 'Time': 1377080004,
60+ 'Queue': 1},
61 ],
62 },
63 }
64
65=== modified file 'server/guiserver/bundles/utils.py'
66--- server/guiserver/bundles/utils.py 2013-08-20 14:05:39 +0000
67+++ server/guiserver/bundles/utils.py 2013-08-21 16:12:55 +0000
68@@ -14,13 +14,97 @@
69 # You should have received a copy of the GNU Affero General Public License
70 # along with this program. If not, see <http://www.gnu.org/licenses/>.
71
72-"""Bundle deployment utility functions."""
73+"""Bundle deployment utility functions and objects."""
74
75 from functools import wraps
76+import itertools
77 import logging
78+import time
79
80 from tornado import gen
81
82+from guiserver.watchers import AsyncWatcher
83+
84+
85+# Change statuses.
86+SCHEDULED = 'scheduled'
87+STARTED = 'started'
88+COMPLETED = 'completed'
89+
90+
91+def create_change(deployment_id, status, queue=None, error=None):
92+ """Return a dict representing a deployment change.
93+
94+ The resulting dict contains at least the following fields:
95+ - DeploymentId: the deployment identifier;
96+ - Status: the deployment's current status;
97+ - Time: the time in seconds since the epoch as an int.
98+
99+ These optional fields can also be present:
100+ - Queue: the deployment position in the queue at the time of this change;
101+ - Error: a message describing an error occurred during the deployment.
102+ """
103+ result = {
104+ 'DeploymentId': deployment_id,
105+ 'Status': status,
106+ 'Time': int(time.time()),
107+ }
108+ if queue is not None:
109+ result['Queue'] = queue
110+ if error is not None:
111+ result['Error'] = error
112+ return result
113+
114+
115+class Observer(object):
116+ """Handle multiple deployment watchers."""
117+
118+ def __init__(self):
119+ # Map deployment identifiers to watchers.
120+ self.deployments = {}
121+ # Map watcher identifiers to deployment identifiers.
122+ self.watchers = {}
123+ # This counter is used to generate deployment identifiers.
124+ self._deployment_counter = itertools.count()
125+ # This counter is used to generate watcher identifiers.
126+ self._watcher_counter = itertools.count()
127+
128+ def add_deployment(self):
129+ """Start observing a deployment.
130+
131+ Generate a deployment id and add it to self.deployments.
132+ Return the generated deployment id.
133+ """
134+ deployment_id = self._deployment_counter.next()
135+ self.deployments[deployment_id] = AsyncWatcher()
136+ return deployment_id
137+
138+ def add_watcher(self, deployment_id):
139+ """Return a new watcher id for the given deployment id.
140+
141+ Also add the generated watcher id to self.watchers.
142+ """
143+ watcher_id = self._watcher_counter.next()
144+ self.watchers[watcher_id] = deployment_id
145+ return watcher_id
146+
147+ def notify_position(self, deployment_id, position):
148+ """Add a change to the deployment watcher notifying a new position.
149+
150+ If the position in the queue is 0, it means the deployment is started
151+ or about to start. Therefore set its status to STARTED.
152+ """
153+ watcher = self.deployments[deployment_id]
154+ status = SCHEDULED if position else STARTED
155+ change = create_change(deployment_id, status, queue=position)
156+ watcher.put(change)
157+
158+ def notify_completed(self, deployment_id, error=None):
159+ """Add a change to the deployment watcher notifying it is completed."""
160+ watcher = self.deployments[deployment_id]
161+ change = create_change(deployment_id, COMPLETED, error=error)
162+ watcher.close(change)
163+
164
165 def require_authenticated_user(view):
166 """Require the user to be authenticated when executing the decorated view.
167
168=== modified file 'server/guiserver/tests/bundles/test_utils.py'
169--- server/guiserver/tests/bundles/test_utils.py 2013-08-20 14:05:39 +0000
170+++ server/guiserver/tests/bundles/test_utils.py 2013-08-21 16:12:55 +0000
171@@ -14,10 +14,11 @@
172 # You should have received a copy of the GNU Affero General Public License
173 # along with this program. If not, see <http://www.gnu.org/licenses/>.
174
175-"""Tests for the deployment utility functions."""
176+"""Tests for the deployment utility functions and objects."""
177
178 import unittest
179
180+import mock
181 from tornado import gen
182 from tornado.testing import(
183 AsyncTestCase,
184@@ -26,10 +27,178 @@
185 LogTrapTestCase,
186 )
187
188+from guiserver import watchers
189 from guiserver.bundles import utils
190 from guiserver.tests import helpers
191
192
193+@mock.patch('time.time', mock.Mock(return_value=1234))
194+class TestCreateChange(unittest.TestCase):
195+
196+ def test_status(self):
197+ # The change includes the deployment status.
198+ expected = {'DeploymentId': 0, 'Status': utils.STARTED, 'Time': 1234}
199+ obtained = utils.create_change(0, utils.STARTED)
200+ self.assertEqual(expected, obtained)
201+
202+ def test_queue(self):
203+ # The change includes the deployment queue length.
204+ expected = {
205+ 'DeploymentId': 1,
206+ 'Status': utils.SCHEDULED,
207+ 'Time': 1234,
208+ 'Queue': 42,
209+ }
210+ obtained = utils.create_change(1, utils.SCHEDULED, queue=42)
211+ self.assertEqual(expected, obtained)
212+
213+ def test_error(self):
214+ # The change includes a deployment error.
215+ expected = {
216+ 'DeploymentId': 2,
217+ 'Status': utils.COMPLETED,
218+ 'Time': 1234,
219+ 'Error': 'an error',
220+ }
221+ obtained = utils.create_change(2, utils.COMPLETED, error='an error')
222+ self.assertEqual(expected, obtained)
223+
224+ def test_all_params(self):
225+ # The change includes all the parameters.
226+ expected = {
227+ 'DeploymentId': 3,
228+ 'Status': utils.COMPLETED,
229+ 'Time': 1234,
230+ 'Queue': 47,
231+ 'Error': 'an error',
232+ }
233+ obtained = utils.create_change(
234+ 3, utils.COMPLETED, queue=47, error='an error')
235+ self.assertEqual(expected, obtained)
236+
237+
238+class TestObserver(unittest.TestCase):
239+
240+ def setUp(self):
241+ self.observer = utils.Observer()
242+
243+ def assert_deployment(self, deployment_id):
244+ """Ensure the given deployment id is being observed.
245+
246+ Also check that a watcher is associated with the given deployment id.
247+ Return the watcher.
248+ """
249+ deployments = self.observer.deployments
250+ self.assertIn(deployment_id, deployments)
251+ watcher = deployments[deployment_id]
252+ self.assertIsInstance(watcher, watchers.AsyncWatcher)
253+ return watcher
254+
255+ def assert_watcher(self, watcher_id, deployment_id):
256+ """Ensure the given watcher id is associated with the deployment id."""
257+ watchers = self.observer.watchers
258+ self.assertIn(watcher_id, watchers)
259+ self.assertEqual(deployment_id, watchers[watcher_id])
260+
261+ def test_initial(self):
262+ # A newly created observer does not contain deployments.
263+ self.assertEqual({}, self.observer.deployments)
264+ self.assertEqual({}, self.observer.watchers)
265+
266+ def test_add_deployment(self):
267+ # A new deployment is correctly added to the observer.
268+ deployment_id = self.observer.add_deployment()
269+ self.assertEqual(1, len(self.observer.deployments))
270+ self.assert_deployment(deployment_id)
271+
272+ def test_add_multiple_deployments(self):
273+ # Multiple deployments can be added to the observer.
274+ deployment1 = self.observer.add_deployment()
275+ deployment2 = self.observer.add_deployment()
276+ self.assertNotEqual(deployment1, deployment2)
277+ self.assertEqual(2, len(self.observer.deployments))
278+ watcher1 = self.assert_deployment(deployment1)
279+ watcher2 = self.assert_deployment(deployment2)
280+ self.assertNotEqual(watcher1, watcher2)
281+
282+ def test_add_watcher(self):
283+ # A new watcher is correctly added to the observer.
284+ deployment_id = self.observer.add_deployment()
285+ watcher_id = self.observer.add_watcher(deployment_id)
286+ self.assertEqual(1, len(self.observer.watchers))
287+ self.assert_watcher(watcher_id, deployment_id)
288+
289+ def test_add_multiple_watchers(self):
290+ # Multiple watchers can be added to the observer.
291+ deployment1 = self.observer.add_deployment()
292+ deployment2 = self.observer.add_deployment()
293+ watcher1 = self.observer.add_watcher(deployment1)
294+ watcher2 = self.observer.add_watcher(deployment2)
295+ self.assertNotEqual(watcher1, watcher2)
296+ self.assertEqual(2, len(self.observer.watchers))
297+ self.assert_watcher(watcher1, deployment1)
298+ self.assert_watcher(watcher2, deployment2)
299+
300+ @mock.patch('time.time', mock.Mock(return_value=1234))
301+ def test_notify_scheduled(self):
302+ # It is possible to notify a new queue position for a deployment.
303+ deployment_id = self.observer.add_deployment()
304+ watcher = self.observer.deployments[deployment_id]
305+ self.observer.notify_position(deployment_id, 3)
306+ expected = {
307+ 'DeploymentId': deployment_id,
308+ 'Status': utils.SCHEDULED,
309+ 'Time': 1234,
310+ 'Queue': 3,
311+ }
312+ self.assertEqual(expected, watcher.getlast())
313+ self.assertFalse(watcher.closed)
314+
315+ @mock.patch('time.time', mock.Mock(return_value=12345))
316+ def test_notify_started(self):
317+ # It is possible to notify that a deployment is (about to be) started.
318+ deployment_id = self.observer.add_deployment()
319+ watcher = self.observer.deployments[deployment_id]
320+ self.observer.notify_position(deployment_id, 0)
321+ expected = {
322+ 'DeploymentId': deployment_id,
323+ 'Status': utils.STARTED,
324+ 'Time': 12345,
325+ 'Queue': 0,
326+ }
327+ self.assertEqual(expected, watcher.getlast())
328+ self.assertFalse(watcher.closed)
329+
330+ @mock.patch('time.time', mock.Mock(return_value=123456))
331+ def test_notify_completed(self):
332+ # It is possible to notify that a deployment is completed.
333+ deployment_id = self.observer.add_deployment()
334+ watcher = self.observer.deployments[deployment_id]
335+ self.observer.notify_completed(deployment_id)
336+ expected = {
337+ 'DeploymentId': deployment_id,
338+ 'Status': utils.COMPLETED,
339+ 'Time': 123456,
340+ }
341+ self.assertEqual(expected, watcher.getlast())
342+ self.assertTrue(watcher.closed)
343+
344+ @mock.patch('time.time', mock.Mock(return_value=1234567))
345+ def test_notify_error(self):
346+ # It is possible to notify that an error occurred during a deployment.
347+ deployment_id = self.observer.add_deployment()
348+ watcher = self.observer.deployments[deployment_id]
349+ self.observer.notify_completed(deployment_id, error='bad wolf')
350+ expected = {
351+ 'DeploymentId': deployment_id,
352+ 'Status': utils.COMPLETED,
353+ 'Time': 1234567,
354+ 'Error': 'bad wolf',
355+ }
356+ self.assertEqual(expected, watcher.getlast())
357+ self.assertTrue(watcher.closed)
358+
359+
360 class TestRequireAuthenticatedUser(
361 helpers.BundlesTestMixin, LogTrapTestCase, AsyncTestCase):
362
363
364=== modified file 'server/guiserver/tests/test_utils.py'
365--- server/guiserver/tests/test_utils.py 2013-07-25 14:22:50 +0000
366+++ server/guiserver/tests/test_utils.py 2013-08-21 16:12:55 +0000
367@@ -17,14 +17,63 @@
368 """Tests for the Juju GUI server utilities."""
369
370 import json
371+import os
372+import shutil
373+import tempfile
374 import unittest
375
376 import mock
377-from tornado.testing import ExpectLog
378+from tornado import (
379+ concurrent,
380+ gen,
381+)
382+from tornado.testing import (
383+ AsyncTestCase,
384+ ExpectLog,
385+ gen_test,
386+)
387
388 from guiserver import utils
389
390
391+class TestAddFuture(AsyncTestCase):
392+
393+ def setUp(self):
394+ # Set up a future object and a result attribute where tests will store
395+ # their results.
396+ super(TestAddFuture, self).setUp()
397+ self.future = concurrent.Future()
398+ self.result = None
399+
400+ @gen.coroutine
401+ def assert_done(self, result):
402+ """Fire the future and ensure the callback has been called.
403+
404+ Callbacks in this test case store their results in self.result.
405+ """
406+ self.assertTrue(self.future.done())
407+ yield self.future
408+ self.assertEqual(result, self.result)
409+
410+ @gen_test
411+ def test_without_args(self):
412+ # A callback without args is correctly called.
413+ def callback(future):
414+ self.result = 'future said: ' + future.result()
415+ utils.add_future(self.io_loop, self.future, callback)
416+ self.future.set_result('I am done')
417+ yield self.assert_done('future said: I am done')
418+
419+ @gen_test
420+ def test_with_args(self):
421+ # A callback with args is correctly called.
422+ def callback(arg1, arg2, future):
423+ self.result = [arg1, arg2, future.result()]
424+ utils.add_future(self.io_loop, self.future, callback, 1, 2)
425+ self.future.set_result(3)
426+ yield self.assert_done([1, 2, 3])
427+
428+
429 class TestGetHeaders(unittest.TestCase):
430
431 def test_propagation(self):
432@@ -64,6 +113,54 @@
433 self.assertIsNone(utils.json_decode_dict('"not-a-dict"'))
434
435
436+class TestMkdir(unittest.TestCase):
437+
438+ def setUp(self):
439+ self.playground = tempfile.mkdtemp()
440+ self.addCleanup(shutil.rmtree, self.playground)
441+
442+ def test_create_dir(self):
443+ # A directory is correctly created.
444+ path = os.path.join(self.playground, 'foo')
445+ utils.mkdir(path)
446+ self.assertTrue(os.path.isdir(path))
447+
448+ def test_intermediate_dirs(self):
449+ # All intermediate directories are created.
450+ path = os.path.join(self.playground, 'foo', 'bar', 'leaf')
451+ utils.mkdir(path)
452+ self.assertTrue(os.path.isdir(path))
453+
454+ def test_expand_user(self):
455+ # The ~ construction is expanded.
456+ with mock.patch('os.environ', {'HOME': self.playground}):
457+ utils.mkdir('~/in/my/home')
458+ path = os.path.join(self.playground, 'in', 'my', 'home')
459+ self.assertTrue(os.path.isdir(path))
460+
461+ def test_existing_dir(self):
462+ # The function exits without errors if the target directory exists.
463+ path = os.path.join(self.playground, 'foo')
464+ os.mkdir(path)
465+ utils.mkdir(path)
466+
467+ def test_existing_file(self):
468+ # An OSError is raised if a file already exists in the target path.
469+ path = os.path.join(self.playground, 'foo')
470+ with open(path, 'w'):
471+ with self.assertRaises(OSError):
472+ utils.mkdir(path)
473+
474+ def test_failure(self):
475+ # Errors are correctly re-raised.
476+ path = os.path.join(self.playground, 'foo')
477+ os.chmod(self.playground, 0000)
478+ self.addCleanup(os.chmod, self.playground, 0700)
479+ with self.assertRaises(OSError):
480+ utils.mkdir(os.path.join(path))
481+ self.assertFalse(os.path.exists(path))
482+
483+
484 class TestRequestSummary(unittest.TestCase):
485
486 def test_summary(self):
487
488=== modified file 'server/guiserver/utils.py'
489--- server/guiserver/utils.py 2013-07-24 12:35:50 +0000
490+++ server/guiserver/utils.py 2013-08-21 16:12:55 +0000
491@@ -17,12 +17,24 @@
492 """Juju GUI server utility functions and classes."""
493
494 import collections
495+import errno
496+import functools
497 import logging
498+import os
499 import urlparse
500
501 from tornado import escape
502
503
504+def add_future(io_loop, future, callback, *args):
505+ """Schedule a callback on the IO loop when the given Future is finished.
506+
507+ The callback will receive the given optional args and the completed Future.
508+ """
509+ partial_callback = functools.partial(callback, *args)
510+ io_loop.add_future(future, partial_callback)
511+
512+
513 def get_headers(request, websocket_url):
514 """Return additional headers to be included in the client connection.
515
516@@ -55,6 +67,21 @@
517 return data
518
519
520+def mkdir(path):
521+ """Create a leaf directory and all intermediate ones.
522+
523+ Also expand ~ and ~user constructions.
524+ If path exists and it's a directory, return without errors.
525+ """
526+ path = os.path.expanduser(path)
527+ try:
528+ os.makedirs(path)
529+ except OSError as err:
530+ # Re-raise the error if the target path exists but it is not a dir.
531+ if (err.errno != errno.EEXIST) or (not os.path.isdir(path)):
532+ raise
533+
534+
535 def request_summary(request):
536 """Return a string representing a summary for the given request."""
537 return '{} {} ({})'.format(request.method, request.uri, request.remote_ip)

Subscribers

People subscribed via source and target branches