Skip to content

Commit 6c526eb

Browse files
authored
Merge pull request #7 from CCExtractor/feat/run-report
Add `sp run report` — the verdict, then who caused it
2 parents 5519913 + f7a1f4e commit 6c526eb

6 files changed

Lines changed: 409 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ Then drill in:
5959

6060
```bash
6161
sp run compare <run> <baseline> # which failures are new vs the baseline
62+
sp run report <run> # verdict + the same diff against resolved
63+
# references; the "is this mine?" answer
6264
sp run summary <run> # counts only, cheapest
6365
sp run error-summary <run> # grouped error counts, server-derived
6466
sp run result <run> <regression_test_id> # one test: exit code, command, outputs

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ request), `--retries N`, `--no-color`, and `--version`.
108108
```bash
109109
sp investigate <run_id> # one-shot triage: info + counts + classified failures
110110
sp run compare <run_id> <baseline> # which of these failures are new?
111+
sp run report <run_id> # ... same question, references resolved for you
111112
sp investigate <run_id> --with-history # ... and whether each failure is new
112113
sp run summary <run_id> # pass/fail summary for a run
113114
sp run failures <run_id> # failing tests, each auto-classified

sp_cli/commands/run.py

Lines changed: 186 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@
88
import click
99

1010
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
1212
from sp_cli.constants import (ARTIFACT_TYPES, CANCEL_REASON_MIN_LENGTH,
1313
COMMIT_SHA_LENGTH, ERROR_GROUP_BY,
1414
ERROR_SEVERITIES, ERROR_TYPES, EXIT_TIMEOUT,
1515
EXIT_WAIT_ABORTED, INFRA_ERROR_TYPES,
1616
LOG_CONTAINS_MAX_LENGTH, LOG_LEVELS, LOG_SOURCES,
1717
MAX_OFFSET, MAX_PAGE_LIMIT,
1818
MAX_REGRESSION_TEST_IDS, PLATFORMS,
19-
PR_SCAN_DEFAULT, PR_SCAN_MAX,
19+
PR_SCAN_DEFAULT, PR_SCAN_MAX, REPORT_LIST_LIMIT,
2020
RUN_PENDING_STATUSES, RUN_STATUSES,
2121
RUN_UNSUCCESSFUL_STATUSES, SAMPLE_STATUSES,
2222
WAIT_INTERVAL_DEFAULT, WAIT_INTERVAL_MAX,
@@ -31,6 +31,18 @@
3131
#: regressions first, missing evidence before good news.
3232
COMPARE_BUCKETS = ('new', 'changed', 'still_failing', 'not_rerun', 'fixed', 'no_baseline')
3333

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+
3446
#: Run fields identifying each side of a comparison.
3547
_COMPARE_RUN_FIELDS = ('run_id', 'platform', 'commit_sha', 'branch', 'pr_number', 'status')
3648

@@ -134,6 +146,178 @@ def _list_runs_for_pr(ctx: click.Context, params: Dict[str, Any],
134146
output, ctx.obj.get('color', False))
135147

136148

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+
137321
@run.command('create')
138322
@click.option('--commit', 'commit_sha', required=True,
139323
help=f'Full {COMMIT_SHA_LENGTH}-char commit SHA. Short SHAs are rejected.')

sp_cli/compare.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,61 @@ def coverage_warnings(run: Dict[str, Any], baseline: Dict[str, Any],
155155
f"{result['counts']['not_rerun']} baseline failure(s) produced no result here; "
156156
'they are reported as not_rerun rather than fixed.')
157157
return warnings
158+
159+
160+
def pick_references(run: Dict[str, Any],
161+
branch_runs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
162+
"""
163+
Choose which earlier runs a run is worth being described against.
164+
165+
Two references answer different questions. The newest run on the target
166+
branch says whether a failure is broken where everyone else is working. The
167+
newest one that predates this run is the closest thing to where the branch
168+
was cut from, which is what separates "this change did it" from "it was
169+
already like that".
170+
171+
This is a *proxy* for ancestry, not ancestry: the API exposes no commit
172+
graph, so a run that predates this one is assumed to precede it in history.
173+
That holds for a branch cut from the target and stops holding for one cut
174+
weeks ago and rebased since. The label says "before this run" rather than
175+
"ancestor" so a reader is not told more than was checked.
176+
177+
:param run: The run being reported on.
178+
:type run: Dict[str, Any]
179+
:param branch_runs: Candidate runs on the target branch, newest first.
180+
:type branch_runs: List[Dict[str, Any]]
181+
:return: References as {label, run}, nearest question first, deduplicated.
182+
:rtype: List[Dict[str, Any]]
183+
"""
184+
usable = []
185+
for candidate in branch_runs:
186+
if candidate.get('run_id') == run.get('run_id'):
187+
continue
188+
if candidate.get('platform') != run.get('platform'):
189+
continue
190+
# A run that never reached a verdict has nothing to say about this one.
191+
if candidate.get('status') not in ('pass', 'fail'):
192+
continue
193+
usable.append(candidate)
194+
if not usable:
195+
return []
196+
197+
created = run.get('created_at') or ''
198+
earlier = []
199+
if created:
200+
for candidate in usable:
201+
if (candidate.get('created_at') or '') < created:
202+
earlier.append(candidate)
203+
204+
chosen: List[Tuple[str, Dict[str, Any]]] = [('the newest run on the target branch', usable[0])]
205+
if earlier:
206+
chosen.append(('the newest run before this one', earlier[0]))
207+
208+
references: List[Dict[str, Any]] = []
209+
seen: Set[int] = set()
210+
for label, candidate in chosen:
211+
if candidate['run_id'] in seen:
212+
continue
213+
seen.add(candidate['run_id'])
214+
references.append({'label': label, 'run': candidate})
215+
return references

sp_cli/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@
4545
#: do map to a code of their own (3 through 8) keep it.
4646
EXIT_WAIT_ABORTED = 10
4747

48+
#: How many failing tests ``sp run report`` lists per reference in table mode.
49+
#: The counts are the answer and the rows are evidence for it; JSON output is
50+
#: never truncated, so nothing is lost to a script.
51+
REPORT_LIST_LIMIT = 15
52+
4853
#: ``sp run wait`` polling bounds, in seconds.
4954
WAIT_INTERVAL_DEFAULT = 30
5055
WAIT_INTERVAL_MIN = 5

0 commit comments

Comments
 (0)