@crvouga/atlas 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/playwright-reporter/src/index.d.ts +11 -0
- package/dist/playwright-reporter/src/index.js +4 -2
- package/dist/reports/src/artifact-tag-store.d.ts +20 -0
- package/dist/reports/src/artifact-tag-store.js +70 -0
- package/dist/reports/src/artifact-tags.d.ts +96 -0
- package/dist/reports/src/artifact-tags.js +96 -0
- package/dist/reports/src/evidence-detail.d.ts +1 -1
- package/dist/reports/src/evidence-detail.js +6 -2
- package/dist/reports/src/evidence-types.d.ts +5 -0
- package/dist/reports/src/layer-merge.d.ts +1 -1
- package/dist/reports/src/layer-merge.js +3 -2
- package/dist/reports/src/layer-types.d.ts +3 -0
- package/dist/reports/src/publish-checks.js +1 -0
- package/dist/reports/src/report-cli.d.ts +1 -1
- package/dist/reports/src/report-cli.js +30 -2
- package/dist/reports/src/reporter.d.ts +1 -1
- package/dist/reports/src/reports-explorer-route.d.ts +8 -0
- package/dist/reports/src/reports-route.d.ts +8 -0
- package/dist/reports/src/test-catalog.d.ts +31 -27
- package/dist/reports/src/test-catalog.js +73 -12
- package/dist/schema/src/api.d.ts +128 -1
- package/dist/schema/src/api.js +38 -4
- package/dist/schema/src/layer.d.ts +59 -0
- package/dist/schema/src/layer.js +28 -1
- package/dist/server/src/app.js +32 -1
- package/dist/server/src/openapi.d.ts +1 -1
- package/dist/server/src/openapi.js +18 -2
- package/docs/extending.md +3 -0
- package/docs/integrating.md +8 -0
- package/docs/reference/cli.md +7 -0
- package/docs/reference/http-api.md +48 -0
- package/docs/reference/playwright-reporter.md +6 -6
- package/docs/reference/reports.md +235 -8
- package/docs/reference/schema.md +52 -1
- package/docs/report-contract.md +1 -0
- package/docs/rich-evidence.md +28 -0
- package/docs/server.md +24 -0
- package/llms-full.txt +119 -0
- package/package.json +1 -1
- package/packages/schema/schemas/check-detail.schema.json +28 -0
- package/packages/schema/schemas/effective-delta.schema.json +44 -0
- package/packages/schema/schemas/effective-evidence.schema.json +44 -0
- package/packages/schema/schemas/evidence-page.schema.json +37 -0
- package/packages/schema/schemas/layer-rows.schema.json +7 -0
- package/packages/schema/schemas/layer.schema.json +100 -0
- package/packages/schema/schemas/publish-checks.schema.json +65 -0
- package/packages/schema/schemas/report-run.schema.json +17 -1
- package/packages/schema/schemas/report-runs.schema.json +17 -1
- package/packages/schema/schemas/test-catalog.schema.json +42 -0
- package/packages/schema/schemas/test-history.schema.json +28 -0
- package/packages/server/openapi.json +886 -3
- package/packages/visualizer/src/evidence/artifact-tags.tsx +285 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +18 -12
- package/packages/visualizer/src/explorer/RunsView.tsx +23 -4
- package/packages/visualizer/src/explorer/parts.tsx +2 -1
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import {
|
|
2
|
+
artifactRef,
|
|
3
|
+
artifactTagsOf,
|
|
4
|
+
displayTag,
|
|
5
|
+
foldArtifactTags,
|
|
6
|
+
hasArtifactTag,
|
|
7
|
+
normalizeTag,
|
|
8
|
+
normalizeTags,
|
|
9
|
+
targetKey,
|
|
10
|
+
type ArtifactTag,
|
|
11
|
+
type ArtifactTagEntry,
|
|
12
|
+
type ReviewerTag
|
|
13
|
+
} from '@crvouga/atlas/reports/artifact-tags';
|
|
14
|
+
import type { EvidenceHolder } from '@crvouga/atlas/reports/evidence-detail';
|
|
15
|
+
import type { Attachment } from '@crvouga/atlas/schema';
|
|
16
|
+
import { Alert, Badge, Button, Chip, CloseButton, Group, PasswordInput, Popover, Stack, Switch, Text, TextInput } from '@mantine/core';
|
|
17
|
+
import { IconTag } from '@tabler/icons-react';
|
|
18
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
19
|
+
import { createContext, useContext, useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
|
20
|
+
import { reportAssetUrl, reportJson, staticEvidence } from './api';
|
|
21
|
+
import { exactTime } from './format';
|
|
22
|
+
|
|
23
|
+
/** What the tags of attachments are filtered by: a tag, and whether tags inherited from the check and run count. */
|
|
24
|
+
type TagFilter = { readonly tag: string; readonly inherited: boolean };
|
|
25
|
+
|
|
26
|
+
type TagChange = { readonly add?: readonly string[]; readonly remove?: readonly string[] };
|
|
27
|
+
|
|
28
|
+
type TagsContext = {
|
|
29
|
+
readonly layer: string;
|
|
30
|
+
readonly check: string;
|
|
31
|
+
readonly kind: string;
|
|
32
|
+
readonly inherited: readonly string[];
|
|
33
|
+
readonly reviewer: ReadonlyMap<string, readonly ReviewerTag[]>;
|
|
34
|
+
/** True when the server records reviewer tags. Whether this visitor may is decided by the server when they try. */
|
|
35
|
+
readonly editable: boolean;
|
|
36
|
+
readonly filter: TagFilter | null;
|
|
37
|
+
readonly setFilter: (filter: TagFilter | null) => void;
|
|
38
|
+
readonly token: string;
|
|
39
|
+
readonly setToken: (token: string) => void;
|
|
40
|
+
readonly edit: (attachment: Attachment, change: TagChange) => Promise<void>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const Tags = createContext<TagsContext | null>(null);
|
|
44
|
+
|
|
45
|
+
/** The tags an attachment shows here: its producer's, its reviewers' and those inherited from its check and run. */
|
|
46
|
+
function shownTags(attachment: Attachment, context: TagsContext | null): ArtifactTag[] {
|
|
47
|
+
if (!context) return artifactTagsOf(attachment);
|
|
48
|
+
const target = { layer: context.layer, check: context.check, kind: context.kind, artifact: artifactRef(attachment) };
|
|
49
|
+
return artifactTagsOf(attachment, { reviewer: context.reviewer.get(targetKey(target)) ?? [], inherited: context.inherited });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every attachment a holder carries, on its steps and attempts too. */
|
|
53
|
+
function everyAttachment(holder: EvidenceHolder): Attachment[] {
|
|
54
|
+
const out: Attachment[] = [...(holder.attachments ?? [])];
|
|
55
|
+
for (const step of holder.steps ?? []) out.push(...(step.attachments ?? []));
|
|
56
|
+
for (const attempt of holder.attempts ?? []) {
|
|
57
|
+
out.push(...(attempt.attachments ?? []));
|
|
58
|
+
for (const step of attempt.steps ?? []) out.push(...(step.attachments ?? []));
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readProblem(response: Response): Promise<string> {
|
|
64
|
+
try {
|
|
65
|
+
const body = (await response.json()) as { detail?: string; error?: string };
|
|
66
|
+
return body.detail ?? body.error ?? `The server refused the change (${response.status}).`;
|
|
67
|
+
} catch {
|
|
68
|
+
return `The server refused the change (${response.status}).`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Makes the tags of the attachments of one result live: reviewers' tags read from the server (when it has them),
|
|
74
|
+
* the tags a result's attachments inherit from their check and run, the filter chosen by selecting a tag, and
|
|
75
|
+
* the edit that records a reviewer's tag. Without this around them, attachments still show their producer's tags.
|
|
76
|
+
*/
|
|
77
|
+
export function ArtifactTagsProvider({ layer, kind, check, holders, children }: { layer: string; kind: string; check: string; holders: readonly EvidenceHolder[]; children: ReactNode }) {
|
|
78
|
+
const client = useQueryClient();
|
|
79
|
+
const [filter, setFilter] = useState<TagFilter | null>(null);
|
|
80
|
+
const [token, setToken] = useState('');
|
|
81
|
+
const overlay = useQuery({
|
|
82
|
+
queryKey: ['artifact-tags'],
|
|
83
|
+
queryFn: ({ signal }) => reportJson<{ entries: ArtifactTagEntry[] }>('/artifact-tags', signal),
|
|
84
|
+
enabled: !staticEvidence,
|
|
85
|
+
staleTime: 5000,
|
|
86
|
+
retry: false
|
|
87
|
+
});
|
|
88
|
+
const reviewer = useMemo(() => foldArtifactTags(overlay.data?.entries ?? []), [overlay.data]);
|
|
89
|
+
const inherited = useMemo(() => normalizeTags(holders.flatMap((holder) => holder.tags ?? [])), [holders]);
|
|
90
|
+
const value = useMemo<TagsContext>(
|
|
91
|
+
() => ({
|
|
92
|
+
layer,
|
|
93
|
+
check,
|
|
94
|
+
kind,
|
|
95
|
+
inherited,
|
|
96
|
+
reviewer,
|
|
97
|
+
editable: overlay.isSuccess,
|
|
98
|
+
filter,
|
|
99
|
+
setFilter,
|
|
100
|
+
token,
|
|
101
|
+
setToken,
|
|
102
|
+
edit: async (attachment, change) => {
|
|
103
|
+
const response = await fetch(reportAssetUrl('/artifact-tags'), {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
106
|
+
body: JSON.stringify({ target: { layer, check, kind, artifact: artifactRef(attachment) }, ...change })
|
|
107
|
+
});
|
|
108
|
+
if (!response.ok) throw new Error(await readProblem(response));
|
|
109
|
+
await client.invalidateQueries({ queryKey: ['artifact-tags'] });
|
|
110
|
+
}
|
|
111
|
+
}),
|
|
112
|
+
[layer, check, kind, inherited, reviewer, overlay.isSuccess, filter, token, client]
|
|
113
|
+
);
|
|
114
|
+
return <Tags.Provider value={value}>{children}</Tags.Provider>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The attachments that pass the chosen tag filter, or all of them when there is none. */
|
|
118
|
+
export function useFilteredAttachments(attachments: readonly Attachment[]): readonly Attachment[] {
|
|
119
|
+
const context = useContext(Tags);
|
|
120
|
+
const filter = context?.filter;
|
|
121
|
+
if (!context || !filter) return attachments;
|
|
122
|
+
return attachments.filter((attachment) => hasArtifactTag(shownTags(attachment, context), filter.tag, { inherited: filter.inherited }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Says which tag the evidence is filtered by, how many attachments match, whether inherited tags count, and clears the filter. Nothing when no tag is chosen. */
|
|
126
|
+
export function TagFilterBar({ holders }: { holders: readonly EvidenceHolder[] }) {
|
|
127
|
+
const context = useContext(Tags);
|
|
128
|
+
const filter = context?.filter;
|
|
129
|
+
if (!context || !filter) return null;
|
|
130
|
+
const all = holders.flatMap(everyAttachment);
|
|
131
|
+
const matching = all.filter((attachment) => hasArtifactTag(shownTags(attachment, context), filter.tag, { inherited: filter.inherited })).length;
|
|
132
|
+
return (
|
|
133
|
+
<Group aria-label="Tag filter" role="group" gap="sm" align="center">
|
|
134
|
+
<Text role="status" component="div">
|
|
135
|
+
{matching} of {all.length} {all.length === 1 ? 'attachment' : 'attachments'} tagged {displayTag(filter.tag)}
|
|
136
|
+
</Text>
|
|
137
|
+
<Switch
|
|
138
|
+
label="Include tags inherited from the check and run"
|
|
139
|
+
checked={filter.inherited}
|
|
140
|
+
onChange={(event) => context.setFilter({ tag: filter.tag, inherited: event.currentTarget.checked })}
|
|
141
|
+
/>
|
|
142
|
+
<Button type="button" variant="subtle" onClick={() => context.setFilter(null)}>
|
|
143
|
+
Clear filter
|
|
144
|
+
</Button>
|
|
145
|
+
</Group>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function Provenance({ tag }: { tag: ArtifactTag }) {
|
|
150
|
+
if (tag.source === 'inherited')
|
|
151
|
+
return (
|
|
152
|
+
<Text span inherit c="dimmed">
|
|
153
|
+
{' '}
|
|
154
|
+
inherited
|
|
155
|
+
</Text>
|
|
156
|
+
);
|
|
157
|
+
if (tag.source === 'reviewer')
|
|
158
|
+
return (
|
|
159
|
+
<Text span inherit c="dimmed">
|
|
160
|
+
{' '}
|
|
161
|
+
added by {tag.by}
|
|
162
|
+
{tag.atMs !== undefined ? `, ${exactTime(tag.atMs)}` : ''}
|
|
163
|
+
</Text>
|
|
164
|
+
);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function TagEditor({ attachment }: { attachment: Attachment }) {
|
|
169
|
+
const context = useContext(Tags)!;
|
|
170
|
+
const [opened, setOpened] = useState(false);
|
|
171
|
+
const [text, setText] = useState('');
|
|
172
|
+
const [error, setError] = useState('');
|
|
173
|
+
const [busy, setBusy] = useState(false);
|
|
174
|
+
const normalized = normalizeTag(text);
|
|
175
|
+
const submit = async (event: FormEvent) => {
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
if (normalized === null || busy) return;
|
|
178
|
+
setBusy(true);
|
|
179
|
+
setError('');
|
|
180
|
+
try {
|
|
181
|
+
await context.edit(attachment, { add: [normalized] });
|
|
182
|
+
setText('');
|
|
183
|
+
setOpened(false);
|
|
184
|
+
} catch (problem) {
|
|
185
|
+
setError(problem instanceof Error ? problem.message : 'The tag could not be saved.');
|
|
186
|
+
} finally {
|
|
187
|
+
setBusy(false);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
return (
|
|
191
|
+
<Popover opened={opened} onChange={setOpened} position="bottom-start" trapFocus withArrow>
|
|
192
|
+
<Popover.Target>
|
|
193
|
+
<Button type="button" variant="subtle" size="compact-sm" leftSection={<IconTag size={16} aria-hidden="true" />} onClick={() => setOpened((now) => !now)}>
|
|
194
|
+
Add tag
|
|
195
|
+
</Button>
|
|
196
|
+
</Popover.Target>
|
|
197
|
+
<Popover.Dropdown>
|
|
198
|
+
<form onSubmit={submit} aria-label={`Add a tag to ${attachment.name}`}>
|
|
199
|
+
<Stack gap="sm">
|
|
200
|
+
<TextInput
|
|
201
|
+
label="Tag"
|
|
202
|
+
description="demo and #demo are the same tag."
|
|
203
|
+
value={text}
|
|
204
|
+
onChange={(event) => setText(event.currentTarget.value)}
|
|
205
|
+
data-autofocus
|
|
206
|
+
/>
|
|
207
|
+
<PasswordInput
|
|
208
|
+
label="Token"
|
|
209
|
+
description="A token that may publish. Kept only in this tab. Not needed on a server without tokens."
|
|
210
|
+
value={context.token}
|
|
211
|
+
onChange={(event) => context.setToken(event.currentTarget.value)}
|
|
212
|
+
autoComplete="off"
|
|
213
|
+
/>
|
|
214
|
+
{error && (
|
|
215
|
+
<Alert color="red" role="alert">
|
|
216
|
+
{error}
|
|
217
|
+
</Alert>
|
|
218
|
+
)}
|
|
219
|
+
<Group>
|
|
220
|
+
<Button type="submit" variant="filled" loading={busy} disabled={normalized === null}>
|
|
221
|
+
Save tag
|
|
222
|
+
</Button>
|
|
223
|
+
</Group>
|
|
224
|
+
</Stack>
|
|
225
|
+
</form>
|
|
226
|
+
</Popover.Dropdown>
|
|
227
|
+
</Popover>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The tags of one attachment, as chips under its card: `#demo` from its producer, from a reviewer (with who and
|
|
233
|
+
* when) or inherited from its check and run (said so). Selecting a chip filters the evidence to the attachments
|
|
234
|
+
* with that tag. A reviewer's tag can be removed, and any attachment tagged, when the server records reviewer tags.
|
|
235
|
+
*/
|
|
236
|
+
export function ArtifactTagList({ attachment }: { attachment: Attachment }) {
|
|
237
|
+
const context = useContext(Tags);
|
|
238
|
+
const error = useRemovalError();
|
|
239
|
+
const tags = shownTags(attachment, context);
|
|
240
|
+
if (!tags.length && !context?.editable) return null;
|
|
241
|
+
return (
|
|
242
|
+
<Stack gap="xs" aria-label={`Tags of ${attachment.name}`}>
|
|
243
|
+
<Group gap="xs" align="center">
|
|
244
|
+
{tags.map((tag) =>
|
|
245
|
+
context ? (
|
|
246
|
+
<Group key={tag.tag} gap={0} wrap="nowrap">
|
|
247
|
+
<Chip
|
|
248
|
+
size="xs"
|
|
249
|
+
variant="light"
|
|
250
|
+
checked={context.filter?.tag === tag.tag}
|
|
251
|
+
onChange={() => context.setFilter(context.filter?.tag === tag.tag ? null : { tag: tag.tag, inherited: tag.source === 'inherited' })}
|
|
252
|
+
>
|
|
253
|
+
{displayTag(tag.tag)}
|
|
254
|
+
<Provenance tag={tag} />
|
|
255
|
+
</Chip>
|
|
256
|
+
{tag.source === 'reviewer' && context.editable && (
|
|
257
|
+
<CloseButton
|
|
258
|
+
size="sm"
|
|
259
|
+
aria-label={`Remove ${displayTag(tag.tag)} from ${attachment.name}`}
|
|
260
|
+
onClick={() => void context.edit(attachment, { remove: [tag.tag] }).catch((problem: unknown) => error.set(problem instanceof Error ? problem.message : 'The tag could not be removed.'))}
|
|
261
|
+
/>
|
|
262
|
+
)}
|
|
263
|
+
</Group>
|
|
264
|
+
) : (
|
|
265
|
+
<Badge key={tag.tag} variant="light" tt="none">
|
|
266
|
+
{displayTag(tag.tag)}
|
|
267
|
+
<Provenance tag={tag} />
|
|
268
|
+
</Badge>
|
|
269
|
+
)
|
|
270
|
+
)}
|
|
271
|
+
{context?.editable && <TagEditor attachment={attachment} />}
|
|
272
|
+
</Group>
|
|
273
|
+
{error.message && (
|
|
274
|
+
<Alert color="red" role="alert">
|
|
275
|
+
{error.message}
|
|
276
|
+
</Alert>
|
|
277
|
+
)}
|
|
278
|
+
</Stack>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function useRemovalError() {
|
|
283
|
+
const [message, set] = useState('');
|
|
284
|
+
return { message, set };
|
|
285
|
+
}
|
|
@@ -6,6 +6,7 @@ import type { Annotation, Attachment, Attempt, Step } from '@crvouga/atlas/schem
|
|
|
6
6
|
import { Accordion, Alert, Anchor, Box, Code, Flex, Image, List, Stack, Text, Title } from '@mantine/core';
|
|
7
7
|
import { useQuery } from '@tanstack/react-query';
|
|
8
8
|
import { useMemo, type ReactNode } from 'react';
|
|
9
|
+
import { ArtifactTagList, ArtifactTagsProvider, TagFilterBar, useFilteredAttachments } from './artifact-tags';
|
|
9
10
|
import { reportAssetUrl, reportJson, reportText } from './api';
|
|
10
11
|
import { duration, plural } from './format';
|
|
11
12
|
import { StatusMark } from './marks';
|
|
@@ -171,10 +172,12 @@ function Artifact({ attachment, layer }: { attachment: Attachment; layer: string
|
|
|
171
172
|
</Text>
|
|
172
173
|
)}
|
|
173
174
|
</Box>
|
|
175
|
+
<ArtifactTagList attachment={attachment} />
|
|
174
176
|
</List.Item>
|
|
175
177
|
);
|
|
176
178
|
}
|
|
177
|
-
export function Attachments({ attachments, layer }: { attachments: readonly Attachment[]; layer: string }) {
|
|
179
|
+
export function Attachments({ attachments: all, layer }: { attachments: readonly Attachment[]; layer: string }) {
|
|
180
|
+
const attachments = useFilteredAttachments(all);
|
|
178
181
|
if (!attachments.length) return null;
|
|
179
182
|
return (
|
|
180
183
|
<Box aria-label="Attachments" component="section" mb="lg">
|
|
@@ -410,16 +413,19 @@ export function ResultEvidence({ layer, row }: { layer: LayerHeader; row: LayerR
|
|
|
410
413
|
Array.isArray(value) ? value.length > 0 : value && typeof value === 'object' ? Object.keys(value).length > 0 : false
|
|
411
414
|
);
|
|
412
415
|
return (
|
|
413
|
-
<
|
|
414
|
-
<Evidence
|
|
415
|
-
|
|
416
|
-
<
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
416
|
+
<ArtifactTagsProvider layer={layer.id} kind={row.kind} check={row.id} holders={[check, run]}>
|
|
417
|
+
<Stack aria-label="Evidence" gap="sm" miw={0}>
|
|
418
|
+
<TagFilterBar holders={[check, run]} />
|
|
419
|
+
<Evidence holder={check} layer={layer.id} steps />
|
|
420
|
+
{hasRun && (
|
|
421
|
+
<Box aria-label="From the run" component="section" mb="lg">
|
|
422
|
+
<Title order={4} mb="sm">
|
|
423
|
+
From the run
|
|
424
|
+
</Title>
|
|
425
|
+
<Evidence holder={run} layer={layer.id} />
|
|
426
|
+
</Box>
|
|
427
|
+
)}
|
|
428
|
+
</Stack>
|
|
429
|
+
</ArtifactTagsProvider>
|
|
424
430
|
);
|
|
425
431
|
}
|
|
@@ -23,6 +23,19 @@ export function RunsView() {
|
|
|
23
23
|
const search = useExplorerSearch();
|
|
24
24
|
return search.openRun ? <RunDetailView runKey={search.openRun} /> : <RunTable />;
|
|
25
25
|
}
|
|
26
|
+
/** What a run is called: the title its producer gave it, else its CI run number or `branch@sha`. */
|
|
27
|
+
function runLabel(run: RunSummary): string {
|
|
28
|
+
return run.title ?? runName(run.key);
|
|
29
|
+
}
|
|
30
|
+
/** Beside a commit: said whenever the run was not recorded from a clean checkout, whatever the run is titled. */
|
|
31
|
+
function Freshness({ run }: { run: RunSummary }) {
|
|
32
|
+
if (!run.freshness) return null;
|
|
33
|
+
return (
|
|
34
|
+
<Text span inherit c="dimmed">
|
|
35
|
+
{run.freshness === 'modified' ? 'with local changes' : 'origin not captured'}
|
|
36
|
+
</Text>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
26
39
|
function Counts({ run }: { run: RunSummary }) {
|
|
27
40
|
return (
|
|
28
41
|
<Text span inherit>
|
|
@@ -98,19 +111,24 @@ function RunTable() {
|
|
|
98
111
|
<Table.Tr key={run.key} data-status={run.counts.failed ? 'failed' : 'passed'}>
|
|
99
112
|
<Table.Td title={exactTime(run.endedAtMs)}>
|
|
100
113
|
<Anchor
|
|
101
|
-
aria-label={`Open run ${
|
|
114
|
+
aria-label={`Open run ${runLabel(run)}`}
|
|
102
115
|
renderRoot={(props) => (
|
|
103
116
|
<Link {...props} to="/" search={patched({ openRun: run.key })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
104
117
|
)}
|
|
105
118
|
>
|
|
106
119
|
{ago(run.endedAtMs, now)}
|
|
107
120
|
</Anchor>
|
|
121
|
+
{run.title && (
|
|
122
|
+
<Text c="dimmed" fz="xs">
|
|
123
|
+
{run.title}
|
|
124
|
+
</Text>
|
|
125
|
+
)}
|
|
108
126
|
</Table.Td>
|
|
109
127
|
<Table.Td title={run.branch ?? ''} c="dimmed">
|
|
110
128
|
{run.branch ?? '—'}
|
|
111
129
|
</Table.Td>
|
|
112
130
|
<Table.Td>
|
|
113
|
-
<Code>{shortSha(run.sha) ?? '—'}</Code>
|
|
131
|
+
<Code>{shortSha(run.sha) ?? '—'}</Code> <Freshness run={run} />
|
|
114
132
|
</Table.Td>
|
|
115
133
|
<Table.Td>{run.environments.map((env) => environmentLabel(env)).join(', ') || '—'}</Table.Td>
|
|
116
134
|
<Table.Td>{run.tests}</Table.Td>
|
|
@@ -215,10 +233,10 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
215
233
|
);
|
|
216
234
|
const { run } = detail.data;
|
|
217
235
|
return (
|
|
218
|
-
<Box aria-label={`Run ${
|
|
236
|
+
<Box aria-label={`Run ${runLabel(run)}`} component="section" mb="lg">
|
|
219
237
|
{back}
|
|
220
238
|
<Title order={2} mb="sm">
|
|
221
|
-
Run {
|
|
239
|
+
Run {runLabel(run)}
|
|
222
240
|
</Title>
|
|
223
241
|
<Text component="div">
|
|
224
242
|
{run.branch && (
|
|
@@ -227,6 +245,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
227
245
|
</Text>
|
|
228
246
|
)}
|
|
229
247
|
{run.sha && <Code>{shortSha(run.sha)}</Code>}
|
|
248
|
+
<Freshness run={run} />
|
|
230
249
|
<Text title={exactTime(run.endedAtMs)} span inherit>
|
|
231
250
|
ended {ago(run.endedAtMs, now)}
|
|
232
251
|
</Text>
|
|
@@ -21,8 +21,9 @@ export function projectTitle(): string {
|
|
|
21
21
|
.join(' ') || 'Project reports'
|
|
22
22
|
);
|
|
23
23
|
}
|
|
24
|
-
/** A run key for a person: its CI run number, or `branch@sha` shortened. */
|
|
24
|
+
/** A run key for a person: the execution id a producer named, its CI run number, or `branch@sha` shortened. */
|
|
25
25
|
export function runName(key: string): string {
|
|
26
|
+
if (key.startsWith('run:')) return key.slice(4);
|
|
26
27
|
const actions = /\/actions\/runs\/(\d+)/.exec(key);
|
|
27
28
|
if (actions) return `#${actions[1]}`;
|
|
28
29
|
if (/^https?:/.test(key)) return key.replace(/^https?:\/\//, '').slice(0, 40);
|