1 # This Source Code Form is subject to the terms of the Mozilla Public
2 # License, v. 2.0. If a copy of the MPL was not distributed with this
3 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
14 from pathlib
import Path
19 from yaml
import CLoader
as Loader
21 from yaml
import Loader
26 from manifestparser
import ManifestParser
27 from manifestparser
.toml
import add_skip_if
, alphabetize_toml_str
, sort_paths
28 from mozci
.task
import TestTask
29 from mozci
.util
.taskcluster
import get_task
31 BUGZILLA_AUTHENTICATION_HELP
= "Must create a Bugzilla API key per https://github.com/mozilla/mozci-tools/blob/main/citools/test_triage_bug_filer.py"
34 class MockResult(object):
35 def __init__(self
, result
):
40 return self
.result
["group"]
44 _ok
= self
.result
["ok"]
48 class MockTask(object):
49 def __init__(self
, task
):
51 if "results" in self
.task
:
52 self
.task
["results"] = [
53 MockResult(result
) for result
in self
.task
["results"]
56 self
.task
["results"] = []
59 def failure_types(self
):
60 if "failure_types" in self
.task
:
61 return self
.task
["failure_types"]
62 else: # note no failure_types in Task object
67 return self
.task
["id"]
71 return self
.task
["label"]
75 return self
.task
["results"]
78 class Classification(object):
79 "Classification of the failure (not the task result)"
81 DISABLE_MANIFEST
= "disable_manifest" # crash found
82 DISABLE_RECOMMENDED
= "disable_recommended" # disable first failing path
83 INTERMITTENT
= "intermittent"
84 SECONDARY
= "secondary" # secondary failing path
85 SUCCESS
= "success" # path always succeeds
91 constant indexes for attributes of a run
101 class Skipfails(object):
102 "mach manifest skip-fails implementation: Update manifests to skip failing tests"
105 REVISION
= "revision"
106 TREEHERDER
= "treeherder.mozilla.org"
107 BUGZILLA_SERVER_DEFAULT
= "bugzilla.allizom.org"
111 command_context
=None,
118 self
.command_context
= command_context
119 if self
.command_context
is not None:
120 self
.topsrcdir
= self
.command_context
.topsrcdir
122 self
.topsrcdir
= Path(__file__
).parent
.parent
123 self
.topsrcdir
= os
.path
.normpath(self
.topsrcdir
)
124 if isinstance(try_url
, list) and len(try_url
) == 1:
125 self
.try_url
= try_url
[0]
127 self
.try_url
= try_url
128 self
.dry_run
= dry_run
129 self
.verbose
= verbose
131 if bugzilla
is not None:
132 self
.bugzilla
= bugzilla
134 if "BUGZILLA" in os
.environ
:
135 self
.bugzilla
= os
.environ
["BUGZILLA"]
137 self
.bugzilla
= Skipfails
.BUGZILLA_SERVER_DEFAULT
138 self
.component
= "skip-fails"
143 self
.headers
= {} # for Treeherder requests
144 self
.headers
["Accept"] = "application/json"
145 self
.headers
["User-Agent"] = "treeherder-pyclient"
146 self
.jobs_url
= "https://treeherder.mozilla.org/api/jobs/"
150 def _initialize_bzapi(self
):
151 """Lazily initializes the Bugzilla API"""
152 if self
._bzapi
is None:
153 self
._bzapi
= bugzilla
.Bugzilla(self
.bugzilla
)
155 def pprint(self
, obj
):
157 self
.pp
= pprint
.PrettyPrinter(indent
=4, stream
=sys
.stderr
)
162 if self
.command_context
is not None:
163 self
.command_context
.log(
164 logging
.ERROR
, self
.component
, {"error": str(e
)}, "ERROR: {error}"
167 print(f
"ERROR: {e}", file=sys
.stderr
, flush
=True)
169 def warning(self
, e
):
170 if self
.command_context
is not None:
171 self
.command_context
.log(
172 logging
.WARNING
, self
.component
, {"error": str(e
)}, "WARNING: {error}"
175 print(f
"WARNING: {e}", file=sys
.stderr
, flush
=True)
178 if self
.command_context
is not None:
179 self
.command_context
.log(
180 logging
.INFO
, self
.component
, {"error": str(e
)}, "INFO: {error}"
183 print(f
"INFO: {e}", file=sys
.stderr
, flush
=True)
193 "Run skip-fails on try_url, return True on success"
195 try_url
= self
.try_url
196 revision
, repo
= self
.get_revision(try_url
)
198 if use_tasks
is not None:
199 if os
.path
.exists(use_tasks
):
200 self
.info(f
"use tasks: {use_tasks}")
201 tasks
= self
.read_json(use_tasks
)
202 tasks
= [MockTask(task
) for task
in tasks
]
204 self
.error(f
"uses tasks JSON file does not exist: {use_tasks}")
207 tasks
= self
.get_tasks(revision
, repo
)
209 if use_failures
is not None:
210 if os
.path
.exists(use_failures
):
211 self
.info(f
"use failures: {use_failures}")
212 failures
= self
.read_json(use_failures
)
214 self
.error(f
"use failures JSON file does not exist: {use_failures}")
217 failures
= self
.get_failures(tasks
)
218 if save_failures
is not None:
219 self
.info(f
"save failures: {save_failures}")
220 self
.write_json(save_failures
, failures
)
222 if save_tasks
is not None:
223 self
.info(f
"save tasks: {save_tasks}")
224 self
.write_tasks(save_tasks
, tasks
)
226 for manifest
in failures
:
227 if not manifest
.endswith(".toml"):
228 self
.warning(f
"cannot process skip-fails on INI manifests: {manifest}")
230 for path
in failures
[manifest
]["path"]:
231 for label
in failures
[manifest
]["path"][path
]:
232 classification
= failures
[manifest
]["path"][path
][label
][
235 if classification
.startswith("disable_") or (
236 self
.turbo
and classification
== Classification
.SECONDARY
238 for task_id
in failures
[manifest
]["path"][path
][label
][
252 break # just use the first task_id
255 def get_revision(self
, url
):
256 parsed
= urllib
.parse
.urlparse(url
)
257 if parsed
.scheme
!= "https":
258 raise ValueError("try_url scheme not https")
259 if parsed
.netloc
!= Skipfails
.TREEHERDER
:
260 raise ValueError(f
"try_url server not {Skipfails.TREEHERDER}")
261 if len(parsed
.query
) == 0:
262 raise ValueError("try_url query missing")
263 query
= urllib
.parse
.parse_qs(parsed
.query
)
264 if Skipfails
.REVISION
not in query
:
265 raise ValueError("try_url query missing revision")
266 revision
= query
[Skipfails
.REVISION
][0]
267 if Skipfails
.REPO
in query
:
268 repo
= query
[Skipfails
.REPO
][0]
272 self
.info(f
"considering {repo} revision={revision}")
273 return revision
, repo
275 def get_tasks(self
, revision
, repo
):
276 push
= mozci
.push
.Push(revision
, repo
)
279 def get_failures(self
, tasks
):
281 find failures and create structure comprised of runs by path:
285 classification: Classification
286 * unknown (default) < 3 runs
287 * intermittent (not enough failures) >3 runs < 0.5 failure rate
288 * disable_recommended (enough repeated failures) >3 runs >= 0.5
289 * disable_manifest (disable DEFAULT if no other failures)
290 * secondary (not first failure in group)
298 if len(task
.results
) == 0:
299 continue # ignore aborted tasks
300 for manifest
in task
.failure_types
:
301 if manifest
not in failures
:
302 failures
[manifest
] = {"sum_by_label": {}, "path": {}}
303 if manifest
not in manifest_paths
:
304 manifest_paths
[manifest
] = []
305 for path_type
in task
.failure_types
[manifest
]:
306 path
, _type
= path_type
309 if path
not in failures
[manifest
]["path"]:
310 failures
[manifest
]["path"][path
] = {}
311 if path
not in manifest_paths
[manifest
]:
312 manifest_paths
[manifest
].append(path
)
313 if task
.label
not in failures
[manifest
]["sum_by_label"]:
314 failures
[manifest
]["sum_by_label"][task
.label
] = {
315 Classification
.UNKNOWN
: 0,
316 Classification
.SECONDARY
: 0,
317 Classification
.INTERMITTENT
: 0,
318 Classification
.DISABLE_RECOMMENDED
: 0,
319 Classification
.DISABLE_MANIFEST
: 0,
320 Classification
.SUCCESS
: 0,
322 if task
.label
not in failures
[manifest
]["path"][path
]:
323 failures
[manifest
]["path"][path
][task
.label
] = {
326 "classification": Classification
.UNKNOWN
,
327 "runs": {task
.id: False},
330 failures
[manifest
]["path"][path
][task
.label
]["runs"][
333 except AttributeError as ae
:
334 self
.warning(f
"unknown attribute in task: {ae}")
336 # calculate success/failure for each known path
337 for manifest
in manifest_paths
:
338 manifest_paths
[manifest
] = sort_paths(manifest_paths
[manifest
])
341 if len(task
.results
) == 0:
342 continue # ignore aborted tasks
343 for result
in task
.results
:
344 manifest
= result
.group
345 if manifest
not in failures
:
347 f
"result for {manifest} not in any failures, ignored"
350 for path
in manifest_paths
[manifest
]:
351 if task
.label
not in failures
[manifest
]["sum_by_label"]:
352 failures
[manifest
]["sum_by_label"][task
.label
] = {
353 Classification
.UNKNOWN
: 0,
354 Classification
.SECONDARY
: 0,
355 Classification
.INTERMITTENT
: 0,
356 Classification
.DISABLE_RECOMMENDED
: 0,
357 Classification
.DISABLE_MANIFEST
: 0,
358 Classification
.SUCCESS
: 0,
360 if task
.label
not in failures
[manifest
]["path"][path
]:
361 failures
[manifest
]["path"][path
][task
.label
] = {
364 "classification": Classification
.UNKNOWN
,
369 not in failures
[manifest
]["path"][path
][task
.label
]["runs"]
372 failures
[manifest
]["path"][path
][task
.label
]["runs"][
378 or failures
[manifest
]["path"][path
][task
.label
]["runs"][
382 failures
[manifest
]["path"][path
][task
.label
]["total_runs"] += 1
384 failures
[manifest
]["path"][path
][task
.label
][
387 except AttributeError as ae
:
388 self
.warning(f
"unknown attribute in task: {ae}")
390 # classify failures and roll up summary statistics
391 for manifest
in failures
:
392 for path
in failures
[manifest
]["path"]:
393 for label
in failures
[manifest
]["path"][path
]:
394 failed_runs
= failures
[manifest
]["path"][path
][label
]["failed_runs"]
395 total_runs
= failures
[manifest
]["path"][path
][label
]["total_runs"]
396 classification
= failures
[manifest
]["path"][path
][label
][
400 if failed_runs
/ total_runs
< 0.5:
402 classification
= Classification
.SUCCESS
404 classification
= Classification
.INTERMITTENT
406 classification
= Classification
.SECONDARY
407 failures
[manifest
]["path"][path
][label
][
410 failures
[manifest
]["sum_by_label"][label
][classification
] += 1
412 # Identify the first failure (for each test, in a manifest, by label)
413 for manifest
in failures
:
414 alpha_paths
= sort_paths(failures
[manifest
]["path"].keys())
415 for path
in alpha_paths
:
416 for label
in failures
[manifest
]["path"][path
]:
418 failures
[manifest
]["sum_by_label"][label
][
419 Classification
.DISABLE_RECOMMENDED
423 if path
== "DEFAULT":
424 classification
= failures
[manifest
]["path"][path
][label
][
428 classification
== Classification
.SECONDARY
429 and failures
[manifest
]["sum_by_label"][label
][
434 # ONLY failure in the manifest for this label => DISABLE
435 failures
[manifest
]["path"][path
][label
][
437 ] = Classification
.DISABLE_MANIFEST
438 failures
[manifest
]["sum_by_label"][label
][
441 failures
[manifest
]["sum_by_label"][label
][
442 Classification
.DISABLE_MANIFEST
448 and failures
[manifest
]["path"][path
][label
][
451 == Classification
.SECONDARY
453 # FIRST failure in the manifest for this label => DISABLE
454 failures
[manifest
]["path"][path
][label
][
456 ] = Classification
.DISABLE_RECOMMENDED
457 failures
[manifest
]["sum_by_label"][label
][
458 Classification
.SECONDARY
460 failures
[manifest
]["sum_by_label"][label
][
461 Classification
.DISABLE_RECOMMENDED
466 def _get_os_version(self
, os
, platform
):
467 """Return the os_version given the label platform string"""
468 i
= platform
.find(os
)
470 yy
= platform
[j
: j
+ 2]
471 mm
= platform
[j
+ 2 : j
+ 4]
474 def get_bug_by_id(self
, id):
475 """Get bug by bug id"""
477 self
._initialize
_bzapi
()
478 bug
= self
._bzapi
.getbug(id)
481 def get_bugs_by_summary(self
, summary
):
482 """Get bug by bug summary"""
484 self
._initialize
_bzapi
()
485 query
= self
._bzapi
.build_query(short_desc
=summary
)
486 query
["include_fields"] = [
495 bugs
= self
._bzapi
.query(query
)
500 summary
="Bug short description",
501 description
="Bug description",
504 version
="unspecified",
509 self
._initialize
_bzapi
()
510 if not self
._bzapi
.logged_in
:
512 "Must create a Bugzilla API key per https://github.com/mozilla/mozci-tools/blob/main/citools/test_triage_bug_filer.py"
514 raise PermissionError(f
"Not authenticated for Bugzilla {self.bugzilla}")
515 createinfo
= self
._bzapi
.build_createbug(
520 description
=description
,
522 createinfo
["type"] = bugtype
523 bug
= self
._bzapi
.createbug(createinfo
)
526 def add_bug_comment(self
, id, comment
, meta_bug_id
=None):
527 """Add a comment to an existing bug"""
529 self
._initialize
_bzapi
()
530 if not self
._bzapi
.logged_in
:
531 self
.error(BUGZILLA_AUTHENTICATION_HELP
)
532 raise PermissionError("Not authenticated for Bugzilla")
533 if meta_bug_id
is not None:
534 blocks_add
= [meta_bug_id
]
537 updateinfo
= self
._bzapi
.build_update(comment
=comment
, blocks_add
=blocks_add
)
538 self
._bzapi
.update_bugs([id], updateinfo
)
554 skip_if
= self
.task_to_skip_if(task_id
)
557 f
"Unable to calculate skip-if condition from manifest={manifest} from failure label={label}"
561 if classification
== Classification
.DISABLE_MANIFEST
:
563 comment
= "Disabled entire manifest due to crash result"
565 filename
= self
.get_filename_in_manifest(manifest
, path
)
566 comment
= f
'Disabled test due to failures: "{filename}"'
567 if classification
== Classification
.SECONDARY
:
568 comment
+= " (secondary)"
569 bug_reference
= " (secondary)"
570 comment
+= f
"\nTry URL = {try_url}"
571 comment
+= f
"\nrevision = {revision}"
572 comment
+= f
"\nrepo = {repo}"
573 comment
+= f
"\nlabel = {label}"
574 comment
+= f
"\ntask_id = {task_id}"
575 push_id
= self
.get_push_id(revision
, repo
)
576 if push_id
is not None:
577 comment
+= f
"\npush_id = {push_id}"
578 job_id
= self
.get_job_id(push_id
, task_id
)
579 if job_id
is not None:
580 comment
+= f
"\njob_id = {job_id}"
581 suggestions_url
, line_number
, line
, log_url
= self
.get_bug_suggestions(
584 if log_url
is not None:
585 comment
+= f
"\n\nBug suggestions: {suggestions_url}"
586 comment
+= f
"\nSpecifically see at line {line_number}:\n"
587 comment
+= f
'\n "{line}"'
588 comment
+= f
"\n\nIn the log: {log_url}"
589 bug_summary
= f
"MANIFEST {manifest}"
590 bugs
= self
.get_bugs_by_summary(bug_summary
)
593 f
"This bug covers excluded failing tests in the MANIFEST {manifest}"
595 description
+= "\n(generated by mach manifest skip-fails)"
596 product
, component
= self
.get_file_info(path
)
599 f
'Dry-run NOT creating bug: {product}::{component} "{bug_summary}"'
603 bug
= self
.create_bug(bug_summary
, description
, product
, component
)
606 f
'Created Bug {bugid} {product}::{component} : "{bug_summary}"'
608 bug_reference
= f
"Bug {bugid}" + bug_reference
611 bug_reference
= f
"Bug {bugid}" + bug_reference
612 product
= bugs
[0].product
613 component
= bugs
[0].component
614 self
.info(f
'Found Bug {bugid} {product}::{component} "{bug_summary}"')
615 if meta_bug_id
is not None:
616 if meta_bug_id
in bugs
[0].blocks
:
617 self
.info(f
" Bug {bugid} already blocks meta bug {meta_bug_id}")
618 meta_bug_id
= None # no need to add again
620 self
.error(f
'More than one bug found for summary: "{bug_summary}"')
623 self
.warning(f
"Dry-run NOT adding comment to Bug {bugid}: {comment}")
624 self
.info(f
'Dry-run NOT editing ["{filename}"] manifest: "{manifest}"')
625 self
.info(f
'would add skip-if condition: "{skip_if}" # {bug_reference}')
627 self
.add_bug_comment(bugid
, comment
, meta_bug_id
)
628 self
.info(f
"Added comment to Bug {bugid}: {comment}")
629 if meta_bug_id
is not None:
630 self
.info(f
" Bug {bugid} blocks meta Bug: {meta_bug_id}")
631 mp
= ManifestParser(use_toml
=True, document
=True)
632 manifest_path
= os
.path
.join(self
.topsrcdir
, os
.path
.normpath(manifest
))
633 mp
.read(manifest_path
)
634 document
= mp
.source_documents
[manifest_path
]
635 add_skip_if(document
, filename
, skip_if
, bug_reference
)
636 manifest_str
= alphabetize_toml_str(document
)
637 fp
= io
.open(manifest_path
, "w", encoding
="utf-8", newline
="\n")
638 fp
.write(manifest_str
)
640 self
.info(f
'Edited ["{filename}"] in manifest: "{manifest}"')
641 self
.info(f
'added skip-if condition: "{skip_if}" # {bug_reference}')
643 def get_variants(self
):
644 """Get mozinfo for each test variants"""
646 if len(self
.variants
) == 0:
647 variants_file
= "taskcluster/ci/test/variants.yml"
648 variants_path
= os
.path
.join(
649 self
.topsrcdir
, os
.path
.normpath(variants_file
)
651 fp
= io
.open(variants_path
, "r", encoding
="utf-8")
652 raw_variants
= load(fp
, Loader
=Loader
)
654 for k
, v
in raw_variants
.items():
657 mozinfo
= v
["mozinfo"]
658 self
.variants
[k
] = mozinfo
661 def get_task(self
, task_id
):
662 """Download details for task task_id"""
664 if task_id
in self
.tasks
: # if cached
665 task
= self
.tasks
[task_id
]
667 task
= get_task(task_id
)
668 self
.tasks
[task_id
] = task
671 def task_to_skip_if(self
, task_id
):
672 """Calculate the skip-if condition for failing task task_id"""
675 task
= self
.get_task(task_id
)
682 test_setting
= task
.get("extra", {}).get("test-setting", {})
683 platform
= test_setting
.get("platform", {})
684 platform_os
= platform
.get("os", {})
685 if "name" in platform_os
:
686 os
= platform_os
["name"]
691 if "version" in platform_os
:
692 os_version
= platform_os
["version"]
693 if len(os_version
) == 4:
694 os_version
= os_version
[0:2] + "." + os_version
[2:4]
695 if "arch" in platform
:
696 arch
= platform
["arch"]
697 if arch
== "x86" or arch
.find("32") >= 0:
699 if "display" in platform
:
700 display
= platform
["display"]
701 if "runtime" in test_setting
:
702 for k
in test_setting
["runtime"]:
703 if k
in self
.variants
:
704 runtimes
.append(self
.variants
[k
]) # adds mozinfo
705 if "build" in test_setting
:
706 tbuild
= test_setting
["build"]
711 if tbuild
[k
] == "opt":
713 elif tbuild
[k
] == "debug":
716 build_types
.append(k
)
717 if len(build_types
) == 0:
719 build_types
.append("!debug")
721 build_types
.append("debug")
724 skip_if
= "os == '" + os
+ "'"
725 if os_version
is not None:
727 skip_if
+= "os_version == '" + os_version
+ "'"
730 skip_if
+= "bits == '" + bits
+ "'"
731 if display
is not None:
733 skip_if
+= "display == '" + display
+ "'"
734 for runtime
in runtimes
:
737 for build_type
in build_types
:
739 skip_if
+= build_type
742 def get_file_info(self
, path
, product
="Testing", component
="General"):
744 Get bugzilla product and component for the path.
745 Provide defaults (in case command_context is not defined
746 or there isn't file info available).
748 if self
.command_context
is not None:
749 reader
= self
.command_context
.mozbuild_reader(config_mode
="empty")
750 info
= reader
.files_info([path
])
751 cp
= info
[path
]["BUG_COMPONENT"]
753 component
= cp
.component
754 return product
, component
756 def get_filename_in_manifest(self
, manifest
, path
):
757 """return relative filename for path in manifest"""
759 filename
= os
.path
.basename(path
)
760 if filename
== "DEFAULT":
762 manifest_dir
= os
.path
.dirname(manifest
)
764 j
= min(len(manifest_dir
), len(path
))
765 while i
< j
and manifest_dir
[i
] == path
[i
]:
767 if i
< len(manifest_dir
):
768 for _
in range(manifest_dir
.count("/", i
) + 1):
769 filename
= "../" + filename
771 filename
= path
[i
+ 1 :]
774 def get_push_id(self
, revision
, repo
):
775 """Return the push_id for revision and repo (or None)"""
777 self
.info(f
"Retrieving push_id for {repo} revision: {revision} ...")
778 if revision
in self
.push_ids
: # if cached
779 push_id
= self
.push_ids
[revision
]
782 push_url
= f
"https://treeherder.mozilla.org/api/project/{repo}/push/"
784 params
["full"] = "true"
786 params
["revision"] = revision
787 r
= requests
.get(push_url
, headers
=self
.headers
, params
=params
)
788 if r
.status_code
!= 200:
789 self
.warning(f
"FAILED to query Treeherder = {r} for {r.url}")
792 if "results" in response
:
793 results
= response
["results"]
798 self
.push_ids
[revision
] = push_id
801 def get_job_id(self
, push_id
, task_id
):
802 """Return the job_id for push_id, task_id (or None)"""
804 self
.info(f
"Retrieving job_id for push_id: {push_id}, task_id: {task_id} ...")
805 if push_id
in self
.job_ids
: # if cached
806 job_id
= self
.job_ids
[push_id
]
810 params
["push_id"] = push_id
811 r
= requests
.get(self
.jobs_url
, headers
=self
.headers
, params
=params
)
812 if r
.status_code
!= 200:
813 self
.warning(f
"FAILED to query Treeherder = {r} for {r.url}")
816 if "results" in response
:
817 results
= response
["results"]
819 for result
in results
:
821 if result
[14] == task_id
:
824 self
.job_ids
[push_id
] = job_id
827 def get_bug_suggestions(self
, repo
, job_id
, path
):
829 Return the (suggestions_url, line_number, line, log_url)
830 for the given repo and job_id
833 f
"Retrieving bug_suggestions for {repo} job_id: {job_id}, path: {path} ..."
835 suggestions_url
= f
"https://treeherder.mozilla.org/api/project/{repo}/jobs/{job_id}/bug_suggestions/"
839 r
= requests
.get(suggestions_url
, headers
=self
.headers
)
840 if r
.status_code
!= 200:
841 self
.warning(f
"FAILED to query Treeherder = {r} for {r.url}")
844 if len(response
) > 0:
845 for sugg
in response
:
846 if sugg
["path_end"] == path
:
847 line_number
= sugg
["line_number"]
848 line
= sugg
["search"]
849 log_url
= f
"https://treeherder.mozilla.org/logviewer?repo={repo}&job_id={job_id}&lineNumber={line_number}"
851 rv
= (suggestions_url
, line_number
, line
, log_url
)
854 def read_json(self
, filename
):
855 """read data as JSON from filename"""
856 fp
= io
.open(filename
, "r", encoding
="utf-8")
861 def write_json(self
, filename
, data
):
862 """saves data as JSON to filename"""
863 fp
= io
.open(filename
, "w", encoding
="utf-8")
864 json
.dump(data
, fp
, indent
=2, sort_keys
=True)
867 def write_tasks(self
, save_tasks
, tasks
):
868 """saves tasks as JSON to save_tasks"""
871 if not isinstance(task
, TestTask
):
874 jtask
["id"] = task
.id
875 jtask
["label"] = task
.label
876 jtask
["duration"] = task
.duration
877 jtask
["result"] = task
.result
878 jtask
["state"] = task
.state
880 for k
, v
in task
.tags
.items():
881 if k
== "createdForUser":
882 jtags
[k
] = "ci@mozilla.com"
885 jtask
["tags"] = jtags
886 jtask
["tier"] = task
.tier
888 {"group": r
.group
, "ok": r
.ok
, "duration": r
.duration
}
889 for r
in task
.results
891 jtask
["errors"] = None # Bug with task.errors property??
893 for k
in task
.failure_types
:
894 jft
[k
] = [[f
[0], f
[1].value
] for f
in task
.failure_types
[k
]]
895 jtask
["failure_types"] = jft
897 self
.write_json(save_tasks
, jtasks
)