Source code for ballparker.phab

# Copyright 2019-2020, Collabora, Ltd.
# SPDX-License-Identifier: Apache-2.0
"""Connect a ballpark with Phabricator."""

import re

from .types import Task

_PROJECT_DESC = 'project'
_PHID_ATTR_NAME = 'phid'
_TASK_NUM_ATTR_NAME = 'task_num'
_PHAB_FIELDS_ATTR_NAME = 'phab_fields'


def _get_phid(task):
    if hasattr(task, _PHID_ATTR_NAME):
        return getattr(task, _PHID_ATTR_NAME)
    return None


def _get_task_num(task):
    if hasattr(task, _TASK_NUM_ATTR_NAME):
        return getattr(task, _TASK_NUM_ATTR_NAME)
    return None


def _get_phab_fields(task):
    if hasattr(task, _PHAB_FIELDS_ATTR_NAME):
        return getattr(task, _PHAB_FIELDS_ATTR_NAME)
    return None


def _make_transactions(d):
    """
    Convert a dict of transactions into an actual list of transaction objects.

    Just a convenience function to make the code more concise and less
    repetitive.
    """
    return [
        {
            'type': k,
            'value': v,
        }
        for k, v in d.items()
    ]


[docs]class PhabSync: """Class to sync some aspects of a ballpark with Phabricator.""" def __init__(self, project_slug, phab=None): """ Construct object. :param project_slug: The project slug, AKA the hashtag without the leading ``#``. :param phab: :class:`phabricator.Phabricator` instance (optional) If `phab` is not specified, it will be created in the default way, which requires ``~/.arcrc`` or ``.arcconfig`` to be populated with a URI and a token. """ self.task_re = re.compile(r'T(?P<task>([1-9][0-9]*)): (?P<title>.*)') self.phab = phab if self.phab is None: from phabricator import Phabricator # type: ignore self.phab = Phabricator() self.phab.update_interfaces() self.project_slug = project_slug results = self.phab.project.search( constraints={"slugs": [project_slug]}) if len(results["data"]) != 1: raise RuntimeError( "Wrong number of results for project slug search: got " + str(len(results["data"])) ) result = results["data"][0] self.project_phid = result["phid"] self.project_fields = result["fields"] self.space_phid = self.project_fields['spacePHID']
[docs] def query_tasks(self, root_task): """ Initialize extra members of the provided task and its children. If a task description is of the form ``"T{task_num}: {desc}"``, it extracts the task num and sets it as a member of the task. Then, for all tasks with a `task_num` member but either no `phid` member or no `phab_fields` member, query Phabricator to retrieve all that data. Can be run multiple times on the same ballpark without harm. """ def extract_task_num_visitor(task, parent=None): if task.is_project: return if _get_task_num(task) is None: m = self.task_re.match(task.description) if m: setattr(task, _TASK_NUM_ATTR_NAME, int(m.group('task'))) root_task.apply_visitor(extract_task_num_visitor) # Record which task numbers need to have a query performed task_nums = [] def accumulate_nums_visitor(task, parent=None): if _get_task_num(task) is None: # Don't have anything to lookup return if _get_phab_fields(task) is None or _get_phid(task) is None: task_nums.append(_get_task_num(task)) root_task.apply_visitor(accumulate_nums_visitor) if not task_nums: # Nothing to query return # Do the query and manipulate the data so it can be more easily used. results = self.phab.maniphest.search(constraints={'ids': task_nums}) data_by_task_num = {x['id']: x['fields'] for x in results['data']} phid_by_task_num = {x['id']: x['phid'] for x in results['data']} # This shouldn't be empty assert(data_by_task_num) # Use query results to populate members of tasks. def populate_data_visitor(task, parent=None): task_num = _get_task_num(task) # no known task num if task_num is None: return # not included in query if task_num not in phid_by_task_num: return # populate members setattr(task, _PHID_ATTR_NAME, phid_by_task_num[task_num]) setattr(task, _PHAB_FIELDS_ATTR_NAME, data_by_task_num[task_num]) root_task.apply_visitor(populate_data_visitor)
[docs] def create_tasks(self, root_task: Task): """ Create Phabricator tasks for each task that lacks one. The task number gets added to the task description: you should output ``root_task.to_dsl()`` after this (see :meth:`ballparker.types.Task.to_dsl`) and update your ballpark definition. You can run this more than once in a single execution, but don't run it in a second execution unless you've updated your ballpark from :meth:`~ballparker.types.Task.to_dsl` or you'll create duplicate tasks. Tasks that are top-level below :func:`~ballparker.dsl.project` are considered epics, and named accordingly in Phabricator. Tasks are created in the same space as the project, and leaf tasks have their "points" set from the ballpark. Subtask/parent task relationships are replicated from the ballpark to Phabricator. Returns the task description of all tasks created. """ # Must query first - calling it repeatedly is harmless. self.query_tasks(root_task) created = [] # Record all newly-created phids here. new_phids = set() # Record phids of tasks with a parent in the ballpark but whose parent # did not have a phid at time of creation needs_parentage_phids = set() # Create tasks for all that don't have one. def create_tasks_visitor(task, parent=None): if task.is_project: return if _get_phid(task) is None: # Since we already did init, if we have no phid now, # we don't have a task now. title = task.description is_epic = parent.is_project() if is_epic: title = '[EPIC] ' + title transactions = _make_transactions({ 'title': title, 'space': self.space_phid, 'projects.set': [self.project_phid] }) if not task.is_grouping: transactions.extend( _make_transactions({ 'points': task.known_story_points }) ) needs_parent = False if not is_epic: assert(parent) parent_phid = _get_phid(parent) if parent_phid is None: needs_parent = True else: transactions.append({ 'type': 'parents.set', 'value': [parent_phid] }) # Actually perform the creation result = self.phab.maniphest.edit( transactions=transactions ) # Extract the parts of the result we care about, # and apply them to the task. phid = result['object']['phid'] task_num = result['object']['id'] setattr(task, _PHID_ATTR_NAME, phid) setattr(task, _TASK_NUM_ATTR_NAME, task_num) # Decorate task description for persistence of association. task.description = f'T{task_num}: {task.description}' # Record our results new_phids.add(phid) created.append(task.description) if needs_parent: needs_parentage_phids.add(phid) root_task.apply_visitor(create_tasks_visitor) # Query again, now that we've populated the task_num and phid # members, to populate other members. self.query_tasks(root_task) if not needs_parentage_phids: # If we don't have any parents to go back in and fix up, # we're done. This is the usual case. return created # Fill in any task parents we missed. def set_parents_visitor(task, parent=None): if task.is_project: return phid = _get_phid(task) if phid is not None and phid in needs_parentage_phids: parent_phid = _get_phid(parent) result = self.phab.maniphest.edit( objectIdentifier=phid, transactions={ 'type': 'parents.set', 'value': [parent_phid] } ) print("Added parent") print(result) root_task.apply_visitor(set_parents_visitor) return created