Merge lp:~gerdusvanzyl/storm/firebird into lp:storm

Proposed by Gerdus van Zyl
Status: Needs review
Proposed branch: lp:~gerdusvanzyl/storm/firebird
Merge into: lp:storm
Diff against target: 587 lines (+427/-27)
4 files modified
storm/databases/firebird.py (+229/-0)
tests/databases/base.py (+25/-25)
tests/databases/firebird.py (+171/-0)
tests/databases/proxy.py (+2/-2)
To merge this branch: bzr merge lp:~gerdusvanzyl/storm/firebird
Reviewer Review Type Date Requested Status
Storm Developers Pending
Storm Developers Pending
Review via email: mp+28559@code.launchpad.net

Description of the change

Basic Firebird database support.

Passes all tests except "Connection.rollback() swallows disconnect errors" which I have so far been unable to fix.

example connection string env variable for tests: STORM_FIREBIRD_URI=firebird://sysdba:masterke@localhost:3050/stormtest.fdb

To post a comment you must log in.
Revision history for this message
Gustavo Niemeyer (niemeyer) wrote :

Hi Gerdus,

Thanks for pushing this forward into a request!

Sorry if this has been asked already, but if not, can you please sign the contributor agreement at:

    http://www.canonical.com/contributors

Revision history for this message
Gerdus van Zyl (gerdusvanzyl) wrote :

No problem, I have just sent the email as per the instructions.

On Tue, Aug 10, 2010 at 7:23 PM, Gustavo Niemeyer <email address hidden> wrote:
> Hi Gerdus,
>
> Thanks for pushing this forward into a request!
>
> Sorry if this has been asked already, but if not, can you please sign the contributor agreement at:
>
>    http://www.canonical.com/contributors
> --
> https://code.launchpad.net/~gerdusvanzyl/storm/firebird/+merge/28559
> You are the owner of lp:~gerdusvanzyl/storm/firebird.
>

--
Gerdus van Zyl

Unmerged revisions

368. By Gerdus van Zyl

merge from trunk

367. By Gerdus van Zyl

cleanup unused comments

366. By Gerdus van Zyl

fixed proxy to work on windows
replaced os.read(self.request.fileno() .. with self.request.recv

365. By Gerdus van Zyl

-

364. By Gerdus van Zyl

disconnect cleanup, push to vpc

363. By Gerdus van Zyl

is_disconnection_error

362. By Gerdus van Zyl

test cases for returning and select limit

361. By Gerdus van Zyl

start of disconnect test, fix base database test to not use "select 1"

360. By Gerdus van Zyl

cleanup and override non implementable tests

359. By Gerdus van Zyl

code to pass test test_execute_expression and test_execute_expression_empty_params

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'storm/databases/firebird.py'
2--- storm/databases/firebird.py 1970-01-01 00:00:00 +0000
3+++ storm/databases/firebird.py 2010-06-26 13:26:23 +0000
4@@ -0,0 +1,229 @@
5+#
6+# Copyright (c) 2010 Canonical
7+#
8+# Initial Code by Gerdus van Zyl
9+#
10+# This file is part of Storm Object Relational Mapper.
11+#
12+#
13+# Storm is free software; you can redistribute it and/or modify
14+# it under the terms of the GNU Lesser General Public License as
15+# published by the Free Software Foundation; either version 2.1 of
16+# the License, or (at your option) any later version.
17+#
18+# Storm is distributed in the hope that it will be useful,
19+# but WITHOUT ANY WARRANTY; without even the implied warranty of
20+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21+# GNU Lesser General Public License for more details.
22+#
23+# You should have received a copy of the GNU Lesser General Public License
24+# along with this program. If not, see <http://www.gnu.org/licenses/>.
25+#
26+#
27+"""
28+Tested only with: Kinterbase 3.3 and Firebird 2.1
29+"""
30+
31+import os.path
32+from datetime import timedelta
33+from storm.databases import dummy
34+
35+from storm.expr import (compile, Select, compile_select, Undef,
36+ Expr, Insert,COLUMN,Sequence)
37+from storm.variables import (Variable,IntVariable)
38+from storm.database import Database, Connection, Result
39+
40+from storm.exceptions import (
41+ install_exceptions, DatabaseError, DatabaseModuleError, InterfaceError,
42+ OperationalError, ProgrammingError, TimeoutError)
43+
44+try:
45+ import kinterbasdb
46+ install_exceptions(kinterbasdb)
47+except ImportError:
48+ kinterbasdb = dummy
49+
50+try:
51+ kinterbasdb.init(type_conv=200)
52+except kinterbasdb.ProgrammingError:
53+ pass #Cannot initialize module more than once.
54+
55+compile = compile.create_child()
56+
57+@compile.when(int, long)
58+def compile_int(compile, expr, state):
59+ state.parameters.append(IntVariable(expr))
60+ return "cast(? as integer)"
61+
62+@compile.when(Select)
63+def compile_select_firebird(compile, select, state):
64+ limit = select.limit
65+ offset = select.offset
66+ # Make sure limit is Undef'ed.
67+ select.offset = select.limit = Undef
68+
69+ if select.default_tables is Undef:
70+ select.default_tables = ["RDB$DATABASE"]
71+
72+ sql = compile_select(compile, select, state)
73+
74+ if limit is not Undef or offset is not Undef:
75+ rowstart = 1
76+ rowstop = None
77+ if offset is not Undef:
78+ rowstart = offset + 1
79+ if limit is not Undef:
80+ rowstop = rowstart + limit - 1
81+ if rowstop < rowstart:
82+ rowstop = rowstart
83+
84+ sql += " ROWS %i" % rowstart
85+ if rowstop:
86+ sql += " TO %i " % rowstop
87+ #print sql
88+
89+ return sql
90+
91+@compile.when(Sequence)
92+def compile_sequence_firebird(compile, sequence, state):
93+ return "gen_id(%s, 1)" % sequence.name
94+
95+class Returning(Expr):
96+ """Appends the "RETURNING <primary_columns>" suffix to an INSERT.
97+
98+ This is only supported in Firebird 2.0
99+ """
100+ def __init__(self, insert):
101+ self.insert = insert
102+
103+@compile.when(Returning)
104+def compile_returning(compile, expr, state):
105+ state.push("context", COLUMN)
106+ columns = compile(expr.insert.primary_columns, state)
107+ state.pop()
108+ state.push("precedence", 0)
109+ insert = compile(expr.insert, state)
110+ state.pop()
111+ return "%s RETURNING %s" % (insert, columns)
112+
113+class FirebirdResult(Result):
114+ @staticmethod
115+ def from_database(row):
116+ """Convert Firebird-specific datatypes to "normal" Python types.
117+
118+ If there are any C{array} instances in the row, convert them
119+ to strings.
120+ """
121+ for value in row:
122+ yield value
123+
124+ def get_insert_identity(self, primary_key, primary_variables):
125+ """
126+ Firebird does not support insert identity
127+ - autoinc is implemented using custom triggers
128+ so no clear way to support it in a generic way
129+ """
130+ raise NotImplementedError
131+
132+class FirebirdConnection(Connection):
133+
134+ result_factory = FirebirdResult
135+ param_mark = "?"
136+ compile = compile
137+ server_version = None
138+
139+ def execute(self, statement, params=None, noresult=False):
140+ """Execute a statement with the given parameters.
141+
142+ This extends the L{Connection.execute} method to add support
143+ for automatic retrieval of inserted primary keys to link
144+ in-memory objects with their specific rows.
145+ """
146+ if not self.server_version:
147+ version = 0
148+ version = self._raw_connection.db_info(kinterbasdb.isc_info_version)
149+ version = str(version).split("Firebird")[1].strip()
150+ version = float(version)
151+ self.server_version = version
152+
153+ if (isinstance(statement, Insert) and
154+ self.server_version >= 2 and
155+ statement.primary_variables is not Undef and
156+ statement.primary_columns is not Undef):
157+
158+ # Here we decorate the Insert statement with a Returning
159+ # expression, so that we get back in the result the values
160+ # for the primary key just inserted. This prevents a round
161+ # trip to the database for obtaining these values.
162+ result = Connection.execute(self, Returning(statement), params)
163+ for variable, value in zip(statement.primary_variables,
164+ result.get_one()):
165+ result.set_variable(variable, value)
166+ return result
167+
168+ return Connection.execute(self, statement, params, noresult)
169+
170+ def is_disconnection_error(self, exc):
171+ """Check whether an exception represents a database disconnection.
172+
173+ """
174+ if isinstance(exc, ProgrammingError) or isinstance(exc,OperationalError):
175+ code,description = exc.args
176+ if code == -902:
177+ return True
178+
179+
180+ return False
181+
182+ def to_database(self, params):
183+ for param in params:
184+ if isinstance(param, Variable):
185+ param = param.get(to_db=True)
186+ if isinstance(param, timedelta):
187+ yield str(param)
188+ else:
189+ yield param
190+
191+class Firebird(Database):
192+
193+ connection_factory = FirebirdConnection
194+ _converters = None
195+
196+ def __init__(self, uri):
197+ if kinterbasdb is dummy:
198+ raise DatabaseModuleError("'kinterbasdb' module not found")
199+ self._connect_kwargs = {}
200+ if uri.database is not None:
201+ if os.path.isfile(uri.database):
202+ uri.database = os.path.abspath(uri.database)
203+ self._connect_kwargs["database"] = uri.database
204+ if uri.host is not None:
205+ self._connect_kwargs["host"] = uri.host
206+ if uri.port is not None:
207+ #firebird expects nonstandard port spec: http://www.firebirdfaq.org/faq259/
208+ self._connect_kwargs["host"] = "%s/%s" % (uri.host,uri.port)
209+ if uri.port is not None:
210+ self._connect_kwargs["port"] = uri.port
211+ if uri.username is not None:
212+ self._connect_kwargs["user"] = uri.username
213+ if uri.password is not None:
214+ self._connect_kwargs["password"] = uri.password
215+ for option in ["unix_socket"]:
216+ if option in uri.options:
217+ self._connect_kwargs[option] = uri.options.get(option)
218+
219+
220+
221+ def raw_connect(self):
222+ customTPB = (
223+ kinterbasdb.isc_tpb_write
224+ + kinterbasdb.isc_tpb_concurrency
225+ )
226+
227+ raw_connection = kinterbasdb.connect(**self._connect_kwargs)
228+ raw_connection.default_tpb = customTPB
229+
230+ return raw_connection
231+
232+
233+create_from_uri = Firebird
234
235=== modified file 'tests/databases/base.py'
236--- tests/databases/base.py 2010-04-16 07:12:13 +0000
237+++ tests/databases/base.py 2010-06-26 13:26:23 +0000
238@@ -132,7 +132,7 @@
239 self.assertTrue(result.get_one())
240
241 def test_execute_result(self):
242- result = self.connection.execute("SELECT 1")
243+ result = self.connection.execute("SELECT 1 FROM TEST")
244 self.assertTrue(isinstance(result, Result))
245 self.assertTrue(result.get_one())
246
247@@ -357,7 +357,7 @@
248 event.hook("register-transaction", register_transaction)
249
250 connection = self.database.connect(event)
251- connection.execute("SELECT 1")
252+ connection.execute("SELECT 1 FROM TEST")
253 self.assertEqual(len(calls), 1)
254 self.assertEqual(calls[0], marker)
255
256@@ -425,16 +425,16 @@
257
258 def test_block_access(self):
259 """Access to the connection is blocked by block_access()."""
260- self.connection.execute("SELECT 1")
261+ self.connection.execute("SELECT 1 FROM TEST")
262 self.connection.block_access()
263 self.assertRaises(ConnectionBlockedError,
264- self.connection.execute, "SELECT 1")
265+ self.connection.execute, "SELECT 1 FROM TEST")
266 self.assertRaises(ConnectionBlockedError, self.connection.commit)
267 # Allow rolling back a blocked connection.
268 self.connection.rollback()
269 # Unblock the connection, allowing access again.
270 self.connection.unblock_access()
271- self.connection.execute("SELECT 1")
272+ self.connection.execute("SELECT 1 FROM TEST")
273
274
275 class UnsupportedDatabaseTest(object):
276@@ -556,20 +556,20 @@
277
278 def test_proxy_works(self):
279 """Ensure that we can talk to the database through the proxy."""
280- result = self.connection.execute("SELECT 1")
281+ result = self.connection.execute(Select(1))
282 self.assertEqual(result.get_one(), (1,))
283
284 def test_catch_disconnect_on_execute(self):
285 """Test that database disconnections get caught on execute()."""
286- result = self.connection.execute("SELECT 1")
287+ result = self.connection.execute(Select(1))
288 self.assertTrue(result.get_one())
289 self.proxy.restart()
290 self.assertRaises(DisconnectionError,
291- self.connection.execute, "SELECT 1")
292+ self.connection.execute, Select(1))
293
294 def test_catch_disconnect_on_commit(self):
295 """Test that database disconnections get caught on commit()."""
296- result = self.connection.execute("SELECT 1")
297+ result = self.connection.execute(Select(1))
298 self.assertTrue(result.get_one())
299 self.proxy.restart()
300 self.assertRaises(DisconnectionError, self.connection.commit)
301@@ -581,13 +581,13 @@
302 then it is possible that Storm won't see the disconnection.
303 It should be able to recover from this situation though.
304 """
305- result = self.connection.execute("SELECT 1")
306+ result = self.connection.execute(Select(1))
307 self.assertTrue(result.get_one())
308 self.proxy.restart()
309 # Perform an action that should result in a disconnection.
310 try:
311 cursor = self.connection._raw_connection.cursor()
312- cursor.execute("SELECT 1")
313+ cursor.execute("select 1 from test")
314 cursor.fetchone()
315 except Error, exc:
316 self.assertTrue(self.connection.is_disconnection_error(exc))
317@@ -614,59 +614,59 @@
318 then it is possible that Storm won't see the disconnection.
319 It should be able to recover from this situation though.
320 """
321- result = self.connection.execute("SELECT 1")
322+ result = self.connection.execute(Select(1))
323 self.assertTrue(result.get_one())
324 self.proxy.restart()
325 # Perform an action that should result in a disconnection.
326 try:
327 cursor = self.connection._raw_connection.cursor()
328- cursor.execute("SELECT 1")
329+ cursor.execute("SELECT 1 FROM TEST")
330 cursor.fetchone()
331 except DatabaseError, exc:
332 self.assertTrue(self.connection.is_disconnection_error(exc))
333 else:
334 self.fail("Disconnection was not caught.")
335 self.assertRaises(DisconnectionError,
336- self.connection.execute, "SELECT 1")
337+ self.connection.execute, Select(1))
338
339 def test_connection_stays_disconnected_in_transaction(self):
340 """Test that connection does not immediately reconnect."""
341- result = self.connection.execute("SELECT 1")
342+ result = self.connection.execute(Select(1))
343 self.assertTrue(result.get_one())
344 self.proxy.restart()
345 self.assertRaises(DisconnectionError,
346- self.connection.execute, "SELECT 1")
347+ self.connection.execute, Select(1))
348 self.assertRaises(DisconnectionError,
349- self.connection.execute, "SELECT 1")
350+ self.connection.execute, Select(1))
351
352 def test_reconnect_after_rollback(self):
353 """Test that we reconnect after rolling back the connection."""
354- result = self.connection.execute("SELECT 1")
355+ result = self.connection.execute(Select(1))
356 self.assertTrue(result.get_one())
357 self.proxy.restart()
358 self.assertRaises(DisconnectionError,
359- self.connection.execute, "SELECT 1")
360+ self.connection.execute, Select(1))
361 self.connection.rollback()
362- result = self.connection.execute("SELECT 1")
363+ result = self.connection.execute(Select(1))
364 self.assertTrue(result.get_one())
365
366 def test_catch_disconnect_on_reconnect(self):
367 """Test that reconnection failures result in DisconnectionError."""
368- result = self.connection.execute("SELECT 1")
369+ result = self.connection.execute(Select(1))
370 self.assertTrue(result.get_one())
371 self.proxy.stop()
372 self.assertRaises(DisconnectionError,
373- self.connection.execute, "SELECT 1")
374+ self.connection.execute, Select(1))
375 # Rollback the connection, but because the proxy is still
376 # down, we get a DisconnectionError again.
377 self.connection.rollback()
378 self.assertRaises(DisconnectionError,
379- self.connection.execute, "SELECT 1")
380+ self.connection.execute, Select(1))
381
382 def test_close_connection_after_disconnect(self):
383- result = self.connection.execute("SELECT 1")
384+ result = self.connection.execute(Select(1))
385 self.assertTrue(result.get_one())
386 self.proxy.stop()
387 self.assertRaises(DisconnectionError,
388- self.connection.execute, "SELECT 1")
389+ self.connection.execute, Select(1))
390 self.connection.close()
391
392=== added file 'tests/databases/firebird.py'
393--- tests/databases/firebird.py 1970-01-01 00:00:00 +0000
394+++ tests/databases/firebird.py 2010-06-26 13:26:23 +0000
395@@ -0,0 +1,171 @@
396+#
397+# Copyright (c) 2010 Canonical
398+#
399+# Initial Code by Gerdus van Zyl
400+#
401+# This file is part of Storm Object Relational Mapper.
402+#
403+# Storm is free software; you can redistribute it and/or modify
404+# it under the terms of the GNU Lesser General Public License as
405+# published by the Free Software Foundation; either version 2.1 of
406+# the License, or (at your option) any later version.
407+#
408+# Storm is distributed in the hope that it will be useful,
409+# but WITHOUT ANY WARRANTY; without even the implied warranty of
410+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
411+# GNU Lesser General Public License for more details.
412+#
413+# You should have received a copy of the GNU Lesser General Public License
414+# along with this program. If not, see <http://www.gnu.org/licenses/>.
415+#
416+import os
417+
418+from storm.database import create_database
419+from storm.database import *
420+
421+from tests.helper import TestHelper
422+
423+from tests.databases.base import (
424+ DatabaseTest, UnsupportedDatabaseTest, DatabaseDisconnectionTest)
425+
426+from storm.variables import (
427+ IntVariable, Variable )
428+
429+from storm.exceptions import (
430+ DatabaseError, DatabaseModuleError,
431+ DisconnectionError, Error, OperationalError, ConnectionBlockedError)
432+
433+from tests.expr import (
434+ Select,Column,Insert, column1, column2, column3, elem1,
435+ table1, TrackContext,Sequence)
436+
437+#import storm.tracer
438+#import sys
439+#storm.tracer.debug(True, stream=sys.stdout)
440+
441+class FirebirdTest(DatabaseTest, TestHelper):
442+ supports_microseconds = False
443+
444+ def is_supported(self):
445+ return bool(os.environ.get("STORM_FIREBIRD_URI"))
446+
447+ def create_database(self):
448+ self.database = create_database(os.environ["STORM_FIREBIRD_URI"])
449+
450+ def create_tables(self):
451+ self.connection.execute('CREATE TABLE NUMBER (one INTEGER, two INTEGER, three INTEGER)')
452+ self.connection.execute('CREATE TABLE TEST (id INTEGER PRIMARY KEY, title VARCHAR(50) CHARACTER SET UTF8)')
453+ self.connection.execute('CREATE TABLE DATETIME_TEST (id INT UNIQUE,dt TIMESTAMP, d DATE, t TIME, td BLOB SUB_TYPE TEXT)')
454+
455+ self.connection.execute('CREATE TABLE BIN_TEST (id INT UNIQUE,b BLOB)')
456+
457+ self.connection.execute('CREATE GENERATOR GEN_TEST_AUTOID')
458+ self.connection.execute('SET GENERATOR GEN_TEST_AUTOID TO 0')
459+
460+ self.connection.execute("""CREATE TRIGGER TEST_PK_AUTO FOR TEST ACTIVE BEFORE INSERT POSITION 0 AS
461+begin
462+ if ( (new.ID is null) or (new.ID = 0) )
463+ then new.ID = gen_id(GEN_TEST_AUTOID, 1);
464+end""")
465+
466+ self.connection.execute("""CREATE TABLE insert_returning_test
467+ (id0 INTEGER,
468+ id1 INTEGER DEFAULT 123,
469+ id2 INTEGER DEFAULT 456)""")
470+
471+ self.connection.commit()
472+
473+ def drop_tables(self):
474+ try:
475+ self.connection.execute("DROP TRIGGER TEST_PK_AUTO")
476+ self.connection.execute("DROP GENERATOR GEN_TEST_AUTOID")
477+ self.connection.commit()
478+ except:
479+ self.connection.rollback()
480+
481+ for table in ["number", "test", "datetime_test", "bin_test", "insert_returning_test"]:
482+ try:
483+ self.connection.execute("DROP TABLE " + table)
484+ self.connection.commit()
485+ except:
486+ self.connection.rollback()
487+
488+ def test_get_insert_identity(self):
489+ """test_get_insert_identity -Does not support insert identity"""
490+ #http://www.firebirdfaq.org/faq243/
491+ pass
492+
493+ def test_get_insert_identity_composed(self):
494+ """test_get_insert_identity_composed - Does not support insert identity"""
495+ #http://www.firebirdfaq.org/faq243/
496+ pass
497+
498+ def test_execute_insert_returning(self):
499+ if self.connection.server_version < 2:
500+ return # Can't run this test with old PostgreSQL versions.
501+
502+ column0 = Column("id0", "insert_returning_test")
503+ column1 = Column("id1", "insert_returning_test")
504+ column2 = Column("id2", "insert_returning_test")
505+ variable1 = IntVariable()
506+ variable2 = IntVariable()
507+ insert = Insert({column0: 999}, primary_columns=(column1, column2),
508+ primary_variables=(variable1, variable2))
509+ self.connection.execute(insert)
510+
511+ self.assertTrue(variable1.is_defined())
512+ self.assertTrue(variable2.is_defined())
513+
514+ self.assertEquals(variable1.get(), 123)
515+ self.assertEquals(variable2.get(), 456)
516+
517+ result = self.connection.execute("SELECT * FROM insert_returning_test")
518+ self.assertEquals(result.get_one(), (999,123, 456))
519+
520+ def test_sequence(self):
521+ expr1 = Select(Sequence("GEN_TEST_AUTOID"))
522+ expr2 = "SELECT gen_id(GEN_TEST_AUTOID,0) FROM RDB$DATABASE"
523+ value1 = self.connection.execute(expr1).get_one()[0]
524+ value2 = self.connection.execute(expr2).get_one()[0]
525+ value3 = self.connection.execute(expr1).get_one()[0]
526+ self.assertEquals(value1, value2)
527+ self.assertEquals(value3-value1, 1)
528+
529+ def test_limit_offset(self):
530+ self.connection.execute("delete from test")
531+ self.connection.commit()
532+
533+ for z in range(100,200+1):
534+ sql = "INSERT INTO test (id,title) VALUES (%i,'%i')" % (z,z)
535+ self.connection.execute(sql)
536+ self.connection.commit()
537+
538+ select = Select(Column("id", "test"))
539+ select.limit = 1
540+ select.offset = 0
541+ select.order_by = Column("id", "test")
542+
543+ result = self.connection.execute(select)
544+ self.assertEquals(result.get_all(), [(100,),] )
545+
546+
547+ select = Select(Column("id", "test"))
548+ select.limit = 2
549+ select.offset = 50
550+ select.order_by = Column("id", "test")
551+
552+ result = self.connection.execute(select)
553+ self.assertEquals(result.get_all(), [(150,),(151,)] )
554+
555+
556+class FirebirdUnsupportedTest(UnsupportedDatabaseTest, TestHelper):
557+
558+ dbapi_module_names = ["kinterbasdb"]
559+ db_module_name = "firebird"
560+
561+
562+class FirebirdDisconnectionTest(DatabaseDisconnectionTest, TestHelper):
563+
564+ environment_variable = "STORM_FIREBIRD_URI"
565+ host_environment_variable = "STORM_FIREBIRD_HOST_URI"
566+ default_port = 3050
567
568=== modified file 'tests/databases/proxy.py'
569--- tests/databases/proxy.py 2007-10-24 06:27:06 +0000
570+++ tests/databases/proxy.py 2010-06-26 13:26:23 +0000
571@@ -52,14 +52,14 @@
572 return
573
574 if self.request in rlist:
575- chunk = os.read(self.request.fileno(), 1024)
576+ chunk = self.request.recv(1024)
577 dst.send(chunk)
578 if chunk == "":
579 readers.remove(self.request)
580 dst.shutdown(socket.SHUT_WR)
581
582 if dst in rlist:
583- chunk = os.read(dst.fileno(), 1024)
584+ chunk = dst.recv(1024)
585 self.request.send(chunk)
586 if chunk == "":
587 readers.remove(dst)

Subscribers

People subscribed via source and target branches

to status/vote changes: