Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame^] | 1 | # Copyright 2016 The Chromium Authors. All rights reserved. |
| 2 | # Use of this source code is governed by a BSD-style |
| 3 | # license that can be found in the LICENSE file or at |
| 4 | # https://developers.google.com/open-source/licenses/bsd |
| 5 | |
| 6 | """Classes and functions for displaying grids of project artifacts. |
| 7 | |
| 8 | A grid is a two-dimensional display of items where the user can choose |
| 9 | the X and Y axes. |
| 10 | """ |
| 11 | from __future__ import print_function |
| 12 | from __future__ import division |
| 13 | from __future__ import absolute_import |
| 14 | |
| 15 | import ezt |
| 16 | |
| 17 | import collections |
| 18 | import logging |
| 19 | import settings |
| 20 | |
| 21 | from features import features_constants |
| 22 | from framework import framework_constants |
| 23 | from framework import sorting |
| 24 | from framework import table_view_helpers |
| 25 | from framework import template_helpers |
| 26 | from framework import urls |
| 27 | from proto import tracker_pb2 |
| 28 | from tracker import tracker_bizobj |
| 29 | from tracker import tracker_constants |
| 30 | from tracker import tracker_helpers |
| 31 | |
| 32 | |
| 33 | # We shorten long attribute values to fit into the table cells. |
| 34 | _MAX_CELL_DISPLAY_CHARS = 70 |
| 35 | |
| 36 | |
| 37 | def SortGridHeadings(col_name, heading_value_list, users_by_id, config, |
| 38 | asc_accessors): |
| 39 | """Sort the grid headings according to well-known status and label order. |
| 40 | |
| 41 | Args: |
| 42 | col_name: String column name that is used on that grid axis. |
| 43 | heading_value_list: List of grid row or column heading values. |
| 44 | users_by_id: Dict mapping user_ids to UserViews. |
| 45 | config: ProjectIssueConfig PB for the current project. |
| 46 | asc_accessors: Dict (col_name -> function()) for special columns. |
| 47 | |
| 48 | Returns: |
| 49 | The same heading values, but sorted in a logical order. |
| 50 | """ |
| 51 | decorated_list = [] |
| 52 | fd = tracker_bizobj.FindFieldDef(col_name, config) |
| 53 | if fd and fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE: # Handle fields. |
| 54 | for value in heading_value_list: |
| 55 | field_value = tracker_bizobj.GetFieldValueWithRawValue( |
| 56 | fd.field_type, None, users_by_id, value) |
| 57 | decorated_list.append([field_value, field_value]) |
| 58 | elif col_name == 'status': |
| 59 | wk_statuses = [wks.status.lower() |
| 60 | for wks in config.well_known_statuses] |
| 61 | decorated_list = [(_WKSortingValue(value.lower(), wk_statuses), value) |
| 62 | for value in heading_value_list] |
| 63 | |
| 64 | elif col_name in asc_accessors: # Special cols still sort alphabetically. |
| 65 | decorated_list = [(value, value) |
| 66 | for value in heading_value_list] |
| 67 | |
| 68 | else: # Anything else is assumed to be a label prefix |
| 69 | col_name_dash = col_name + '-' |
| 70 | wk_labels = [] |
| 71 | for wkl in config.well_known_labels: |
| 72 | lab_lower = wkl.label.lower() |
| 73 | if lab_lower.startswith(col_name_dash): |
| 74 | wk_labels.append(lab_lower.split('-', 1)[-1]) |
| 75 | decorated_list = [(_WKSortingValue(value.lower(), wk_labels), value) |
| 76 | for value in heading_value_list] |
| 77 | |
| 78 | decorated_list.sort() |
| 79 | result = [decorated_tuple[1] for decorated_tuple in decorated_list] |
| 80 | logging.info('Headers for %s are: %r', col_name, result) |
| 81 | return result |
| 82 | |
| 83 | |
| 84 | def _WKSortingValue(value, well_known_list): |
| 85 | """Return a value used to sort headings so that well-known ones are first.""" |
| 86 | if not value: |
| 87 | return sorting.MAX_STRING # Undefined values sort last. |
| 88 | try: |
| 89 | # well-known values sort by index |
| 90 | return well_known_list.index(value) |
| 91 | except ValueError: |
| 92 | return value # odd-ball values lexicographically after all well-known ones |
| 93 | |
| 94 | |
| 95 | def MakeGridData( |
| 96 | artifacts, x_attr, x_headings, y_attr, y_headings, users_by_id, |
| 97 | artifact_view_factory, all_label_values, config, related_issues, |
| 98 | hotlist_context_dict=None): |
| 99 | """Return a list of grid row items for display by EZT. |
| 100 | |
| 101 | Args: |
| 102 | artifacts: a list of issues to consider showing. |
| 103 | x_attr: lowercase name of the attribute that defines the x-axis. |
| 104 | x_headings: list of values for column headings. |
| 105 | y_attr: lowercase name of the attribute that defines the y-axis. |
| 106 | y_headings: list of values for row headings. |
| 107 | users_by_id: dict {user_id: user_view, ...} for referenced users. |
| 108 | artifact_view_factory: constructor for grid tiles. |
| 109 | all_label_values: pre-parsed dictionary of values from the key-value |
| 110 | labels on each issue: {issue_id: {key: [val,...], ...}, ...} |
| 111 | config: ProjectIssueConfig PB for the current project. |
| 112 | related_issues: dict {issue_id: issue} of pre-fetched related issues. |
| 113 | hotlist_context_dict: dict{issue_id: {hotlist_item_field: field_value, ..}} |
| 114 | |
| 115 | Returns: |
| 116 | A list of EZTItems, each representing one grid row, and each having |
| 117 | a nested list of grid cells. |
| 118 | |
| 119 | Each grid row has a row name, and a list of cells. Each cell has a |
| 120 | list of tiles. Each tile represents one artifact. Artifacts are |
| 121 | represented once in each cell that they match, so one artifact that |
| 122 | has multiple values for a certain attribute can occur in multiple cells. |
| 123 | """ |
| 124 | x_attr = x_attr.lower() |
| 125 | y_attr = y_attr.lower() |
| 126 | |
| 127 | # A flat dictionary {(x, y): [cell, ...], ...] for the whole grid. |
| 128 | x_y_data = collections.defaultdict(list) |
| 129 | |
| 130 | # Put each issue into the grid cell(s) where it belongs. |
| 131 | for art in artifacts: |
| 132 | if hotlist_context_dict: |
| 133 | hotlist_issues_context = hotlist_context_dict[art.issue_id] |
| 134 | else: |
| 135 | hotlist_issues_context = None |
| 136 | label_value_dict = all_label_values[art.local_id] |
| 137 | x_vals = GetArtifactAttr( |
| 138 | art, x_attr, users_by_id, label_value_dict, config, related_issues, |
| 139 | hotlist_issue_context=hotlist_issues_context) |
| 140 | y_vals = GetArtifactAttr( |
| 141 | art, y_attr, users_by_id, label_value_dict, config, related_issues, |
| 142 | hotlist_issue_context=hotlist_issues_context) |
| 143 | tile = artifact_view_factory(art) |
| 144 | |
| 145 | # Put the current issue into each cell where it belongs, which will usually |
| 146 | # be exactly 1 cell, but it could be a few. |
| 147 | if x_attr != '--' and y_attr != '--': # User specified both axes. |
| 148 | for x in x_vals: |
| 149 | for y in y_vals: |
| 150 | x_y_data[x, y].append(tile) |
| 151 | elif y_attr != '--': # User only specified Y axis. |
| 152 | for y in y_vals: |
| 153 | x_y_data['All', y].append(tile) |
| 154 | elif x_attr != '--': # User only specified X axis. |
| 155 | for x in x_vals: |
| 156 | x_y_data[x, 'All'].append(tile) |
| 157 | else: # User specified neither axis. |
| 158 | x_y_data['All', 'All'].append(tile) |
| 159 | |
| 160 | # Convert the dictionary to a list-of-lists so that EZT can iterate over it. |
| 161 | grid_data = [] |
| 162 | i = 0 |
| 163 | for y in y_headings: |
| 164 | cells_in_row = [] |
| 165 | for x in x_headings: |
| 166 | tiles = x_y_data[x, y] |
| 167 | for tile in tiles: |
| 168 | tile.data_idx = i |
| 169 | i += 1 |
| 170 | |
| 171 | drill_down = '' |
| 172 | if x_attr != '--': |
| 173 | drill_down = MakeDrillDownSearch(x_attr, x) |
| 174 | if y_attr != '--': |
| 175 | drill_down += MakeDrillDownSearch(y_attr, y) |
| 176 | |
| 177 | cells_in_row.append(template_helpers.EZTItem( |
| 178 | tiles=tiles, count=len(tiles), drill_down=drill_down)) |
| 179 | grid_data.append(template_helpers.EZTItem( |
| 180 | grid_y_heading=y, cells_in_row=cells_in_row)) |
| 181 | |
| 182 | return grid_data |
| 183 | |
| 184 | |
| 185 | def MakeDrillDownSearch(attr, value): |
| 186 | """Constructs search term for drill-down. |
| 187 | |
| 188 | Args: |
| 189 | attr: lowercase name of the attribute to narrow the search on. |
| 190 | value: value to narrow the search to. |
| 191 | |
| 192 | Returns: |
| 193 | String with user-query term to narrow a search to the given attr value. |
| 194 | """ |
| 195 | if value == framework_constants.NO_VALUES: |
| 196 | return '-has:%s ' % attr |
| 197 | else: |
| 198 | return '%s=%s ' % (attr, value) |
| 199 | |
| 200 | |
| 201 | def MakeLabelValuesDict(art): |
| 202 | """Return a dict of label values and a list of one-word labels. |
| 203 | |
| 204 | Args: |
| 205 | art: artifact object, e.g., an issue PB. |
| 206 | |
| 207 | Returns: |
| 208 | A dict {prefix: [suffix,...], ...} for each key-value label. |
| 209 | """ |
| 210 | label_values = collections.defaultdict(list) |
| 211 | for label_name in tracker_bizobj.GetLabels(art): |
| 212 | if '-' in label_name: |
| 213 | key, value = label_name.split('-', 1) |
| 214 | label_values[key.lower()].append(value) |
| 215 | |
| 216 | return label_values |
| 217 | |
| 218 | |
| 219 | def GetArtifactAttr( |
| 220 | art, attribute_name, users_by_id, label_attr_values_dict, |
| 221 | config, related_issues, hotlist_issue_context=None): |
| 222 | """Return the requested attribute values of the given artifact. |
| 223 | |
| 224 | Args: |
| 225 | art: a tracked artifact with labels, local_id, summary, stars, and owner. |
| 226 | attribute_name: lowercase string name of attribute to get. |
| 227 | users_by_id: dictionary of UserViews already created. |
| 228 | label_attr_values_dict: dictionary {'key': [value, ...], }. |
| 229 | config: ProjectIssueConfig PB for the current project. |
| 230 | related_issues: dict {issue_id: issue} of pre-fetched related issues. |
| 231 | hotlist_issue_context: dict of {hotlist_issue_field: field_value,..} |
| 232 | |
| 233 | Returns: |
| 234 | A list of string attribute values, or [framework_constants.NO_VALUES] |
| 235 | if the artifact has no value for that attribute. |
| 236 | """ |
| 237 | if attribute_name == '--': |
| 238 | return [] |
| 239 | if attribute_name == 'id': |
| 240 | return [art.local_id] |
| 241 | if attribute_name == 'summary': |
| 242 | return [art.summary] |
| 243 | if attribute_name == 'status': |
| 244 | return [tracker_bizobj.GetStatus(art)] |
| 245 | if attribute_name == 'stars': |
| 246 | return [art.star_count] |
| 247 | if attribute_name == 'attachments': |
| 248 | return [art.attachment_count] |
| 249 | # TODO(jrobbins): support blocking |
| 250 | if attribute_name == 'project': |
| 251 | return [art.project_name] |
| 252 | if attribute_name == 'mergedinto': |
| 253 | if art.merged_into and art.merged_into != 0: |
| 254 | return [tracker_bizobj.FormatIssueRef(( |
| 255 | related_issues[art.merged_into].project_name, |
| 256 | related_issues[art.merged_into].local_id))] |
| 257 | else: |
| 258 | return [framework_constants.NO_VALUES] |
| 259 | if attribute_name == 'blocked': |
| 260 | return ['Yes' if art.blocked_on_iids else 'No'] |
| 261 | if attribute_name == 'blockedon': |
| 262 | if not art.blocked_on_iids: |
| 263 | return [framework_constants.NO_VALUES] |
| 264 | else: |
| 265 | return [tracker_bizobj.FormatIssueRef(( |
| 266 | related_issues[blocked_on_iid].project_name, |
| 267 | related_issues[blocked_on_iid].local_id)) for |
| 268 | blocked_on_iid in art.blocked_on_iids] |
| 269 | if attribute_name == 'blocking': |
| 270 | if not art.blocking_iids: |
| 271 | return [framework_constants.NO_VALUES] |
| 272 | return [tracker_bizobj.FormatIssueRef(( |
| 273 | related_issues[blocking_iid].project_name, |
| 274 | related_issues[blocking_iid].local_id)) for |
| 275 | blocking_iid in art.blocking_iids] |
| 276 | if attribute_name == 'adder': |
| 277 | if hotlist_issue_context: |
| 278 | adder_id = hotlist_issue_context['adder_id'] |
| 279 | return [users_by_id[adder_id].display_name] |
| 280 | else: |
| 281 | return [framework_constants.NO_VALUES] |
| 282 | if attribute_name == 'added': |
| 283 | if hotlist_issue_context: |
| 284 | return [hotlist_issue_context['date_added']] |
| 285 | else: |
| 286 | return [framework_constants.NO_VALUES] |
| 287 | if attribute_name == 'reporter': |
| 288 | return [users_by_id[art.reporter_id].display_name] |
| 289 | if attribute_name == 'owner': |
| 290 | owner_id = tracker_bizobj.GetOwnerId(art) |
| 291 | if not owner_id: |
| 292 | return [framework_constants.NO_VALUES] |
| 293 | else: |
| 294 | return [users_by_id[owner_id].display_name] |
| 295 | if attribute_name == 'cc': |
| 296 | cc_ids = tracker_bizobj.GetCcIds(art) |
| 297 | if not cc_ids: |
| 298 | return [framework_constants.NO_VALUES] |
| 299 | else: |
| 300 | return [users_by_id[cc_id].display_name for cc_id in cc_ids] |
| 301 | if attribute_name == 'component': |
| 302 | comp_ids = list(art.component_ids) + list(art.derived_component_ids) |
| 303 | if not comp_ids: |
| 304 | return [framework_constants.NO_VALUES] |
| 305 | else: |
| 306 | paths = [] |
| 307 | for comp_id in comp_ids: |
| 308 | cd = tracker_bizobj.FindComponentDefByID(comp_id, config) |
| 309 | if cd: |
| 310 | paths.append(cd.path) |
| 311 | return paths |
| 312 | |
| 313 | # Check to see if it is a field. Process as field only if it is not an enum |
| 314 | # type because enum types are stored as key-value labels. |
| 315 | fd = tracker_bizobj.FindFieldDef(attribute_name, config) |
| 316 | if fd and fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE: |
| 317 | values = [] |
| 318 | for fv in art.field_values: |
| 319 | if fv.field_id == fd.field_id: |
| 320 | value = tracker_bizobj.GetFieldValueWithRawValue( |
| 321 | fd.field_type, fv, users_by_id, None) |
| 322 | values.append(value) |
| 323 | return values |
| 324 | |
| 325 | # Since it is not a built-in attribute or a field, it must be a key-value |
| 326 | # label. |
| 327 | return label_attr_values_dict.get( |
| 328 | attribute_name, [framework_constants.NO_VALUES]) |
| 329 | |
| 330 | |
| 331 | def AnyArtifactHasNoAttr( |
| 332 | artifacts, attr_name, users_by_id, all_label_values, config, |
| 333 | related_issues, hotlist_context_dict=None): |
| 334 | """Return true if any artifact does not have a value for attr_name.""" |
| 335 | # TODO(jrobbins): all_label_values needs to be keyed by issue_id to allow |
| 336 | # cross-project grid views. |
| 337 | for art in artifacts: |
| 338 | if hotlist_context_dict: |
| 339 | hotlist_issue_context = hotlist_context_dict[art.issue_id] |
| 340 | else: |
| 341 | hotlist_issue_context = None |
| 342 | vals = GetArtifactAttr( |
| 343 | art, attr_name.lower(), users_by_id, all_label_values[art.local_id], |
| 344 | config, related_issues, hotlist_issue_context=hotlist_issue_context) |
| 345 | if framework_constants.NO_VALUES in vals: |
| 346 | return True |
| 347 | |
| 348 | return False |
| 349 | |
| 350 | |
| 351 | def GetGridViewData( |
| 352 | mr, results, config, users_by_id, starred_iid_set, |
| 353 | grid_limited, related_issues, hotlist_context_dict=None): |
| 354 | """EZT template values to render a Grid View of issues. |
| 355 | Args: |
| 356 | mr: commonly used info parsed from the request. |
| 357 | results: The Issue PBs that are the search results to be displayed. |
| 358 | config: The ProjectConfig PB for the project this view is in. |
| 359 | users_by_id: A dictionary {user_id: user_view,...} for all the users |
| 360 | involved in results. |
| 361 | starred_iid_set: Set of issues that the user has starred. |
| 362 | grid_limited: True if the results were limited to fit within the grid. |
| 363 | related_issues: dict {issue_id: issue} of pre-fetched related issues. |
| 364 | hotlist_context_dict: dict for building a hotlist grid table |
| 365 | |
| 366 | Returns: |
| 367 | Dictionary for EZT template rendering of the Grid View. |
| 368 | """ |
| 369 | # We need ordered_columns because EZT loops have no loop-counter available. |
| 370 | # And, we use column number in the Javascript to hide/show columns. |
| 371 | columns = mr.col_spec.split() |
| 372 | ordered_columns = [template_helpers.EZTItem(col_index=i, name=col) |
| 373 | for i, col in enumerate(columns)] |
| 374 | other_built_in_cols = (features_constants.OTHER_BUILT_IN_COLS if |
| 375 | hotlist_context_dict else |
| 376 | tracker_constants.OTHER_BUILT_IN_COLS) |
| 377 | unshown_columns = table_view_helpers.ComputeUnshownColumns( |
| 378 | results, columns, config, other_built_in_cols) |
| 379 | |
| 380 | grid_x_attr = (mr.x or config.default_x_attr or '--').lower() |
| 381 | grid_y_attr = (mr.y or config.default_y_attr or '--').lower() |
| 382 | |
| 383 | # Prevent the user from using an axis that we don't support. |
| 384 | for bad_axis in tracker_constants.NOT_USED_IN_GRID_AXES: |
| 385 | lower_bad_axis = bad_axis.lower() |
| 386 | if grid_x_attr == lower_bad_axis: |
| 387 | grid_x_attr = '--' |
| 388 | if grid_y_attr == lower_bad_axis: |
| 389 | grid_y_attr = '--' |
| 390 | # Using the same attribute on both X and Y is not useful. |
| 391 | if grid_x_attr == grid_y_attr: |
| 392 | grid_x_attr = '--' |
| 393 | |
| 394 | all_label_values = {} |
| 395 | for art in results: |
| 396 | all_label_values[art.local_id] = ( |
| 397 | MakeLabelValuesDict(art)) |
| 398 | |
| 399 | if grid_x_attr == '--': |
| 400 | grid_x_headings = ['All'] |
| 401 | else: |
| 402 | grid_x_items = table_view_helpers.ExtractUniqueValues( |
| 403 | [grid_x_attr], results, users_by_id, config, related_issues, |
| 404 | hotlist_context_dict=hotlist_context_dict) |
| 405 | grid_x_headings = grid_x_items[0].filter_values |
| 406 | if AnyArtifactHasNoAttr( |
| 407 | results, grid_x_attr, users_by_id, all_label_values, |
| 408 | config, related_issues, hotlist_context_dict= hotlist_context_dict): |
| 409 | grid_x_headings.append(framework_constants.NO_VALUES) |
| 410 | grid_x_headings = SortGridHeadings( |
| 411 | grid_x_attr, grid_x_headings, users_by_id, config, |
| 412 | tracker_helpers.SORTABLE_FIELDS) |
| 413 | |
| 414 | if grid_y_attr == '--': |
| 415 | grid_y_headings = ['All'] |
| 416 | else: |
| 417 | grid_y_items = table_view_helpers.ExtractUniqueValues( |
| 418 | [grid_y_attr], results, users_by_id, config, related_issues, |
| 419 | hotlist_context_dict=hotlist_context_dict) |
| 420 | grid_y_headings = grid_y_items[0].filter_values |
| 421 | if AnyArtifactHasNoAttr( |
| 422 | results, grid_y_attr, users_by_id, all_label_values, |
| 423 | config, related_issues, hotlist_context_dict= hotlist_context_dict): |
| 424 | grid_y_headings.append(framework_constants.NO_VALUES) |
| 425 | grid_y_headings = SortGridHeadings( |
| 426 | grid_y_attr, grid_y_headings, users_by_id, config, |
| 427 | tracker_helpers.SORTABLE_FIELDS) |
| 428 | |
| 429 | logging.info('grid_x_headings = %s', grid_x_headings) |
| 430 | logging.info('grid_y_headings = %s', grid_y_headings) |
| 431 | grid_data = PrepareForMakeGridData( |
| 432 | results, starred_iid_set, grid_x_attr, grid_x_headings, |
| 433 | grid_y_attr, grid_y_headings, users_by_id, all_label_values, |
| 434 | config, related_issues, hotlist_context_dict=hotlist_context_dict) |
| 435 | |
| 436 | grid_axis_choice_dict = {} |
| 437 | for oc in ordered_columns: |
| 438 | grid_axis_choice_dict[oc.name] = True |
| 439 | for uc in unshown_columns: |
| 440 | grid_axis_choice_dict[uc] = True |
| 441 | for bad_axis in tracker_constants.NOT_USED_IN_GRID_AXES: |
| 442 | if bad_axis in grid_axis_choice_dict: |
| 443 | del grid_axis_choice_dict[bad_axis] |
| 444 | grid_axis_choices = list(grid_axis_choice_dict.keys()) |
| 445 | grid_axis_choices.sort() |
| 446 | |
| 447 | grid_cell_mode = mr.cells |
| 448 | if len(results) > settings.max_tiles_in_grid and mr.cells == 'tiles': |
| 449 | grid_cell_mode = 'ids' |
| 450 | |
| 451 | grid_view_data = { |
| 452 | 'grid_limited': ezt.boolean(grid_limited), |
| 453 | 'grid_shown': len(results), |
| 454 | 'grid_x_headings': grid_x_headings, |
| 455 | 'grid_y_headings': grid_y_headings, |
| 456 | 'grid_data': grid_data, |
| 457 | 'grid_axis_choices': grid_axis_choices, |
| 458 | 'grid_cell_mode': grid_cell_mode, |
| 459 | 'results': results, # Really only useful in if-any. |
| 460 | } |
| 461 | return grid_view_data |
| 462 | |
| 463 | |
| 464 | def PrepareForMakeGridData( |
| 465 | allowed_results, starred_iid_set, x_attr, |
| 466 | grid_col_values, y_attr, grid_row_values, users_by_id, all_label_values, |
| 467 | config, related_issues, hotlist_context_dict=None): |
| 468 | """Return all data needed for EZT to render the body of the grid view.""" |
| 469 | |
| 470 | def IssueViewFactory(issue): |
| 471 | return template_helpers.EZTItem( |
| 472 | summary=issue.summary, local_id=issue.local_id, issue_id=issue.issue_id, |
| 473 | status=issue.status or issue.derived_status, starred=None, data_idx=0, |
| 474 | project_name=issue.project_name) |
| 475 | |
| 476 | grid_data = MakeGridData( |
| 477 | allowed_results, x_attr, grid_col_values, y_attr, grid_row_values, |
| 478 | users_by_id, IssueViewFactory, all_label_values, config, related_issues, |
| 479 | hotlist_context_dict=hotlist_context_dict) |
| 480 | issue_dict = {issue.issue_id: issue for issue in allowed_results} |
| 481 | for grid_row in grid_data: |
| 482 | for grid_cell in grid_row.cells_in_row: |
| 483 | for tile in grid_cell.tiles: |
| 484 | if tile.issue_id in starred_iid_set: |
| 485 | tile.starred = ezt.boolean(True) |
| 486 | issue = issue_dict[tile.issue_id] |
| 487 | tile.issue_url = tracker_helpers.FormatRelativeIssueURL( |
| 488 | issue.project_name, urls.ISSUE_DETAIL, id=tile.local_id) |
| 489 | tile.issue_ref = issue.project_name + ':' + str(tile.local_id) |
| 490 | |
| 491 | return grid_data |