@cat-factory/app 0.227.0 → 0.228.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/README.md +25 -0
- package/app/components/board/nodes/TaskCard.vue +68 -8
- package/app/components/outcome/OutcomeSummaryWindow.vue +614 -0
- package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
- package/app/components/panels/inspector/TaskExecution.vue +33 -0
- package/app/composables/useRunDeepLink.ts +12 -4
- package/app/modular/result-views.ts +4 -0
- package/app/stores/ui/resultViews.ts +28 -0
- package/app/stores/ui.dispatch.spec.ts +55 -0
- package/app/utils/runOutcome.spec.ts +492 -0
- package/app/utils/runOutcome.ts +509 -0
- package/i18n/locales/de.json +107 -0
- package/i18n/locales/en.json +107 -0
- package/i18n/locales/es.json +107 -0
- package/i18n/locales/fr.json +107 -0
- package/i18n/locales/he.json +107 -0
- package/i18n/locales/it.json +107 -0
- package/i18n/locales/ja.json +107 -0
- package/i18n/locales/pl.json +107 -0
- package/i18n/locales/tr.json +107 -0
- package/i18n/locales/uk.json +107 -0
- package/package.json +2 -2
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
// The RUN OUTCOME summary: the non-code answer to "what did this run change, and what backs
|
|
2
|
+
// that up".
|
|
3
|
+
//
|
|
4
|
+
// Reading a finished run used to mean reading a pull request: a branch name, a title, and a
|
|
5
|
+
// diff. Everything a person who does not read diffs needs was already captured (the tester's
|
|
6
|
+
// structured report, the screenshots it took, the visual-confirmation pairs a human reviewed,
|
|
7
|
+
// the per-requirement verdicts it returned) and each of those sat behind its own window, keyed
|
|
8
|
+
// by the STEP that produced it, so nobody who had not already learned the pipeline could find
|
|
9
|
+
// any of it. This module is the reduction that puts them in one place, keyed by the RUN.
|
|
10
|
+
//
|
|
11
|
+
// Three rules shape it, and they are the reason it is a pure module rather than computation
|
|
12
|
+
// inside the window:
|
|
13
|
+
//
|
|
14
|
+
// 1. **Nothing here is asserted.** Every field is read off state a producer already recorded,
|
|
15
|
+
// or COUNTED from it. The one derived judgement (a regression: an `established` requirement
|
|
16
|
+
// the tester observed to fail) is computed in code from the spec's state and the tester's
|
|
17
|
+
// verdict, exactly as the PR verification report computes its own, so a reader can
|
|
18
|
+
// re-derive it from the rows. No model is asked for a headline.
|
|
19
|
+
// 2. **Absent and zero never render the same.** Every section is a discriminated union whose
|
|
20
|
+
// `absent` arm carries a `gap` CODE (mapped to translated copy at the render site, never
|
|
21
|
+
// prose from here), because "no tester ran" and "the tester found nothing wrong" are
|
|
22
|
+
// opposite facts that a blank section states identically.
|
|
23
|
+
// 3. **The join to the spec is optional and says when it did not happen.** Requirement
|
|
24
|
+
// verdicts are keyed by the spec's own requirement id; without the service spec loaded
|
|
25
|
+
// there is no title to show, so `spec: 'unavailable'` says the ids are all there is rather
|
|
26
|
+
// than letting an id read as the requirement's name.
|
|
27
|
+
import type { Block, RequirementVerdictStatus, TestConcernSeverity } from '~/types/domain'
|
|
28
|
+
import type { ExecutionInstance, PipelineStep } from '~/types/execution'
|
|
29
|
+
import type { RequirementState, ServiceSpecView } from '~/types/spec'
|
|
30
|
+
import type { ReproductionStatus } from '~/types/reproduction'
|
|
31
|
+
import type { PullRequestRef, TestEnvironment } from '@cat-factory/contracts'
|
|
32
|
+
import { allPullRequests } from '@cat-factory/contracts'
|
|
33
|
+
import { isTesterKind } from '~/utils/catalog'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where the run stands, in the terms the person reading the outcome cares about. Derived from
|
|
37
|
+
* the BLOCK's status first (it is what the merge lifecycle writes) and from the run only for
|
|
38
|
+
* the states a block cannot distinguish.
|
|
39
|
+
*/
|
|
40
|
+
export type OutcomeDisposition =
|
|
41
|
+
| 'merged'
|
|
42
|
+
| 'awaiting_merge'
|
|
43
|
+
| 'in_flight'
|
|
44
|
+
| 'needs_attention'
|
|
45
|
+
| 'not_run'
|
|
46
|
+
/**
|
|
47
|
+
* The block names a run nobody resolved (see {@link RunUnavailableGap}), and its status is
|
|
48
|
+
* not one the merge lifecycle writes. What the run did is exactly the fact a block alone
|
|
49
|
+
* cannot carry, and `not_run` would be this card's most visible lie.
|
|
50
|
+
*/
|
|
51
|
+
| 'unknown'
|
|
52
|
+
|
|
53
|
+
/** One pull request the run opened: the own-service PR, plus a peer PR per connected repo. */
|
|
54
|
+
export interface OutcomePullRequest {
|
|
55
|
+
url: string
|
|
56
|
+
number: number | null
|
|
57
|
+
branch: string | null
|
|
58
|
+
/** `owner/name` for a PEER repo's PR; null for the task's own service. */
|
|
59
|
+
repo: string | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- Requirement coverage --------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The gap EVERY evidence section shares: the block names a run (`block.executionId`) the
|
|
66
|
+
* caller could not resolve, so nothing any step recorded is knowable here.
|
|
67
|
+
*
|
|
68
|
+
* It is kept apart from every other gap in this module, which report what a RESOLVED run did
|
|
69
|
+
* or did not produce. "The store does not have this run" and "this pipeline has no tester
|
|
70
|
+
* step" are opposite facts about opposite things, and a card that reported the second for the
|
|
71
|
+
* first would blame the pipeline for a read that never happened, on the one surface whose
|
|
72
|
+
* whole job is to say what is known and what is not.
|
|
73
|
+
*/
|
|
74
|
+
export type RunUnavailableGap = 'run_unavailable'
|
|
75
|
+
|
|
76
|
+
/** Why there is no requirement coverage to show. Each needs a different reaction. */
|
|
77
|
+
export type RequirementsGap =
|
|
78
|
+
| RunUnavailableGap
|
|
79
|
+
| 'no_tester_step'
|
|
80
|
+
| 'tester_not_reported'
|
|
81
|
+
| 'no_verdicts'
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether the requirement rows carry the spec's titles and, when they do not, WHY. The two
|
|
85
|
+
* causes leave IDENTICAL rows behind and need different fixes, so they are never merged:
|
|
86
|
+
* `not_read` is a spec this card never got (there was nothing to join against), `unmatched` is
|
|
87
|
+
* a spec it DID read that holds none of the ids the tester reported (a spec rewritten since,
|
|
88
|
+
* or a tester keying its verdicts by something else). Reporting the second as the first would
|
|
89
|
+
* send a reader to fix a read that worked.
|
|
90
|
+
*/
|
|
91
|
+
export type OutcomeSpecJoin = 'joined' | 'not_read' | 'unmatched'
|
|
92
|
+
|
|
93
|
+
/** One requirement the tester ruled on, joined to the spec when the spec could be read. */
|
|
94
|
+
export interface OutcomeRequirement {
|
|
95
|
+
/** The spec requirement id: the join key, and all there is when the join did not land. */
|
|
96
|
+
id: string
|
|
97
|
+
/**
|
|
98
|
+
* The requirement's headline from `spec/`, or null when this id is not in the spec that was
|
|
99
|
+
* read. Null on a row of an otherwise JOINED section is the partial-miss case the render
|
|
100
|
+
* site marks per row: the id is all there is for THIS requirement, and left unmarked beside
|
|
101
|
+
* its titled neighbours it reads as a requirement someone named after a slug.
|
|
102
|
+
*/
|
|
103
|
+
title: string | null
|
|
104
|
+
verdict: RequirementVerdictStatus
|
|
105
|
+
/** What the tester observed, when it said. */
|
|
106
|
+
detail: string | null
|
|
107
|
+
/** Implementation state as `spec/` recorded it, or null when unjoined. */
|
|
108
|
+
state: RequirementState | null
|
|
109
|
+
/**
|
|
110
|
+
* An `established` requirement the tester observed to FAIL: behaviour the platform had
|
|
111
|
+
* previously seen hold and no longer does. Computed here, never read off the report, and the
|
|
112
|
+
* one reading of this section that says the change BROKE something rather than merely not
|
|
113
|
+
* finishing it. An `aspirational` requirement failing is in-flight work, not a regression.
|
|
114
|
+
*/
|
|
115
|
+
regression: boolean
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type OutcomeRequirements =
|
|
119
|
+
| { status: 'absent'; gap: RequirementsGap }
|
|
120
|
+
| {
|
|
121
|
+
status: 'reported'
|
|
122
|
+
/** Whether the rows carry spec titles, or only the ids the tester keyed them by. */
|
|
123
|
+
spec: OutcomeSpecJoin
|
|
124
|
+
met: number
|
|
125
|
+
notMet: number
|
|
126
|
+
notCovered: number
|
|
127
|
+
regressions: number
|
|
128
|
+
/** Regressions first, then failures, then what was met, then what nobody checked. */
|
|
129
|
+
entries: OutcomeRequirement[]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- The tester's report ---------------------------------------------------
|
|
133
|
+
|
|
134
|
+
export type TestsGap = RunUnavailableGap | 'no_tester_step' | 'tester_not_reported'
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The tester's disposition. `could_not_run` is kept apart from `concerns` because they call
|
|
138
|
+
* for opposite reactions: one is a change with bugs in it, the other is a change nobody
|
|
139
|
+
* managed to exercise at all, and a report that collapses them reads as tested either way.
|
|
140
|
+
*/
|
|
141
|
+
export type TestsVerdict = 'greenlit' | 'concerns' | 'could_not_run'
|
|
142
|
+
|
|
143
|
+
export interface OutcomeConcern {
|
|
144
|
+
title: string
|
|
145
|
+
severity: TestConcernSeverity
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type OutcomeTests =
|
|
149
|
+
| { status: 'absent'; gap: TestsGap }
|
|
150
|
+
| {
|
|
151
|
+
status: 'reported'
|
|
152
|
+
verdict: TestsVerdict
|
|
153
|
+
/** The tester's own prose about the session, attributed as such at the render site. */
|
|
154
|
+
summary: string | null
|
|
155
|
+
/** Verbatim reason the tester could not run at all; null unless `could_not_run`. */
|
|
156
|
+
abortReason: string | null
|
|
157
|
+
/** What it exercised, by name. */
|
|
158
|
+
areas: string[]
|
|
159
|
+
passed: number
|
|
160
|
+
failed: number
|
|
161
|
+
skipped: number
|
|
162
|
+
concerns: OutcomeConcern[]
|
|
163
|
+
environment: TestEnvironment | null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---- What it looked like ---------------------------------------------------
|
|
167
|
+
|
|
168
|
+
export type VisualsGap = RunUnavailableGap | 'no_visual_step' | 'none_captured'
|
|
169
|
+
|
|
170
|
+
/** One captured view, paired with the reference design it was reviewed against when there is one. */
|
|
171
|
+
export interface OutcomeVisual {
|
|
172
|
+
view: string
|
|
173
|
+
artifactId: string | null
|
|
174
|
+
referenceArtifactId: string | null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type OutcomeVisuals =
|
|
178
|
+
| {
|
|
179
|
+
status: 'absent'
|
|
180
|
+
gap: VisualsGap
|
|
181
|
+
/** The gate's own verbatim explanation, when it recorded one. Detail, never the headline. */
|
|
182
|
+
detail: string | null
|
|
183
|
+
}
|
|
184
|
+
| {
|
|
185
|
+
status: 'reported'
|
|
186
|
+
/**
|
|
187
|
+
* Which producer the views came from. `visual_confirm` pairs were put in front of a
|
|
188
|
+
* human and carry a verdict; `tester` shots are captures nobody was asked about, and the
|
|
189
|
+
* card must not let the second read as the first.
|
|
190
|
+
*/
|
|
191
|
+
source: 'visual_confirm' | 'tester'
|
|
192
|
+
/** The gate's phase when the views came from it: awaiting a human, fixing, or approved. */
|
|
193
|
+
phase: 'awaiting_human' | 'fixing' | 'approved' | null
|
|
194
|
+
views: OutcomeVisual[]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---- The machine checks ----------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/** The three recorded machine verdicts a non-code reader still needs: did it build, does it work. */
|
|
200
|
+
export type OutcomeCheckKind = 'ci' | 'validation' | 'reproduction'
|
|
201
|
+
export type OutcomeCheckState = 'pass' | 'fail' | 'pending' | 'inconclusive'
|
|
202
|
+
|
|
203
|
+
export interface OutcomeCheck {
|
|
204
|
+
kind: OutcomeCheckKind
|
|
205
|
+
state: OutcomeCheckState
|
|
206
|
+
/**
|
|
207
|
+
* The producer's own qualifier, when the state alone would under-report it: the reproduction
|
|
208
|
+
* verdict that earned an `inconclusive`. Rendered through an exhaustive map, never as prose.
|
|
209
|
+
*/
|
|
210
|
+
reproduction: ReproductionStatus | null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- The whole summary -----------------------------------------------------
|
|
214
|
+
|
|
215
|
+
export interface RunOutcome {
|
|
216
|
+
disposition: OutcomeDisposition
|
|
217
|
+
/** The task's title: the product-language name of what was asked for. */
|
|
218
|
+
title: string
|
|
219
|
+
/** The requester's own description of the ask, trimmed; null when the task carried none. */
|
|
220
|
+
ask: string | null
|
|
221
|
+
/** Every PR the run opened, so the diff stays exactly one click from the summary. */
|
|
222
|
+
pullRequests: OutcomePullRequest[]
|
|
223
|
+
requirements: OutcomeRequirements
|
|
224
|
+
tests: OutcomeTests
|
|
225
|
+
visuals: OutcomeVisuals
|
|
226
|
+
/** Only the checks that actually ran: an absent check is omitted, never rendered as passing. */
|
|
227
|
+
checks: OutcomeCheck[]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface ComposeRunOutcomeInput {
|
|
231
|
+
block: Block
|
|
232
|
+
/**
|
|
233
|
+
* The run, or null when the caller has none. Null is TWO facts, and the block tells them
|
|
234
|
+
* apart: a task with no `executionId` never ran, while a task that names one the caller
|
|
235
|
+
* could not resolve has run and this card simply cannot see it (see
|
|
236
|
+
* {@link RunUnavailableGap}). Callers pass what their store holds and never substitute one
|
|
237
|
+
* for the other.
|
|
238
|
+
*/
|
|
239
|
+
instance: ExecutionInstance | null
|
|
240
|
+
/** The enclosing service's spec, when it has been loaded. Absent ⇒ ids without titles. */
|
|
241
|
+
spec?: ServiceSpecView | null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The tester step whose report describes the PR as it stands: a pipeline may carry more than
|
|
246
|
+
* one, and a later one supersedes an earlier one. Falls back to the first tester step so the
|
|
247
|
+
* caller can tell "no tester in this pipeline" from "one is there and has not reported".
|
|
248
|
+
*/
|
|
249
|
+
function testerStep(steps: readonly PipelineStep[]): PipelineStep | null {
|
|
250
|
+
const candidates = steps.filter((s) => isTesterKind(s.agentKind))
|
|
251
|
+
const reported = candidates.filter((s) => s.test?.lastReport)
|
|
252
|
+
return reported.at(-1) ?? candidates[0] ?? null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Index the service spec's requirements by id, for the verdict join. */
|
|
256
|
+
function specIndex(spec: ServiceSpecView | null | undefined) {
|
|
257
|
+
const byId = new Map<string, { title: string; state: RequirementState }>()
|
|
258
|
+
for (const module of spec?.spec?.modules ?? []) {
|
|
259
|
+
for (const group of module.groups ?? []) {
|
|
260
|
+
for (const req of group.requirements ?? []) {
|
|
261
|
+
byId.set(req.id, { title: req.title, state: req.state ?? 'aspirational' })
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return byId
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Regressions first, then failures, then what held, then what nobody checked. */
|
|
269
|
+
const VERDICT_ORDER: Record<RequirementVerdictStatus, number> = {
|
|
270
|
+
not_met: 1,
|
|
271
|
+
met: 2,
|
|
272
|
+
not_covered: 3,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* How the rows joined to the spec. Asked of the index rather than of the rows alone, because
|
|
277
|
+
* "no titles" has two causes and only the index can tell them apart: an index with entries
|
|
278
|
+
* that matched nothing is a spec that WAS read (see {@link OutcomeSpecJoin}).
|
|
279
|
+
*/
|
|
280
|
+
function specJoin(entries: readonly OutcomeRequirement[], specRead: boolean): OutcomeSpecJoin {
|
|
281
|
+
if (entries.some((e) => e.title !== null)) return 'joined'
|
|
282
|
+
return specRead ? 'unmatched' : 'not_read'
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function composeRequirements(
|
|
286
|
+
step: PipelineStep | null,
|
|
287
|
+
spec: ServiceSpecView | null | undefined,
|
|
288
|
+
): OutcomeRequirements {
|
|
289
|
+
if (!step) return { status: 'absent', gap: 'no_tester_step' }
|
|
290
|
+
const report = step.test?.lastReport
|
|
291
|
+
if (!report) return { status: 'absent', gap: 'tester_not_reported' }
|
|
292
|
+
const verdicts = report.requirementVerdicts ?? []
|
|
293
|
+
if (verdicts.length === 0) return { status: 'absent', gap: 'no_verdicts' }
|
|
294
|
+
|
|
295
|
+
const index = specIndex(spec)
|
|
296
|
+
const entries: OutcomeRequirement[] = verdicts.map((verdict) => {
|
|
297
|
+
const known = index.get(verdict.requirementId)
|
|
298
|
+
return {
|
|
299
|
+
id: verdict.requirementId,
|
|
300
|
+
title: known?.title ?? null,
|
|
301
|
+
verdict: verdict.status,
|
|
302
|
+
detail: verdict.detail?.trim() || null,
|
|
303
|
+
state: known?.state ?? null,
|
|
304
|
+
regression: known?.state === 'established' && verdict.status === 'not_met',
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
entries.sort((a, b) => {
|
|
308
|
+
if (a.regression !== b.regression) return a.regression ? -1 : 1
|
|
309
|
+
const order = VERDICT_ORDER[a.verdict] - VERDICT_ORDER[b.verdict]
|
|
310
|
+
return order !== 0 ? order : (a.title ?? a.id).localeCompare(b.title ?? b.id)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
status: 'reported',
|
|
315
|
+
// A spec that resolved NO id says WHICH of the two reasons applies rather than reading as a
|
|
316
|
+
// joined spec full of blank titles: the rows look the same either way and mean opposite
|
|
317
|
+
// things about whether the reader is seeing everything.
|
|
318
|
+
spec: specJoin(entries, spec?.spec != null),
|
|
319
|
+
met: entries.filter((e) => e.verdict === 'met').length,
|
|
320
|
+
notMet: entries.filter((e) => e.verdict === 'not_met').length,
|
|
321
|
+
notCovered: entries.filter((e) => e.verdict === 'not_covered').length,
|
|
322
|
+
regressions: entries.filter((e) => e.regression).length,
|
|
323
|
+
entries,
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function composeTests(step: PipelineStep | null): OutcomeTests {
|
|
328
|
+
if (!step) return { status: 'absent', gap: 'no_tester_step' }
|
|
329
|
+
const report = step.test?.lastReport
|
|
330
|
+
if (!report) return { status: 'absent', gap: 'tester_not_reported' }
|
|
331
|
+
|
|
332
|
+
const abortReason = report.abort?.reason?.trim() || null
|
|
333
|
+
const tally = { passed: 0, failed: 0, skipped: 0 }
|
|
334
|
+
for (const outcome of report.outcomes) tally[outcome.status] += 1
|
|
335
|
+
return {
|
|
336
|
+
status: 'reported',
|
|
337
|
+
verdict: abortReason ? 'could_not_run' : report.greenlight ? 'greenlit' : 'concerns',
|
|
338
|
+
summary: report.summary?.trim() || null,
|
|
339
|
+
abortReason,
|
|
340
|
+
areas: report.tested,
|
|
341
|
+
...tally,
|
|
342
|
+
concerns: report.concerns.map((c) => ({ title: c.title, severity: c.severity })),
|
|
343
|
+
environment: report.environment ?? null,
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function composeVisuals(
|
|
348
|
+
steps: readonly PipelineStep[],
|
|
349
|
+
tester: PipelineStep | null,
|
|
350
|
+
): OutcomeVisuals {
|
|
351
|
+
// The visual-confirmation gate is preferred over the tester's raw captures: its pairs were
|
|
352
|
+
// put in FRONT of a human and carry the reference they were judged against.
|
|
353
|
+
const gate = steps.filter((s) => s.visualConfirm).at(-1)?.visualConfirm ?? null
|
|
354
|
+
const pairs = (gate?.pairs ?? []).filter((p) => p.actualArtifactId || p.referenceArtifactId)
|
|
355
|
+
if (pairs.length > 0) {
|
|
356
|
+
return {
|
|
357
|
+
status: 'reported',
|
|
358
|
+
source: 'visual_confirm',
|
|
359
|
+
phase: gate?.phase ?? null,
|
|
360
|
+
views: pairs.map((p) => ({
|
|
361
|
+
view: p.view,
|
|
362
|
+
artifactId: p.actualArtifactId ?? null,
|
|
363
|
+
referenceArtifactId: p.referenceArtifactId ?? null,
|
|
364
|
+
})),
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const shots = tester?.test?.lastReport?.screenshots ?? []
|
|
369
|
+
if (shots.length > 0) {
|
|
370
|
+
return {
|
|
371
|
+
status: 'reported',
|
|
372
|
+
source: 'tester',
|
|
373
|
+
phase: null,
|
|
374
|
+
views: shots.map((s) => ({
|
|
375
|
+
view: s.view,
|
|
376
|
+
artifactId: s.artifactId,
|
|
377
|
+
referenceArtifactId: s.referenceArtifactId ?? null,
|
|
378
|
+
})),
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Nothing to show: say whether anything was ever meant to capture a view. A gate that ran
|
|
383
|
+
// and gathered nothing recorded WHY, and that reason is the whole answer for the reader.
|
|
384
|
+
//
|
|
385
|
+
// Asked of EVERY step, not of the tester whose report was selected: a pipeline can carry a
|
|
386
|
+
// `tester-ui` that has not reported beside a `tester-api` that has, and the selected step is
|
|
387
|
+
// then the api one. Reading the producer off it would tell a reader looking at a UI pipeline
|
|
388
|
+
// that nothing in it captures the interface.
|
|
389
|
+
const degraded = gate?.degradedReason?.trim() || null
|
|
390
|
+
const hadProducer = Boolean(gate) || steps.some((s) => s.agentKind === 'tester-ui')
|
|
391
|
+
return {
|
|
392
|
+
status: 'absent',
|
|
393
|
+
gap: hadProducer ? 'none_captured' : 'no_visual_step',
|
|
394
|
+
detail: degraded,
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function composeChecks(steps: readonly PipelineStep[]): OutcomeCheck[] {
|
|
399
|
+
const checks: OutcomeCheck[] = []
|
|
400
|
+
|
|
401
|
+
const ci = steps.filter((s) => s.agentKind === 'ci' && s.gate).at(-1)?.gate ?? null
|
|
402
|
+
// A CI gate that has not probed yet has no verdict to report; `pending` is what the gate
|
|
403
|
+
// itself records for "the checks are still running", so an unprobed gate is not folded onto it.
|
|
404
|
+
if (ci?.lastVerdict) checks.push({ kind: 'ci', state: ci.lastVerdict, reproduction: null })
|
|
405
|
+
|
|
406
|
+
const validation = steps.filter((s) => s.validation).at(-1)?.validation ?? null
|
|
407
|
+
if (validation) {
|
|
408
|
+
checks.push({
|
|
409
|
+
kind: 'validation',
|
|
410
|
+
state: validation.passed ? 'pass' : 'fail',
|
|
411
|
+
reproduction: null,
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const reproduction = steps.filter((s) => s.reproduction).at(-1)?.reproduction ?? null
|
|
416
|
+
if (reproduction) {
|
|
417
|
+
// Only red-on-the-pre-fix-tree then green-on-the-final-tree is proof; every other verdict
|
|
418
|
+
// is the absence of proof rather than a failure, which is why it is not a `fail`.
|
|
419
|
+
checks.push({
|
|
420
|
+
kind: 'reproduction',
|
|
421
|
+
state: reproduction.status === 'reproduced' ? 'pass' : 'inconclusive',
|
|
422
|
+
reproduction: reproduction.status,
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return checks
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function composeDisposition(
|
|
430
|
+
block: Block,
|
|
431
|
+
instance: ExecutionInstance | null,
|
|
432
|
+
unresolvedRun: boolean,
|
|
433
|
+
): OutcomeDisposition {
|
|
434
|
+
if (block.status === 'done') return 'merged'
|
|
435
|
+
if (block.status === 'pr_ready') return 'awaiting_merge'
|
|
436
|
+
if (instance?.status === 'failed' || block.status === 'blocked') return 'needs_attention'
|
|
437
|
+
// `in_progress` is the block's OWN word for a live run, so it stands whether or not the run
|
|
438
|
+
// itself resolved — the one in-flight reading that needs nothing from the instance.
|
|
439
|
+
if (instance || block.status === 'in_progress') return 'in_flight'
|
|
440
|
+
return unresolvedRun ? 'unknown' : 'not_run'
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Compose a run's outcome summary from what the run already carries. Pure: every input is a
|
|
445
|
+
* value the caller read off its store, so the whole reduction unit-tests without mounting the
|
|
446
|
+
* window that renders it.
|
|
447
|
+
*/
|
|
448
|
+
export function composeRunOutcome({ block, instance, spec }: ComposeRunOutcomeInput): RunOutcome {
|
|
449
|
+
const steps = instance?.steps ?? []
|
|
450
|
+
const tester = testerStep(steps)
|
|
451
|
+
// The block names a run the caller could not resolve. Everything below is read off that run's
|
|
452
|
+
// steps, so composing from the empty list would report a pipeline that ran and produced
|
|
453
|
+
// nothing — the exact misreading this card exists to prevent (see `RunUnavailableGap`).
|
|
454
|
+
const unresolvedRun = !instance && Boolean(block.executionId)
|
|
455
|
+
const asked = {
|
|
456
|
+
disposition: composeDisposition(block, instance, unresolvedRun),
|
|
457
|
+
title: block.title,
|
|
458
|
+
ask: block.description?.trim() || null,
|
|
459
|
+
// Read off the BLOCK, so they survive a run this card cannot see: the pull request is what
|
|
460
|
+
// a merged task is usually reopened for, long after its run left the store.
|
|
461
|
+
pullRequests: allPullRequests(block).map(({ repo, ref }) => toOutcomePr(ref, repo)),
|
|
462
|
+
}
|
|
463
|
+
if (unresolvedRun) {
|
|
464
|
+
return {
|
|
465
|
+
...asked,
|
|
466
|
+
requirements: { status: 'absent', gap: 'run_unavailable' },
|
|
467
|
+
tests: { status: 'absent', gap: 'run_unavailable' },
|
|
468
|
+
visuals: { status: 'absent', gap: 'run_unavailable', detail: null },
|
|
469
|
+
checks: [],
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
...asked,
|
|
474
|
+
requirements: composeRequirements(tester, spec),
|
|
475
|
+
tests: composeTests(tester),
|
|
476
|
+
visuals: composeVisuals(steps, tester),
|
|
477
|
+
checks: composeChecks(steps),
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function toOutcomePr(ref: PullRequestRef, repo: string | undefined): OutcomePullRequest {
|
|
482
|
+
return {
|
|
483
|
+
url: ref.url,
|
|
484
|
+
number: ref.number ?? null,
|
|
485
|
+
branch: ref.branch ?? null,
|
|
486
|
+
repo: repo ?? null,
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Whether a run has anything an outcome summary could show beyond the task's own title: a PR to
|
|
492
|
+
* open, or a step that recorded evidence. EVERY entry point asks this (the board card and the
|
|
493
|
+
* inspector alike, off the one reduction, so they can never disagree) so the affordance appears
|
|
494
|
+
* on a run that produced something and stays absent on one that has not yet, rather than
|
|
495
|
+
* offering a card whose every section reads "nothing here".
|
|
496
|
+
*
|
|
497
|
+
* A run this card could not resolve answers false unless the block still carries a pull
|
|
498
|
+
* request: there is nothing to show, and an affordance that opened onto four "not loaded"
|
|
499
|
+
* notices would be the same empty card by another route.
|
|
500
|
+
*/
|
|
501
|
+
export function hasOutcomeToShow(outcome: RunOutcome): boolean {
|
|
502
|
+
return (
|
|
503
|
+
outcome.pullRequests.length > 0 ||
|
|
504
|
+
outcome.requirements.status === 'reported' ||
|
|
505
|
+
outcome.tests.status === 'reported' ||
|
|
506
|
+
outcome.visuals.status === 'reported' ||
|
|
507
|
+
outcome.checks.length > 0
|
|
508
|
+
)
|
|
509
|
+
}
|
package/i18n/locales/de.json
CHANGED
|
@@ -1584,6 +1584,7 @@
|
|
|
1584
1584
|
},
|
|
1585
1585
|
"prNumber": "PR #{number}",
|
|
1586
1586
|
"pullRequest": "Pull Request",
|
|
1587
|
+
"readOutcome": "Ergebnis lesen",
|
|
1587
1588
|
"stepState": {
|
|
1588
1589
|
"pending": "Ausstehend",
|
|
1589
1590
|
"working": "In Arbeit",
|
|
@@ -2934,6 +2935,8 @@
|
|
|
2934
2935
|
"pr": "PR",
|
|
2935
2936
|
"prNumber": "PR #{number}",
|
|
2936
2937
|
"openPrOnGithub": "{pr} auf GitHub öffnen",
|
|
2938
|
+
"readOutcome": "Ergebnis",
|
|
2939
|
+
"readOutcomeHint": "Lies nach, was sich geändert hat, mit den Belegen dafür",
|
|
2937
2940
|
"review": "Überprüfen",
|
|
2938
2941
|
"merge": "Mergen",
|
|
2939
2942
|
"mergeConfirm": {
|
|
@@ -5899,6 +5902,110 @@
|
|
|
5899
5902
|
"fixRequested": "Korrektur angefordert"
|
|
5900
5903
|
}
|
|
5901
5904
|
},
|
|
5905
|
+
"outcome": {
|
|
5906
|
+
"title": "Ergebnis",
|
|
5907
|
+
"subtitle": "Was dieser Lauf geändert hat, und die Belege dafür",
|
|
5908
|
+
"diff": "Diff öffnen",
|
|
5909
|
+
"diffNumbered": "Diff öffnen (#{number})",
|
|
5910
|
+
"peerDiff": "Diff in {repo} öffnen",
|
|
5911
|
+
"disposition": {
|
|
5912
|
+
"merged": "Zusammengeführt",
|
|
5913
|
+
"awaiting_merge": "Wartet auf das Zusammenführen",
|
|
5914
|
+
"in_flight": "Läuft noch",
|
|
5915
|
+
"needs_attention": "Braucht Aufmerksamkeit",
|
|
5916
|
+
"not_run": "Noch nicht gelaufen",
|
|
5917
|
+
"unknown": "Lauf nicht geladen"
|
|
5918
|
+
},
|
|
5919
|
+
"ask": {
|
|
5920
|
+
"title": "Was gefordert war",
|
|
5921
|
+
"none": "Diese Aufgabe hat keine Beschreibung, es gibt hier also nichts, womit sich das Ergebnis vergleichen ließe."
|
|
5922
|
+
},
|
|
5923
|
+
"gap": {
|
|
5924
|
+
"run_unavailable": "Der Lauf dieser Aufgabe wurde nicht geladen, deshalb können die von ihm erfassten Belege hier nicht gezeigt werden. Das ist nicht dasselbe wie ein Lauf, der nichts erfasst hat."
|
|
5925
|
+
},
|
|
5926
|
+
"requirements": {
|
|
5927
|
+
"title": "Geprüfte Anforderungen",
|
|
5928
|
+
"met": "Erfüllt: {count}",
|
|
5929
|
+
"notMet": "Nicht erfüllt: {count}",
|
|
5930
|
+
"notCovered": "Nicht geprüft: {count}",
|
|
5931
|
+
"regressions": "Regressionen: {count}",
|
|
5932
|
+
"regressionTag": "Regression",
|
|
5933
|
+
"idOnly": "nur ID",
|
|
5934
|
+
"idOnlyHint": "Diese ID steht nicht in der Service-Spezifikation, deshalb gibt es dafür keinen Titel.",
|
|
5935
|
+
"spec": {
|
|
5936
|
+
"not_read": "Die Service-Spezifikation wurde nicht gelesen, deshalb stehen hier die vom Tester gemeldeten Anforderungs-IDs ohne ihre Titel.",
|
|
5937
|
+
"unmatched": "Keine dieser Anforderungs-IDs steht in der gelesenen Service-Spezifikation, deshalb werden nur die vom Tester gemeldeten IDs gezeigt."
|
|
5938
|
+
},
|
|
5939
|
+
"verdict": {
|
|
5940
|
+
"met": "Bestätigt",
|
|
5941
|
+
"not_met": "Fehlgeschlagen",
|
|
5942
|
+
"not_covered": "Nicht geprüft"
|
|
5943
|
+
},
|
|
5944
|
+
"gap": {
|
|
5945
|
+
"no_tester_step": "Diese Pipeline hat keinen Test-Schritt, daher wurde keine Anforderung geprüft.",
|
|
5946
|
+
"tester_not_reported": "Der Tester hat noch nicht berichtet, daher wurde über keine Anforderung entschieden.",
|
|
5947
|
+
"no_verdicts": "Der Tester hat berichtet, ohne über eine Anforderung der Spezifikation zu entscheiden."
|
|
5948
|
+
}
|
|
5949
|
+
},
|
|
5950
|
+
"tests": {
|
|
5951
|
+
"title": "Wie getestet wurde",
|
|
5952
|
+
"openReport": "Vollständiger Testbericht",
|
|
5953
|
+
"summary": "Der Tester berichtet: {summary}",
|
|
5954
|
+
"abort": "Der Tester konnte nicht sinnvoll testen: {reason}",
|
|
5955
|
+
"counts": "{passed} bestanden, {failed} fehlgeschlagen, {skipped} übersprungen",
|
|
5956
|
+
"verdict": {
|
|
5957
|
+
"greenlit": "Freigegeben",
|
|
5958
|
+
"concerns": "Bedenken gemeldet",
|
|
5959
|
+
"could_not_run": "Konnte nicht getestet werden"
|
|
5960
|
+
},
|
|
5961
|
+
"environment": {
|
|
5962
|
+
"local": "Gegen lokale Abhängigkeiten getestet",
|
|
5963
|
+
"ephemeral": "Gegen eine Vorschau-Umgebung getestet"
|
|
5964
|
+
},
|
|
5965
|
+
"severity": {
|
|
5966
|
+
"low": "Niedrig",
|
|
5967
|
+
"medium": "Mittel",
|
|
5968
|
+
"high": "Hoch",
|
|
5969
|
+
"critical": "Kritisch"
|
|
5970
|
+
},
|
|
5971
|
+
"gap": {
|
|
5972
|
+
"no_tester_step": "Diese Pipeline hat keinen Test-Schritt, es wurde also nichts ausgeführt.",
|
|
5973
|
+
"tester_not_reported": "Der Tester hat noch nicht berichtet."
|
|
5974
|
+
}
|
|
5975
|
+
},
|
|
5976
|
+
"visuals": {
|
|
5977
|
+
"title": "Wie es aussieht",
|
|
5978
|
+
"shotAlt": "Screenshot von {view}",
|
|
5979
|
+
"hasReference": "Für diese Ansicht wurde eine Referenzvorlage hinterlegt",
|
|
5980
|
+
"source": {
|
|
5981
|
+
"visual_confirm": "Diese Ansichten wurden mit ihren Referenzvorlagen verglichen.",
|
|
5982
|
+
"tester": "Diese Ansichten wurden beim Testen aufgenommen. Niemand wurde um eine Prüfung gebeten."
|
|
5983
|
+
},
|
|
5984
|
+
"gap": {
|
|
5985
|
+
"no_visual_step": "In dieser Pipeline nimmt nichts die Oberfläche auf, es gibt also nichts zu sehen.",
|
|
5986
|
+
"none_captured": "Die Oberfläche sollte aufgenommen werden, es wurde aber keine Ansicht erfasst."
|
|
5987
|
+
}
|
|
5988
|
+
},
|
|
5989
|
+
"checks": {
|
|
5990
|
+
"title": "Prüfungen",
|
|
5991
|
+
"row": "{kind}: {state}",
|
|
5992
|
+
"kind": {
|
|
5993
|
+
"ci": "CI",
|
|
5994
|
+
"validation": "Projektprüfungen",
|
|
5995
|
+
"reproduction": "Fehler-Reproduktion"
|
|
5996
|
+
},
|
|
5997
|
+
"state": {
|
|
5998
|
+
"pass": "bestanden",
|
|
5999
|
+
"fail": "fehlgeschlagen",
|
|
6000
|
+
"pending": "läuft noch",
|
|
6001
|
+
"inconclusive": "ohne Ergebnis"
|
|
6002
|
+
}
|
|
6003
|
+
},
|
|
6004
|
+
"empty": {
|
|
6005
|
+
"title": "Noch nichts zu zeigen",
|
|
6006
|
+
"body": "Diese Aufgabe hat noch kein lesbares Ergebnis hervorgebracht."
|
|
6007
|
+
}
|
|
6008
|
+
},
|
|
5902
6009
|
"focus": {
|
|
5903
6010
|
"board": "Board",
|
|
5904
6011
|
"typeSubtitle": "{type} · Fokusansicht",
|