Merge lp:~chad.smith/lp2kanban/merge-kanban-cross-team-fixes into lp:lp2kanban

Proposed by Chad Smith
Status: Merged
Merged at revision: 136
Proposed branch: lp:~chad.smith/lp2kanban/merge-kanban-cross-team-fixes
Merge into: lp:lp2kanban
Prerequisite: lp:~chad.smith/lp2kanban/multitask-multibranch-bugs
Diff against target: 491 lines (+164/-91) (has conflicts)
5 files modified
Makefile (+57/-0)
run.sh (+0/-21)
setup.py (+1/-0)
src/lp2kanban/bugs2cards.py (+41/-25)
src/lp2kanban/tests/test_bugs2cards.py (+65/-45)
Conflict adding file Makefile.  Moved existing file to Makefile.moved.
To merge this branch: bzr merge lp:~chad.smith/lp2kanban/merge-kanban-cross-team-fixes
Reviewer Review Type Date Requested Status
Данило Шеган (community) Approve
Benji York (community) Approve
Review via email: mp+291414@code.launchpad.net

Description of the change

The cross-team jenkins job (https://ci.lscape.net/job/kanban-cross-team/) is already running on lp:lp2kanban and it relies on configuration files maas.ini, juju.ini, charms.ini and server.ini which are already added to our lp:~landscape/landscape/lp2kanban-configs repo.

There are some missing config option changes needed to support those ini files which David originally made to the lp2kanban fork at lp:~landscape/lp2kanban/cross-team.

This cross team changes add the following support for configuration options:

**new_lanes**: the lane(s) into which cards at "New", "Confirmed" "Triaged" or "Incomplete"
                    bug statuses are moved
**done_lanes**: DROPPED in favor of the more granular done_fix_lanes and done_nofix_lanes

**done_fix_lanes**: the lane(s) into which bugs in "Fix Released" status are moved

**done_nofix_lanes**: the lane(s) into which bugs in "Opinion", "Invalid", "Won't Fix", "Expired"
                    bug statuses are move

This branch also changes lp2kanban to rename todo_lane to new_lane as they have the same intended behavior.

To support testing this branch, rev 9 of lp:~landscape/landscape/lp2kanban-configs adds these new lane config options to our kanban-sync's "sync.ini" file so existing lp2kanban can continue to use the old todo_lane and done_lane.

Unit tests were added to cover the changes to better describe and test new behavior.

To test:

# Either make this project
make configs
make credentials
make
make check
./bin/py ./src/lp2kanban/bugs2cards.py -c configs/sync.ini -b "Landscape 2016"

# Or Hijack jenkins kanban sync job and point the Repository URL to lp:~chad.smith/lp2kanban/merge-kanban-cross-team-fixes instead of lp:lp2kanban

https://ci.lscape.net/job/kanban-sync/configure

# Or stop the jenkins kanban-sync job and patch the existing checkout (which is refreshed each run anyway)
ssh <email address hidden>; sudo -u jenkins bash; cd /var/lib/jenkins/workspaces/kanban-sync; bzr merge lp:~chad.smith/lp2kanban/merge-kanban-cross-team-fixes; ./bin/py -c configs/sync.ini -b "Landscape 2016"

To post a comment you must log in.
Revision history for this message
Benji York (benji) wrote :

This branch looks good.

review: Approve
Revision history for this message
Данило Шеган (danilo) wrote :

Looks good, thanks!

Make sure it still works with cross-team job, too :)

review: Approve
142. By Chad Smith

drop unused run.sh

143. By Chad Smith

revert Makefile change

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'Makefile'
2--- Makefile 1970-01-01 00:00:00 +0000
3+++ Makefile 2016-04-13 15:47:22 +0000
4@@ -0,0 +1,57 @@
5+# Copyright 2005-2011 Canonical Ltd. This software is licensed under the
6+# GNU Affero General Public License version 3 (see the file LICENSE).
7+
8+PYTHON=python
9+WD:=$(shell pwd)
10+PY=$(WD)/bin/py
11+
12+BUILDOUT_CFG=buildout.cfg
13+
14+CONFIGS_BRANCH=lp:~landscape/landscape/lp2kanban-configs
15+
16+# Do not add bin/buildout to this list.
17+# It is impossible to get buildout to tell us all the files it would
18+# build, since each egg's setup.py doesn't tell us that information.
19+#
20+# NB: It's important BUILDOUT_BIN only mentions things genuinely produced by
21+# buildout.
22+BUILDOUT_BIN = $(PY) bin/tags bin/test
23+
24+default: check
25+
26+
27+download-cache:
28+ mkdir download-cache
29+
30+
31+bin/buildout: download-cache
32+ $(PYTHON) bootstrap.py -v 1.7.1 -f eggs -c buildout.cfg
33+ touch --no-create $@
34+
35+
36+$(PY): bin/buildout $(BUILDOUT_CFG) setup.py
37+ ./bin/buildout -c $(BUILDOUT_CFG)
38+
39+
40+$(subst $(PY),,$(BUILDOUT_BIN)): $(PY)
41+
42+
43+needs-xdg-utils:
44+ @xdg-open --help > /dev/null \
45+ || (echo "missing dependency: please install xdg-utils"; false)
46+
47+
48+credentials: needs-xdg-utils create_creds.py
49+ ./create_creds.py
50+
51+check: bin/test
52+ ./bin/test -vv
53+
54+configs:
55+ @if [ -d configs ]; then \
56+ (cd configs && bzr pull --overwrite $(CONFIGS_BRANCH)); \
57+ else \
58+ bzr branch $(CONFIGS_BRANCH) configs; \
59+ fi
60+
61+.PHONY: check default configs credentials needs-xdg-utils
62
63=== renamed file 'Makefile' => 'Makefile.moved'
64=== removed file 'run.sh'
65--- run.sh 2016-04-11 21:48:37 +0000
66+++ run.sh 1970-01-01 00:00:00 +0000
67@@ -1,21 +0,0 @@
68-#!/bin/bash
69-
70-LOGFILE="logs/${1}-`date '+%Y-%m-%d'`.log"
71-LOCKFILE="$HOME/lp2kanban/run-$1.lock"
72-exit_status=0
73-export https_proxy=http://squid.external:3128
74-
75-cd $HOME/lp2kanban
76-echo -n "START " >> $LOGFILE
77-date "+%c" >> $LOGFILE
78-lockfile -10 -r 4 $LOCKFILE
79-if [ "$?" -eq 0 ]; then
80- bin/py src/lp2kanban/bugs2cards.py -c configs/${1}.ini 2>&1 >> $LOGFILE
81- exit_status=$?
82- rm -f $LOCKFILE
83-else
84- echo "Failed to grab lockfile $LOCKFILE" >> $LOGFILE
85- exit_status=1
86-fi
87-echo -n "END " >> $LOGFILE; date "+%c" >> $LOGFILE
88-exit $?
89
90=== modified file 'setup.py'
91--- setup.py 2016-04-11 21:48:37 +0000
92+++ setup.py 2016-04-13 15:47:22 +0000
93@@ -17,6 +17,7 @@
94 'lazr.uri',
95 'requests',
96 'setuptools',
97+ 'simplejson',
98 'testtools',
99 ],
100 )
101
102=== modified file 'src/lp2kanban/bugs2cards.py'
103--- src/lp2kanban/bugs2cards.py 2016-04-11 21:51:29 +0000
104+++ src/lp2kanban/bugs2cards.py 2016-04-13 15:47:22 +0000
105@@ -134,13 +134,13 @@
106 self.lp_to_group.update(parse_groups_config(groups_config))
107
108 self.feature_lanes = {}
109- todo_lane = config.get("todo_lane")
110- if not todo_lane:
111+ new_lane = config.get("new_lanes")
112+ if not new_lane:
113 return
114- if "${group}" in todo_lane:
115+ if "${group}" in new_lane:
116 # Build squad feature lanes to allow for squad-$group tags
117 for group in set(self.lp_to_group.values()):
118- lane_path = todo_lane.replace("${group}", group)
119+ lane_path = new_lane.replace("${group}", group)
120 lane = board.getLaneByPath(lane_path)
121 group_tag = "squad-%s" % group.lower().replace(" ","-")
122 self.feature_lanes[group_tag] = lane
123@@ -148,19 +148,19 @@
124 if not card.tags:
125 print ("Info: feature card '%s' has no tags. New bugs tagged "
126 "for this feature will not be moved to the proper "
127- "todo_lane." % card.title)
128+ "new_lane." % card.title)
129 continue
130- if "${group}" in todo_lane:
131+ if "${group}" in new_lane:
132 lp_user = self.kanban_to_lp.get(card.assigned_user_id)
133 if not lp_user:
134 print ("Info: feature card '%s' has no assigned user. New "
135 "bugs tagged for this feature will not be moved to "
136- "the proper todo_lane." % card.title)
137+ "the proper new_lane." % card.title)
138 continue
139- lane_path = todo_lane.replace(
140+ lane_path = new_lane.replace(
141 "${group}", self.lp_to_group[lp_user.name])
142 else:
143- lane_path = todo_lane
144+ lane_path = new_lane
145 for tag in card.tags.split(","):
146 if tag != "no-sync":
147 self.feature_lanes[tag.strip()] = board.getLaneByPath(
148@@ -382,41 +382,53 @@
149 return enabled and active
150
151
152+NEW_BUG_STATUSES = [
153+ "New", "Confirmed", "Triaged", "Incomplete"
154+]
155+
156 NOOP_BUG_STATUSES = [
157- "New", "Incomplete", "Confirmed", "Triaged",
158 ]
159 IN_PROGRESS_BUG_STATUSES = [
160 "In Progress", "Fix Committed",
161 ]
162-DONE_BUG_STATUSES = [
163- "Fix Released", "Opinion", "Invalid", "Won't Fix", "Expired",
164+DONE_FIX_BUG_STATUSES = [
165+ "Fix Released",
166 ]
167+DONE_NOFIX_BUG_STATUSES = [
168+ "Opinion", "Invalid", "Won't Fix", "Expired"]
169+
170 ORDERED_BUG_STATUSES = (
171- NOOP_BUG_STATUSES + IN_PROGRESS_BUG_STATUSES + DONE_BUG_STATUSES)
172+ NEW_BUG_STATUSES + NOOP_BUG_STATUSES + IN_PROGRESS_BUG_STATUSES +
173+ DONE_FIX_BUG_STATUSES + DONE_NOFIX_BUG_STATUSES)
174
175
176 class CardStatus:
177 """Card is consider to be in only one of these statuses."""
178- CODING = 0
179- REVIEW = 1
180- LANDING = 2
181- QA = 3
182- DEPLOY = 4
183- DOWNTIME_DEPLOY = 5
184- DONE = 6
185+ NEW = 0
186+ CODING = 1
187+ REVIEW = 2
188+ LANDING = 3
189+ QA = 4
190+ DEPLOY = 5
191+ DOWNTIME_DEPLOY = 6
192+ DONE_FIX = 7
193+ DONE_NOFIX = 8
194
195
196 # Configuration variables for individual card statuses.
197 ConfigOptionByStatus = {
198+ CardStatus.NEW: 'new_lanes',
199 CardStatus.CODING: 'coding_lanes',
200 CardStatus.REVIEW: 'review_lanes',
201 CardStatus.LANDING: 'landing_lanes',
202 CardStatus.QA: 'qa_lanes',
203 CardStatus.DEPLOY: 'deploy_lanes',
204 CardStatus.DOWNTIME_DEPLOY: 'downtime_deploy_lanes',
205- CardStatus.DONE: 'done_lanes',
206+ CardStatus.DONE_FIX: 'done_fix_lanes',
207+ CardStatus.DONE_NOFIX: 'done_nofix_lanes',
208 }
209
210+
211 def get_new_bugs(board, bugs):
212 """Return the bugs that aren't already in the board."""
213 existing_bugs = set()
214@@ -450,7 +462,9 @@
215 branch_card_status = CardStatus.LANDING
216 if bug_status is None:
217 return branch_card_status
218- if bug_status in IN_PROGRESS_BUG_STATUSES:
219+ if bug_status in NEW_BUG_STATUSES:
220+ status = CardStatus.NEW
221+ elif bug_status in IN_PROGRESS_BUG_STATUSES:
222 status = CardStatus.CODING
223 if branch_info.status == 'Merged':
224 if bug_status == 'Fix Committed':
225@@ -465,8 +479,10 @@
226 status = CardStatus.LANDING
227 elif branch_card_status:
228 return branch_card_status
229- elif bug_status in DONE_BUG_STATUSES:
230- status = CardStatus.DONE
231+ elif bug_status in DONE_FIX_BUG_STATUSES:
232+ status = CardStatus.DONE_FIX
233+ elif bug_status in DONE_NOFIX_BUG_STATUSES:
234+ status = CardStatus.DONE_NOFIX
235 return status
236
237
238@@ -529,6 +545,7 @@
239 If a bug is tagged with the given tag, a new card will be created in
240 the board and the tag will be removed from the bug.
241 """
242+ print " Creating new cards for %s:" % launchpad_project.name
243 if not feature_lanes:
244 feature_lanes = {}
245 new_cardtype = board.default_cardtype.id
246@@ -627,7 +644,6 @@
247 new_bug_tag = bconf.get("bug_to_card_tag")
248 if new_bug_tag is not None:
249 for project in all_projects:
250- print " Creating new cards for %s:" % project.name
251 create_new_cards(
252 board, project, new_bug_tag, bconf.get("bug_to_card_type"),
253 bconf.get("bug_to_card_lane"), lp_users.feature_lanes)
254
255=== modified file 'src/lp2kanban/tests/test_bugs2cards.py'
256--- src/lp2kanban/tests/test_bugs2cards.py 2016-04-11 21:48:37 +0000
257+++ src/lp2kanban/tests/test_bugs2cards.py 2016-04-13 15:47:22 +0000
258@@ -352,7 +352,7 @@
259 matches, status, assignees, mp_url, mp_type = (
260 get_bug_status(bug, ['launchpad'], []))
261 self.assertTrue(matches)
262- self.assertEqual(CardStatus.DONE, status)
263+ self.assertEqual(CardStatus.DONE_FIX, status)
264 self.assertEqual([user], assignees)
265
266 def test_get_bug_status_multi_statuses(self):
267@@ -608,13 +608,21 @@
268 self.assertFalse(should_sync_card(card1, conf))
269 self.assertFalse(should_sync_card(card2, conf))
270
271- def test_get_card_status_noop_no_branch(self):
272- # For a bug in 'New', 'Triaged', 'Confirmed' or 'Incomplete' statuses,
273- # the status is unknown.
274- self.assertEqual(None, get_card_status('New', [], None))
275- self.assertEqual(None, get_card_status('Confirmed', [], None))
276- self.assertEqual(None, get_card_status('Incomplete', [], None))
277- self.assertEqual(None, get_card_status('Triaged', [], None))
278+ def test_get_card_status_new_no_branch(self):
279+ # For a bug in 'New', 'Triaged' or 'Confirmed' or 'Incomplete' statuses,
280+ # the branch status is new state.
281+ self.assertEqual(
282+ CardStatus.NEW,
283+ get_card_status('New', [], None))
284+ self.assertEqual(
285+ CardStatus.NEW,
286+ get_card_status('Confirmed', [], None))
287+ self.assertEqual(
288+ CardStatus.NEW,
289+ get_card_status('Triaged', [], None))
290+ self.assertEqual(
291+ CardStatus.NEW,
292+ get_card_status('Incomplete', [], None))
293
294 def test_get_card_status_coding_no_bug(self):
295 # For a card with no associated bug, an 'In Progress' branch status
296@@ -730,19 +738,22 @@
297 CardStatus.LANDING,
298 get_card_status('In Progress', [], branch_info))
299
300- def test_get_card_status_done(self):
301- # For a bug in 'Fix Released', 'Opinion', 'Won't fix', 'Invalid' or
302- # or 'Expired' statuses, it is considered completely done.
303- self.assertEqual(
304- CardStatus.DONE, get_card_status('Fix Released', [], None))
305- self.assertEqual(
306- CardStatus.DONE, get_card_status('Opinion', [], None))
307- self.assertEqual(
308- CardStatus.DONE, get_card_status("Won't Fix", [], None))
309- self.assertEqual(
310- CardStatus.DONE, get_card_status('Invalid', [], None))
311- self.assertEqual(
312- CardStatus.DONE, get_card_status('Expired', [], None))
313+ def test_get_card_status_done_with_fix(self):
314+ # For a bug in 'Fix Released' status, it is considered completely done.
315+ self.assertEqual(
316+ CardStatus.DONE_FIX, get_card_status('Fix Released', [], None))
317+
318+ def test_get_card_status_done_with_no_fix(self):
319+ # For a bug in 'Opinion', 'Won't fix', 'Invalid' or'Expired' statuses,
320+ # it is considered completely done without a fix
321+ self.assertEqual(
322+ CardStatus.DONE_NOFIX, get_card_status('Opinion', [], None))
323+ self.assertEqual(
324+ CardStatus.DONE_NOFIX, get_card_status("Won't Fix", [], None))
325+ self.assertEqual(
326+ CardStatus.DONE_NOFIX, get_card_status('Invalid', [], None))
327+ self.assertEqual(
328+ CardStatus.DONE_NOFIX, get_card_status('Expired', [], None))
329
330
331 class GetCardsForFeatureTest(unittest.TestCase):
332@@ -881,7 +892,8 @@
333
334 class FauxLaunchpadProject:
335
336- def __init__(self, bug_tasks=None):
337+ def __init__(self, name="MyFauxProject", bug_tasks=None):
338+ self.name = name
339 if bug_tasks is None:
340 bug_tasks = []
341 self.bug_tasks = bug_tasks
342@@ -1146,20 +1158,20 @@
343
344 def test_feature_lanes_unset_without_feature_cards(self):
345 # When no feature cards exist on a board, the feature_lanes dictionary
346- # is empty and todo_lane setting is not represented.
347+ # is empty and new_lanes setting is not represented.
348 board = FauxBoard(users=[], feature_cards=[])
349 coding = board.addLane("dev::coding")
350 card = coding.addCard(FauxCard(tags=["sometag"]))
351 card.type = self.branch_type
352 lp = None
353- config = {"todo_lane": "my-Next-lane"}
354+ config = {"new_lanes": "my-Next-lane"}
355 lp_users = LaunchpadUsersForBoard()
356 lp_users.set_up_users(lp, board, config)
357 self.assertEqual({}, lp_users.feature_lanes)
358
359- def test_feature_lanes_unset_without_todo_lane_config(self):
360+ def test_feature_lanes_unset_without_new_lanes_config(self):
361 # When feature cards are present, feature_lanes is empty when no
362- # todo_lane setting is in the config.
363+ # new_lanes setting is in the config.
364 feature_card = FauxCard(tags=["sometag"])
365 feature_card.type = self.feature_type
366 board = FauxBoard(
367@@ -1167,14 +1179,14 @@
368 coding_lane = board.addLane("dev::coding")
369 coding_lane.addCard(feature_card)
370 lp = None
371- config = {"todo_lane": ""}
372+ config = {"new_lanes": ""}
373 lp_users = LaunchpadUsersForBoard()
374 lp_users.set_up_users(lp, board, config)
375 self.assertEqual({}, lp_users.feature_lanes)
376
377- def test_feature_lanes_set_with_feature_card_and_static_todo_lane(self):
378+ def test_feature_lanes_set_with_feature_card_and_static_new_lanes(self):
379 # When a feature card is present and tagged and the config contains
380- # todo_lane which doesn't specify the ${group} variable, feature_lanes
381+ # new_lanes which doesn't specify the ${group} variable, feature_lanes
382 # will be set for all tags.
383 feature_card = FauxCard(tags="sometag,anothertag")
384 feature_card.type = self.feature_type
385@@ -1183,15 +1195,15 @@
386 coding_lane = board.addLane("dev::coding")
387 coding_lane.addCard(feature_card)
388 lp = None
389- config = {"todo_lane": "dev::coding"}
390+ config = {"new_lanes": "dev::coding"}
391 lp_users = LaunchpadUsersForBoard()
392 lp_users.set_up_users(lp, board, config)
393 self.assertEqual(
394 {"sometag": coding_lane, "anothertag": coding_lane},
395 lp_users.feature_lanes)
396
397- def test_feature_lanes_unset_with_group_todo_lane_and_no_assignee(self):
398- # When a feature card is present and tagged and the todo_lane
399+ def test_feature_lanes_unset_with_group_new_lanes_and_no_assignee(self):
400+ # When a feature card is present and tagged and the new_lanes
401 # in the config specifies the ${group} variable,
402 # feature_lanes will be unset when there are no assigned users on the
403 # card.
404@@ -1202,14 +1214,14 @@
405 coding_lane = board.addLane("dev::group1::coding")
406 coding_lane.addCard(feature_card)
407 lp = None
408- config = {"todo_lane": "dev::${group}::coding"}
409+ config = {"new_lanes": "dev::${group}::coding"}
410 lp_users = LaunchpadUsersForBoard()
411 lp_users.set_up_users(lp, board, config)
412 self.assertIsNone(feature_card.assigned_user_id)
413 self.assertEqual({}, lp_users.feature_lanes)
414
415 def test_feature_lanes_set_for_squad_tags_for_each_configured_group(self):
416- # When todo_lane specifies ${group} variable substitution, feature tag
417+ # When new_lanes specifies ${group} variable substitution, feature tag
418 # lanes will be set for each configured group name.
419 groups_conf = '''
420 [Group 1]
421@@ -1225,15 +1237,15 @@
422 coding_lane2 = board.addLane("dev::Group 2::coding")
423 lp = None
424 config = {"groups_config_file": groups_file.name,
425- "todo_lane": "dev::${group}::coding"}
426+ "new_lanes": "dev::${group}::coding"}
427 lp_users = LaunchpadUsersForBoard(kanban_to_lp={1:FauxPerson("User1")})
428 lp_users.set_up_users(lp, board, config)
429 self.assertEqual(
430 {"squad-group-1": coding_lane, "squad-group-2": coding_lane2},
431 lp_users.feature_lanes)
432
433- def test_feature_lanes_set_with_group_todo_lane_and_assignee(self):
434- # When a feature card is present and tagged and the todo_lane
435+ def test_feature_lanes_set_with_group_new_lanes_and_assignee(self):
436+ # When a feature card is present and tagged and the new_lanes
437 # in the config specifies the ${group} variable,
438 # feature_lanes will be set when there are is an assigned user on the
439 # card.
440@@ -1256,7 +1268,7 @@
441 coding_lane.addCard(feature_card)
442 lp = None
443 config = {"groups_config_file": groups_file.name,
444- "todo_lane": "dev::${group}::coding"}
445+ "new_lanes": "dev::${group}::coding"}
446 lp_users = LaunchpadUsersForBoard(kanban_to_lp={1:FauxPerson("User1")})
447 lp_users.set_up_users(lp, board, config)
448 self.assertEqual(
449@@ -1340,7 +1352,8 @@
450 "qa_lanes": "qa",
451 "deploy_lanes": "deploy",
452 "downtime_deploy_lanes": "downtime",
453- "done_lanes": "done",
454+ "done_fix_lanes": "done-with-fix",
455+ "done_nofix_lanes": "done-without-fix",
456 }
457 self.board = FauxBoard()
458 self.backlog = self.board.addLane("backlog")
459@@ -1350,7 +1363,8 @@
460 self.qa = self.board.addLane("qa")
461 self.board.addLane("deploy")
462 self.board.addLane("downtime")
463- self.board.addLane("done")
464+ self.board.addLane("done-with-fix")
465+ self.board.addLane("done-without-fix")
466 self.lp_users = LaunchpadUsersForBoard()
467
468 def test_coding(self):
469@@ -1390,11 +1404,17 @@
470 card, CardStatus.DOWNTIME_DEPLOY, self.bconf, [], self.lp_users)
471 self.assertEqual("downtime", card.moved_to.path)
472
473- def test_done(self):
474- # DONE cards are moved to the done lane.
475- card = self.backlog.addCard(FauxCard())
476- move_card(card, CardStatus.DONE, self.bconf, [], self.lp_users)
477- self.assertEqual("done", card.moved_to.path)
478+ def test_done_without_fix(self):
479+ # DONE_NOFIX cards are moved to the done_nofix_lanes target
480+ card = self.backlog.addCard(FauxCard())
481+ move_card(card, CardStatus.DONE_NOFIX, self.bconf, [], self.lp_users)
482+ self.assertEqual("done-without-fix", card.moved_to.path)
483+
484+ def test_done_with_fix(self):
485+ # DONE_FIX cards are moved to the done_fix_lanes target
486+ card = self.backlog.addCard(FauxCard())
487+ move_card(card, CardStatus.DONE_FIX, self.bconf, [], self.lp_users)
488+ self.assertEqual("done-with-fix", card.moved_to.path)
489
490 def test_backwards(self):
491 # Cards can be moved backwards as well, e.g. go from QA to

Subscribers

People subscribed via source and target branches

to all changes: