Merge lp:~frankban/lpsetup/remove-ssh-args into lp:lpsetup

Proposed by Francesco Banconi
Status: Merged
Approved by: Brad Crittenden
Approved revision: 70
Merged at revision: 68
Proposed branch: lp:~frankban/lpsetup/remove-ssh-args
Merge into: lp:lpsetup
Diff against target: 424 lines (+89/-179)
6 files modified
lpsetup/handlers.py (+7/-62)
lpsetup/subcommands/inithost.py (+13/-35)
lpsetup/subcommands/initlxc.py (+2/-2)
lpsetup/tests/subcommands/test_inithost.py (+28/-40)
lpsetup/tests/subcommands/test_initlxc.py (+1/-1)
lpsetup/tests/test_handlers.py (+38/-39)
To merge this branch: bzr merge lp:~frankban/lpsetup/remove-ssh-args
Reviewer Review Type Date Requested Status
Francesco Banconi (community) Approve
Brad Crittenden (community) code Approve
Review via email: mp+118330@code.launchpad.net

Commit message

Removed ssh private and public key arguments.

Description of the change

== Changes ==

Removed ssh private and public key arguments: updated subcommands and tests accordingly.

The handler *handle_ssh_keys* now just adds to namespace the names *ssh_key_path* and *valid_ssh_keys*, and raises an error if a key (of the ssh key pair) is found but not the other.

The integration test `test_install_lxc.py` pass.

To post a comment you must log in.
Revision history for this message
Brad Crittenden (bac) wrote :

Looks good. Thanks Francesco.

review: Approve (code)
Revision history for this message
Launchpad QA Bot (lpqabot) wrote :

The attempt to merge lp:~frankban/lpsetup/remove-ssh-args into lp:lpsetup failed. Below is the output from the failed tests.

./lpsetup/handlers.py
     179: E111 indentation is not a multiple of four

Revision history for this message
Francesco Banconi (frankban) :
review: Approve
Revision history for this message
Launchpad QA Bot (lpqabot) wrote :
Download full text (13.2 KiB)

The attempt to merge lp:~frankban/lpsetup/remove-ssh-args into lp:lpsetup failed. Below is the output from the failed tests.

nose.plugins.cover: ERROR: Coverage not available: unable to import coverage module
.........F.......................usage: nosetests [-h] {init-repo,help} ...
nosetests: error: argument user can not be omitted if the script is run as root.
Eusage: nosetests [-h] {init-repo,help} ...
nosetests: error: argument user can not be omitted if the script is run as root.
Eusage: nosetests [-h] {init-repo,help} ...
nosetests: error: argument user can not be omitted if the script is run as root.
Eusage: nosetests init-repo [-h]
                           [-s {fetch,setup_bzr_locations} [{fetch,setup_bzr_locations} ...]]
                           [--skip-steps {fetch,setup_bzr_locations} [{fetch,setup_bzr_locations} ...]]
                           [-y] [--dry-run] [--source SOURCE] [--use-http]
                           [--branch-name BRANCH_NAME]
                           [--checkout-name CHECKOUT_NAME] [-r REPOSITORY]
                           [--no-checkout]
nosetests init-repo: error: argument user can not be omitted if the script is run as root.
Eusage: nosetests init-repo [-h]
                           [-s {fetch,setup_bzr_locations} [{fetch,setup_bzr_locations} ...]]
                           [--skip-steps {fetch,setup_bzr_locations} [{fetch,setup_bzr_locations} ...]]
                           [-y] [--dry-run] [--source SOURCE] [--use-http]
                           [--branch-name BRANCH_NAME]
                           [--checkout-name CHECKOUT_NAME] [-r REPOSITORY]
                           [--no-checkout]
nosetests init-repo: error: argument user can not be omitted if the script is run as root.
Eusage: nosetests init-repo [-h]
                           [-s {fetch,setup_bzr_locations} [{fetch,setup_bzr_locations} ...]]
                           [--skip-steps {fetch,setup_bzr_locations} [{fetch,setup_bzr_locations} ...]]
                           [-y] [--dry-run] [--source SOURCE] [--use-http]
                           [--branch-name BRANCH_NAME]
                           [--checkout-name CHECKOUT_NAME] [-r REPOSITORY]
                           [--no-checkout]
nosetests init-repo: error: argument user can not be omitted if the script is run as root.
E...usage: nosetests init-host [-h]
                           [-s {initialize,setup_home,initialize_lxc,setup_apt} [{initialize,setup_home,initialize_lxc,setup_apt} ...]]
                           [--skip-steps {initialize,setup_home,initialize_lxc,setup_apt} [{initialize,setup_home,initialize_lxc,setup_apt} ...]]
                           [-y] [--dry-run] [-u USER] [-E EMAIL]
                           [-f FULL_NAME] [-l LPUSER] [-S SSH_KEY_NAME]
nosetests init-host: error: argument user can not be omitted if the script is run as root.
E.usage: nosetests [-h] {update,help} ...
nosetests: error: argument user can not be omitted if the script is run as root.
Eusage: nosetests [-h] {update,help} ...
nosetests: error: argument user can not be omitted if the script is run as root.
Eusage: nosetests [-h] {update,help} ...
nosetests: error: argument user can not ...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lpsetup/handlers.py'
2--- lpsetup/handlers.py 2012-07-31 09:41:24 +0000
3+++ lpsetup/handlers.py 2012-08-06 15:14:31 +0000
4@@ -168,72 +168,17 @@
5 The namespace must contain *home_dir* and *ssh_key_name*.
6 It should be invoked after handle_user.
7
8- Keys contained in the namespace are escaped::
9-
10- >>> import argparse
11- >>> private = r'PRIVATE\nKEY'
12- >>> public = r'PUBLIC\nKEY'
13- >>> namespace = argparse.Namespace(
14- ... private_key=private, public_key=public,
15- ... ssh_key_name='id_rsa', home_dir='/tmp/')
16- >>> handle_ssh_keys(namespace)
17- >>> namespace.private_key == private.decode('string-escape')
18- True
19- >>> namespace.public_key == public.decode('string-escape')
20- True
21- >>> namespace.valid_ssh_keys
22- True
23-
24- After this handler is called, the ssh key path is present as an attribute
25- of the namespace::
26-
27- >>> namespace.ssh_key_path
28- '/tmp/.ssh/id_rsa'
29-
30- Keys are None if they are not provided and can not be found in the
31- current home directory::
32-
33- >>> namespace = argparse.Namespace(
34- ... private_key=None, public_key=None, ssh_key_name='id_rsa',
35- ... home_dir='/tmp/__does_not_exists__')
36- >>> handle_ssh_keys(namespace) # doctest: +ELLIPSIS
37- >>> print namespace.private_key
38- None
39- >>> print namespace.public_key
40- None
41- >>> namespace.valid_ssh_keys
42- False
43-
44- If only one of private_key and public_key is provided, a
45- ValidationError will be raised.
46-
47- >>> namespace = argparse.Namespace(
48- ... private_key=private, public_key=None, ssh_key_name='id_rsa',
49- ... home_dir='/tmp/__does_not_exists__')
50- >>> handle_ssh_keys(namespace) # doctest: +ELLIPSIS
51- Traceback (most recent call last):
52- ValidationError: arguments private-key...
53+ This handler adds *ssh_key_path* and *valid_ssh_keys* to the namespace.
54 """
55- namespace.valid_ssh_keys = True
56 namespace.ssh_key_path = os.path.join(
57 namespace.home_dir, '.ssh', namespace.ssh_key_name)
58- for attr, path in (
59- ('private_key', namespace.ssh_key_path),
60- ('public_key', namespace.ssh_key_path + '.pub')):
61- value = getattr(namespace, attr, None)
62- if value:
63- setattr(namespace, attr, value.decode('string-escape'))
64- else:
65- try:
66- value = open(path).read()
67- except IOError:
68- value = None
69- namespace.valid_ssh_keys = False
70- setattr(namespace, attr, value)
71- if bool(namespace.private_key) != bool(namespace.public_key):
72+ namespace.valid_ssh_keys = os.path.isfile(namespace.ssh_key_path)
73+ public_key_path = namespace.ssh_key_path + '.pub'
74+ # Exits with an error if only one key of the ssh pair is found.
75+ if namespace.valid_ssh_keys != os.path.isfile(public_key_path):
76 raise ValidationError(
77- "arguments private-key and public-key: "
78- "both must be provided or neither must be provided.")
79+ 'ssh private and public keys {0}(.pub): both must exist '
80+ 'or neither must exist.'.format(namespace.ssh_key_path))
81
82
83 def handle_directories(namespace):
84
85=== modified file 'lpsetup/subcommands/inithost.py'
86--- lpsetup/subcommands/inithost.py 2012-07-30 12:04:54 +0000
87+++ lpsetup/subcommands/inithost.py 2012-08-06 15:14:31 +0000
88@@ -99,7 +99,7 @@
89 os.chmod(filename, 0644)
90
91
92-def setup_ssh(ssh_dir, private_key, public_key, valid_ssh_keys, ssh_key_path):
93+def setup_ssh(ssh_dir, valid_ssh_keys, ssh_key_path):
94 """Set up the user's `.ssh` directory.
95
96 Also ensure that SSH is configured correctly for the user.
97@@ -107,28 +107,24 @@
98 # Set up the user's ssh directory. The ssh key must be associated
99 # with the lpuser's Launchpad account.
100 mkdirs(ssh_dir)
101- # Generate user ssh keys if none are supplied.
102+ # Generate user ssh keys if they don't exist.
103 pub_key_path = ssh_key_path + '.pub'
104 if not valid_ssh_keys:
105 generate_ssh_keys(ssh_key_path)
106- private_key = open(ssh_key_path).read()
107- public_key = open(pub_key_path).read()
108- auth_file = os.path.join(ssh_dir, 'authorized_keys')
109+ # Write the user's public key to `authorized_keys` so that the user can
110+ # non-interactively ssh to the container.
111+ authorized_keys = os.path.join(ssh_dir, 'authorized_keys')
112+ authorized_keys_contents = open(pub_key_path).read()
113+ # Add bazaar.launchpad.net to `known_hosts`.
114 known_hosts = os.path.join(ssh_dir, 'known_hosts')
115- known_host_content = run(
116+ known_host_contents = run(
117 'ssh-keyscan', '-t', 'rsa', 'bazaar.launchpad.net')
118 files_to_write = [
119- (auth_file, public_key, 'a'),
120- (known_hosts, known_host_content, 'a'),
121+ (authorized_keys, authorized_keys_contents, 'a'),
122+ (known_hosts, known_host_contents, 'a'),
123 ]
124- if valid_ssh_keys:
125- # We only write out the SSH keys if we haven't had to
126- # generate them further up this function.
127- files_to_write.append((ssh_key_path, private_key, 'w'))
128- files_to_write.append((pub_key_path, public_key, 'w'))
129 for filename, contents, mode in files_to_write:
130 write_file_contents(filename, contents, mode, header=get_file_header())
131- os.chmod(ssh_key_path, 0600)
132
133
134 def initialize_base(user):
135@@ -182,9 +178,7 @@
136 """.format(modules=LP_APACHE_MODULES, hosts=HOSTS_FILE)
137
138
139-def setup_home(
140- user, full_name, email, lpuser, private_key, public_key, valid_ssh_keys,
141- ssh_key_path):
142+def setup_home(user, full_name, email, lpuser, valid_ssh_keys, ssh_key_path):
143 """Initialize the user home directory.
144
145 This is a separate step for several reasons::
146@@ -198,9 +192,7 @@
147 """
148 with su(user) as env:
149 # Set up the `.ssh` directory.
150- setup_ssh(
151- os.path.join(env.home, '.ssh'), private_key, public_key,
152- valid_ssh_keys, ssh_key_path)
153+ setup_ssh(os.path.join(env.home, '.ssh'), valid_ssh_keys, ssh_key_path)
154
155 # Set up bzr and Launchpad authentication.
156 call('bzr', 'whoami', formataddr([full_name, email]))
157@@ -272,7 +264,7 @@
158
159 setup_home_step = (setup_home,
160 'user', 'full_name', 'email', 'lpuser',
161- 'private_key', 'public_key', 'valid_ssh_keys', 'ssh_key_path')
162+ 'valid_ssh_keys', 'ssh_key_path')
163
164 initialize_lxc_step = (initialize_lxc, )
165
166@@ -321,20 +313,6 @@
167 'check out dependencies. If not provided, the system '
168 'user name is used.')
169 parser.add_argument(
170- '-v', '--private-key',
171- help='The SSH private key for the Launchpad user (without '
172- 'passphrase). If this argument is omitted and a keypair is '
173- 'not found in the home directory of the system user a new '
174- 'SSH keypair will be generated and the checkout of the '
175- 'Launchpad code will use HTTP rather than bzr+ssh.')
176- parser.add_argument(
177- '-b', '--public-key',
178- help='The SSH public key for the Launchpad user. '
179- 'If this argument is omitted and a keypair is not found '
180- 'in the home directory of the system user a new SSH '
181- 'keypair will be generated and the checkout of the '
182- 'Launchpad code will use HTTP rather than bzr+ssh.')
183- parser.add_argument(
184 '-S', '--ssh-key-name', default=SSH_KEY_NAME,
185 help='{0} [DEFAULT={1}]'.format(
186 'The ssh key name used to connect to Launchpad.',
187
188=== modified file 'lpsetup/subcommands/initlxc.py'
189--- lpsetup/subcommands/initlxc.py 2012-07-31 12:35:51 +0000
190+++ lpsetup/subcommands/initlxc.py 2012-08-06 15:14:31 +0000
191@@ -208,7 +208,7 @@
192
193
194 def inithost_in_lxc(lxc_name, ssh_key_path, user, email, full_name, lpuser,
195- private_key, public_key, ssh_key_name, home_dir):
196+ ssh_key_name, home_dir):
197 """Prepare the Launchpad environment inside an LXC."""
198 # Use ssh to call this script from inside the container.
199 args = ['init-host', '--yes', '-u', user, '-E', email, '-f', full_name,
200@@ -244,7 +244,7 @@
201 'lpsetup_branch')
202 inithost_in_lxc_step = (inithost_in_lxc,
203 'lxc_name', 'ssh_key_path', 'user', 'email', 'full_name', 'lpuser',
204- 'private_key', 'public_key', 'ssh_key_name', 'home_dir')
205+ 'ssh_key_name', 'home_dir')
206 stop_lxc_step = (stop_lxc,
207 'lxc_name', 'ssh_key_path')
208
209
210=== modified file 'lpsetup/tests/subcommands/test_inithost.py'
211--- lpsetup/tests/subcommands/test_inithost.py 2012-07-18 10:53:15 +0000
212+++ lpsetup/tests/subcommands/test_inithost.py 2012-08-06 15:14:31 +0000
213@@ -21,7 +21,7 @@
214 initialize_step = (inithost.initialize, ['user'])
215 setup_home_step = (
216 inithost.setup_home, ['user', 'full_name', 'email', 'lpuser',
217- 'private_key', 'public_key', 'valid_ssh_keys', 'ssh_key_path',
218+ 'valid_ssh_keys', 'ssh_key_path',
219 ])
220 initialize_lxc_step = (inithost.initialize_lxc, [])
221 setup_apt_step = (inithost.setup_apt, [])
222@@ -32,12 +32,9 @@
223 email = get_random_string()
224 full_name = get_random_string() + '@example.com'
225 lpuser = get_random_string()
226- private_key = get_random_string()
227- public_key = get_random_string()
228 ssh_key_name = get_random_string()
229- return (
230- '-u', user, '-E', email, '-f', full_name, '-l', lpuser,
231- '-v', private_key, '-b', public_key, '-S', ssh_key_name)
232+ return ('-u', user, '-E', email, '-f', full_name, '-l', lpuser,
233+ '-S', ssh_key_name)
234
235
236 class InithostSmokeTest(StepsBasedSubCommandTestMixin, unittest.TestCase):
237@@ -112,7 +109,7 @@
238 '/tmp/foo', 'Hello, world!', 'a+')
239
240
241-class SetupSSHTestCase(unittest.TestCase):
242+class SetupSSHTest(unittest.TestCase):
243 """Tests for inithost.setup_ssh()."""
244
245 def setUp(self):
246@@ -122,45 +119,36 @@
247 self.ssh_dir = tempfile.mkdtemp()
248 self.addCleanup(shutil.rmtree, self.ssh_dir)
249 self.addCleanup(os.remove, self.temp_filename)
250- self.addCleanup(os.remove, self.temp_filename + ".pub")
251-
252- def test_setup_ssh_writes_to_ssh_key_path(self):
253- # If inithost.setup_ssh() is told that the keys it has
254- # been passed are valid, it will write them out to the provided
255- # ssh_key_path.
256- public_key = "Public"
257- private_key = "Private"
258- inithost.setup_ssh(
259- self.ssh_dir, private_key, public_key, True, self.temp_filename)
260- with open(self.temp_filename + '.pub', 'r') as pub_file:
261- self.assertIn(public_key, pub_file.read())
262- with open(self.temp_filename, 'r') as priv_file:
263- self.assertIn(private_key, priv_file.read())
264-
265- def test_setup_ssh_generates_keys_if_not_passed(self):
266- # If SSH keys aren't passed to setup_ssh(), the function
267- # will generate some.
268- inithost.setup_ssh(
269- self.ssh_dir, None, None, False, self.temp_filename)
270+ self.addCleanup(os.remove, self.temp_filename + '.pub')
271+
272+ def test_setup_ssh_generates_keys_if_not_present(self):
273+ # If SSH keys do not exist, the function will generate some.
274+ inithost.setup_ssh(self.ssh_dir, False, self.temp_filename)
275 self.assertTrue(os.path.exists(self.temp_filename))
276- self.assertTrue(os.path.exists(self.temp_filename + ".pub"))
277-
278- def test_setup_ssh_generates_other_files(self):
279- # setup_ssh() also generates an authorized_keys file and a
280- # known_hosts file in the ssh dir.
281- inithost.setup_ssh(
282- self.ssh_dir, None, None, False, self.temp_filename)
283- self.assertTrue(
284- os.path.exists(os.path.join(self.ssh_dir, 'authorized_keys')))
285- self.assertTrue(
286- os.path.exists(os.path.join(self.ssh_dir, 'known_hosts')))
287+ self.assertTrue(os.path.exists(self.temp_filename + '.pub'))
288+
289+ def test_setup_ssh_generates_authorized_keys(self):
290+ # setup_ssh() also generates an authorized_keys file containing
291+ # the public key.
292+ inithost.setup_ssh(self.ssh_dir, False, self.temp_filename)
293+ authorized_keys = os.path.join(self.ssh_dir, 'authorized_keys')
294+ self.assertTrue(os.path.exists(authorized_keys))
295+ public_key = open(self.temp_filename + '.pub').read()
296+ self.assertIn(public_key, open(authorized_keys).read())
297+
298+ def test_setup_ssh_generates_known_hosts(self):
299+ # setup_ssh() also generates a `known_host` file containing
300+ # 'bazaar.launchpad.net'.
301+ inithost.setup_ssh(self.ssh_dir, False, self.temp_filename)
302+ known_hosts = os.path.join(self.ssh_dir, 'known_hosts')
303+ self.assertTrue(os.path.exists(known_hosts))
304+ self.assertIn('bazaar.launchpad.net', open(known_hosts).read())
305
306 def test_setup_ssh_creates_ssh_dir(self):
307 # If the ssh_dir passed to setup_ssh() doesn't exist, it
308 # will be created.
309 shutil.rmtree(self.ssh_dir)
310- inithost.setup_ssh(
311- self.ssh_dir, None, None, False, self.temp_filename)
312+ inithost.setup_ssh(self.ssh_dir, False, self.temp_filename)
313 self.assertTrue(os.path.exists(self.ssh_dir))
314 self.assertTrue(os.path.isdir(self.ssh_dir))
315
316
317=== modified file 'lpsetup/tests/subcommands/test_initlxc.py'
318--- lpsetup/tests/subcommands/test_initlxc.py 2012-07-31 09:38:14 +0000
319+++ lpsetup/tests/subcommands/test_initlxc.py 2012-08-06 15:14:31 +0000
320@@ -30,7 +30,7 @@
321 inithost_in_lxc_step = (
322 initlxc.inithost_in_lxc,
323 ['lxc_name', 'ssh_key_path', 'user', 'email', 'full_name', 'lpuser',
324- 'private_key', 'public_key', 'ssh_key_name', 'home_dir'])
325+ 'ssh_key_name', 'home_dir'])
326 stop_lxc_step = (initlxc.stop_lxc, ['lxc_name', 'ssh_key_path'])
327
328
329
330=== modified file 'lpsetup/tests/test_handlers.py'
331--- lpsetup/tests/test_handlers.py 2012-07-31 09:41:24 +0000
332+++ lpsetup/tests/test_handlers.py 2012-08-06 15:14:31 +0000
333@@ -228,53 +228,52 @@
334
335 class HandleSSHKeysTest(HandlersTestMixin, unittest.TestCase):
336
337- home_dir = '/tmp/__does_not_exist__'
338- private = r'PRIVATE\nKEY'
339- public = r'PUBLIC\nKEY'
340- ssh_key_name = 'id_rsa'
341+ ssh_key_name = 'my_id_rsa'
342
343- def test_key_escaping(self):
344- # Ensure the keys contained in the namespace are correctly escaped.
345- namespace = argparse.Namespace(
346- private_key=self.private, public_key=self.public,
347+ def setUp(self):
348+ self.home_dir = tempfile.mkdtemp()
349+ self.addCleanup(shutil.rmtree, self.home_dir)
350+ self.namespace = argparse.Namespace(
351 ssh_key_name=self.ssh_key_name, home_dir=self.home_dir)
352- handle_ssh_keys(namespace)
353- self.assertEqual(
354- self.private.decode('string-escape'),
355- namespace.private_key)
356- self.assertEqual(
357- self.public.decode('string-escape'),
358- namespace.public_key)
359- self.assertTrue(namespace.valid_ssh_keys)
360+
361+ def get_ssh_key_path(self):
362+ return os.path.join(self.home_dir, '.ssh', self.ssh_key_name)
363+
364+ def write_key(self, path):
365+ directory = os.path.dirname(path)
366+ if not os.path.exists(directory):
367+ os.mkdir(os.path.dirname(path))
368+ with open(path, 'w') as f:
369+ f.write('KEY CONTENTS')
370
371 def test_ssh_key_path_in_namespace(self):
372 # After the handler is called, the ssh key path is present
373 # as an attribute of the namespace.
374- namespace = argparse.Namespace(
375- private_key=self.private, public_key=self.public,
376- ssh_key_name=self.ssh_key_name, home_dir=self.home_dir)
377- handle_ssh_keys(namespace)
378- expected = self.home_dir + '/.ssh/id_rsa'
379- self.assertEqual(expected, namespace.ssh_key_path)
380-
381- def test_no_keys(self):
382- # Keys are None if they are not provided and can not be found in the
383- # current home directory.
384- namespace = argparse.Namespace(
385- private_key=None, ssh_key_name=self.ssh_key_name,
386- home_dir=self.home_dir)
387- handle_ssh_keys(namespace)
388- self.assertIsNone(namespace.private_key)
389- self.assertIsNone(namespace.public_key)
390- self.assertFalse(namespace.valid_ssh_keys)
391+ handle_ssh_keys(self.namespace)
392+ self.assertEqual(self.get_ssh_key_path(), self.namespace.ssh_key_path)
393+
394+ def test_invalid_keys(self):
395+ # Keys are marked as invalid if they can not be found in the
396+ # current home directory.
397+ handle_ssh_keys(self.namespace)
398+ self.assertFalse(self.namespace.valid_ssh_keys)
399+
400+ def test_valid_keys(self):
401+ # Keys are marked as valid if they are correctly found in the
402+ # current home directory.
403+ ssh_key_path = self.get_ssh_key_path()
404+ self.write_key(ssh_key_path)
405+ self.write_key(ssh_key_path + '.pub')
406+ handle_ssh_keys(self.namespace)
407+ self.assertTrue(self.namespace.valid_ssh_keys)
408
409 def test_only_one_key(self):
410- # Ensure a `ValidationError` is raised if only one key is provided.
411- namespace = argparse.Namespace(
412- private_key=self.private, public_key=None,
413- ssh_key_name=self.ssh_key_name, home_dir=self.home_dir)
414- with self.assertNotValid('private-key'):
415- handle_ssh_keys(namespace)
416+ # Ensure the handler raises a ValidationError if only one key
417+ # of the private/public pair is found.
418+ ssh_key_path = self.get_ssh_key_path()
419+ self.write_key(ssh_key_path)
420+ with self.assertNotValid(ssh_key_path):
421+ handle_ssh_keys(self.namespace)
422
423
424 class HandleTargetFromRepositoryTest(unittest.TestCase):

Subscribers

People subscribed via source and target branches

to all changes: