File: work_item.py

package info (click to toggle)
azure-devops-cli-extension 1.0.2-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 20,384 kB
  • sloc: python: 160,782; xml: 198; makefile: 56; sh: 51
file content (392 lines) | stat: -rw-r--r-- 19,250 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

from __future__ import print_function
import webbrowser

from knack.log import get_logger
from knack.util import CLIError
from azext_devops.devops_sdk.exceptions import AzureDevOpsServiceError
from azext_devops.devops_sdk.v5_0.work_item_tracking.models import JsonPatchOperation, Wiql
from azext_devops.dev.common.arguments import convert_date_string_to_iso8601
from azext_devops.dev.common.identities import (ME, get_current_identity,
                                                resolve_identity,
                                                get_account_from_identity)
from azext_devops.dev.common.services import (get_work_item_tracking_client,
                                              resolve_instance,
                                              resolve_instance_and_project)
from azext_devops.dev.common.uri import uri_quote

logger = get_logger(__name__)


def create_work_item(work_item_type, title, description=None, assigned_to=None, area=None,
                     iteration=None, reason=None, discussion=None, fields=None, open=False,  # pylint: disable=redefined-builtin
                     organization=None, project=None, detect=None):
    r"""Create a work item.
    :param work_item_type: Name of the work item type (e.g. Bug).
    :type work_item_type: str
    :param title: Title of the work item.
    :type title: str
    :param description: Description of the work item.
    :type description: str
    :param assigned_to: Name of the person the work item is assigned-to (e.g. fabrikam).
    :type assigned_to: str
    :param area: Area the work item is assigned to (e.g. Demos)
    :type area: str
    :param iteration: Iteration path of the work item (e.g. Demos\Iteration 1).
    :type iteration: str
    :param reason: Reason for the state of the work item.
    :type reason: str
    :param discussion: Comment to add to a discussion in a work item.
    :type discussion: str
    :param fields: Space separated "field=value" pairs for custom fields you would like to set.
    In case of multiple fields : "field1=value1" "field2=value2".
    Refer https://aka.ms/azure-devops-cli-field-api for more details on fields.
    :type fields: [str]
    :param open: Open the work item in the default web browser.
    :type open: bool
    :rtype: :class:`<WorkItem> <v5_0.work-item-tracking.models.WorkItem>`
    """
    try:
        organization, project = resolve_instance_and_project(
            detect=detect, organization=organization, project=project, project_required=True)
        patch_document = []
        if title is not None:
            patch_document.append(_create_work_item_field_patch_operation('add', 'System.Title', title))
        else:
            raise ValueError('--title is a required argument.')
        if description is not None:
            patch_document.append(_create_work_item_field_patch_operation('add', 'System.Description', description))
        if assigned_to is not None:
            # 'assigned to' does not take an identity id.  Display name works.
            assigned_to = assigned_to.strip()
            if assigned_to == '':
                resolved_assigned_to = ''
            else:
                resolved_assigned_to = _resolve_identity_as_unique_user_id(assigned_to, organization)
            if resolved_assigned_to is not None:
                patch_document.append(_create_work_item_field_patch_operation('add', 'System.AssignedTo',
                                                                              resolved_assigned_to))
        if area is not None:
            patch_document.append(_create_work_item_field_patch_operation('add', 'System.AreaPath', area))
        if iteration is not None:
            patch_document.append(_create_work_item_field_patch_operation('add', 'System.IterationPath', iteration))
        if reason is not None:
            patch_document.append(_create_work_item_field_patch_operation('add', 'System.Reason', reason))
        if discussion is not None:
            patch_document.append(_create_work_item_field_patch_operation('add', 'System.History', discussion))
        if fields is not None and fields:
            for field in fields:
                kvp = field.split('=', 1)
                if len(kvp) == 2:
                    patch_document.append(_create_work_item_field_patch_operation('add', kvp[0], kvp[1]))
                else:
                    raise ValueError('The --fields argument should consist of space separated "field=value" pairs.')
        client = get_work_item_tracking_client(organization)
        work_item = client.create_work_item(document=patch_document, project=project, type=work_item_type)
        if open:
            _open_work_item(work_item, organization)
        return work_item
    except AzureDevOpsServiceError as ex:
        _handle_vsts_service_error(ex)


def update_work_item(id, title=None, description=None, assigned_to=None, state=None, area=None,  # pylint: disable=redefined-builtin
                     iteration=None, reason=None, discussion=None, fields=None, open=False,  # pylint: disable=redefined-builtin
                     organization=None, detect=None):
    r"""Update work items.
    :param id: The id of the work item to update.
    :type id: int
    :param title: Title of the work item.
    :type title: str
    :param description: Description of the work item.
    :type description: str
    :param assigned_to: Name of the person the work item is assigned-to (e.g. fabrikam).
    :type assigned_to: str
    :param state: State of the work item (e.g. active).
    :type state: str
    :param area: Area the work item is assigned to (e.g. Demos).
    :type area: str
    :param iteration: Iteration path of the work item (e.g. Demos\Iteration 1).
    :type iteration: str
    :param reason: Reason for the state of the work item.
    :type reason: str
    :param discussion: Comment to add to a discussion in a work item.
    :type discussion: str
    :param fields: Space separated "field=value" pairs for custom fields you would like to set.
    Refer https://aka.ms/azure-devops-cli-field-api for more details on fields.
    :type fields: [str]
    :param open: Open the work item in the default web browser.
    :type open: bool
    :rtype: :class:`<WorkItem> <v5_0.work-item-tracking.models.WorkItem>`
    """
    organization = resolve_instance(detect=detect, organization=organization)
    patch_document = []
    if title is not None:
        patch_document.append(_create_work_item_field_patch_operation('add', 'System.Title', title))
    if description is not None:
        patch_document.append(_create_work_item_field_patch_operation('add', 'System.Description', description))
    if assigned_to is not None:
        assigned_to = assigned_to.strip()
        # 'assigned to' does not take an identity id.  Display name works.
        if assigned_to == '':
            resolved_assigned_to = ''
        else:
            resolved_assigned_to = _resolve_identity_as_unique_user_id(assigned_to, organization)
        if resolved_assigned_to is not None:
            patch_document.append(_create_work_item_field_patch_operation('add', 'System.AssignedTo',
                                                                          resolved_assigned_to))
    if state is not None:
        patch_document.append(_create_work_item_field_patch_operation('add', 'System.State', state))
    if area is not None:
        patch_document.append(_create_work_item_field_patch_operation('add', 'System.AreaPath', area))
    if iteration is not None:
        patch_document.append(_create_work_item_field_patch_operation('add', 'System.IterationPath', iteration))
    if reason is not None:
        patch_document.append(_create_work_item_field_patch_operation('add', 'System.Reason', reason))
    if discussion is not None:
        patch_document.append(_create_work_item_field_patch_operation('add', 'System.History', discussion))
    if fields is not None and fields:
        for field in fields:
            kvp = field.split('=', 1)
            if len(kvp) == 2:
                patch_document.append(_create_work_item_field_patch_operation('add', kvp[0], kvp[1]))
            else:
                raise ValueError('The --fields argument should consist of space separated "field=value" pairs.')
    client = get_work_item_tracking_client(organization)
    work_item = client.update_work_item(document=patch_document, id=id)
    if open:
        _open_work_item(work_item, organization)
    return work_item


def delete_work_item(id,  # pylint: disable=redefined-builtin
                     destroy=False, organization=None, project=None, detect=None):
    """Delete a work item.
    :param id: Unique id of the work item.
    :type id: int
    :param destroy: Permanently delete this work item.
    :type destroy: bool
    :rtype: :class:`<WorkItem> <v5_0.work-item-tracking.models.WorkItemDelete>`
    """
    try:
        organization, project = resolve_instance_and_project(detect=detect, organization=organization, project=project)
        client = get_work_item_tracking_client(organization)
        delete_response = client.delete_work_item(id=id, project=project, destroy=destroy)
        print('Deleted work item {}'.format(id))
        return delete_response
    except AzureDevOpsServiceError as ex:
        _handle_vsts_service_error(ex)


def _handle_vsts_service_error(ex):
    logger.debug(ex, exc_info=True)
    if ex.type_key == 'RuleValidationException' and "FieldReferenceName" in ex.custom_properties:
        if ex.message is not None:
            message = ex.message
            if message and message[len(message) - 1] != '.':
                message += '.'
            name = ex.custom_properties["FieldReferenceName"]
            if name is not None:
                if name in _SYSTEM_FIELD_ARGS:
                    message += ' Use the --{} argument to supply this value.'.format(_SYSTEM_FIELD_ARGS[name])
                else:
                    message += ' To specify a value for this field, use the --field argument and set the name of the ' \
                               + 'name/value pair to {}.'.format(name)
        else:
            message = "RuleValidationException for FieldReferenceName: " + ex.custom_properties["FieldReferenceName"]
        raise CLIError(ValueError(message))

    raise CLIError(ex)


def show_work_item(id, as_of=None, expand='all', fields=None, open=False, organization=None, detect=None):  # pylint: disable=redefined-builtin
    """Show details for a work item.
    :param id: The ID of the work item
    :type id: int
    :param as_of: Work item details as of a particular date and time. Provide a date or date time string.
    Assumes local time zone. Example: '2019-01-20', '2019-01-20 00:20:00'.
    For UTC, append 'UTC' to the date time string, '2019-01-20 00:20:00 UTC'.
    :type as_of:string
    :param expand: The expand parameters for work item attributes.
    :type expand:str
    :param fields: Comma-separated list of requested fields. Example:System.Id,System.AreaPath.
    Refer https://aka.ms/azure-devops-cli-field-api for more details on fields.
    :type fields: str
    :param open: Open the work item in the default web browser.
    :type open: bool
    :rtype: :class:`<WorkItem> <v5_0.work-item-tracking.models.WorkItem>`
    """
    organization = resolve_instance(detect=detect, organization=organization)
    try:
        client = get_work_item_tracking_client(organization)
        as_of_iso = None
        if as_of:
            as_of_iso = convert_date_string_to_iso8601(value=as_of, argument='as-of')
        if fields:
            fields = fields.split(',')
        work_item = client.get_work_item(id, as_of=as_of_iso, fields=fields, expand=expand)
    except AzureDevOpsServiceError as ex:
        _handle_vsts_service_error(ex)

    if open:
        _open_work_item(work_item, organization)
    return work_item


# pylint: disable=too-many-statements
def query_work_items(wiql=None, id=None, path=None, organization=None, project=None, detect=None):  # pylint: disable=redefined-builtin
    """Query for a list of work items. Only supports flat queries.
    :param wiql: The query in Work Item Query Language format.  Ignored if --id or --path is specified.
    :type wiql: str
    :param id: The ID of an existing query.  Required unless --path or --wiql are specified.
    :type id: str
    :param path: The path of an existing query.  Ignored if --id is specified.
    :type path: str
    :rtype: :class:`<WorkItem> <v5_0.work-item-tracking.models.WorkItem>`
    """
    if wiql is None and path is None and id is None:
        raise CLIError("Either the --wiql, --id, or --path argument must be specified.")
    organization, project = resolve_instance_and_project(
        detect=detect, organization=organization, project=project, project_required=False)
    client = get_work_item_tracking_client(organization)
    if id is None and path is not None:
        if project is None:
            raise CLIError("The --project argument must be specified for this query.")
        query = client.get_query(project=project, query=path)
        id = query.id
    if id is not None:
        query_result = client.query_by_id(id=id)
    else:
        wiql_object = Wiql()
        wiql_object.query = wiql
        query_result = client.query_by_wiql(wiql=wiql_object)
    if query_result.work_items:
        _last_query_result[_LAST_QUERY_RESULT_KEY] = query_result  # store query result for table view
        safety_buffer = 100  # a buffer in the max url length to protect going over the limit
        remaining_url_length = 2048 - safety_buffer
        remaining_url_length -= len(organization)
        # following subtracts relative url, the asof parameter and beginning of id and field parameters.
        # asof value length will vary, but this should be the longest possible
        remaining_url_length -=\
            len('/_apis/wit/workItems?ids=&fields=&asOf=2017-11-07T17%3A05%3A34.06699999999999999Z')
        fields = []
        fields_length_in_url = 0
        if query_result.columns:
            for field_ref in query_result.columns:
                fields.append(field_ref.reference_name)
                if fields_length_in_url > 0:
                    fields_length_in_url += 3  # add 3 for %2C delimiter
                fields_length_in_url += len(uri_quote(field_ref.reference_name))
                if fields_length_in_url > 800:
                    logger.info("Not retrieving all fields due to max url length.")
                    break
        remaining_url_length -= fields_length_in_url
        max_work_items = 1000
        work_items_batch_size = 200
        current_batch = []
        work_items = []
        work_item_url_length = 0
        for work_item_ref in query_result.work_items:
            if len(work_items) >= max_work_items:
                logger.info("Only retrieving the first %s work items.", max_work_items)
                break
            if work_item_url_length > 0:
                work_item_url_length += 3  # add 3 for %2C delimiter
            work_item_url_length += len(str(work_item_ref.id))
            current_batch.append(work_item_ref.id)

            if remaining_url_length - work_item_url_length <= 0 or len(current_batch) == work_items_batch_size:
                # url is near max length, go ahead and send first request for details.
                # url can go over by an id length because we have a safety buffer
                current_batched_items = client.get_work_items(ids=current_batch,
                                                              as_of=query_result.as_of,
                                                              fields=fields)
                for work_item in current_batched_items:
                    work_items.append(work_item)
                current_batch = []
                work_item_url_length = 0

        if current_batch:
            current_batched_items = client.get_work_items(ids=current_batch,
                                                          as_of=query_result.as_of,
                                                          fields=fields)
            for work_item in current_batched_items:
                work_items.append(work_item)
        # put items in the same order they appeared in the initial query results
        work_items = sorted(work_items, key=_get_sort_key_from_last_query_results)
        return work_items
    return None


def _get_sort_key_from_last_query_results(work_item):
    work_items = get_last_query_result().work_items
    i = 0
    num_items = len(work_items)
    while i < num_items:
        if work_items[i].id == work_item.id:
            return i
        i += 1
    # following lines should never be reached
    raise CLIError("Work Item {} was not found in the original query results.".format(work_item.id))


_last_query_result = {}
_LAST_QUERY_RESULT_KEY = 'value'


def get_last_query_result():
    return _last_query_result.get(_LAST_QUERY_RESULT_KEY, None)


def _open_work_item(work_item, organization):
    """Opens the work item in the default browser.
    :param work_item: The work item to open.
    :type work_item: :class:`<WorkItem> <v5_0.work-item-tracking.models.WorkItem>`
    """
    project = work_item.fields['System.TeamProject']
    url = organization.rstrip('/') + '/' + uri_quote(project) + '/_workitems?id='\
        + uri_quote(str(work_item.id))
    logger.debug('Opening web page: %s', url)
    webbrowser.open_new(url=url)


def _create_patch_operation(op, path, value):
    patch_operation = JsonPatchOperation()
    patch_operation.op = op
    patch_operation.path = path
    patch_operation.value = value
    return patch_operation


def _create_work_item_field_patch_operation(op, field, value):
    path = '/fields/{field}'.format(field=field)
    return _create_patch_operation(op=op, path=path, value=value)


def _resolve_identity_as_unique_user_id(identity_filter, organization):
    """Takes an identity name, email, alias, or id, and returns the unique_user_id.
    """
    if identity_filter.find(' ') > 0 or identity_filter.find('@') > 0:
        return identity_filter
    if identity_filter.lower() == ME:
        identity = get_current_identity(organization)
    else:
        # For alias
        identity = resolve_identity(identity_filter, organization)
    if identity is not None:
        return get_account_from_identity(identity)
    return None


_SYSTEM_FIELD_ARGS = {'System.Title': 'title',
                      'System.Description': 'description',
                      'System.AssignedTo': 'assigned-to',
                      'System.State': 'state',
                      'System.AreaPath': 'area-path',
                      'System.IterationPath': 'iteration-path',
                      'System.Reason': 'reason',
                      'System.History': 'history'}