Skip to content

Commit 42c669d

Browse files
authored
skill (triage-e2e-test): add Last Seen column to failure table (#15217)
### Summary The triage failure-pattern table had no recency signal, so an acute burst that a merged fix already closed looked identical to an ongoing drip and could get recommended days after it stopped occurring. This adds a per-pattern `lastSeen` and a Last seen column. - `triage-history.js` emits `lastSeen: { date, daysAgo, sha }` per pattern - Date derived from the sha's local git commit date, with the GitHub run's `created_at` as fallback (the test-health API has no timestamp on occurrences) - Degrades to naming the latest occurrence without a date when neither source resolves - Patterns stay sorted count-descending; recency is a separate axis, not a re-sort - SKILL.md renders it as `5d ago (Jul 24)` / `today` and requires calling out a stale pattern as an already-fixed candidate - Mechanics documented in `references/history-query.md`; 3 unit tests added ### QA Notes Skill tooling only, no product code. `node --test .claude/skills/triage-e2e-test/scripts/test/*.test.js` -- 57/57 pass.
1 parent d3972b0 commit 42c669d

4 files changed

Lines changed: 132 additions & 13 deletions

File tree

.claude/skills/triage-e2e-test/SKILL.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,19 +97,24 @@ saved data is invalid, or the branch/test identity changed.
9797
A non-`none` verdict changes the plan -- read [`references/prior-triage.md`](references/prior-triage.md).
9898
`open-attempt-in-flight` means stop and point at the open PR.
9999
6. **Present the failure modes as a table** (never a run-on sentence). The Rate
100-
column comes from each pattern's `rates` array, never `count / totalRuns`.
100+
column comes from each pattern's `rates` array, never `count / totalRuns`;
101+
Last seen renders `lastSeen` as `5d ago (Jul 24)`, or just `today` at 0d.
101102
Include a "Seen on" column whenever two branches were queried:
102103

103-
| # | Failure mode | Count | Rate | Environments | Seen on |
104-
|---|---|---|---|---|---|
105-
| A | `locator.click` timeout: `.codicon-maximize` | 4 | 100% on feature/x | ubuntu/chromium | feature/x only |
106-
| B | `toBeVisible()` timeout: `getByLabel('...')` | 3 | 1.9% on main | ubuntu/electron | main only |
104+
| # | Failure mode | Count | Rate | Last seen | Environments | Seen on |
105+
|---|---|---|---|---|---|---|
106+
| A | `locator.click` timeout: `.codicon-maximize` | 4 | 100% on feature/x | 5d ago (Jul 24) | ubuntu/chromium | feature/x only |
107+
| B | `toBeVisible()` timeout: `getByLabel('...')` | 3 | 1.9% on main | today | ubuntu/electron | main only |
108+
109+
Count and Rate are cumulative over the lookback, so they cannot separate an
110+
acute burst a merged fix already closed from an ongoing drip. **A pattern whose
111+
`daysAgo` is stale next to the others is an already-fixed candidate: say so in
112+
your recommendation** instead of steering the engineer there on count alone.
107113

108114
7. **Ask which pattern to prioritize whenever the table has more than one row.**
109115
Give your own read ("A is dominant at 99% -- start there, or focus on B?")
110-
but let the engineer decide; they may know a recent fix made the dominant
111-
share stale, or already know which failure they care about. A single pattern
112-
needs no choice. Save the selection to the checkpoint (`--set
116+
but let the engineer decide; they may already know which failure they care
117+
about. A single pattern needs no choice. Save the selection to the checkpoint (`--set
113118
selectedPattern=A --set phase=pattern-selected`).
114119

115120
## Investigate the selected pattern

.claude/skills/triage-e2e-test/references/history-query.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,28 @@ for the full worked example.
2525
| `zero-runs-both` | **every** queried branch reports `total_runs: 0` | **stop.** This is a key mismatch, not a clean record -- rebuild the full hierarchical key (above) and re-run |
2626
| `clean` | nonzero runs, no failure patterns | **stop.** Nothing to triage -- report a clean bill of health for the lookback window |
2727

28+
## How `lastSeen` is derived
29+
30+
The test-health API puts **no timestamp on occurrences**, so `triage-history.js`
31+
derives one per pattern into `lastSeen: { date, daysAgo, sha }`:
32+
33+
1. `git show -s --format=%cI <sha>` -- the local commit date. Offline, instant,
34+
and within minutes of the CI run date.
35+
2. `gh api repos/{owner}/{repo}/actions/runs/<id> --jq .created_at` -- fallback
36+
when the sha isn't in the local clone (shallow clone, force-push, unfetched
37+
branch). Authoritative but needs network.
38+
3. Neither resolves -> `{ date: null, daysAgo: null, sha }`. The sha still names
39+
the latest occurrence, because the API returns occurrences most-recent-first.
40+
41+
That ordering is why `lastSeen` is accurate even at the default
42+
`--occurrences-per-pattern 1`: index 0 already *is* the most recent occurrence,
43+
so the date only has to be resolved, not searched for.
44+
45+
`lastSeen` does **not** reorder the table -- patterns stay count-descending. It
46+
is a separate axis: a high-count pattern with a stale `daysAgo` was likely an
47+
acute burst that a merged fix already closed, which count and rate alone cannot
48+
show. Check it against `find-prior-triage.js` before recommending it.
49+
2850
## API unreachable
2951

3052
`triage-history.js` exits non-zero with `{ "error": ... }` when a branch query

.claude/skills/triage-e2e-test/scripts/test/history-merge.test.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { test } from 'node:test';
22
import assert from 'node:assert/strict';
3-
import { normalizePattern, mergeHistory, classifyVerdict, patternLabel, scopedRunsForEnvironments } from '../triage-history.js';
3+
import { normalizePattern, mergeHistory, classifyVerdict, patternLabel, scopedRunsForEnvironments, resolveLastSeen } from '../triage-history.js';
44

55
const mkTest = (runs, patterns, environmentBreakdown) => ({ history: { total_runs: runs }, failure_patterns: patterns, environment_breakdown: environmentBreakdown });
66
const occ = (sha, os = 'ubuntu', browser = 'electron') => ({ sha, os, browser, outcome: 'flaky', report_url: `https://x/${sha}/index.html` });
@@ -68,6 +68,39 @@ test('mergeHistory prefers a current-branch representative occurrence and keeps
6868
assert.equal(patterns[0].keptOccurrences.length, 1);
6969
});
7070

71+
test('resolveLastSeen picks the newest occurrence by date, not API order', () => {
72+
const now = Date.parse('2026-07-29T00:00:00Z');
73+
const dates = { old: '2026-07-20T10:00:00Z', new: '2026-07-28T10:00:00Z' };
74+
// Deliberately out of order: the newest must win on date, not position.
75+
const got = resolveLastSeen([occ('old'), occ('new')], o => dates[o.sha], now);
76+
assert.deepEqual(got, { date: '2026-07-28', daysAgo: 1, sha: 'new' });
77+
});
78+
79+
test('resolveLastSeen falls back to the first occurrence identity when no date resolves', () => {
80+
// A shallow clone with no gh fallback must still name the latest occurrence
81+
// (API order is most-recent-first) rather than dropping lastSeen entirely.
82+
const got = resolveLastSeen([occ('a'), occ('b')], () => null);
83+
assert.deepEqual(got, { date: null, daysAgo: null, sha: 'a' });
84+
assert.equal(resolveLastSeen([], () => null), null);
85+
});
86+
87+
test('mergeHistory surfaces lastSeen per pattern so a stale burst is distinguishable from a live drip', () => {
88+
const now = Date.parse('2026-07-29T00:00:00Z');
89+
const dates = { stale: '2026-07-24T10:00:00Z', live: '2026-07-29T10:00:00Z' };
90+
const main = mkTest(100, [
91+
{ pattern: 'acute burst, already fixed', count: 10, occurrences: [occ('stale')] },
92+
{ pattern: 'ongoing drip', count: 3, occurrences: [occ('live')] },
93+
]);
94+
const { patterns } = mergeHistory(null, main, 'main', 1, o => dates[o.sha]);
95+
// Sort stays count-descending; recency is surfaced, not used to reorder.
96+
assert.deepEqual(
97+
patterns.map(p => [p.failure, p.lastSeen.date]),
98+
[['acute burst, already fixed', '2026-07-24'], ['ongoing drip', '2026-07-29']],
99+
);
100+
assert.equal(mergeHistory(null, main, 'main', 1, o => dates[o.sha]).patterns[0].lastSeen.daysAgo > 0, true);
101+
assert.equal(resolveLastSeen([occ('live')], o => dates[o.sha], now).daysAgo, 0);
102+
});
103+
71104
test('classifyVerdict: both branches zero-runs is a key mismatch, not clean', () => {
72105
const v = classifyVerdict({ currentBranch: 'feature/x', currentRuns: 0, mainRuns: 0, patternCount: 0, queriedCurrent: true });
73106
assert.equal(v.verdict, 'zero-runs-both');

.claude/skills/triage-e2e-test/scripts/triage-history.js

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//
44
// Wraps e2e-failure-analyzer/scripts/e2e-query-history.js: resolves the branch,
55
// queries the current branch and main, merges their failure patterns by failure
6-
// text, computes counts/percentages/seen-on, detects zero-run conditions,
6+
// text, computes counts/percentages/seen-on/last-seen, detects zero-run conditions,
77
// selects ONE representative occurrence per pattern, writes the full responses
88
// to disk, and prints only a compact JSON summary to stdout.
99
//
@@ -55,6 +55,64 @@ function envKey(os, browser) {
5555
return [os, browser].filter(Boolean).join('/');
5656
}
5757

58+
const occurrenceDateCache = new Map();
59+
60+
/**
61+
* Calendar date of one failure occurrence. The test-health API returns no
62+
* timestamp on occurrences, so derive one: the local git commit date of the sha
63+
* (offline and instant, and within minutes of the CI run), falling back to the
64+
* GitHub run's created_at when the sha is not in the local clone (shallow clone,
65+
* force-push, or a branch never fetched). Returns null when neither answers.
66+
*/
67+
export function occurrenceDate(o) {
68+
const key = o?.sha || o?.run_url;
69+
if (!key) { return null; }
70+
if (occurrenceDateCache.has(key)) { return occurrenceDateCache.get(key); }
71+
72+
let iso = null;
73+
if (o.sha) {
74+
const r = tryRun('git', ['show', '-s', '--format=%cI', o.sha]);
75+
if (r.ok) { iso = r.stdout.trim().split('\n').pop() || null; }
76+
}
77+
if (!iso && o.run_url) {
78+
const runId = String(o.run_url).match(/\/runs\/(\d+)/)?.[1];
79+
if (runId) {
80+
// {owner}/{repo} are resolved by gh from the working directory.
81+
const r = tryRun('gh', ['api', `repos/{owner}/{repo}/actions/runs/${runId}`, '--jq', '.created_at']);
82+
if (r.ok) { iso = r.stdout.trim() || null; }
83+
}
84+
}
85+
occurrenceDateCache.set(key, iso);
86+
return iso;
87+
}
88+
89+
/**
90+
* Most recent occurrence of a pattern. Recency is what separates an acute burst
91+
* that a merged fix already closed from an ongoing drip -- without it a stale
92+
* pattern and a live one look identical in the failure table.
93+
*
94+
* Occurrences arrive most-recent-first from the API, so index 0 is the fallback
95+
* identity when no date resolves: a stale clone must still report *which*
96+
* occurrence was latest, just without a date.
97+
*/
98+
export function resolveLastSeen(occurrences, dateFor, now = Date.now()) {
99+
let best = null;
100+
for (const o of occurrences || []) {
101+
const t = Date.parse(dateFor(o) || '');
102+
if (Number.isNaN(t)) { continue; }
103+
if (!best || t > best.t) { best = { t, iso: dateFor(o), o }; }
104+
}
105+
if (!best) {
106+
const first = (occurrences || [])[0];
107+
return first ? { date: null, daysAgo: null, sha: first.sha ?? null } : null;
108+
}
109+
return {
110+
date: best.iso.slice(0, 10),
111+
daysAgo: Math.max(0, Math.round((now - best.t) / 86400000)),
112+
sha: best.o.sha ?? null,
113+
};
114+
}
115+
58116
/**
59117
* Sum `total_runs` from a test object's `environment_breakdown` for exactly the
60118
* environments a pattern occurred in. Returns null when the breakdown is
@@ -100,7 +158,7 @@ export function patternLabel(i) {
100158
return label;
101159
}
102160

103-
export function mergeHistory(current, main, currentBranch, occurrencesPerPattern = 1) {
161+
export function mergeHistory(current, main, currentBranch, occurrencesPerPattern = 1, dateFor = () => null) {
104162
const byKey = new Map();
105163

106164
const ingest = (testObj, branchLabel) => {
@@ -168,6 +226,7 @@ export function mergeHistory(current, main, currentBranch, occurrencesPerPattern
168226
rates,
169227
environments,
170228
seenOn,
229+
lastSeen: resolveLastSeen(entry.occurrences, dateFor),
171230
representativeOccurrence: rep && {
172231
branch: rep.branch, sha: rep.sha, os: rep.os,
173232
browser: rep.browser, outcome: rep.outcome, report_url: rep.report_url,
@@ -258,7 +317,7 @@ function main() {
258317
const testName = (mainTest || currentTest)?.testName || testKey.split('|||')[0];
259318
const specPath = (mainTest || currentTest)?.specPath || testKey.split('|||')[1];
260319

261-
const merged = mergeHistory(currentTest, mainTest, currentBranch, occ);
320+
const merged = mergeHistory(currentTest, mainTest, currentBranch, occ, occurrenceDate);
262321
const verdict = classifyVerdict({
263322
currentBranch,
264323
currentRuns: merged.currentRuns,
@@ -281,7 +340,7 @@ function main() {
281340
},
282341
patterns: merged.patterns.map(p => ({
283342
id: p.id, failure: p.failure, count: p.count, rates: p.rates,
284-
environments: p.environments, seenOn: p.seenOn,
343+
environments: p.environments, seenOn: p.seenOn, lastSeen: p.lastSeen,
285344
representativeOccurrence: p.representativeOccurrence,
286345
})),
287346
verdict: verdict.verdict,

0 commit comments

Comments
 (0)