Merge lp:~benji/lazr.restful/tweak-etag into lp:lazr.restful

Proposed by Benji York
Status: Merged
Approved by: Paul Hummer
Approved revision: 155
Merged at revision: 151
Proposed branch: lp:~benji/lazr.restful/tweak-etag
Merge into: lp:lazr.restful
Diff against target: 527 lines (+347/-51)
6 files modified
src/lazr/restful/_resource.py (+61/-48)
src/lazr/restful/testing/helpers.py (+12/-0)
src/lazr/restful/tests/test_etag.py (+253/-0)
src/lazr/restful/tests/test_utils.py (+15/-3)
src/lazr/restful/utils.py (+5/-0)
versions.cfg (+1/-0)
To merge this branch: bzr merge lp:~benji/lazr.restful/tweak-etag
Reviewer Review Type Date Requested Status
Paul Hummer (community) Approve
Review via email: mp+37194@code.launchpad.net

Description of the change

The ETag generation in lazr.restful was a bit too conservative. It used the revision number in every ETag when it is only needed in a subset. This branch moves the inclusion of revno "down" into the subclasses that need it. Tests for the various ETag generating method/functions were added as well as tests for the (existing) read/write ETag checking routines.

Leonard and I had pre-, intra-, and post-implementation discussions.

To post a comment you must log in.
Revision history for this message
Paul Hummer (rockstar) :
review: Approve
Revision history for this message
Stuart Bishop (stub) wrote :

I notice you are adding a roman numeral package to buildout, but are not using it.

Revision history for this message
Benji York (benji) wrote :

On Thu, Sep 30, 2010 at 11:15 PM, Stuart Bishop
<email address hidden> wrote:
> I notice you are adding a roman numeral package to buildout, but are not using it.

That's a change from another branch that I'll probably be reverting. I
think I got a hold of a bad docutils somehow that was missing roman.
--
Benji York

Revision history for this message
Leonard Richardson (leonardr) wrote :

I have some minor comments, but overall this is a good branch.

In addition to the 'roman' package, you've got a normalizer that makes Unicode strings look the same as regular strings. I don't think that's the best way to solve your problem, but you should talk it over with Gary.

make_entry_etag_cores no longer cares whether a field "might change over time", so you should remove that bit from the docstring.

Similarly, you don't need "modifiable" in test_writable_fields and the similar tests. And you don't need test_modifiable_fields at all. It would be nice to test fields with this property, but you can't test them in a unit test of make_entry_etag_cores, which no longer looks at the 'modifiable' attribute.

Do you want to add a test to test_cores_change_with_value that shows a case where the write core changes and the read core stays the same?

Revision history for this message
Benji York (benji) wrote :

I fixed the roman problem by nuking my docutils eggs and reinstalling.

lp:~benji/lazr.restful/tweak-etag updated
156. By Benji York <benji@benji-laptop>

remove unneeded reNormalizer

Revision history for this message
Benji York (benji) wrote :

I made all the changes Leonard suggested except for the last one which we discussed away.

lp:~benji/lazr.restful/tweak-etag updated
157. By Benji York <benji@benji-laptop>

make some changes suggested in review

158. By Benji York <benji@benji-laptop>

add a comment about read/write fields

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/lazr/restful/_resource.py'
--- src/lazr/restful/_resource.py 2010-09-23 19:14:55 +0000
+++ src/lazr/restful/_resource.py 2010-10-04 15:46:40 +0000
@@ -89,7 +89,10 @@
89 IWebServiceConfiguration, IWebServiceLayer, IWebServiceVersion,89 IWebServiceConfiguration, IWebServiceLayer, IWebServiceVersion,
90 LAZR_WEBSERVICE_NAME)90 LAZR_WEBSERVICE_NAME)
91from lazr.restful.utils import (91from lazr.restful.utils import (
92 get_current_web_service_request, sorted_named_things)92 extract_write_portion,
93 get_current_web_service_request,
94 sorted_named_things,
95 )
9396
9497
95# The path to the WADL XML Schema definition.98# The path to the WADL XML Schema definition.
@@ -378,12 +381,6 @@
378 # different representations of a resource interchangeably.381 # different representations of a resource interchangeably.
379 core_hashes[-1].update("\0" + media_type)382 core_hashes[-1].update("\0" + media_type)
380383
381 # Append the revision number, because the algorithm for
382 # generating the representation might itself change across
383 # versions.
384 revno = getUtility(IWebServiceConfiguration).code_revision
385 core_hashes[-1].update("\0" + revno)
386
387 etag = '"%s"' % "-".join([core.hexdigest() for core in core_hashes])384 etag = '"%s"' % "-".join([core.hexdigest() for core in core_hashes])
388 self.etags_by_media_type[media_type] = etag385 self.etags_by_media_type[media_type] = etag
389 return etag386 return etag
@@ -1255,14 +1252,21 @@
12551252
1256 do_PATCH = do_PUT1253 do_PATCH = do_PUT
12571254
1258 def _getETagCores(self, unmarshalled_field_values=None):1255 def _getETagCores(self, cache=None):
1259 """Calculate the ETag for an entry field.1256 """Calculate the ETag for an entry field.
12601257
1261 The core of the ETag is the field value itself.1258 The core of the ETag is the field value itself.
1259
1260 :arg cache: is ignored.
1262 """1261 """
1263 name, value = self._unmarshallField(1262 value = self._unmarshallField(
1264 self.context.name, self.context.field)1263 self.context.name, self.context.field)[1]
1265 return [str(value)]1264
1265 # Append the revision number, because the algorithm for
1266 # generating the representation might itself change across
1267 # versions.
1268 revno = getUtility(IWebServiceConfiguration).code_revision
1269 return [core.encode('utf-8') for core in [revno, unicode(value)]]
12661270
1267 def _representation(self, media_type):1271 def _representation(self, media_type):
1268 """Create a representation of the field value."""1272 """Create a representation of the field value."""
@@ -1304,6 +1308,29 @@
1304 self.context = entryfield1308 self.context = entryfield
1305 self.request = request1309 self.request = request
13061310
1311def make_entry_etag_cores(field_details):
1312 """Given the details of an entry's fields, calculate its ETag cores.
1313
1314 :arg field_details: A list of field names and relevant field information,
1315 in particular whether or not the field is writable and its current value.
1316 """
1317 unwritable_values = []
1318 writable_values = []
1319 for name, details in field_details:
1320 if details['writable']:
1321 # The client can write to this value.
1322 bucket = writable_values
1323 else:
1324 # The client can't write to this value (it might be read-only or
1325 # it might just be non-web-service writable.
1326 bucket = unwritable_values
1327 bucket.append(decode_value(details['value']))
1328
1329 unwritable = "\0".join(unwritable_values).encode("utf-8")
1330 writable = "\0".join(writable_values).encode("utf-8")
1331 return [unwritable, writable]
1332
1333
13071334
1308class EntryResource(CustomOperationResourceMixin,1335class EntryResource(CustomOperationResourceMixin,
1309 FieldUnmarshallerMixin, EntryManipulatingResource):1336 FieldUnmarshallerMixin, EntryManipulatingResource):
@@ -1338,31 +1365,23 @@
1338 unmarshalled values, obtained during some other operation such1365 unmarshalled values, obtained during some other operation such
1339 as the construction of a representation.1366 as the construction of a representation.
1340 """1367 """
1341 unwritable_values = []1368 if unmarshalled_field_values is None:
1342 writable_values = []1369 unmarshalled_field_values = {}
1370
1371 field_details = []
1343 for name, field in getFieldsInOrder(self.entry.schema):1372 for name, field in getFieldsInOrder(self.entry.schema):
1344 if self.isModifiableField(field, True):1373 details = {}
1345 # The client can write to this value.1374 # Add in any values provided by the caller.
1346 bucket = writable_values1375 # The value of the field is either passed in, or extracted.
1347 elif self.isModifiableField(field, False):1376 details['value'] = unmarshalled_field_values.get(
1348 # The client can't write this value, but it still1377 name, self._unmarshallField(name, field)[1])
1349 # might change.1378
1350 bucket = unwritable_values1379 # The client can write to this field.
1351 else:1380 details['writable'] = self.isModifiableField(field, True)
1352 # This value can never change, and as such does not1381
1353 # need to be included in the ETag.1382 field_details.append((name, details))
1354 continue1383
1355 if (unmarshalled_field_values is not None1384 return make_entry_etag_cores(field_details)
1356 and unmarshalled_field_values.get(name)):
1357 value = unmarshalled_field_values[name]
1358 else:
1359 ignored, value = self._unmarshallField(name, field)
1360 bucket.append(decode_value(value))
1361
1362 unwritable = "\0".join(unwritable_values).encode("utf-8")
1363 writable = "\0".join(writable_values).encode("utf-8")
1364 return [unwritable, writable]
1365
13661385
1367 def _etagMatchesForWrite(self, existing_etag, incoming_etags):1386 def _etagMatchesForWrite(self, existing_etag, incoming_etags):
1368 """Make sure no other client has modified this resource.1387 """Make sure no other client has modified this resource.
@@ -1373,15 +1392,9 @@
1373 on conditional writes where the only fields that changed are1392 on conditional writes where the only fields that changed are
1374 read-only fields that can't possibly cause a conflict.1393 read-only fields that can't possibly cause a conflict.
1375 """1394 """
1376 existing_write_portion = existing_etag.split('-', 1)[-1]1395 incoming_write_portions = map(extract_write_portion, incoming_etags)
1377 for etag in incoming_etags:1396 existing_write_portion = extract_write_portion(existing_etag)
1378 if '-' in etag:1397 return existing_write_portion in incoming_write_portions
1379 incoming_write_portion = etag.rsplit('-', 1)[-1]
1380 else:
1381 incoming_write_portion = etag
1382 if existing_write_portion == incoming_write_portion:
1383 return True
1384 return False
13851398
13861399
1387 def toDataForJSON(self):1400 def toDataForJSON(self):
@@ -1412,7 +1425,7 @@
1412 "Cannot create data structure for media type %s"1425 "Cannot create data structure for media type %s"
1413 % media_type)1426 % media_type)
1414 data[repr_name] = repr_value1427 data[repr_name] = repr_value
1415 unmarshalled_field_values[name] = repr_value1428 unmarshalled_field_values[name] = repr_value
14161429
1417 etag = self.getETag(media_type, unmarshalled_field_values)1430 etag = self.getETag(media_type, unmarshalled_field_values)
1418 data['http_etag'] = etag1431 data['http_etag'] = etag
@@ -1778,10 +1791,10 @@
1778 """Calculate an ETag for a representation of this resource.1791 """Calculate an ETag for a representation of this resource.
17791792
1780 The service root resource changes only when the software1793 The service root resource changes only when the software
1781 itself changes. This information goes into the ETag already,1794 itself changes.
1782 so there's no need to provide anything.
1783 """1795 """
1784 return ['']1796 revno = getUtility(IWebServiceConfiguration).code_revision
1797 return [revno.encode('utf-8')]
17851798
1786 def __call__(self, REQUEST=None):1799 def __call__(self, REQUEST=None):
1787 """Handle a GET request."""1800 """Handle a GET request."""
17881801
=== modified file 'src/lazr/restful/testing/helpers.py'
--- src/lazr/restful/testing/helpers.py 2010-07-15 14:24:56 +0000
+++ src/lazr/restful/testing/helpers.py 2010-10-04 15:46:40 +0000
@@ -5,6 +5,12 @@
5from zope.interface import implements5from zope.interface import implements
66
7from lazr.restful.interfaces import IWebServiceConfiguration7from lazr.restful.interfaces import IWebServiceConfiguration
8from lazr.restful.simple import (
9 Request,
10 RootResource,
11 )
12from lazr.restful.testing.webservice import WebServiceTestPublication
13from lazr.restful.utils import tag_request_with_version_name
814
915
10def create_test_module(name, *contents):16def create_test_module(name, *contents):
@@ -40,6 +46,12 @@
40 last_version_with_mutator_named_operations = "1.0"46 last_version_with_mutator_named_operations = "1.0"
41 code_revision = "1.0b"47 code_revision = "1.0b"
42 default_batch_size = 5048 default_batch_size = 50
49 hostname = 'example.com'
4350
44 def get_request_user(self):51 def get_request_user(self):
45 return 'A user'52 return 'A user'
53
54 def createRequest(self, body_instream, environ):
55 request = Request(body_instream, environ)
56 request.setPublication(WebServiceTestPublication(RootResource))
57 return request
4658
=== added file 'src/lazr/restful/tests/test_etag.py'
--- src/lazr/restful/tests/test_etag.py 1970-01-01 00:00:00 +0000
+++ src/lazr/restful/tests/test_etag.py 2010-10-04 15:46:40 +0000
@@ -0,0 +1,253 @@
1# Copyright 2008 Canonical Ltd. All rights reserved.
2"""Tests for ETag generation."""
3
4__metaclass__ = type
5
6import unittest
7
8from zope.component import provideUtility
9
10from lazr.restful.interfaces import IWebServiceConfiguration
11from lazr.restful.testing.helpers import TestWebServiceConfiguration
12from lazr.restful.testing.webservice import create_web_service_request
13from lazr.restful._resource import (
14 EntryFieldResource,
15 EntryResource,
16 HTTPResource,
17 ServiceRootResource,
18 make_entry_etag_cores,
19 )
20
21
22class TestEntryResourceETags(unittest.TestCase):
23 # The EntryResource uses the field values that can be written or might
24 # othwerise change as the basis for its ETags. The make_entry_etag_cores
25 # function is passed the data about the fields and returns the read and
26 # write cores.
27
28 def test_no_field_details(self):
29 # If make_entry_etag_cores is given no field details (because no
30 # fields exist), the resulting cores empty strings.
31 self.assertEquals(make_entry_etag_cores([]), ['', ''])
32
33 def test_writable_fields(self):
34 # If there are writable fields, their values are incorporated into the
35 # writable portion of the cores.
36 field_details = [
37 ('first_field',
38 {'writable': True,
39 'value': 'first'}),
40 ('second_field',
41 {'writable': True,
42 'value': 'second'}),
43 ]
44 self.assertEquals(
45 make_entry_etag_cores(field_details), ['', 'first\0second'])
46
47 def test_unchanging_fields(self):
48 # If there are fields that are not writable their values are still
49 # reflected in the generated cores because we want and addition or
50 # removal of read-only fields to trigger a new ETag.
51 field_details = [
52 ('first_field',
53 {'writable': False,
54 'value': 'the value'}),
55 ]
56 self.assertEquals(
57 make_entry_etag_cores(field_details),
58 ['the value', ''])
59
60 def test_combinations_of_fields(self):
61 # If there are a combination of writable, changable, and unchanable
62 # fields, their values are reflected in the resulting cores.
63 field_details = [
64 ('first_writable',
65 {'writable': True,
66 'value': 'first-writable'}),
67 ('second_writable',
68 {'writable': True,
69 'value': 'second-writable'}),
70 ('first_non_writable',
71 {'writable': False,
72 'value': 'first-not-writable'}),
73 ('second_non_writable',
74 {'writable': False,
75 'value': 'second-not-writable'}),
76 ]
77 self.assertEquals(
78 make_entry_etag_cores(field_details),
79 ['first-not-writable\x00second-not-writable',
80 'first-writable\x00second-writable'])
81
82
83class TestHTTPResourceETags(unittest.TestCase):
84
85 def test_getETag_is_a_noop(self):
86 # The HTTPResource class implements a do-nothing _getETagCores in order to
87 # be conservative (because it's not aware of the nature of all possible
88 # subclasses).
89 self.assertEquals(HTTPResource(None, None)._getETagCores(), None)
90
91
92class TestHTTPResourceETags(unittest.TestCase):
93
94 def test_getETag_is_a_noop(self):
95 # The HTTPResource class implements a do-nothing _getETagCores in order to
96 # be conservative (because it's not aware of the nature of all possible
97 # subclasses).
98 self.assertEquals(HTTPResource(None, None)._getETagCores(), None)
99
100
101class FauxEntryField:
102 entry = None
103 name = 'field_name'
104 field = None
105
106
107class EntryFieldResourceTests(unittest.TestCase):
108 # Tests for ETags of EntryFieldResource objects.
109
110 # Because the ETag generation only takes into account the field value and
111 # the web service revision number (and not whether the field is read-write
112 # or read-only) these tests don't mention the read-write/read-only nature
113 # of the field in question.
114
115 def setUp(self):
116 self.config = TestWebServiceConfiguration()
117 provideUtility(self.config, IWebServiceConfiguration)
118 self.resource = EntryFieldResource(FauxEntryField(), None)
119
120 def set_field_value(self, value):
121 """Set the value of the fake field the EntryFieldResource references.
122 """
123 self.resource._unmarshalled_field_cache['field_name'] = (
124 'field_name', value)
125 # We have to clear the etag cache for a new value to be generated.
126 # XXX benji 2010-09-30 [bug=652459] Does this mean there is an error
127 # condition that occurs when something other than applyChanges (which
128 # invalidates the cache) modifies a field's value?
129 self.resource.etags_by_media_type = {}
130
131 def test_cores_change_with_revno(self):
132 # The ETag cores should change if the revision (not the version) of
133 # the web service change.
134 self.set_field_value('this is the field value')
135
136 # Find the cores generated with a given revision...
137 self.config.code_revision = u'42'
138 first_cores = self.resource._getETagCores(self.resource.JSON_TYPE)
139
140 # ...find the cores generated with a different revision.
141 self.config.code_revision = u'99'
142 second_cores = self.resource._getETagCores(self.resource.JSON_TYPE)
143
144 # The cores should be different.
145 self.assertNotEqual(first_cores, second_cores)
146 # In particular, the read core should be the same between the two, but
147 # the write core should be different.
148 self.assertEqual(first_cores[1], second_cores[1])
149 self.assertNotEqual(first_cores[0], second_cores[0])
150
151 def test_cores_change_with_value(self):
152 # The ETag cores should change if the value of the field change.
153 # Find the cores generated with a given value...
154 self.set_field_value('first value')
155 first_cores = self.resource._getETagCores(self.resource.JSON_TYPE)
156
157 # ...find the cores generated with a different value.
158 self.set_field_value('second value')
159 second_cores = self.resource._getETagCores(self.resource.JSON_TYPE)
160
161 # The cores should be different.
162 self.assertNotEqual(first_cores, second_cores)
163 # In particular, the read core should be different between the two,
164 # but the write core should be the same.
165 self.assertNotEqual(first_cores[1], second_cores[1])
166 self.assertEqual(first_cores[0], second_cores[0])
167
168
169class ServiceRootResourceTests(unittest.TestCase):
170 # Tests for ETags of EntryFieldResource objects.
171
172 def setUp(self):
173 self.config = TestWebServiceConfiguration()
174 provideUtility(self.config, IWebServiceConfiguration)
175 self.resource = ServiceRootResource()
176
177 def test_cores_change_with_revno(self):
178 # The ETag core should change if the revision (not the version) of the
179 # web service change.
180
181 # Find the cores generated with a given revision...
182 self.config.code_revision = u'42'
183 first_cores = self.resource._getETagCores(self.resource.JSON_TYPE)
184
185 # ...find the cores generated with a different revision.
186 self.config.code_revision = u'99'
187 second_cores = self.resource._getETagCores(self.resource.JSON_TYPE)
188
189 # The cores should be different.
190 self.assertNotEqual(first_cores, second_cores)
191
192
193class TestableHTTPResource(HTTPResource):
194 """A HTTPResource that lest us set the ETags from the outside."""
195
196 def _parseETags(self, *args):
197 return self.incoming_etags
198
199 def getETag(self, *args):
200 return self.existing_etag
201
202
203class TestConditionalGet(unittest.TestCase):
204
205 def setUp(self):
206 self.config = TestWebServiceConfiguration()
207 provideUtility(self.config, IWebServiceConfiguration)
208 self.request = create_web_service_request('/1.0')
209 self.resource = TestableHTTPResource(None, self.request)
210
211 def test_etags_are_the_same(self):
212 # If one of the ETags present in an incoming request is the same as
213 # the ETag that represents the current object's state, then
214 # a conditional GET should return "Not Modified" (304).
215 self.resource.incoming_etags = ['1', '2', '3']
216 self.resource.existing_etag = '2'
217 self.assertEquals(self.resource.handleConditionalGET(), None)
218 self.assertEquals(self.request.response.getStatus(), 304)
219
220 def test_etags_differ(self):
221 # If none of the ETags present in an incoming request is the same as
222 # the ETag that represents the current object's state, then a
223 # conditional GET should result in a new representation of the object
224 # being returned.
225 self.resource.incoming_etags = ['1', '2', '3']
226 self.resource.existing_etag = '99'
227 self.assertNotEquals(self.resource.handleConditionalGET(), None)
228
229
230class TestConditionalWrite(unittest.TestCase):
231
232 def setUp(self):
233 self.config = TestWebServiceConfiguration()
234 provideUtility(self.config, IWebServiceConfiguration)
235 self.request = create_web_service_request('/1.0')
236 self.resource = TestableHTTPResource(None, self.request)
237
238 def test_etags_are_the_same(self):
239 # If one of the ETags present in an incoming request is the same as
240 # the ETag that represents the current object's state, then
241 # the write should be applied.
242 self.resource.incoming_etags = ['1', '2', '3']
243 self.resource.existing_etag = '2'
244 self.assertNotEquals(self.resource.handleConditionalWrite(), None)
245
246 def test_etags_differ(self):
247 # If one of the ETags present in an incoming request is the same as
248 # the ETag that represents the current object's state, then
249 # the write should fail.
250 self.resource.incoming_etags = ['1', '2', '3']
251 self.resource.existing_etag = '99'
252 self.assertEquals(self.resource.handleConditionalWrite(), None)
253 self.assertEquals(self.request.response.getStatus(), 412)
0254
=== modified file 'src/lazr/restful/tests/test_utils.py'
--- src/lazr/restful/tests/test_utils.py 2010-08-24 22:12:38 +0000
+++ src/lazr/restful/tests/test_utils.py 2010-10-04 15:46:40 +0000
@@ -11,12 +11,25 @@
11from zope.security.management import (11from zope.security.management import (
12 endInteraction, newInteraction, queryInteraction)12 endInteraction, newInteraction, queryInteraction)
1313
14from lazr.restful.utils import (get_current_browser_request,14from lazr.restful.utils import (
15 is_total_size_link_active, sorted_named_things)15 extract_write_portion,
16 get_current_browser_request,
17 is_total_size_link_active,
18 sorted_named_things,
19 )
1620
1721
18class TestUtils(unittest.TestCase):22class TestUtils(unittest.TestCase):
1923
24 def test_two_part_extract_write_portion(self):
25 # ETags are sometimes two-part. A hyphen seperates the parts if so.
26 self.assertEqual('write', extract_write_portion('read-write'))
27
28 def test_one_part_extract_write_portion(self):
29 # ETags are sometimes one-part. If so, writes are predicated on the
30 # whole ETag.
31 self.assertEqual('etag', extract_write_portion('etag'))
32
20 def test_get_current_browser_request_no_interaction(self):33 def test_get_current_browser_request_no_interaction(self):
21 # When there's no interaction setup, get_current_browser_request()34 # When there's no interaction setup, get_current_browser_request()
22 # returns None.35 # returns None.
@@ -77,4 +90,3 @@
7790
78 # For the sake of convenience, test_get_current_web_service_request()91 # For the sake of convenience, test_get_current_web_service_request()
79 # and tag_request_with_version_name() are tested in test_webservice.py.92 # and tag_request_with_version_name() are tested in test_webservice.py.
80
8193
=== modified file 'src/lazr/restful/utils.py'
--- src/lazr/restful/utils.py 2010-08-24 22:12:38 +0000
+++ src/lazr/restful/utils.py 2010-10-04 15:46:40 +0000
@@ -38,6 +38,11 @@
38missing = object()38missing = object()
3939
4040
41def extract_write_portion(etag):
42 """Retrieve the portion of an etag that predicates writes."""
43 return etag.split('-', 1)[-1]
44
45
41def is_total_size_link_active(version, config):46def is_total_size_link_active(version, config):
42 versions = config.active_versions47 versions = config.active_versions
43 total_size_link_version = config.first_version_with_total_size_link48 total_size_link_version = config.first_version_with_total_size_link
4449
=== modified file 'versions.cfg'
--- versions.cfg 2010-08-24 20:04:30 +0000
+++ versions.cfg 2010-10-04 15:46:40 +0000
@@ -24,6 +24,7 @@
24lxml = 2.2.724lxml = 2.2.7
25martian = 0.1125martian = 0.11
26pytz = 2010h26pytz = 2010h
27roman = 1.4.0
27setuptools = 0.6c1128setuptools = 0.6c11
28simplejson = 2.0.929simplejson = 2.0.9
29transaction = 1.0.030transaction = 1.0.0

Subscribers

People subscribed via source and target branches