=== modified file 'src/lp2kanban/bugs2cards.py'
--- src/lp2kanban/bugs2cards.py	2015-12-04 15:59:31 +0000
+++ src/lp2kanban/bugs2cards.py	2015-12-17 00:04:21 +0000
@@ -1,4 +1,4 @@
-# Copyright 2011 Canonical Ltd
+# Copyright 2011-2015 Canonical Ltd
 #
 from ConfigParser import ConfigParser
 from argparse import ArgumentParser
@@ -9,7 +9,9 @@
     update_blueprints_from_work_items,
     )
 from lp2kanban.kanban import (
+    BRANCH_REGEX,
     LeankitKanban,
+    LeankitTaskBoard,
     Record,
     )
 import os.path
@@ -164,8 +166,10 @@
 
     def _get_mp_info():
         mps = []
-        for bug_branch in branches:
-            for mp in bug_branch.branch.landing_targets:
+        for branch in branches:
+            if hasattr(branch, 'branch') :
+                branch = branch.branch
+            for mp in branch.landing_targets:
                 mp_info = Record(rank=None, status=None, mp=None)
                 status = mp.queue_status
                 mp_info.rank = ORDERED_STATUSES.get(status, 1)
@@ -225,8 +229,11 @@
     if conf.get('autosync', None) == 'on':
         has_id = (card.external_card_id is not None and
                   card.external_card_id.strip() != '')
-        return (has_id and NO_SYNC_MARKER not in card.title and
-                NO_SYNC_MARKER not in card.description)
+        has_branch = (card.external_system_url is not None and
+                      BRANCH_REGEX.match(card.external_system_url))
+        if NO_SYNC_MARKER in card.title or NO_SYNC_MARKER in card.description:
+            return False
+        return (has_id or has_branch)
     else:
         return (card.title.startswith(TITLE_MARKER) or
                 DESCRIPTION_MARKER in card.description)
@@ -299,15 +306,21 @@
     """Return the status of the card as one of CardStatus values."""
     # status of None means card needs not to be moved.
     status = None
-    if bug_status in IN_PROGRESS_BUG_STATUSES:
-        status = CardStatus.CODING
+    branch_card_status = None
+    if branch_info:
         if branch_info.status == 'In Progress':
-            status = CardStatus.CODING
+            branch_card_status = CardStatus.CODING
         elif branch_info.status == 'In Review':
-            status = CardStatus.REVIEW
+            branch_card_status = CardStatus.REVIEW
         elif branch_info.status == 'Approved':
-            status = CardStatus.LANDING
+            branch_card_status = CardStatus.REVIEW
         elif branch_info.status == 'Merged':
+            branch_card_status = CardStatus.LANDING
+        if bug_status is None:
+            return branch_card_status
+    if bug_status in IN_PROGRESS_BUG_STATUSES:
+        status = CardStatus.CODING
+        if branch_info.status == 'Merged':
             if bug_status == 'Fix Committed':
                 if 'qa-needstesting' in bug_tags:
                     status = CardStatus.QA
@@ -318,6 +331,8 @@
                         status = CardStatus.DEPLOY
             else:
                 status = CardStatus.LANDING
+        elif branch_card_status:
+            return branch_card_status
     elif bug_status in DONE_BUG_STATUSES:
         status = CardStatus.DONE
     return status
@@ -457,6 +472,43 @@
                 board, project, new_bug_tag, bconf.get("bug_to_card_type"),
                 bconf.get("bug_to_card_lane"))
     print "   Syncing cards:"
+    # Process linked branch cards
+    for card in board.getCardsWithExternalLinks(only_branches=True):
+        if card.external_card_id:
+            # Cards with external_id are processed by getCardsWithExternalIds
+            continue
+        branch_name = card.external_system_url.replace(
+            "https://code.launchpad.net/", "")
+        branch = lp.branches.getByUniqueName(unique_name=branch_name)
+        if not branch:
+            print "Invalid branch url ({}) for card '{}'".format(
+                card.external_system_url, card.title)
+            continue
+        if should_sync_card(card, bconf):
+            branch_info = get_branch_info([branch])
+            owner_name = BRANCH_REGEX.match(card.external_system_url).group(1)
+            card_status = get_card_status(None, '', branch_info)
+            card_path = card.lane.path
+            if isinstance(card.lane.board, LeankitTaskBoard):
+                task_card = card.lane.board.parent_card
+                card_path = "{}::{}::{}".format(
+                    task_card.lane.path, task_card.title, card_path)
+            if branch_info.status and should_move_card(card, bconf):
+                assignee = Record(name=owner_name)
+                try:
+                    move_card(card, card_status, bconf, [assignee], lp_users)
+                except IOError as e:
+                    print "   * %s (in %s)" % (card.title, card_path)
+                    print "     >>> Error moving card:"
+                    print "     ", e
+                    continue
+                kanban_user = lp_users.lp_to_kanban.get(assignee.name, None)
+                if kanban_user:
+                    card.assigned_user_id = kanban_user.id
+                    card.kanban_user = kanban_user
+                else:
+                    print "   * %s (in %s)" % (card.title, card_path)
+
     for card in board.getCardsWithExternalIds():
         if should_sync_card(card, bconf):
             try:
@@ -475,13 +527,19 @@
             synced, card_status, assignees, mp_url, mp_type = get_bug_status(
                 lp_bug, all_projects, lp_users.lp_to_kanban.keys())
 
+            card_path = card.lane.path
+            if isinstance(card.lane.board, LeankitTaskBoard):
+                task_card = card.lane.board.parent_card
+                card_path = "{}::{}::{}".format(
+                    task_card.lane.path, task_card.title, card_path)
+
             # Move card to new lane if configured to do so.
             if card_status is not None and should_move_card(card, bconf):
                 try:
                     move_card(card, card_status, bconf, assignees, lp_users)
                 except IOError as e:
                     print "   * %s: %s (in %s)" % (
-                        bug_id, card.title, card.lane.path)
+                        bug_id, card.title, card_path)
                     print "     >>> Error moving card:"
                     print "     ", e
                     continue
@@ -500,8 +558,7 @@
                 if mp_url and mp_type:
                     card.external_system_url = mp_url
                     card.external_system_name = mp_type
-                print "   * %s: %s (in %s)" % (
-                    bug_id, card.title, card.lane.path)
+                print "   * %s: %s (in %s)" % (bug_id, card.title, card_path)
             else:
                 print "   * %s: %s -- Not synced" % (
                     bug_id, card.title)

=== modified file 'src/lp2kanban/kanban.py'
--- src/lp2kanban/kanban.py	2014-03-18 20:49:48 +0000
+++ src/lp2kanban/kanban.py	2015-12-17 00:04:21 +0000
@@ -11,6 +11,8 @@
 
 
 ANNOTATION_REGEX = re.compile('^\s*{.*}\s*$', re.MULTILINE|re.DOTALL)
+BRANCH_REGEX = re.compile(
+    '^https://code.launchpad.net/~([.\w]*)/([-\w]*)/([-\w]*)$')
 
 
 class Record(dict):
@@ -55,8 +57,7 @@
 
 class LeankitConnector(object):
     def __init__(self, account, username=None, password=None, throttle=1):
-        host = 'https://' + account + '.leankitkanban.com'
-        self.base_api_url = host + '/Kanban/Api'
+        self.base_api_url = 'https://' + account + '.leankit.com'
         self.http = self._configure_auth(username, password)
         self.last_request_time = time.time() - throttle
         self.throttle = throttle
@@ -197,14 +198,17 @@
     optional_attributes = [
         'ExternalCardID', 'AssignedUserId', 'Size', 'IsBlocked',
         'BlockReason', 'ExternalSystemName', 'ExternalSystemUrl',
-        'ClassOfServiceId', 'DueDate',
+        'ClassOfServiceId', 'DueDate', 'CurrentTaskBoardId'
         ]
 
     def __init__(self, card_dict, lane):
         super(LeankitCard, self).__init__(card_dict)
 
         self.lane = lane
-        self.tags_list = set([tag.strip() for tag in self.tags.split(',')])
+        if not self.tags:
+            self.tags_list = []
+        else:
+            self.tags_list = set([tag.strip() for tag in self.tags.split(',')])
         if '' in self.tags_list:
             self.tags_list.remove('')
         self.type = lane.board.cardtypes[self.type_id]
@@ -218,7 +222,7 @@
         tag = tag.strip()
         if tag not in self.tags_list or self.tags.startswith(','):
             if tag != '':
-                self.tags_list.add(tag)
+                self.tags_list.append(tag)
             self.tags = ', '.join(self.tags_list)
 
     def save(self):
@@ -241,17 +245,20 @@
             #print "Storing %s in %s..." % (attr, self._toCamelCase(attr))
             data[self._toCamelCase(attr)] = getattr(self, attr)
 
+        board_id = str(self.lane.board.id)
+        if isinstance(LeankitTaskBoard):
+            board_id = str(self.lane.board.parent_board.id)
         if self.is_new:
             del data['Id']
             del data['LaneId']
             position = len(self.lane.cards)
-            url_parts = ['/Board', str(self.lane.board.id), 'AddCard',
-                         'Lane', str(self.lane.id), 'Position', str(position)]
+            url_parts = ['/Kanban/Api/Board', board_id,
+                         'AddCard', 'Lane', str(self.lane.id),
+                         'Position', str(position)]
         else:
-            url_parts = ['/Board', str(self.lane.board.id), 'UpdateCard']
+            url_parts = ['/Kanban/Api/Board', board_id, 'UpdateCard']
 
         url = '/'.join(url_parts)
-
         result = self.lane.board.connector.post(url, data=data)
 
         if (self.is_new and
@@ -271,9 +278,24 @@
         else:
             return None
 
+    def moveToTaskBoard(self, target_card):
+        """Move the current card into a subtask of target_card."""
+        url = '/Api/Card/MoveCardToTaskboard'
+        result = self.lane.board.connector.post(url, data={
+            'boardId': self.lane.board.id,
+            'destCardId': target_card.id,
+            'srcCardId': [self.id]})
+        if result.ReplyCode in LeankitResponseCodes.SUCCESS_CODES:
+            return result.ReplyData[0]
+        else:
+            raise Exception(
+                "Moving card %s (%s) to %s failed. " % (
+                   self.title, self.id, self.lane.path) +
+                "Error %s: %s" % (result.ReplyCode, result.ReplyText))
+
     def _moveCard(self):
         target_pos = len(self.lane.cards)
-        url = '/Board/%d/MoveCard/%d/Lane/%d/Position/%d' % (
+        url = '/Kanban/Api/Board/%d/MoveCard/%d/Lane/%d/Position/%d' % (
             self.lane.board.id, self.id, self.lane.id, target_pos)
         result = self.lane.board.connector.post(url, data=None)
         if result.ReplyCode in LeankitResponseCodes.SUCCESS_CODES:
@@ -327,6 +349,8 @@
                  text_after_json), where json_annotations contains the
                  JSON loaded with json.loads().
         """
+        if not self.description:
+            self.description = ""
         match = ANNOTATION_REGEX.search(self.description)
         if match:
             start = match.start()
@@ -450,11 +474,12 @@
         self.cards.append(card)
         return card
 
+
 class LeankitBoard(Converter):
 
     attributes = ['Id', 'Title', 'CreationDate', 'IsArchived']
 
-    base_uri = '/Boards/'
+    base_uri = '/Kanban/Api/Boards/'
 
     def __init__(self, board_dict, connector):
         super(LeankitBoard, self).__init__(board_dict)
@@ -473,12 +498,20 @@
         self._cards_with_external_ids = set()
         self._cards_with_description_annotations = set()
         self._cards_with_external_links = set()
+        self._cards_with_branches = set()
         self.default_cardtype = None
 
     def getCardsWithExternalIds(self):
         return self._cards_with_external_ids
 
-    def getCardsWithExternalLinks(self):
+    def getCardsWithExternalLinks(self, only_branches=False):
+        """Return cards with external links
+
+        @param only_merge_proposals: Only return cards that have merge
+            proposals specified as the card's external link
+        """
+        if only_branches:
+            return self._cards_with_branches
         return self._cards_with_external_links
 
     def getCardsWithDescriptionAnnotations(self):
@@ -491,15 +524,23 @@
         self._populateUsers(self.details['BoardUsers'])
         self._populateCardTypes(self.details['CardTypes'])
         self._archive = self.connector.get(
-            "/Board/" + str(self.id) + "/Archive").ReplyData[0]
+            "/Kanban/Api/Board/" + str(self.id) + "/Archive").ReplyData[0]
         archive_lanes = [lane_dict['Lane'] for lane_dict in self._archive]
         archive_lanes.extend(
             [lane_dict['Lane'] for
             lane_dict in self._archive[0]['ChildLanes']])
         self._backlog = self.connector.get(
-            "/Board/" + str(self.id) + "/Backlog").ReplyData[0]
+            "/Kanban/Api/Board/" + str(self.id) + "/Backlog").ReplyData[0]
         self._populateLanes(
             self.details['Lanes'] + archive_lanes + self._backlog)
+        for card in self.cards:
+            if card.current_task_board_id:  # We are a task board
+                taskboard_data = self.connector.get(
+                    "/Kanban/Api/v1/board/%s/card/%s/taskboard" % (self.id, card.id))
+                card.taskboard = LeankitTaskBoard(
+                    taskboard_data["ReplyData"][0], self, card)
+                card.taskboard.fetchDetails()
+                self.cards.extend(card.taskboard.cards)
         self._classifyCards()
 
     def _classifyCards(self):
@@ -512,12 +553,16 @@
                 self._cards_with_description_annotations.add(card)
             if card.external_system_url:
                 self._cards_with_external_links.add(card)
+                if BRANCH_REGEX.match(card.external_system_url):
+                    self._cards_with_branches.add(card)
         print "   - %s cards with external ids" % len(
             self._cards_with_external_ids)
         print "   - %s cards with external links" % len(
             self._cards_with_external_links)
         print "   - %s cards with description annotations" % len(
             self._cards_with_description_annotations)
+        print "   - %s cards with branches" % len(
+            self._cards_with_branches)
 
     def _populateUsers(self, user_data):
         self.users = {}
@@ -629,6 +674,23 @@
             self._printLanes(lane, indent, include_cards)
 
 
+class LeankitTaskBoard(LeankitBoard):
+
+    attributes = ['Id', 'Title']
+
+    def __init__(self, taskboard_dict, board, parent_card):
+        super(LeankitTaskBoard, self).__init__(taskboard_dict, board.connector)
+        self.base_uri = '/Api/Board/%d/TaskBoard/%s/Get' % (board.id, self.id)
+        self.cardtypes = board.cardtypes
+        self.parent_board = board
+        self.parent_card = parent_card
+        self.is_archived = False
+
+    def fetchDetails(self):
+        self.details = self.connector.get(self.base_uri).ReplyData[0]
+        self._populateLanes(self.details['Lanes'])
+
+
 class LeankitKanban(object):
 
     def __init__(self, account, username=None, password=None):
@@ -642,7 +704,7 @@
 
         :param include_archived: if True, include archived boards as well.
         """
-        boards_data = self.connector.get('/Boards').ReplyData
+        boards_data = self.connector.get('/Kanban/Api/Boards').ReplyData
         boards = []
         for board_dict in boards_data[0]:
             board = LeankitBoard(board_dict, self.connector)
@@ -687,13 +749,14 @@
 if __name__ == '__main__':
     kanban = LeankitKanban('launchpad.leankitkanban.com',
                            'user@email', 'password')
+
     print "Active boards:"
     boards = kanban.getBoards()
     for board in boards:
         print " * %s (%d)" % (board.title, board.id)
 
     # Get a board by the title.
-    board_name = 'lp2kanban test'
+    board_name = 'Landscape Test Board'
     print "Getting board '%s'..." % board_name
     board = kanban.getBoard(title=board_name)
     board.printLanes()

=== modified file 'src/lp2kanban/tests/common.py'
--- src/lp2kanban/tests/common.py	2013-04-08 12:48:10 +0000
+++ src/lp2kanban/tests/common.py	2015-12-17 00:04:21 +0000
@@ -33,13 +33,16 @@
         self.is_archived = is_archived
         self._cards_with_description_annotations = set()
         self._cards_with_external_links = set()
+        self._cards_with_branches = set()
         self.lanes = {}
         self.root_lane = self.addLane('ROOT LANE')
 
     def getCardsWithDescriptionAnnotations(self):
         return self._cards_with_description_annotations
 
-    def getCardsWithExternalLinks(self):
+    def getCardsWithExternalLinks(self, only_branches=False):
+        if only_branches:
+            return self._cards_with_branches
         return self._cards_with_external_links
 
     def getLaneByPath(self, path):
@@ -93,7 +96,7 @@
     def __init__(self, external_card_id=None, title=u"", description=u"",
                  description_annotations=None, lane=None,
                  assigned_user_id=None, external_system_name=None,
-                 external_system_url=None):
+                 external_system_url=u""):
         self.external_card_id = external_card_id
         self.title = title
         self.description = description

=== modified file 'src/lp2kanban/tests/test_bugs2cards.py'
--- src/lp2kanban/tests/test_bugs2cards.py	2013-04-08 12:53:06 +0000
+++ src/lp2kanban/tests/test_bugs2cards.py	2015-12-17 00:04:21 +0000
@@ -346,6 +346,7 @@
         # in CODING.
         self.assertEqual(CardStatus.CODING, status)
 
+BRANCH_URL = "https://code.launchpad.net/~me/project/branch-name"
 
 class CardStatusTest(unittest.TestCase):
 
@@ -375,23 +376,43 @@
 
     def test_should_sync_card_autosync_no(self):
         # When autosync is 'on', cards with no external card IDs
-        # are not synced.
+        # and no branches are not synced.
         card = Record(title=u'no sync', description=u'',
-                      external_card_id=None)
+                      external_card_id=None, external_system_url=None)
         self.assertFalse(should_sync_card(card, {'sync_cards': 'on'}))
 
-    def test_should_sync_card_autosync_yes(self):
+    def test_should_sync_card_autosync_no_sync_with_non_branch_urls(self):
+        # When autosync is 'on', cards with external_system_urls which are not
+        # valid launchpad branches are not synced.
+        non_branch_urls = [
+            "http://www.google.com/",
+            "https://bugs.launchpad.net/charms/+source/hacluster/+bug/1",
+            "https://code.launchpad.net/~person/project/blah/+merge/111"
+        ]
+        for url in non_branch_urls:
+            card = Record(title=u'no sync', description=u'',
+                          external_card_id=None, external_system_url=url)
+            self.assertFalse(should_sync_card(card, {'sync_cards': 'on'}))
+
+    def test_should_sync_card_autosync_synced_with_external_card_id(self):
         # When autosync is 'on', cards with external card IDs are synced.
         card = Record(title=u'no sync', description=u'',
-                      external_card_id='11')
+                      external_card_id=u'11', external_system_url=u'')
+        self.assertTrue(should_sync_card(card, {'autosync': 'on',
+                                                'sync_cards': 'on'}))
+
+    def test_should_sync_card_autosync_synced_with_branch(self):
+        # When autosync is 'on', cards with a valid branch are synced.
+        card = Record(title=u'no sync', description=u'',
+                      external_card_id=u'11', external_system_url=BRANCH_URL)
         self.assertTrue(should_sync_card(card, {'autosync': 'on',
                                                 'sync_cards': 'on'}))
 
     def test_should_sync_card_autosync_nosync(self):
-        # When autosync is 'on', cards with external card IDs are not
-        # synced when they contain the no-sync marker.
+        # When autosync is 'on', cards with external card IDs or a valid
+        # branch are not synced when they contain the no-sync marker.
         card = Record(title=u'(no-sync)', description=u'(no-sync)',
-                      external_card_id='11')
+                      external_card_id=u'11', external_system_url=BRANCH_URL)
         self.assertFalse(should_sync_card(card, {'autosync': 'on',
                                                  'sync_cards': 'on'}))
 
@@ -452,7 +473,7 @@
         self.assertFalse(should_sync_card(card1, conf))
         self.assertFalse(should_sync_card(card2, conf))
 
-    def test_get_card_status_noop(self):
+    def test_get_card_status_noop_no_branch(self):
         # For a bug in 'New', 'Triaged', 'Confirmed' or 'Incomplete' statuses,
         # the status is unknown.
         self.assertEqual(None, get_card_status('New', [], None))
@@ -460,6 +481,33 @@
         self.assertEqual(None, get_card_status('Incomplete', [], None))
         self.assertEqual(None, get_card_status('Triaged', [], None))
 
+    def test_get_card_status_coding_no_bug(self):
+        # For a card with no associated bug, an 'In Progress' branch status
+        # will be in the coding state. 
+        branch_info = Record(status='In Progress', target=None)
+        self.assertEqual(
+            CardStatus.CODING, 
+            get_card_status(None, [], branch_info))
+
+    def test_get_card_status_review_no_bug(self):
+        # For a card with no associated bug, an 'Approved' or 'In Review'
+        # branch status will be in the review state. 
+        branch_infos = [
+            Record(status='In Review', target=None),
+            Record(status='Approved', target=None)]
+        for branch_info in branch_infos:
+            self.assertEqual(
+                CardStatus.REVIEW, 
+                get_card_status(None, [], branch_info))
+
+    def test_get_card_status_landing_no_bug(self):
+        # For a card with no associated bug, a merged branch status will be in
+        # the landing state. 
+        branch_info = Record(status='Merged', target=None)
+        self.assertEqual(
+            CardStatus.LANDING, 
+            get_card_status(None, [], branch_info))
+
     def test_get_card_status_coding_no_branch(self):
         # For a bug in 'In Progress' or 'Fix Committed' status, it is
         # considered to be in the coding state if there is no branch.
@@ -496,16 +544,12 @@
             get_card_status('Fix Committed', [], branch_info))
 
     def test_get_card_status_landing(self):
-        # For a bug in 'In Progress' or 'Fix Committed' status, it is
-        # considered to be in the landing phase if there is a branch
-        # approved for landing.
-        branch_info = Record(status='Approved', target=None)
+        # For a bug in 'In Progress' status, it is considered to be in the
+        # landing phase if there is a branch 'Merged'.
+        branch_info = Record(status='Merged', target=None)
         self.assertEqual(
             CardStatus.LANDING,
             get_card_status('In Progress', [], branch_info))
-        self.assertEqual(
-            CardStatus.LANDING,
-            get_card_status('Fix Committed', [], branch_info))
 
     def test_get_card_status_qa(self):
         # For a bug in the 'Fix Committed' status, it is considered

