|
8 | 8 | import click |
9 | 9 |
|
10 | 10 | from sp_cli.client import ApiError |
11 | | -from sp_cli.compare import compare_runs, coverage_warnings |
| 11 | +from sp_cli.compare import compare_runs, coverage_warnings, pick_references |
12 | 12 | from sp_cli.constants import (ARTIFACT_TYPES, CANCEL_REASON_MIN_LENGTH, |
13 | 13 | COMMIT_SHA_LENGTH, ERROR_GROUP_BY, |
14 | 14 | ERROR_SEVERITIES, ERROR_TYPES, EXIT_TIMEOUT, |
15 | 15 | EXIT_WAIT_ABORTED, INFRA_ERROR_TYPES, |
16 | 16 | LOG_CONTAINS_MAX_LENGTH, LOG_LEVELS, LOG_SOURCES, |
17 | 17 | MAX_OFFSET, MAX_PAGE_LIMIT, |
18 | 18 | MAX_REGRESSION_TEST_IDS, PLATFORMS, |
19 | | - PR_SCAN_DEFAULT, PR_SCAN_MAX, |
| 19 | + PR_SCAN_DEFAULT, PR_SCAN_MAX, REPORT_LIST_LIMIT, |
20 | 20 | RUN_PENDING_STATUSES, RUN_STATUSES, |
21 | 21 | RUN_UNSUCCESSFUL_STATUSES, SAMPLE_STATUSES, |
22 | 22 | WAIT_INTERVAL_DEFAULT, WAIT_INTERVAL_MAX, |
|
31 | 31 | #: regressions first, missing evidence before good news. |
32 | 32 | COMPARE_BUCKETS = ('new', 'changed', 'still_failing', 'not_rerun', 'fixed', 'no_baseline') |
33 | 33 |
|
| 34 | +#: How a failure's standing against one reference reads in a table. Short |
| 35 | +#: enough to keep a column narrow, and worded so a reader does not have to |
| 36 | +#: remember which bucket name means what. |
| 37 | +_STANDING_LABELS = { |
| 38 | + 'new': 'NEW HERE', |
| 39 | + 'changed': 'differs', |
| 40 | + 'still_failing': 'fails there too', |
| 41 | + 'fixed': 'fixed here', |
| 42 | + 'not_rerun': 'not rerun', |
| 43 | + 'no_baseline': 'never ran there', |
| 44 | +} |
| 45 | + |
34 | 46 | #: Run fields identifying each side of a comparison. |
35 | 47 | _COMPARE_RUN_FIELDS = ('run_id', 'platform', 'commit_sha', 'branch', 'pr_number', 'status') |
36 | 48 |
|
@@ -134,6 +146,178 @@ def _list_runs_for_pr(ctx: click.Context, params: Dict[str, Any], |
134 | 146 | output, ctx.obj.get('color', False)) |
135 | 147 |
|
136 | 148 |
|
| 149 | +@run.command('report') |
| 150 | +@click.argument('run_id', type=int) |
| 151 | +@click.option('--against', 'against', type=int, multiple=True, |
| 152 | + help='Compare against these runs instead of resolving them (repeatable).') |
| 153 | +@click.option('--branch', default='master', show_default=True, |
| 154 | + help='Branch whose runs are used as references.') |
| 155 | +@click.option('--scan', type=click.IntRange(1, MAX_PAGE_LIMIT), default=25, show_default=True, |
| 156 | + help='How many recent branch runs to consider when resolving references.') |
| 157 | +@click.pass_context |
| 158 | +def run_report(ctx: click.Context, run_id: int, against: Tuple[int, ...], |
| 159 | + branch: str, scan: int) -> None: |
| 160 | + """Describe a run's failures against the approved output and against earlier runs. |
| 161 | +
|
| 162 | + Pass and fail answer one question: did the output match the approved file. |
| 163 | + Who made that true is a different question, and it is the one a reviewer |
| 164 | + needs -- a test that has failed for a month says nothing about the change in |
| 165 | + front of them. |
| 166 | +
|
| 167 | + So the verdict is reported as-is, and every failure is then described |
| 168 | + against earlier runs: the newest on the target branch, and the newest that |
| 169 | + predates this run. A failure present in both is not this change's doing; one |
| 170 | + that is new relative to the run before it is where to start reading. |
| 171 | +
|
| 172 | + The second reference is a proxy for "where this branch was cut from", not |
| 173 | + ancestry -- the API exposes no commit graph, so ordering by time is the best |
| 174 | + available. Pass --against explicitly when you know the right baseline. |
| 175 | + """ |
| 176 | + client = ctx.obj['client'] |
| 177 | + output = ctx.obj['output'] |
| 178 | + try: |
| 179 | + with Spinner(f'Building report for run {run_id}', output != 'json'): |
| 180 | + run_detail = client.get(f'/runs/{run_id}') |
| 181 | + run_samples = client.get_paginated(f'/runs/{run_id}/samples') |
| 182 | + if against: |
| 183 | + candidates = [client.get(f'/runs/{baseline_id}') for baseline_id in against] |
| 184 | + else: |
| 185 | + candidates = client.get_paginated( |
| 186 | + '/runs', params={'branch': branch, 'platform': run_detail.get('platform')}, |
| 187 | + max_items=scan) |
| 188 | + except ApiError as error: |
| 189 | + render_error(error, output) |
| 190 | + raise SystemExit(error.exit_code) |
| 191 | + |
| 192 | + if against: |
| 193 | + references = [{'label': 'the run you named', 'run': candidate} for candidate in candidates] |
| 194 | + else: |
| 195 | + references = pick_references(run_detail, candidates) |
| 196 | + |
| 197 | + failing = [row for row in run_samples if is_failure(row)] |
| 198 | + report: Dict[str, Any] = { |
| 199 | + 'run': {field: run_detail.get(field) for field in _COMPARE_RUN_FIELDS}, |
| 200 | + 'verdict': { |
| 201 | + 'against': 'the approved output', |
| 202 | + 'tests_with_results': len(run_samples), |
| 203 | + 'failing': len(failing), |
| 204 | + # Named, not just counted: "69 failed" is not something a reviewer |
| 205 | + # can act on, and the whole point of the references below is to say |
| 206 | + # something about each one of them. |
| 207 | + # classify_sample supplies the code; the raw sample rows carry the |
| 208 | + # ingredients for it (exit codes, output states) but not the verdict. |
| 209 | + 'failures': [{'regression_test_id': row.get('regression_test_id'), |
| 210 | + 'sample_name': row.get('sample_name'), |
| 211 | + 'code': classify_sample(row).get('code')} for row in failing], |
| 212 | + }, |
| 213 | + 'references': [], |
| 214 | + } |
| 215 | + |
| 216 | + for reference in references: |
| 217 | + try: |
| 218 | + with Spinner(f"Comparing against run {reference['run']['run_id']}", output != 'json'): |
| 219 | + baseline_samples = client.get_paginated( |
| 220 | + f"/runs/{reference['run']['run_id']}/samples") |
| 221 | + except ApiError as error: |
| 222 | + render_error(error, output) |
| 223 | + raise SystemExit(error.exit_code) |
| 224 | + result = compare_runs(run_samples, baseline_samples) |
| 225 | + report['references'].append({ |
| 226 | + 'label': reference['label'], |
| 227 | + 'run': {field: reference['run'].get(field) for field in _COMPARE_RUN_FIELDS}, |
| 228 | + 'counts': result['counts'], |
| 229 | + 'new': result['new'], |
| 230 | + 'fixed': result['fixed'], |
| 231 | + 'warnings': coverage_warnings(run_detail, reference['run'], result), |
| 232 | + 'standing': _standing_by_test(result), |
| 233 | + }) |
| 234 | + |
| 235 | + if output == 'json': |
| 236 | + render(report, output, ctx.obj.get('color', False)) |
| 237 | + return |
| 238 | + _print_report(report, ctx.obj.get('color', False)) |
| 239 | + |
| 240 | + |
| 241 | +def _standing_by_test(result: Dict[str, Any]) -> Dict[int, str]: |
| 242 | + """ |
| 243 | + Map each regression test to how it stood against one reference. |
| 244 | +
|
| 245 | + :param result: A ``compare_runs`` result. |
| 246 | + :type result: Dict[str, Any] |
| 247 | + :return: Regression test id mapped to its bucket name. |
| 248 | + :rtype: Dict[int, str] |
| 249 | + """ |
| 250 | + standing: Dict[int, str] = {} |
| 251 | + for bucket in ('new', 'changed', 'still_failing', 'fixed', 'not_rerun', 'no_baseline'): |
| 252 | + for row in result.get(bucket, []): |
| 253 | + test_id = row.get('regression_test_id') |
| 254 | + if test_id is not None: |
| 255 | + standing[test_id] = bucket |
| 256 | + return standing |
| 257 | + |
| 258 | + |
| 259 | +def _print_report(report: Dict[str, Any], color: bool) -> None: |
| 260 | + """ |
| 261 | + Print a report as something a person reads top to bottom. |
| 262 | +
|
| 263 | + :param report: The payload built by ``run report``. |
| 264 | + :type report: Dict[str, Any] |
| 265 | + :param color: Whether colour was requested. |
| 266 | + :type color: bool |
| 267 | + """ |
| 268 | + run_info = report['run'] |
| 269 | + verdict = report['verdict'] |
| 270 | + click.echo(f"run {run_info['run_id']} {run_info['platform']} " |
| 271 | + f"{(run_info.get('commit_sha') or '')[:8]} status={run_info.get('status')}") |
| 272 | + click.echo(f" {verdict['failing']} of {verdict['tests_with_results']} tests " |
| 273 | + f"do not match {verdict['against']}") |
| 274 | + |
| 275 | + if not report['references']: |
| 276 | + click.echo('\n No earlier run to compare against, so nothing here says ' |
| 277 | + 'whether this change caused them.') |
| 278 | + return |
| 279 | + |
| 280 | + for reference in report['references']: |
| 281 | + counts = reference['counts'] |
| 282 | + reference_run = reference['run'] |
| 283 | + click.echo(f"\n vs {reference['label']} — run {reference_run['run_id']} " |
| 284 | + f"({(reference_run.get('commit_sha') or '')[:8]})") |
| 285 | + tail = f" not_rerun {counts['not_rerun']}" if counts['not_rerun'] else '' |
| 286 | + click.echo(f" new {counts['new']} changed {counts['changed']} " |
| 287 | + f"still_failing {counts['still_failing']} fixed {counts['fixed']}{tail}") |
| 288 | + for warning in reference['warnings']: |
| 289 | + click.echo(f" note: {warning}", err=True) |
| 290 | + |
| 291 | + failures = report['verdict']['failures'] |
| 292 | + if failures: |
| 293 | + click.echo(f"\n the {len(failures)} that do not match, and how each stands:") |
| 294 | + shown = failures[:REPORT_LIST_LIMIT] |
| 295 | + rows = [] |
| 296 | + for failure in shown: |
| 297 | + row = {'test': failure['regression_test_id'], |
| 298 | + 'sample': failure['sample_name'], |
| 299 | + 'code': failure['code']} |
| 300 | + for reference in report['references']: |
| 301 | + column = f"vs {reference['run']['run_id']}" |
| 302 | + row[column] = _STANDING_LABELS.get( |
| 303 | + reference['standing'].get(failure['regression_test_id']), 'no result') |
| 304 | + rows.append(row) |
| 305 | + render({'data': rows}, 'table', color) |
| 306 | + held_back = len(failures) - len(shown) |
| 307 | + if held_back: |
| 308 | + click.echo(f" ... and {held_back} more (all of them in --output json)") |
| 309 | + |
| 310 | + nearest = report['references'][-1] |
| 311 | + if nearest['counts']['new']: |
| 312 | + click.echo(f"\n {nearest['counts']['new']} of {verdict['failing']} failures are new " |
| 313 | + f"relative to {nearest['label']}. Those are this change's.") |
| 314 | + elif verdict['failing']: |
| 315 | + click.echo(f"\n None of the {verdict['failing']} failures are new relative to " |
| 316 | + f"{nearest['label']}; they fail there too.") |
| 317 | + else: |
| 318 | + click.echo('\n Everything matched the approved output.') |
| 319 | + |
| 320 | + |
137 | 321 | @run.command('create') |
138 | 322 | @click.option('--commit', 'commit_sha', required=True, |
139 | 323 | help=f'Full {COMMIT_SHA_LENGTH}-char commit SHA. Short SHAs are rejected.') |
|
0 commit comments