Merge lp:~mars/launchpad/fix-js-unittests into lp:launchpad/db-devel

Proposed by Māris Fogels
Status: Rejected
Rejected by: Māris Fogels
Proposed branch: lp:~mars/launchpad/fix-js-unittests
Merge into: lp:launchpad/db-devel
Diff against target: 524 lines (+218/-52)
12 files modified
database/schema/security.cfg (+3/-0)
lib/canonical/launchpad/javascript/bugs/filebug-dupefinder.js (+5/-0)
lib/canonical/launchpad/javascript/client/client.js (+2/-2)
lib/canonical/launchpad/javascript/lp/comment.js (+0/-1)
lib/canonical/launchpad/javascript/lp/lp.js (+2/-3)
lib/canonical/launchpad/javascript/soyuz/lp_dynamic_dom_updater.js (+5/-5)
lib/lp/code/browser/branch.py (+5/-0)
lib/lp/code/browser/codereviewcomment.py (+4/-3)
lib/lp/code/errors.py (+51/-0)
lib/lp/code/templates/branchmergeproposal-index.pt (+62/-27)
lib/lp/registry/browser/team.py (+4/-2)
lib/lp/translations/tests/test_translationimportqueue.py (+75/-9)
To merge this branch: bzr merge lp:~mars/launchpad/fix-js-unittests
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+15546@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Māris Fogels (mars) wrote :

Hi,

This branch fixes a number of failures in the JavaScript unit test suite that
resulted from the YUI 3.0.0 upgrade.

I manually ran all of the unit tests under lib/canonical/launchpad/javascript/
to ensure that the features under test still work.

My changes are free of lint. There is one spurious lint error in lp.js.

Maris

Revision history for this message
Gavin Panella (allenap) wrote :

Has Y.fail() been renamed as Y.error(), or is it a deprecated synonym? If neither, then I'd be inclined to leave it alone; "fail" more closely matches the terminology used in the Python testing world. Y.error() sounds like it should be used when test set-up fails, not when an assertion fails.

I'm very happy to see that the 'click' simulation has been fixed (re. test_me_too.js). Figuring that one out caused me a very sweary afternoon of pain.

Thanks for doing this :)

review: Approve
Revision history for this message
Māris Fogels (mars) wrote :

On Wed, Dec 2, 2009 at 8:54 AM, Gavin Panella <email address hidden> wrote:
> Review: Approve
> Has Y.fail() been renamed as Y.error(), or is it a deprecated synonym? If neither, then I'd be inclined to leave it alone; "fail" more closely matches the terminology used in the Python testing world. Y.error() sounds like it should be used when test set-up fails, not when an assertion fails.

Yes, Y.fail() became Y.error(). Y.fail() is now only in the 'test' module, and has become the standard xUnit "fail()" call.

This caused a subtle bug: my tests were failing when they should have been raising errors. Turns out the code under test was calling Y.fail() itself, so it raised a failure exception instead of a real error.

>
> I'm very happy to see that the 'click' simulation has been fixed (re. test_me_too.js). Figuring that one out caused me a very sweary afternoon of pain.

It took me a while, too. I remembered a comment Bjorn made during the lazr-js sprint about using click because mousedown was unreliable. Turns out it was true!

>
> Thanks for doing this :)
>

My pleasure! Thank you for the review.

Mars

lp:~mars/launchpad/fix-js-unittests updated
8737. By Māris Fogels

Merge from db-devel

8738. By Māris Fogels

Merged trunk instead of db-devel so we can land on the mainline.

8739. By Māris Fogels

Merge from RF. Fixed one conflict.

Revision history for this message
Māris Fogels (mars) wrote :

Rejecting my own branch, as it has become stale (because PQM refuses to land it, argh!)

Unmerged revisions

8739. By Māris Fogels

Merge from RF. Fixed one conflict.

8738. By Māris Fogels

Merged trunk instead of db-devel so we can land on the mainline.

8737. By Māris Fogels

Merge from db-devel

8736. By Māris Fogels

Fixed a number of JS unit tests that broke during the YUI 3.0.0 upgrade.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'database/schema/security.cfg'
2--- database/schema/security.cfg 2009-12-02 13:08:18 +0000
3+++ database/schema/security.cfg 2009-12-11 13:41:18 +0000
4@@ -1216,6 +1216,9 @@
5 public.libraryfilecontent = SELECT, INSERT
6
7 # rosetta auto imports
8+public.pofile = SELECT
9+public.potemplate = SELECT
10+public.translationgroup = SELECT
11 public.translationimportqueueentry = SELECT, INSERT, UPDATE
12
13 # Closing bugs.
14
15=== modified file 'lib/canonical/launchpad/javascript/bugs/filebug-dupefinder.js'
16--- lib/canonical/launchpad/javascript/bugs/filebug-dupefinder.js 2009-12-10 13:45:40 +0000
17+++ lib/canonical/launchpad/javascript/bugs/filebug-dupefinder.js 2009-12-11 13:41:18 +0000
18@@ -56,8 +56,13 @@
19
20 // Check that the details_div actually exists and raise an error if
21 // we can't find it.
22+<<<<<<< TREE
23 if (!Y.Lang.isValue(details_div)) {
24 Y.fail(
25+=======
26+ if (!Y.Lang.isValue(details_div)) {
27+ Y.error(
28+>>>>>>> MERGE-SOURCE
29 "Unable to find details div for expander " + expander.get('id'));
30 } else {
31 return details_div;
32
33=== modified file 'lib/canonical/launchpad/javascript/client/client.js'
34--- lib/canonical/launchpad/javascript/client/client.js 2009-12-07 12:26:37 +0000
35+++ lib/canonical/launchpad/javascript/client/client.js 2009-12-11 13:41:18 +0000
36@@ -694,11 +694,11 @@
37 */
38 initializer: function(config) {
39 if (!Y.Lang.isString(config.patch)) {
40- Y.fail("missing config: 'patch' containing the attribute name");
41+ Y.error("missing config: 'patch' containing the attribute name");
42 }
43
44 if (!Y.Lang.isString(config.resource)) {
45- Y.fail("missing config: 'resource' containing the URL to patch");
46+ Y.error("missing config: 'resource' containing the URL to patch");
47 }
48
49 // Save the config object that the user passed in so that we can pass
50
51=== modified file 'lib/canonical/launchpad/javascript/lp/comment.js'
52--- lib/canonical/launchpad/javascript/lp/comment.js 2009-11-26 19:54:52 +0000
53+++ lib/canonical/launchpad/javascript/lp/comment.js 2009-12-11 13:41:18 +0000
54@@ -361,7 +361,6 @@
55 },
56 renderUI: function() {
57 CodeReviewComment.superclass.renderUI.apply(this);
58- Y.one('#inline-add-comment').setStyle('display', 'block');
59 },
60 /**
61 * Implementation of Widget.bindUI: Bind events to methods.
62
63=== modified file 'lib/canonical/launchpad/javascript/lp/lp.js'
64--- lib/canonical/launchpad/javascript/lp/lp.js 2009-12-07 12:26:37 +0000
65+++ lib/canonical/launchpad/javascript/lp/lp.js 2009-12-11 13:41:18 +0000
66@@ -66,10 +66,10 @@
67
68 // If either the wrapper or the icon is null, raise an error.
69 if (wrapper_div === null) {
70- Y.fail("Collapsible has no wrapper div.");
71+ Y.error("Collapsible has no wrapper div.");
72 }
73 if (icon === null) {
74- Y.fail("Collapsible has no icon.");
75+ Y.error("Collapsible has no icon.");
76 }
77
78 // Work out the target icon and animation based on the state of
79@@ -791,4 +791,3 @@
80 tag2.style.display = display;
81 return false;
82 }
83-
84
85=== modified file 'lib/canonical/launchpad/javascript/soyuz/lp_dynamic_dom_updater.js'
86--- lib/canonical/launchpad/javascript/soyuz/lp_dynamic_dom_updater.js 2009-11-24 16:11:43 +0000
87+++ lib/canonical/launchpad/javascript/soyuz/lp_dynamic_dom_updater.js 2009-12-11 13:41:18 +0000
88@@ -14,7 +14,7 @@
89 var lp = Y.namespace('lp');
90
91 /**
92- * The DomUpdater class provides the ability to plugin functionality
93+ * The DomUpdater class provides the ability to plugin functionality
94 * to a DOM subtree so that it can update itself when given data in an
95 * expected format.
96 *
97@@ -79,7 +79,7 @@
98 Y.lp.DomUpdater = DomUpdater;
99
100 /**
101- * The DynamicDomUpdater class provides the ability to plug functionality
102+ * The DynamicDomUpdater class provides the ability to plug functionality
103 * into a DOM subtree so that it can update itself using an LP api method.
104 *
105 * For example:
106@@ -94,7 +94,7 @@
107 * Once configured, the 'table' dom subtree will now update itself
108 * by calling the user defined domUpdateFunction (with a default interval
109 * of 6000ms) with the result of the LPs api call.
110- *
111+ *
112 * @class DynamicDomUpdater
113 * @extends DomUpdater
114 * @constructor
115@@ -326,7 +326,7 @@
116 }
117
118 if (actual_interval_updated) {
119- Y.log("Actual poll interval updated to " +
120+ Y.log("Actual poll interval updated to " +
121 this._actual_interval + "ms.");
122 }
123
124@@ -344,7 +344,7 @@
125 * @private
126 */
127 _handleFailure: function(id, request) {
128- Y.fail("LP.DynamicDomUpdater for " + this.get("host") +
129+ Y.error("LP.DynamicDomUpdater for " + this.get("host") +
130 " failed to get dynamic data.");
131 }
132 });
133
134=== modified file 'lib/lp/code/browser/branch.py'
135--- lib/lp/code/browser/branch.py 2009-12-11 00:56:16 +0000
136+++ lib/lp/code/browser/branch.py 2009-12-11 13:41:18 +0000
137@@ -79,9 +79,14 @@
138 from lp.code.browser.branchmergeproposal import (
139 latest_proposals_for_each_branch)
140 from lp.code.enums import (
141+<<<<<<< TREE
142 BranchLifecycleStatus, BranchType, RevisionControlSystems,
143 UICreatableBranchType)
144 from lp.code.errors import InvalidBranchMergeProposal
145+=======
146+ BranchLifecycleStatus, BranchType, UICreatableBranchType)
147+from lp.code.errors import InvalidBranchMergeProposal
148+>>>>>>> MERGE-SOURCE
149 from lp.code.interfaces.branch import (
150 BranchCreationForbidden, BranchExists, IBranch,
151 user_has_special_branch_access)
152
153=== modified file 'lib/lp/code/browser/branchmergeproposal.py'
154=== modified file 'lib/lp/code/browser/codereviewcomment.py'
155--- lib/lp/code/browser/codereviewcomment.py 2009-10-29 23:51:08 +0000
156+++ lib/lp/code/browser/codereviewcomment.py 2009-12-11 13:41:18 +0000
157@@ -204,7 +204,7 @@
158
159 class MyDropWidget(DropdownWidget):
160 "Override the default no-value display name to -Select-."
161- _messageNoValue = '-Select-'
162+ _messageNoValue = 'Comment only'
163
164 schema = IEditCodeReviewComment
165
166@@ -251,10 +251,11 @@
167 @action('Save Comment', name='add')
168 def add_action(self, action, data):
169 """Create the comment..."""
170+ vote = data.get('vote')
171+ review_type = data.get('review_type')
172 comment = self.branch_merge_proposal.createComment(
173 self.user, subject=None, content=data['comment'],
174- parent=self.reply_to, vote=data['vote'],
175- review_type=data['review_type'])
176+ parent=self.reply_to, vote=vote, review_type=review_type)
177
178 @property
179 def next_url(self):
180
181=== modified file 'lib/lp/code/configure.zcml'
182=== modified file 'lib/lp/code/errors.py'
183--- lib/lp/code/errors.py 2009-12-07 06:51:42 +0000
184+++ lib/lp/code/errors.py 2009-12-11 13:41:18 +0000
185@@ -1,3 +1,4 @@
186+<<<<<<< TREE
187 # Copyright 2009 Canonical Ltd. This software is licensed under the
188 # GNU Affero General Public License version 3 (see the file LICENSE).
189
190@@ -54,3 +55,53 @@
191
192 class WrongBranchMergeProposal(Exception):
193 """The comment requested is not associated with this merge proposal."""
194+=======
195+# Copyright 2009 Canonical Ltd. This software is licensed under the
196+# GNU Affero General Public License version 3 (see the file LICENSE).
197+
198+"""Errors used in the lp/code modules."""
199+
200+__metaclass__ = type
201+__all__ = [
202+ 'BadBranchMergeProposalSearchContext',
203+ 'BadStateTransition',
204+ 'BranchMergeProposalExists',
205+ 'InvalidBranchMergeProposal',
206+ 'UserNotBranchReviewer',
207+ 'WrongBranchMergeProposal',
208+]
209+
210+
211+class BadBranchMergeProposalSearchContext(Exception):
212+ """The context is not valid for a branch merge proposal search."""
213+
214+
215+class BadStateTransition(Exception):
216+ """The user requested a state transition that is not possible."""
217+
218+
219+class InvalidBranchMergeProposal(Exception):
220+ """Raised during the creation of a new branch merge proposal.
221+
222+ The text of the exception is the rule violation.
223+ """
224+
225+
226+class BranchMergeProposalExists(InvalidBranchMergeProposal):
227+ """Raised if there is already a matching BranchMergeProposal."""
228+
229+
230+class UserNotBranchReviewer(Exception):
231+ """The user who attempted to review the merge proposal isn't a reviewer.
232+
233+ A specific reviewer may be set on a branch. If a specific reviewer
234+ isn't set then any user in the team of the owner of the branch is
235+ considered a reviewer.
236+ """
237+
238+
239+class WrongBranchMergeProposal(Exception):
240+ """The comment requested is not associated with this merge proposal."""
241+
242+
243+>>>>>>> MERGE-SOURCE
244
245=== modified file 'lib/lp/code/model/tests/test_branchmergeproposals.py'
246=== modified file 'lib/lp/code/templates/branch-import-details.pt'
247=== modified file 'lib/lp/code/templates/branchmergeproposal-index.pt'
248--- lib/lp/code/templates/branchmergeproposal-index.pt 2009-11-26 23:36:50 +0000
249+++ lib/lp/code/templates/branchmergeproposal-index.pt 2009-12-11 13:41:18 +0000
250@@ -21,6 +21,24 @@
251 #commit-message, #edit-commit-message {
252 margin: 1em 0 0 0;
253 }
254+ #add-comment-form {
255+ max-width: 60em;
256+ padding-bottom: 3em;
257+ }
258+ #add-comment-form textarea{
259+ width: 100%;
260+ max-width: inherit;
261+ }
262+ #add-comment-form .actions {
263+ float: right;
264+ margin: 0 -0.5em;
265+ }
266+ #add-comment-review-fields {
267+ margin-top: 1em;
268+ }
269+ #add-comment-review-fields div {
270+ display: inline;
271+ }
272 /* A page-specific fix for inline text are editing to line up box. */
273 #edit-commit-message .yui-ieditor-input { top: 0; }
274 </style>
275@@ -92,6 +110,12 @@
276 </div>
277
278 <div class="yui-g">
279+ <tal:not-logged-in condition="not: view/user">
280+ <div align="center" id="add-comment-login-first">
281+ To post a comment you must <a href="+login">log in</a>.
282+ </div>
283+ </tal:not-logged-in>
284+
285 <div tal:define="link menu/add_comment"
286 tal:condition="link/enabled"
287 tal:content="structure link/render">
288@@ -101,20 +125,27 @@
289 <div id="conversation"
290 tal:content="structure view/conversation/@@+render"/>
291 </div>
292- <!-- Hide inline commenting if YUI isn't used. -->
293- <div id="inline-add-comment" style="display: none">
294- <tal:comment replace="structure context/@@+comment/++form++" />
295- <div class="actions" id="launchpad-form-actions">
296- <input type="submit" id="field.actions.add" name="field.actions.add" value="Save Comment" class="button" />
297- </div>
298- </div>
299-
300- <script type="text/javascript">
301- LPS.use('lp.comment', function(Y) {
302- var comment = new Y.lp.CodeReviewComment();
303- comment.render();
304- })
305- </script>
306+
307+ <tal:logged-in condition="view/user">
308+ <div tal:define="comment_form nocall:context/@@+comment;
309+ dummy comment_form/initialize">
310+ <h2 id="add-comment">Add comment</h2>
311+ <form action="+comment"
312+ method="post"
313+ enctype="multipart/form-data"
314+ accept-charset="UTF-8"
315+ id="add-comment-form">
316+ <tal:comment-input replace="structure comment_form/widgets/comment"/>
317+ <div id="add-comment-review-fields">
318+ Review: <tal:review replace="structure comment_form/widgets/vote"/>
319+ Review type: <tal:review replace="structure comment_form/widgets/review_type"/>
320+ <div class="actions"
321+ tal:content="structure comment_form/actions/field.actions.add/render" />
322+ </div>
323+ </form>
324+ </div>
325+ </tal:logged-in>
326+
327 <div class="yui-g">
328 <div class="yui-u first">
329 <div id="source-revisions"
330@@ -159,21 +190,25 @@
331 string:&lt;script id='codereview-script' type='text/javascript'&gt;" />
332 conf = <tal:status-config replace="view/status_config" />
333 <!--
334- LPS.use('io-base', 'code.codereview', 'code.branchmergeproposal',
335+ LPS.use('io-base', 'code.codereview', 'code.branchmergeproposal', 'lp.comment',
336 function(Y) {
337
338-
339- if(Y.UA.ie) {
340- return;
341- }
342-
343- Y.on('domready', function() {
344- Y.code.codereview.connect_links();
345- Y.code.branchmergeproposal.connect_status(conf);
346- });
347-
348- (new Y.codereview.NumberToggle()).render();
349-
350+ Y.on('load', function() {
351+ var logged_in = LP.client.links['me'] !== undefined;
352+
353+ if (logged_in) {
354+ var comment = new Y.lp.CodeReviewComment();
355+ comment.render();
356+
357+ if(Y.UA.ie) {
358+ return;
359+ }
360+
361+ Y.code.codereview.connect_links();
362+ Y.code.branchmergeproposal.connect_status(conf);
363+ }
364+ (new Y.codereview.NumberToggle()).render();
365+ }, window);
366 });
367 -->
368 <tal:script replace="structure string:&lt;/script&gt;" />
369
370=== modified file 'lib/lp/registry/browser/team.py'
371--- lib/lp/registry/browser/team.py 2009-12-01 22:09:05 +0000
372+++ lib/lp/registry/browser/team.py 2009-12-11 13:41:18 +0000
373@@ -901,7 +901,6 @@
374 return None
375
376
377-
378 class ProposedTeamMembersEditView(LaunchpadFormView):
379 schema = Interface
380 label = 'Proposed team members'
381@@ -915,7 +914,10 @@
382 status = TeamMembershipStatus.APPROVED
383 elif action == "decline":
384 status = TeamMembershipStatus.DECLINED
385- elif action == "hold":
386+ else:
387+ # The action is "hold" or no action was specified for this
388+ # person, which could happen if the set of proposed members
389+ # changed while the form was being processed.
390 continue
391
392 self.context.setMembershipData(
393
394=== modified file 'lib/lp/translations/tests/test_translationimportqueue.py'
395--- lib/lp/translations/tests/test_translationimportqueue.py 2009-11-19 11:24:43 +0000
396+++ lib/lp/translations/tests/test_translationimportqueue.py 2009-12-11 13:41:18 +0000
397@@ -5,6 +5,7 @@
398
399 __metaclass__ = type
400
401+import transaction
402 import unittest
403
404 from zope.component import getUtility
405@@ -17,25 +18,27 @@
406 from canonical.testing import LaunchpadZopelessLayer
407
408
409-class TestTranslationImportQueueEntryStatus(TestCaseWithFactory):
410- """Test handling of the status of a queue entry."""
411+class TestCanSetStatusBase(TestCaseWithFactory):
412+ """Base for tests that check that canSetStatus works ."""
413
414 layer = LaunchpadZopelessLayer
415+ dbuser = None
416+ entry = None
417
418 def setUp(self):
419 """Set up context to test in."""
420- super(TestTranslationImportQueueEntryStatus, self).setUp()
421+ super(TestCanSetStatusBase, self).setUp()
422
423 self.queue = getUtility(ITranslationImportQueue)
424 self.rosetta_experts = (
425 getUtility(ILaunchpadCelebrities).rosetta_experts)
426 self.productseries = self.factory.makeProductSeries()
427 self.uploaderperson = self.factory.makePerson()
428- self.potemplate = self.factory.makePOTemplate(
429- productseries=self.productseries)
430- self.entry = self.queue.addOrUpdateEntry(
431- 'demo.pot', '#demo', False, self.uploaderperson,
432- productseries=self.productseries, potemplate=self.potemplate)
433+
434+ def _switch_dbuser(self):
435+ if self.dbuser != None:
436+ transaction.commit()
437+ self.layer.switchDbUser(self.dbuser)
438
439 def _assertCanSetStatus(self, user, entry, expected_list):
440 # Helper to check for all statuses.
441@@ -49,6 +52,7 @@
442 RosettaImportStatus.IMPORTED,
443 RosettaImportStatus.NEEDS_REVIEW,
444 ]
445+ self._switch_dbuser()
446 # Do *not* use assertContentEqual here, as the order matters.
447 self.assertEqual(expected_list,
448 [entry.canSetStatus(status, user)
449@@ -71,6 +75,7 @@
450 # If the entry has no import target set, even Rosetta experts
451 # cannot set it to approved.
452 self.entry.potemplate = None
453+ self.entry.pofile = None
454 self._assertCanSetStatus(self.rosetta_experts, self.entry,
455 # A B D F I NR
456 [False, True, True, True, True, True])
457@@ -115,5 +120,66 @@
458 [False, False, False, False, False, False])
459
460
461+class TestCanSetStatusPOTemplate(TestCanSetStatusBase):
462+ """Test canStatus applied to an entry with a POTemplate."""
463+
464+ def setUp(self):
465+ """Create the entry to test on."""
466+ super(TestCanSetStatusPOTemplate, self).setUp()
467+
468+ self.potemplate = self.factory.makePOTemplate(
469+ productseries=self.productseries)
470+ self.entry = self.queue.addOrUpdateEntry(
471+ 'demo.pot', '#demo', False, self.uploaderperson,
472+ productseries=self.productseries, potemplate=self.potemplate)
473+
474+
475+class TestCanSetStatusPOFile(TestCanSetStatusBase):
476+ """Test canStatus applied to an entry with a POFile."""
477+
478+ def setUp(self):
479+ """Create the entry to test on."""
480+ super(TestCanSetStatusPOFile, self).setUp()
481+
482+ self.potemplate = self.factory.makePOTemplate(
483+ productseries=self.productseries)
484+ self.pofile = self.factory.makePOFile('eo', potemplate=self.potemplate)
485+ self.entry = self.queue.addOrUpdateEntry(
486+ 'demo.po', '#demo', False, self.uploaderperson,
487+ productseries=self.productseries, pofile=self.pofile)
488+
489+
490+class TestCanSetStatusPOTemplateWithQueuedUser(TestCanSetStatusPOTemplate):
491+ """Test handling of the status of a queue entry with 'queued' db user.
492+
493+ The archive uploader needs to set (and therefore check) the status of a
494+ queue entry. It connects as a different database user and therefore we
495+ need to make sure that setStatus stays within this user's permissions.
496+ This is the version for POTemplate entries.
497+ """
498+
499+ dbuser = 'queued'
500+
501+
502+class TestCanSetStatusPOFileWithQueuedUser(TestCanSetStatusPOFile):
503+ """Test handling of the status of a queue entry with 'queued' db user.
504+
505+ The archive uploader needs to set (and therefore check) the status of a
506+ queue entry. It connects as a different database user and therefore we
507+ need to make sure that setStatus stays within this user's permissions.
508+ This is the version for POFile entries.
509+ """
510+
511+ dbuser = 'queued'
512+
513+
514 def test_suite():
515- return unittest.TestLoader().loadTestsFromName(__name__)
516+ """Add only specific test cases and leave out the base case."""
517+ suite = unittest.TestSuite()
518+ suite.addTest(unittest.makeSuite(TestCanSetStatusPOTemplate))
519+ suite.addTest(unittest.makeSuite(TestCanSetStatusPOFile))
520+ suite.addTest(
521+ unittest.makeSuite(TestCanSetStatusPOTemplateWithQueuedUser))
522+ suite.addTest(unittest.makeSuite(TestCanSetStatusPOFileWithQueuedUser))
523+ return suite
524+

Subscribers

People subscribed via source and target branches

to status/vote changes: