Merge lp:~azzar1/update-manager/canonical-livepatch into lp:update-manager

Proposed by Andrea Azzarone
Status: Merged
Merged at revision: 2788
Proposed branch: lp:~azzar1/update-manager/canonical-livepatch
Merge into: lp:update-manager
Diff against target: 440 lines (+366/-2)
5 files modified
UpdateManager/Core/LivePatchSocket.py (+118/-0)
UpdateManager/Dialogs.py (+46/-1)
debian/control (+2/-0)
tests/test_livepatch_socket.py (+200/-0)
tests/test_utils.py (+0/-1)
To merge this branch: bzr merge lp:~azzar1/update-manager/canonical-livepatch
Reviewer Review Type Date Requested Status
Brian Murray Needs Fixing
Review via email: mp+329452@code.launchpad.net

Commit message

Add Canonical LivePatch status to update-manager.

Description of the change

Add Canonical LivePatch status to update-manager as per https://wiki.ubuntu.com/SoftwareUpdates?action=recall&rev=221

To post a comment you must log in.
Revision history for this message
Brian Murray (brian-murray) wrote :

How will these changes allow a message like "7 Livepatch updates applied since the last restart. 1 other update failed to apply.”, from the specification, to be displayed? Does set_desc allow both the failure and success messages to be displayed?

review: Needs Fixing
Revision history for this message
Andrea Azzarone (azzar1) wrote :
Download full text (3.3 KiB)

That's a problem with the specification. With livepatch you either get all
the patches correctly applied or none.

Il 26 ago 2017 00:29, "Brian Murray" <email address hidden> ha scritto:

> Review: Needs Fixing
>
> How will these changes allow a message like "7 Livepatch updates applied
> since the last restart. 1 other update failed to apply.”, from the
> specification, to be displayed? Does set_desc allow both the failure and
> success messages to be displayed?
>
> Diff comments:
>
> >
> > === modified file 'UpdateManager/Dialogs.py'
> > --- UpdateManager/Dialogs.py 2017-08-07 20:54:58 +0000
> > +++ UpdateManager/Dialogs.py 2017-08-23 14:09:35 +0000
> > @@ -142,6 +144,45 @@
> > self.main_container.add(content_widget)
> > self.main_container.set_visible(bool(content_widget))
> >
> > + def on_livepatch_status_ready(self, active, check_state,
> patch_state, fixes):
> > + self.set_desc(None)
> > +
> > + if not active:
> > + return
> > +
> > + needs_reschedule = False
> > +
> > + if check_state == "needs-check":
> > + needs_reschedule = True
> > + elif check_state == "check-failed":
> > + pass
> > + elif check_state == "checked":
> > + if patch_state == "unapplied" or patch_state == "applying":
> > + needs_reschedule = True
> > + elif patch_state == "applied":
> > + patched_fixes = [fix for fix in fixes if fix.patched]
> > + if len(patched_fixes) == 1:
> > + self.set_desc(_("1 Livepatch update applied since
> the last restart."))
> > + elif len(patched_fixes) > 1:
> > + self.set_desc(_("%d Livepatch updates applied since
> the last restart." % len(patched_fixes)))
> > + elif patch_state == "applied-with-bug" or patch_state ==
> "apply-failed":
> > + patched_fixes = [fix for fix in fixes if fix.patched]
> > + if len(patched_fixes) == 1:
> > + self.set_desc(_("1 update failed to apply since
> the last restart"))
>
> The other calls to set_desc end with a period so lets have this one also
> end with a period.
>
> > + elif len(patched_fixes) > 1:
> > + self.set_desc(_("%d updates failed to apply since
> the last restart" % len(patched_fixes)))
>
> The other calls to set_desc end with a period so lets have this one also
> end with a period.
>
> > + elif patch_state == "nothing-to-apply":
> > + pass
> > + elif patch_state == "unknown":
> > + pass
> > +
> > + if needs_reschedule:
> > + self.lp_socket.get_status(self.on_livepatch_status_ready)
> > +
> > + def check_livepatch_status(self):
> > + self.lp_socket = LivePatchSocket()
> > + self.lp_socket.get_status(self.on_livepatch_status_ready)
> > +
> >
> > class StoppedUpdatesDialog(InternalDialog):
> > def __init__(self, window_main):
>
>
> --
> https://code.launchpad.net/~azzar1/update-manager/
> canonical-livepatch/+merge/329452
> You are the owner of lp:~azzar1/update-manager/canonical-livepatch.
>...

Read more...

Revision history for this message
Brian Murray (brian-murray) wrote :

Andrea - I'd also made a couple of in-line comments in my review. If you make those changes, I'll get this merged and uploaded.

Revision history for this message
Andrea Azzarone (azzar1) wrote :

Fixed.

Revision history for this message
Brian Murray (brian-murray) wrote :
Download full text (5.7 KiB)

As I was getting ready to upload this I discovered there are a fair number of pep8 failures with the added code.

 $ xvfb-run nosetests3
............................................................./home/bdmurray/source-trees/update-manager/trunk/tests/../tests/test_livepatch_socket.py:62:80: E501 line too long (127 > 79 characters)
/home/bdmurray/source-trees/update-manager/trunk/tests/../tests/test_livepatch_socket.py:83:80: E501 line too long (98 > 79 characters)
/home/bdmurray/source-trees/update-manager/trunk/tests/../tests/test_livepatch_socket.py:136:80: E501 line too long (94 > 79 characters)
/home/bdmurray/source-trees/update-manager/trunk/tests/../tests/test_livepatch_socket.py:137:80: E501 line too long (107 > 79 characters)
/home/bdmurray/source-trees/update-manager/trunk/tests/../tests/test_livepatch_socket.py:154:9: E306 expected 1 blank line before a nested definition, found 0
/home/bdmurray/source-trees/update-manager/trunk/tests/../tests/test_livepatch_socket.py:161:80: E501 line too long (80 > 79 characters)
F.F..................................
======================================================================
FAIL: test_pep8_clean (test_pep8.TestPep8Clean)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/bdmurray/source-trees/update-manager/trunk/tests/test_pep8.py", line 33, in test_pep8_clean
    self.assertEqual(0, ret_code)
AssertionError: 0 != 1

======================================================================
FAIL: test_pyflakes_clean (test_pyflakes.TestPyflakesClean)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/bdmurray/source-trees/update-manager/trunk/tests/test_pyflakes.py", line 56, in test_pyflakes_clean
    self.assertEqual(0, len(filtered_contents))
AssertionError: 0 != 8
-------------------- >> begin captured stdout << ---------------------
../tests/test_utils.py:5: 'glob' imported but unused
../tests/test_livepatch_socket.py:18: 'gi' imported but unused
../tests/test_livepatch_socket.py:186: undefined name 'logging'
../tests/test_livepatch_socket.py:186: undefined name 'logging'
../UpdateManager/Dialogs.py:29: 'gi.repository.GObject' imported but unused
../UpdateManager/Dialogs.py:30: 'gi.repository.Pango' imported but unused
../UpdateManager/Core/LivePatchSocket.py:23: 'gi' imported but unused
../UpdateManager/Core/LivePatchSocket.py:115: undefined name 'other'

--------------------- >> end captured stdout << ----------------------

----------------------------------------------------------------------

Sorry about asking for additional fixes, but could you address the ones in the code you've added? Thanks!
/home/bdmurray/source-trees/update-manager/trunk/tests/../tests/test_livepatch_socket.py:162:60: E712 comparison to True should be 'if cond is True:' or 'if cond:'
/home/bdmurray/source-trees/update-manager/trunk/tests/../tests/test_livepatch_socket.py:170:9: E306 expected 1 blank line before a nested definition, found 0
/home/bdmurray/source-trees/update-manager/trunk/tests/../tests/test_livepatch_socket.py:175:80: E501 line too long (173 > 79 char...

Read more...

review: Needs Fixing
Revision history for this message
Andrea Azzarone (azzar1) wrote :

Done!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'UpdateManager/Core/LivePatchSocket.py'
2--- UpdateManager/Core/LivePatchSocket.py 1970-01-01 00:00:00 +0000
3+++ UpdateManager/Core/LivePatchSocket.py 2017-08-31 07:59:52 +0000
4@@ -0,0 +1,118 @@
5+# LivePatchSocket.py
6+# -*- Mode: Python; indent-tabs-mode: nil; tab-width: 4; coding: utf-8 -*-
7+#
8+# Copyright (c) 2017 Canonical
9+#
10+# Author: Andrea Azzarone <andrea.azzarone@canonical.com>
11+#
12+# This program is free software; you can redistribute it and/or
13+# modify it under the terms of the GNU General Public License as
14+# published by the Free Software Foundation; either version 2 of the
15+# License, or (at your option) any later version.
16+#
17+# This program is distributed in the hope that it will be useful,
18+# but WITHOUT ANY WARRANTY; without even the implied warranty of
19+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20+# GNU General Public License for more details.
21+#
22+# You should have received a copy of the GNU General Public License
23+# along with this program; if not, write to the Free Software
24+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
25+# USA
26+
27+from gi.repository import GLib
28+import http.client
29+import socket
30+import threading
31+import yaml
32+
33+HOST_NAME = '/var/snap/canonical-livepatch/current/livepatchd.sock'
34+
35+
36+class UHTTPConnection(http.client.HTTPConnection):
37+
38+ def __init__(self, path):
39+ http.client.HTTPConnection.__init__(self, 'localhost')
40+ self.path = path
41+
42+ def connect(self):
43+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
44+ sock.connect(self.path)
45+ self.sock = sock
46+
47+
48+class LivePatchSocket(object):
49+
50+ def __init__(self, http_conn=None):
51+ if http_conn is None:
52+ self.conn = UHTTPConnection(HOST_NAME)
53+ else:
54+ self.conn = http_conn
55+
56+ def get_status(self, on_done):
57+
58+ def do_call():
59+ try:
60+ self.conn.request('GET', '/status?verbose=True')
61+ r = self.conn.getresponse()
62+ active = r.status == 200
63+ data = yaml.safe_load(r.read())
64+ except Exception as e:
65+ active = False
66+ data = dict()
67+ check_state = LivePatchSocket.get_check_state(data)
68+ patch_state = LivePatchSocket.get_patch_state(data)
69+ fixes = LivePatchSocket.get_fixes(data)
70+ GLib.idle_add(lambda: on_done(
71+ active, check_state, patch_state, fixes))
72+
73+ thread = threading.Thread(target=do_call)
74+ thread.start()
75+
76+ @staticmethod
77+ def get_check_state(data):
78+ try:
79+ status = data['status']
80+ kernel = next((k for k in status if k['running']), None)
81+ return kernel['livepatch']['checkState']
82+ except Exception as e:
83+ return 'check-failed'
84+
85+ @staticmethod
86+ def get_patch_state(data):
87+ try:
88+ status = data['status']
89+ kernel = next((k for k in status if k['running']), None)
90+ return kernel['livepatch']['patchState']
91+ except Exception as e:
92+ return 'unknown'
93+
94+ @staticmethod
95+ def get_fixes(data):
96+ try:
97+ status = data['status']
98+ kernel = next((k for k in status if k['running']), None)
99+ fixes = kernel['livepatch']['fixes']
100+ return [LivePatchFix(f)
101+ for f in fixes.replace('* ', '').split('\n') if len(f) > 0]
102+ except Exception as e:
103+ return list()
104+
105+
106+class LivePatchFix(object):
107+
108+ def __init__(self, text):
109+ patched_pattern = ' (unpatched)'
110+ self.patched = text.find(patched_pattern) == -1
111+ self.name = text.replace(patched_pattern, '')
112+
113+ def __eq__(self, other):
114+ if isinstance(other, LivePatchFix):
115+ return self.name == other.name and self.patched == other.patched
116+ return NotImplemented
117+
118+ def __ne__(self, other):
119+ result = self.__eq__(other)
120+ if result is NotImplemented:
121+ return result
122+ return not result
123
124=== modified file 'UpdateManager/Dialogs.py'
125--- UpdateManager/Dialogs.py 2017-08-07 20:54:58 +0000
126+++ UpdateManager/Dialogs.py 2017-08-31 07:59:52 +0000
127@@ -26,7 +26,6 @@
128 gi.require_version("Gtk", "3.0")
129 from gi.repository import Gtk
130 from gi.repository import Gdk
131-
132 import warnings
133 warnings.filterwarnings(
134 "ignore", "Accessed deprecated property", DeprecationWarning)
135@@ -37,8 +36,10 @@
136 import os
137
138 import HweSupportStatus.consts
139+from .Core.LivePatchSocket import LivePatchSocket
140
141 from gettext import gettext as _
142+from gettext import ngettext
143
144
145 class Dialog(object):
146@@ -142,6 +143,49 @@
147 self.main_container.add(content_widget)
148 self.main_container.set_visible(bool(content_widget))
149
150+ def on_livepatch_status_ready(self, active, cs, ps, fixes):
151+ self.set_desc(None)
152+
153+ if not active:
154+ return
155+
156+ needs_reschedule = False
157+
158+ if cs == "needs-check":
159+ needs_reschedule = True
160+ elif cs == "check-failed":
161+ pass
162+ elif cs == "checked":
163+ if ps == "unapplied" or ps == "applying":
164+ needs_reschedule = True
165+ elif ps == "applied":
166+ fixes = [fix for fix in fixes if fix.patched]
167+ d = ngettext("1 Livepatch update applied since the last "
168+ "restart.",
169+ "%d Livepatch updates applied since the last "
170+ "restart." % len(fixes),
171+ len(fixes))
172+ self.set_desc(d)
173+ elif ps == "applied-with-bug" or ps == "apply-failed":
174+ fixes = [fix for fix in fixes if fix.patched]
175+ d = ngettext("1 Livepatch update failed to apply since the "
176+ "last restart.",
177+ "%d Livepatch updates failed to apply since the "
178+ "last restart." % len(fixes),
179+ len(fixes))
180+ self.set_desc(d)
181+ elif ps == "nothing-to-apply":
182+ pass
183+ elif ps == "unknown":
184+ pass
185+
186+ if needs_reschedule:
187+ self.lp_socket.get_status(self.on_livepatch_status_ready)
188+
189+ def check_livepatch_status(self):
190+ self.lp_socket = LivePatchSocket()
191+ self.lp_socket.get_status(self.on_livepatch_status_ready)
192+
193
194 class StoppedUpdatesDialog(InternalDialog):
195 def __init__(self, window_main):
196@@ -166,6 +210,7 @@
197 self.add_settings_button()
198 self.focus_button = self.add_button(Gtk.STOCK_OK,
199 self.window_main.close)
200+ self.check_livepatch_status()
201
202
203 class DistUpgradeDialog(InternalDialog):
204
205=== modified file 'debian/control'
206--- debian/control 2017-07-18 22:32:49 +0000
207+++ debian/control 2017-08-31 07:59:52 +0000
208@@ -7,6 +7,7 @@
209 python3-distutils-extra (>= 2.38),
210 python3-dbus,
211 python3-gi (>= 3.8),
212+ python3-yaml,
213 gir1.2-gtk-3.0,
214 lsb-release,
215 apt-clone (>= 0.2.3~ubuntu1)
216@@ -59,6 +60,7 @@
217 policykit-1,
218 python3-dbus,
219 python3-gi (>= 3.8),
220+ python3-yaml,
221 gir1.2-gtk-3.0,
222 ubuntu-release-upgrader-gtk,
223 update-notifier,
224
225=== added file 'tests/test_livepatch_socket.py'
226--- tests/test_livepatch_socket.py 1970-01-01 00:00:00 +0000
227+++ tests/test_livepatch_socket.py 2017-08-31 07:59:52 +0000
228@@ -0,0 +1,200 @@
229+#
230+# This program is free software; you can redistribute it and/or
231+# modify it under the terms of the GNU General Public License as
232+# published by the Free Software Foundation; either version 2 of the
233+# License, or (at your option) any later version.
234+#
235+# This program is distributed in the hope that it will be useful,
236+# but WITHOUT ANY WARRANTY; without even the implied warranty of
237+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
238+# GNU General Public License for more details.
239+#
240+# You should have received a copy of the GNU General Public License
241+# along with this program; if not, write to the Free Software
242+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
243+# USA
244+
245+import datetime
246+from gi.repository import GLib
247+import http.client
248+from mock import Mock
249+import logging
250+import sys
251+import unittest
252+import yaml
253+
254+from UpdateManager.Core.LivePatchSocket import LivePatchSocket, LivePatchFix
255+
256+
257+status0 = {'architecture': 'x86_64',
258+ 'boot-time': datetime.datetime(2017, 6, 27, 11, 16),
259+ 'client-version': '7.21',
260+ 'cpu-model': 'Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz',
261+ 'last-check': datetime.datetime(2017, 6, 28, 14, 23, 29, 683361),
262+ 'machine-id': 123456789,
263+ 'machine-token': 987654321,
264+ 'uptime': '27h12m12s'}
265+
266+status1 = {'architecture': 'x86_64',
267+ 'boot-time': datetime.datetime(2017, 6, 27, 11, 16),
268+ 'client-version': '7.21',
269+ 'cpu-model': 'Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz',
270+ 'last-check': datetime.datetime(2017, 6, 28, 14, 23, 29, 683361),
271+ 'machine-id': 123456789,
272+ 'machine-token': 987654321,
273+ 'status': [{'kernel': '4.4.0-78.99-generic',
274+ 'livepatch': {'checkState': 'needs-check',
275+ 'fixes': '',
276+ 'patchState': 'nothing-to-apply',
277+ 'version': '24.2'},
278+ 'running': True}],
279+ 'uptime': '27h12m12s'}
280+
281+status2 = {'architecture': 'x86_64',
282+ 'boot-time': datetime.datetime(2017, 6, 27, 11, 16),
283+ 'client-version': '7.21',
284+ 'cpu-model': 'Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz',
285+ 'last-check': datetime.datetime(2017, 6, 28, 14, 23, 29, 683361),
286+ 'machine-id': 123456789,
287+ 'machine-token': 987654321,
288+ 'status': [{'kernel': '4.4.0-78.99-generic',
289+ 'livepatch': {'checkState': 'checked',
290+ 'fixes': '* CVE-2016-0001\n'
291+ '* CVE-2016-0002\n'
292+ '* CVE-2017-0001 (unpatched)\n'
293+ '* CVE-2017-0001',
294+ 'patchState': 'applied',
295+ 'version': '24.2'},
296+ 'running': True}],
297+ 'uptime': '27h12m12s'}
298+
299+
300+class TestUtils(object):
301+
302+ @staticmethod
303+ def __TimeoutCallback(user_data=None):
304+ user_data[0] = True
305+ return False
306+
307+ @staticmethod
308+ def __ScheduleTimeout(timeout_reached, timeout_duration=10):
309+ return GLib.timeout_add(timeout_duration,
310+ TestUtils.__TimeoutCallback,
311+ timeout_reached)
312+
313+ @staticmethod
314+ def WaitUntilMSec(instance, check_function, expected_result=True,
315+ max_wait=500, error_msg=''):
316+ instance.assertIsNotNone(check_function)
317+
318+ timeout_reached = [False]
319+ timeout_id = TestUtils.__ScheduleTimeout(timeout_reached, max_wait)
320+
321+ result = None
322+ while not timeout_reached[0]:
323+ result = check_function()
324+ if result == expected_result:
325+ break
326+ GLib.MainContext.default().iteration(True)
327+
328+ if result == expected_result:
329+ GLib.Source.remove(timeout_id)
330+
331+ instance.assertEqual(expected_result, result, error_msg)
332+
333+
334+class MockResponse():
335+
336+ def __init__(self, status, data):
337+ self.status = status
338+ self.data = data
339+
340+ def read(self):
341+ return yaml.dump(self.data)
342+
343+
344+class TestLivePatchSocket(unittest.TestCase):
345+
346+ def test_get_check_state(self):
347+ check_state = LivePatchSocket.get_check_state(status0)
348+ self.assertEqual(check_state, 'check-failed')
349+ check_state = LivePatchSocket.get_check_state(status1)
350+ self.assertEqual(check_state, 'needs-check')
351+ check_state = LivePatchSocket.get_check_state(status2)
352+ self.assertEqual(check_state, 'checked')
353+
354+ def test_get_patch_state(self):
355+ patch_state = LivePatchSocket.get_patch_state(status0)
356+ self.assertEqual(patch_state, 'unknown')
357+ patch_state = LivePatchSocket.get_patch_state(status1)
358+ self.assertEqual(patch_state, 'nothing-to-apply')
359+ patch_state = LivePatchSocket.get_patch_state(status2)
360+ self.assertEqual(patch_state, 'applied')
361+
362+ def test_get_fixes(self):
363+ fixes = LivePatchSocket.get_fixes(status0)
364+ self.assertEqual(fixes, [])
365+ fixes = LivePatchSocket.get_fixes(status1)
366+ self.assertEqual(fixes, [])
367+ fixes = LivePatchSocket.get_fixes(status2)
368+ self.assertEqual(fixes, [LivePatchFix('CVE-2016-0001'),
369+ LivePatchFix('CVE-2016-0002'),
370+ LivePatchFix('CVE-2017-0001 (unpatched)'),
371+ LivePatchFix('CVE-2017-0001')])
372+
373+ def test_livepatch_fix(self):
374+ fix = LivePatchFix('CVE-2016-0001')
375+ self.assertEqual(fix.name, 'CVE-2016-0001')
376+ self.assertTrue(fix.patched)
377+
378+ fix = LivePatchFix('CVE-2016-0001 (unpatched)')
379+ self.assertEqual(fix.name, 'CVE-2016-0001')
380+ self.assertFalse(fix.patched)
381+
382+ def test_callback_not_active(self):
383+ mock_http_conn = Mock(spec=http.client.HTTPConnection)
384+ attrs = {'getresponse.return_value': MockResponse(400, None)}
385+ mock_http_conn.configure_mock(**attrs)
386+
387+ cb_called = [False]
388+
389+ def on_done(active, check_state, patch_state, fixes):
390+ cb_called[0] = True
391+ self.assertFalse(active)
392+
393+ lp = LivePatchSocket(mock_http_conn)
394+ lp.get_status(on_done)
395+
396+ mock_http_conn.request.assert_called_with(
397+ 'GET', '/status?verbose=True')
398+ TestUtils.WaitUntilMSec(self, lambda: cb_called[0] is True)
399+
400+ def test_callback_active(self):
401+ mock_http_conn = Mock(spec=http.client.HTTPConnection)
402+ attrs = {'getresponse.return_value': MockResponse(200, status2)}
403+ mock_http_conn.configure_mock(**attrs)
404+
405+ cb_called = [False]
406+
407+ def on_done(active, check_state, patch_state, fixes):
408+ cb_called[0] = True
409+ self.assertTrue(active)
410+ self.assertEqual(check_state, 'checked')
411+ self.assertEqual(patch_state, 'applied')
412+ self.assertEqual(fixes, [LivePatchFix('CVE-2016-0001'),
413+ LivePatchFix('CVE-2016-0002'),
414+ LivePatchFix('CVE-2017-0001 (unpatched)'),
415+ LivePatchFix('CVE-2017-0001')])
416+
417+ lp = LivePatchSocket(mock_http_conn)
418+ lp.get_status(on_done)
419+
420+ mock_http_conn.request.assert_called_with(
421+ 'GET', '/status?verbose=True')
422+ TestUtils.WaitUntilMSec(self, lambda: cb_called[0] is True)
423+
424+
425+if __name__ == '__main__':
426+ if len(sys.argv) > 1 and sys.argv[1] == "-v":
427+ logging.basicConfig(level=logging.DEBUG)
428+ unittest.main()
429
430=== modified file 'tests/test_utils.py'
431--- tests/test_utils.py 2017-08-09 23:21:06 +0000
432+++ tests/test_utils.py 2017-08-31 07:59:52 +0000
433@@ -2,7 +2,6 @@
434 # -*- Mode: Python; indent-tabs-mode: nil; tab-width: 4; coding: utf-8 -*-
435
436 import logging
437-import glob
438 import mock
439 import sys
440 import unittest

Subscribers

People subscribed via source and target branches

to status/vote changes: