diff --git a/jirate/jboard.py b/jirate/jboard.py index 4568375..08726c0 100644 --- a/jirate/jboard.py +++ b/jirate/jboard.py @@ -325,7 +325,7 @@ def comment(self, issues, text, visibility=None): ret.append(issue) return ret - def close(self, issues): + def close(self, issues, **args): """Close an issue XXX this might not be a usable API and/or may be a higher @@ -336,11 +336,13 @@ def close(self, issues): Parameters: issues: list of issue keys or IDs (list of string) + args: dictionary of fields to set on transition + (resolution only really known right now) Returns: requests.Response """ - return self.move(issues, 'Closed') + return self.move(issues, 'Closed', **args) def create(self, field_definitions=None, **args): """Create a new issue using key/value pairs @@ -472,27 +474,25 @@ def transitions(self, issue): issue_alias: key or issue ID (string) Returns: - dict - {'state': 'id', 'state2': 'id2'} + list of transitions w/ metadata """ if isinstance(issue, str): issue = self.issue(issue) - possible = {} - url = os.path.join(issue.raw['self'], 'transitions') + url = os.path.join(issue.raw['self'], 'transitions?expand=transitions.fields') transitions = json_loads(self.jira._session.get(url)) - for transition in transitions['transitions']: - possible[transition['to']['id']] = {'id': transition['id'], 'name': transition['to']['name']} - if not possible: - return None - return possible + if transitions: + return transitions['transitions'] def _find_transition(self, issue, status): transitions = self.transitions(issue) - for state_id in transitions: - if transitions[state_id]['name'] == status or nym(transitions[state_id]['name']) == status or str(state_id) == str(status): - return transitions[state_id]['id'] + for transition in transitions: + trans_state = transition['to']['name'] + trans_state_id = transition['to']['id'] + if trans_state == status or nym(trans_state) == status or str(status) == str(trans_state_id): + return transition return None - def move(self, issue_list, status): + def move(self, issue_list, status, **args): """Execute a transition to move a set of issues to the desired status Jira doesn't have a status you can update; you have to retrieve possible @@ -502,6 +502,7 @@ def move(self, issue_list, status): Parameters: issue_aliases: list keys or issue IDs (list of string) status: Desired status + **args: field=value pairs to set on transition Returns: list of successfully moved issues (list of string) @@ -518,9 +519,17 @@ def move(self, issue_list, status): if not transition: continue + data = {'transition': {'id': transition['id']}} + if args and 'fields' in transition: + new_args = transmogrify_input(transition['fields'], **args) + data['fields'] = new_args + if new_args == {}: + oops = [args.keys()] + raise ValueError(f'field(s) not allowed in transition: {oops}') + # POST /rest/api/2/issue/{issueIdOrKey}/transitions url = os.path.join(issue.raw['self'], 'transitions') - self.jira._session.post(url, data={'transition': {'id': transition}}) + self.jira._session.post(url, data=data) return issues def link_types(self): diff --git a/jirate/jira_cli.py b/jirate/jira_cli.py index 0191925..07fc82a 100644 --- a/jirate/jira_cli.py +++ b/jirate/jira_cli.py @@ -36,8 +36,11 @@ def move(args): def close_issues(args): ret = 0 + close_args = {} + if args.resolution: + close_args['resolution'] = args.resolution for issue in args.target: - if not args.project.close(issue): + if not args.project.close(issue, **close_args): ret = 1 return (ret, False) @@ -766,7 +769,7 @@ def print_issue(project, issue_obj, verbose=False, no_comments=False, no_format= vsep_print(None, 'URL', lsize, issue_obj.permalink()) trans = project.transitions(issue_obj.raw['key']) if trans: - vsep_print(' ', 'Next States', lsize, [tr['name'] for tr in trans.values()]) + vsep_print(' ', 'Next States', lsize, [tr['name'] for tr in trans]) else: vsep_print(None, 'Next States', lsize, 'No valid transitions; cannot alter status') @@ -1068,6 +1071,7 @@ def create_parser(): cmd.add_argument('issue', help='Existing Issue (more fields available here)', nargs='?') cmd = parser.command('close', help='Move issue(s) to closed/done/resolved', handler=close_issues) + cmd.add_argument('-r', '--resolution', help='Set resolution on transition') cmd.add_argument('target', nargs='+', help='Target issue(s)') cmd = parser.command('call-api', help='Call an API directly and print the resulting JSON', handler=call_api) diff --git a/jirate/jira_input.py b/jirate/jira_input.py index 25a3824..1adc796 100644 --- a/jirate/jira_input.py +++ b/jirate/jira_input.py @@ -45,6 +45,7 @@ def in_owc(value): 'securitylevel': in_name, 'option-with-child': in_owc, 'issuelink': in_key, + 'resolution': in_name, 'user': in_string # When setting array, you specify 'name': name # When setting assignee, you just give the name # as a string @@ -103,7 +104,7 @@ def allowed_value_validate(field_name, values, allowed_values=None): for key in ['name', 'value']: if key not in av: continue - if val not in (av[key], nym(av[key])): + if val not in (av[key], nym(av[key]), av[key].lower()): continue ret.append(av[key]) found = True diff --git a/jirate/tests/__init__.py b/jirate/tests/__init__.py index 541ae57..8bbca78 100644 --- a/jirate/tests/__init__.py +++ b/jirate/tests/__init__.py @@ -264,6 +264,23 @@ fake_metadata = {val['id']: val for val in fake_fields} +fake_transitions = {'expand': 'transitions', + 'transitions': [{'id': '11', 'name': 'New', 'to': {'name': 'New', 'id': '10000'}}, + {'id': '12', 'name': 'In Progress', 'to': {'name': 'In Progres', 'id': '10001'}}, + {'id': '13', 'name': 'Done', 'to': {'name': 'Done', 'id': '10002'}, + 'fields': + {'resolution': { + 'name': 'Resolution', + 'fieldId': 'resolution', + 'schema': { + 'type': 'resolution', + 'system': 'resolution'}, + 'operations': ['set'], + 'allowedValues': [{'name': 'Done'}, {'name': 'Won\'t Do'}, {'name': 'Duplicate'}]}}}, + {'id': '14', 'name': 'New', 'to': {'name': 'New', 'id': '10003'}}] + } + + fake_issues = { 'TEST-1': {'expand': 'renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations', 'fields': {'aggregateprogress': {'progress': 0, 'total': 0}, @@ -468,18 +485,26 @@ class fake_jira_session(): + def __init__(self): + self.get_urls = [] + self.post_urls = {} + self.delete_urls = [] + def get(self, url): - pass + self.get_urls.append(url) def post(self, url, data=None): - pass + self.post_urls[url] = data def delete(self, url): - pass + self.delete_urls.append(url) def close(self): pass + def reset(self): + self.__init__() + class fake_jira(JIRA): def __init__(self, **kwargs): diff --git a/jirate/tests/test_input.py b/jirate/tests/test_input.py index 4a7525c..06f6346 100644 --- a/jirate/tests/test_input.py +++ b/jirate/tests/test_input.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -from jirate.tests import fake_metadata +from jirate.tests import fake_metadata, fake_transitions from jirate.jira_input import transmogrify_input import os @@ -141,3 +141,7 @@ def test_trans_version_value(): def test_trans_missing_version(): with pytest.raises(ValueError): transmogrify_input(fake_metadata, **{'version_value': '999'}) + + +def test_trans_metadata(): + transition_fields = fake_transitions diff --git a/jirate/tests/test_jirate.py b/jirate/tests/test_jirate.py index ebdba3c..0823e72 100644 --- a/jirate/tests/test_jirate.py +++ b/jirate/tests/test_jirate.py @@ -1,14 +1,23 @@ #!/usr/bin/env python from jirate.jboard import Jirate -from jirate.tests import fake_jira, fake_user +from jirate.tests import fake_jira, fake_user, fake_transitions import pytest # NOQA +import types fake_jirate = Jirate(fake_jira()) +def transitions_override(obj, issue): + return fake_transitions['transitions'] + + +# XXX we don't have subs in at the level we need, so fake transitions this way +fake_jirate.transitions = types.MethodType(transitions_override, fake_jirate) + + def test_jirate_myself(): assert not fake_jirate._user me = fake_jirate.user @@ -72,3 +81,29 @@ def test_jirate_customfields(): # Negative test with pytest.raises(AttributeError): issue.field('arglebargle') + + +def test_transition_resolutions(): + issue = fake_jirate.issue('TEST-1') + assert fake_jirate.transitions(issue) == fake_transitions['transitions'] + + fake_jirate.jira._session.reset() + assert fake_jirate.move('TEST-1', 'done') == [issue] + assert fake_jirate.jira._session.post_urls == {'https://domain.com/rest/api/2/issue/1000001/transitions': {'transition': {'id': '13'}}} + + fake_jirate.jira._session.reset() + assert fake_jirate.move('TEST-1', 'done', resolution='won\'t do') == [issue] + assert fake_jirate.jira._session.post_urls == {'https://domain.com/rest/api/2/issue/1000001/transitions': {'fields': {'resolution': {'name': 'Won\'t Do'}}, 'transition': {'id': '13'}}} + + fake_jirate.jira._session.reset() + assert fake_jirate.move('TEST-1', 'done', resolution='done') == [issue] + assert fake_jirate.jira._session.post_urls == {'https://domain.com/rest/api/2/issue/1000001/transitions': {'fields': {'resolution': {'name': 'Done'}}, 'transition': {'id': '13'}}} + + +def test_transition_bad_field(): + issue = fake_jirate.issue('TEST-1') + assert fake_jirate.transitions(issue) == fake_transitions['transitions'] + + fake_jirate.jira._session.reset() + with pytest.raises(ValueError): + assert fake_jirate.move('TEST-1', 'done', beastly_fido='odif_yltsaeb') == [issue]