@cat-factory/app 0.270.3 → 0.272.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/app/components/panels/AgentStepDetail.vue +48 -14
- package/app/components/panels/StepMetadataCard.vue +105 -6
- package/app/components/tasks/BugHuntModal.logic.spec.ts +83 -0
- package/app/components/tasks/BugHuntModal.logic.ts +54 -0
- package/app/components/tasks/BugHuntModal.vue +149 -25
- package/app/stores/bugHunt.spec.ts +122 -1
- package/app/stores/bugHunt.ts +35 -1
- package/app/stores/tasks.spec.ts +2 -0
- package/app/utils/intakePredicates.spec.ts +1 -0
- package/i18n/locales/de.json +20 -2
- package/i18n/locales/en.json +20 -2
- package/i18n/locales/es.json +20 -2
- package/i18n/locales/fr.json +20 -2
- package/i18n/locales/he.json +20 -2
- package/i18n/locales/it.json +20 -2
- package/i18n/locales/ja.json +20 -2
- package/i18n/locales/pl.json +20 -2
- package/i18n/locales/tr.json +20 -2
- package/i18n/locales/uk.json +20 -2
- package/package.json +4 -4
|
@@ -13,7 +13,7 @@ import StepFragmentAdherence from '~/components/panels/StepFragmentAdherence.vue
|
|
|
13
13
|
import BinaryOutputReport from '~/components/binaryOutput/BinaryOutputReport.vue'
|
|
14
14
|
import EnvironmentStatusPanel from '~/components/environments/EnvironmentStatusPanel.vue'
|
|
15
15
|
import FrontendBindingsResolved from '~/components/panels/inspector/FrontendBindingsResolved.vue'
|
|
16
|
-
import { UI_TESTER_AGENT_KIND } from '@cat-factory/contracts'
|
|
16
|
+
import { UI_TESTER_AGENT_KIND, blockingReviewComments } from '@cat-factory/contracts'
|
|
17
17
|
import type { GateApprovalRefusal } from '@cat-factory/contracts'
|
|
18
18
|
import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
|
|
19
19
|
import IterationCapPrompt from '~/components/pipeline/IterationCapPrompt.vue'
|
|
@@ -171,10 +171,31 @@ const proposalEditable = computed(() => step.value?.outputIsRendered !== true)
|
|
|
171
171
|
// approve/request-changes/reject rail, it shows the shared iteration-cap prompt
|
|
172
172
|
// (one more round / proceed / stop & reset), resolved through its own endpoint.
|
|
173
173
|
const companionExceeded = computed(() => approvalPending.value && !!step.value?.companion?.exceeded)
|
|
174
|
+
/**
|
|
175
|
+
* The must-fix findings the reviewer left open on its last round.
|
|
176
|
+
*
|
|
177
|
+
* They are why the cap prompts read differently, and the difference is not cosmetic: a cap
|
|
178
|
+
* reached on the rating alone is the loop reporting that this is as good as it got, while an open
|
|
179
|
+
* blocker is the reviewer saying the work must not go on as it stands. That second one is also the
|
|
180
|
+
* park no risk policy will answer, so the person reading it is the only route past it and should
|
|
181
|
+
* be told what they are being asked to overrule.
|
|
182
|
+
*/
|
|
183
|
+
const blockingFindings = computed(() => blockingReviewComments(latestVerdict.value?.comments))
|
|
174
184
|
// The SAME park, reached for the opposite reason: the loop was abandoned with rounds still on the
|
|
175
185
|
// budget because the producer handed back the work it was asked to change and the rating did not
|
|
176
186
|
// move. The three choices are identical, so this only picks the wording — the cap copy states a
|
|
177
187
|
// spent limit, which is a false claim about this park (`companion.stalled`).
|
|
188
|
+
//
|
|
189
|
+
// It can hold TOGETHER with `blockingFindings`, and that pair is what splits the wording across
|
|
190
|
+
// the two slots rather than ranking them: the HEADING says how the loop ended (a stalled one did
|
|
191
|
+
// not reach its limit, so only it may say so) and the DETAIL says what this person is being asked
|
|
192
|
+
// to decide (an open blocker outranks a bar that went unmet, and its copy claims nothing about
|
|
193
|
+
// rounds). Neither slot can then state something untrue of the park it is describing.
|
|
194
|
+
//
|
|
195
|
+
// Which is why the stalled heading claims nothing about the RATING either. Standing still is
|
|
196
|
+
// unchanged output at an unmoved rating (`companionLoopStalled`), never a rating under the bar: a
|
|
197
|
+
// round held by an open blocker fails at a rating that cleared it, and the copy said "the rating
|
|
198
|
+
// held below the 80% bar" over a 95% one. What the number was is on the verdict card above.
|
|
178
199
|
const companionStalled = computed(() => companionExceeded.value && !!step.value?.companion?.stalled)
|
|
179
200
|
// A park a DEDICATED window owns (fork choice / follow-up triage): the generic approve
|
|
180
201
|
// resolver refuses these server-side, so the rail is replaced by a redirect to that window.
|
|
@@ -492,10 +513,12 @@ async function copyOutput() {
|
|
|
492
513
|
:step-index="ctx?.stepIndex ?? null"
|
|
493
514
|
/>
|
|
494
515
|
|
|
495
|
-
<!-- companion rework budget spent, OR the loop abandoned early as unproductive
|
|
496
|
-
|
|
497
|
-
output / stop & reset). One prompt,
|
|
498
|
-
the
|
|
516
|
+
<!-- companion rework budget spent, OR the loop abandoned early as unproductive,
|
|
517
|
+
with or without must-fix findings still open: the shared iteration-cap decision
|
|
518
|
+
(one more round / proceed with the current output / stop & reset). One prompt,
|
|
519
|
+
and the two slots are picked on different facts — the choices are the same but
|
|
520
|
+
the reason is not, the spent-limit wording is untrue of a stalled loop, and an
|
|
521
|
+
open blocker is what the person is actually being asked to overrule. -->
|
|
499
522
|
<IterationCapPrompt
|
|
500
523
|
v-if="companionExceeded"
|
|
501
524
|
:heading="
|
|
@@ -504,18 +527,29 @@ async function copyOutput() {
|
|
|
504
527
|
agent: agent.label,
|
|
505
528
|
attempts: step.companion?.attempts,
|
|
506
529
|
maxAttempts: step.companion?.maxAttempts,
|
|
507
|
-
threshold: pctOf(latestVerdict?.threshold ?? 0),
|
|
508
|
-
})
|
|
509
|
-
: t('panels.stepDetail.companionCapHeading', {
|
|
510
|
-
agent: agent.label,
|
|
511
|
-
attempts: step.companion?.maxAttempts,
|
|
512
|
-
threshold: pctOf(latestVerdict?.threshold ?? 0),
|
|
513
530
|
})
|
|
531
|
+
: blockingFindings.length
|
|
532
|
+
? t(
|
|
533
|
+
'panels.stepDetail.companionCapBlockedHeading',
|
|
534
|
+
{
|
|
535
|
+
agent: agent.label,
|
|
536
|
+
attempts: step.companion?.maxAttempts,
|
|
537
|
+
count: blockingFindings.length,
|
|
538
|
+
},
|
|
539
|
+
blockingFindings.length,
|
|
540
|
+
)
|
|
541
|
+
: t('panels.stepDetail.companionCapHeading', {
|
|
542
|
+
agent: agent.label,
|
|
543
|
+
attempts: step.companion?.maxAttempts,
|
|
544
|
+
threshold: pctOf(latestVerdict?.threshold ?? 0),
|
|
545
|
+
})
|
|
514
546
|
"
|
|
515
547
|
:detail="
|
|
516
|
-
|
|
517
|
-
? t('panels.stepDetail.
|
|
518
|
-
:
|
|
548
|
+
blockingFindings.length
|
|
549
|
+
? t('panels.stepDetail.companionCapBlockedDetail')
|
|
550
|
+
: companionStalled
|
|
551
|
+
? t('panels.stepDetail.companionStalledDetail')
|
|
552
|
+
: t('panels.stepDetail.companionCapDetail')
|
|
519
553
|
"
|
|
520
554
|
:loading="resolvingCap"
|
|
521
555
|
@resolve="resolveCompanionCap"
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
bySeverityWorstFirst,
|
|
5
|
+
isReviewCommentSeverity,
|
|
6
|
+
type ReviewCommentSeverity,
|
|
7
|
+
} from '@cat-factory/contracts'
|
|
3
8
|
import type { AgentState, PipelineStep, CompanionVerdict, StepApproval } from '~/types/execution'
|
|
9
|
+
import type { BadgeColor } from '~/utils/badge'
|
|
4
10
|
import { subtaskIconClass } from '~/utils/pipelineRender'
|
|
5
11
|
import StepModelActivity from '~/components/observability/StepModelActivity.vue'
|
|
6
12
|
import StepContainerStatus from '~/components/panels/StepContainerStatus.vue'
|
|
@@ -65,6 +71,71 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
65
71
|
|
|
66
72
|
const pctOf = (n: number) => `${Math.round(n * 100)}%`
|
|
67
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The colour each finding grade renders at. `ungraded` is its own member rather than a fallback
|
|
76
|
+
* arm: a person's comment carries no severity and neither does a verdict recorded before reviewers
|
|
77
|
+
* graded anything, and painting either of those `major` would put a level on the screen that
|
|
78
|
+
* nobody chose. `unrecognized` is the same argument for the other direction (see
|
|
79
|
+
* {@link findingGrade}). An exhaustive `Record` so a severity added to the contract fails to
|
|
80
|
+
* compile here, typed against the shared `BadgeColor` rather than a hand-picked subset of it.
|
|
81
|
+
*/
|
|
82
|
+
const SEVERITY_COLOR: Record<ReviewCommentSeverity | 'ungraded' | 'unrecognized', BadgeColor> = {
|
|
83
|
+
blocker: 'error',
|
|
84
|
+
major: 'warning',
|
|
85
|
+
minor: 'neutral',
|
|
86
|
+
ungraded: 'neutral',
|
|
87
|
+
unrecognized: 'neutral',
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How one finding's grade renders: the level itself, or which of the two NON-levels it is.
|
|
92
|
+
*
|
|
93
|
+
* `unrecognized` is what a level this build has retired reads as. The severity vocabulary is closed
|
|
94
|
+
* but persisted, and a stored verdict is mapped onto the type rather than re-parsed, so the schema's
|
|
95
|
+
* `major` fallback never runs on this path (contracts' `isReviewCommentSeverity` states the rule).
|
|
96
|
+
* Left unnarrowed the value indexes both maps and comes back `undefined`, which renders an unstyled
|
|
97
|
+
* badge over a raw i18n key; guessed onto a current level it would show an urgency nobody graded, on
|
|
98
|
+
* the panel asking a person to act on it. So it is NAMED, and the copy carries the stored value.
|
|
99
|
+
*/
|
|
100
|
+
function findingGrade(severity: string | undefined): {
|
|
101
|
+
key: ReviewCommentSeverity | 'ungraded' | 'unrecognized'
|
|
102
|
+
level: string
|
|
103
|
+
} {
|
|
104
|
+
if (severity === undefined) return { key: 'ungraded', level: '' }
|
|
105
|
+
if (isReviewCommentSeverity(severity)) return { key: severity, level: severity }
|
|
106
|
+
return { key: 'unrecognized', level: severity }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Each round paired with its findings, worst first (the order the reviewer's asks should be worked
|
|
111
|
+
* in) and each finding with its resolved grade.
|
|
112
|
+
*
|
|
113
|
+
* A `computed` rather than methods the template calls, because a run panel re-renders on every
|
|
114
|
+
* pushed instance update while the template needs the list twice per round (the `v-if` and the
|
|
115
|
+
* `v-for`) and the grade three times per finding: as methods that is a copy, a sort and a narrowing
|
|
116
|
+
* per reader per push, all off state that only changes when a verdict lands.
|
|
117
|
+
*/
|
|
118
|
+
const verdictRounds = computed(() =>
|
|
119
|
+
props.companionVerdicts.map((verdict) => ({
|
|
120
|
+
verdict,
|
|
121
|
+
findings: bySeverityWorstFirst(verdict.comments ?? []).map((finding) => ({
|
|
122
|
+
body: finding.body,
|
|
123
|
+
grade: findingGrade(finding.severity),
|
|
124
|
+
})),
|
|
125
|
+
})),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether this round's rating actually reached its bar.
|
|
130
|
+
*
|
|
131
|
+
* Distinct from the verdict's own `passed`, which is what the ENGINE decided and can be `false` at a
|
|
132
|
+
* rating well above the threshold: an open `blocker` holds the step whatever the number says. The
|
|
133
|
+
* two were one expression, so the panel printed a false inequality over the findings that explained
|
|
134
|
+
* it. `>=` matches kernel's `disposeCompanionVerdict`, where a threshold typed by an operator must be
|
|
135
|
+
* met exactly by a rating equal to it.
|
|
136
|
+
*/
|
|
137
|
+
const ratingMeetsBar = (verdict: CompanionVerdict) => verdict.rating >= verdict.threshold
|
|
138
|
+
|
|
68
139
|
const APPROVAL_STATUS_KEYS: Record<StepApproval['status'], string> = {
|
|
69
140
|
pending: 'panels.stepMeta.approvalStatus.pending',
|
|
70
141
|
approved: 'panels.stepMeta.approvalStatus.approved',
|
|
@@ -278,17 +349,23 @@ async function copyRunId() {
|
|
|
278
349
|
<span class="text-[11px] uppercase tracking-wide text-slate-500">
|
|
279
350
|
{{ t('panels.stepMeta.companionReview') }}
|
|
280
351
|
</span>
|
|
352
|
+
<!-- The COLOUR is the verdict (`passed`) and the GLYPH is the arithmetic, which are no
|
|
353
|
+
longer the same fact: a round holding an open `blocker` fails at a rating that cleared
|
|
354
|
+
its bar, and reading the inequality off `passed` printed "95% < 80%" over the findings
|
|
355
|
+
explaining why. -->
|
|
281
356
|
<UBadge :color="latestVerdict?.passed ? 'success' : 'warning'" variant="subtle" size="sm">
|
|
282
357
|
{{ pctOf(latestVerdict!.rating) }}
|
|
283
|
-
{{ latestVerdict
|
|
358
|
+
{{ ratingMeetsBar(latestVerdict!) ? '≥' : '<' }} {{ pctOf(latestVerdict!.threshold) }}
|
|
284
359
|
</UBadge>
|
|
285
360
|
</div>
|
|
286
|
-
<!-- One card per correction round: the score on its own line, then the reviewer's
|
|
287
|
-
|
|
288
|
-
line, which turned a multi-point review into one unreadable
|
|
361
|
+
<!-- One card per correction round: the score on its own line, then the reviewer's verdict
|
|
362
|
+
as rendered markdown, then its graded findings worst first. The feedback used to trail
|
|
363
|
+
the score inside the same line, which turned a multi-point review into one unreadable
|
|
364
|
+
run of text; the findings used not to be rendered at all, so a "must fix" holding the
|
|
365
|
+
run was invisible to the person being asked to resolve it. -->
|
|
289
366
|
<ol class="mt-2 space-y-2">
|
|
290
367
|
<li
|
|
291
|
-
v-for="(v, i) in
|
|
368
|
+
v-for="({ verdict: v, findings }, i) in verdictRounds"
|
|
292
369
|
:key="i"
|
|
293
370
|
data-testid="companion-verdict"
|
|
294
371
|
class="relative rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2"
|
|
@@ -304,14 +381,36 @@ async function copyRunId() {
|
|
|
304
381
|
{{ i + 1 }}
|
|
305
382
|
</span>
|
|
306
383
|
<span :class="v.passed ? 'text-emerald-300' : 'text-amber-300'">
|
|
307
|
-
{{ pctOf(v.rating) }} {{ v
|
|
384
|
+
{{ pctOf(v.rating) }} {{ ratingMeetsBar(v) ? '≥' : '<' }} {{ pctOf(v.threshold) }}
|
|
308
385
|
</span>
|
|
309
386
|
</div>
|
|
310
387
|
<MarkdownProse
|
|
311
388
|
v-if="v.feedback"
|
|
312
389
|
:text="v.feedback"
|
|
390
|
+
data-testid="companion-verdict-summary"
|
|
313
391
|
class="mt-1.5 pe-6 text-[12px] leading-relaxed text-slate-300"
|
|
314
392
|
/>
|
|
393
|
+
<ul v-if="findings.length" class="mt-2 space-y-1.5">
|
|
394
|
+
<li
|
|
395
|
+
v-for="(finding, fi) in findings"
|
|
396
|
+
:key="fi"
|
|
397
|
+
data-testid="companion-finding"
|
|
398
|
+
class="flex gap-2"
|
|
399
|
+
>
|
|
400
|
+
<UBadge
|
|
401
|
+
:color="SEVERITY_COLOR[finding.grade.key]"
|
|
402
|
+
variant="subtle"
|
|
403
|
+
size="sm"
|
|
404
|
+
class="mt-px h-4 shrink-0"
|
|
405
|
+
>
|
|
406
|
+
{{ t(`panels.stepMeta.findingSeverity.${finding.grade.key}`, finding.grade) }}
|
|
407
|
+
</UBadge>
|
|
408
|
+
<MarkdownProse
|
|
409
|
+
:text="finding.body"
|
|
410
|
+
class="min-w-0 text-[12px] leading-relaxed text-slate-300"
|
|
411
|
+
/>
|
|
412
|
+
</li>
|
|
413
|
+
</ul>
|
|
315
414
|
</li>
|
|
316
415
|
</ol>
|
|
317
416
|
<p v-if="companionVerdicts.length > 1" class="mt-1 text-[11px] text-slate-500">
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { TaskSourceState } from '@cat-factory/contracts'
|
|
3
|
+
import { boardFromService, huntRequest } from './BugHuntModal.logic'
|
|
4
|
+
|
|
5
|
+
// What scopes a hunt. The rule is small and the failures are silent in both directions: a
|
|
6
|
+
// repo-backed tracker that sends a board would be refused on submit, and one that sent an empty
|
|
7
|
+
// string instead of an explicit null would read as a board named as blank.
|
|
8
|
+
|
|
9
|
+
function state(overrides: Partial<TaskSourceState> = {}): TaskSourceState {
|
|
10
|
+
return {
|
|
11
|
+
source: 'github',
|
|
12
|
+
label: 'GitHub Issues',
|
|
13
|
+
icon: 'i-lucide-github',
|
|
14
|
+
credentialFields: [],
|
|
15
|
+
refLabel: 'Issue URL',
|
|
16
|
+
refPlaceholder: 'acme/web#123',
|
|
17
|
+
available: true,
|
|
18
|
+
enabled: true,
|
|
19
|
+
ridesVcsProvider: 'github',
|
|
20
|
+
supportsIntake: true,
|
|
21
|
+
ignoredIntakePredicates: [],
|
|
22
|
+
repoBacked: true,
|
|
23
|
+
...overrides,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const FORM = { containerId: 'blk-1', board: '', issueType: '', labels: '' }
|
|
28
|
+
|
|
29
|
+
describe('boardFromService', () => {
|
|
30
|
+
it('follows what the SOURCE declares, not which source it is', () => {
|
|
31
|
+
expect(boardFromService(state({ source: 'acme:forge' }))).toBe(true)
|
|
32
|
+
expect(boardFromService(state({ source: 'jira', repoBacked: false }))).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('treats an unresolved source as having a board, so a control is still rendered', () => {
|
|
36
|
+
expect(boardFromService(undefined)).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('huntRequest', () => {
|
|
41
|
+
it('sends an explicit null board for a repo-backed tracker, whatever was typed before', () => {
|
|
42
|
+
const request = huntRequest({ ...FORM, source: state(), board: 'someone-else/web' })
|
|
43
|
+
|
|
44
|
+
// Null, never '' and never the stale text: the backend REFUSES a board named for such a
|
|
45
|
+
// source, so a hunt that carried one would be rejected rather than scoped.
|
|
46
|
+
expect(request).toEqual({ containerId: 'blk-1', board: null })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('sends the trimmed board a repo-less tracker names', () => {
|
|
50
|
+
const source = state({ source: 'jira', repoBacked: false })
|
|
51
|
+
|
|
52
|
+
expect(huntRequest({ ...FORM, source, board: ' PROJ ' })).toEqual({
|
|
53
|
+
containerId: 'blk-1',
|
|
54
|
+
board: 'PROJ',
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('describes no scan until a repo-less tracker has a board', () => {
|
|
59
|
+
const source = state({ source: 'jira', repoBacked: false })
|
|
60
|
+
|
|
61
|
+
expect(huntRequest({ ...FORM, source, board: ' ' })).toBeNull()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('describes no scan without a container, which decides the repository too', () => {
|
|
65
|
+
expect(huntRequest({ ...FORM, source: state(), containerId: undefined })).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('carries only the predicates that were actually filled in', () => {
|
|
69
|
+
const request = huntRequest({
|
|
70
|
+
...FORM,
|
|
71
|
+
source: state(),
|
|
72
|
+
issueType: ' defect ',
|
|
73
|
+
labels: 'regression, , checkout ',
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(request).toEqual({
|
|
77
|
+
containerId: 'blk-1',
|
|
78
|
+
board: null,
|
|
79
|
+
issueType: 'defect',
|
|
80
|
+
labels: ['regression', 'checkout'],
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { RunBugHuntInput, TaskSourceState } from '@cat-factory/contracts'
|
|
2
|
+
|
|
3
|
+
// The pure half of BugHuntModal: what SCOPES a hunt. Extracted for the reason every `*.logic.ts`
|
|
4
|
+
// here is (a decision worth a test should not need a mounted component to reach), and this one
|
|
5
|
+
// carries the whole rule the surface exists to enforce: a repo-backed tracker hunts the
|
|
6
|
+
// repository of the service the bug will land in, and names no board of its own.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether this tracker's board is the chosen service's repository rather than a choice.
|
|
10
|
+
*
|
|
11
|
+
* Read off the source's declared `repoBacked`, never its id: a deployment that registers its own
|
|
12
|
+
* repo-backed source, or one running GitLab instead of GitHub, must behave identically, and a
|
|
13
|
+
* source list compared here would be a second authority that drifts from the backend's.
|
|
14
|
+
* An unresolved source (still loading, or one this workspace no longer offers) is NOT repo-backed:
|
|
15
|
+
* the answer decides which control to render, and rendering none is the option a user cannot
|
|
16
|
+
* correct.
|
|
17
|
+
*/
|
|
18
|
+
export function boardFromService(source: TaskSourceState | undefined): boolean {
|
|
19
|
+
return source?.repoBacked === true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The scan request, or null when the form does not yet name one.
|
|
24
|
+
*
|
|
25
|
+
* `board` is explicitly `null` for a repo-backed tracker rather than an empty string: the backend
|
|
26
|
+
* REFUSES a board named for such a source instead of ignoring it, so the difference between "no
|
|
27
|
+
* board to name" and "a board named as blank" has to survive this far. The container is required
|
|
28
|
+
* either way: it is where an adopted bug lands, and on a repo-backed tracker it is also what
|
|
29
|
+
* decides which repository is read at all.
|
|
30
|
+
*/
|
|
31
|
+
export function huntRequest(input: {
|
|
32
|
+
source: TaskSourceState | undefined
|
|
33
|
+
containerId: string | undefined
|
|
34
|
+
board: string
|
|
35
|
+
issueType: string
|
|
36
|
+
labels: string
|
|
37
|
+
}): RunBugHuntInput | null {
|
|
38
|
+
const { containerId } = input
|
|
39
|
+
if (!input.source || !containerId) return null
|
|
40
|
+
const fromService = boardFromService(input.source)
|
|
41
|
+
const board = input.board.trim()
|
|
42
|
+
if (!fromService && !board) return null
|
|
43
|
+
const issueType = input.issueType.trim()
|
|
44
|
+
const labels = input.labels
|
|
45
|
+
.split(',')
|
|
46
|
+
.map((label) => label.trim())
|
|
47
|
+
.filter((label) => label.length > 0)
|
|
48
|
+
return {
|
|
49
|
+
containerId,
|
|
50
|
+
board: fromService ? null : board,
|
|
51
|
+
...(issueType ? { issueType } : {}),
|
|
52
|
+
...(labels.length ? { labels } : {}),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Bug hunt: pick a connected tracker,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Bug hunt: pick a connected tracker, scope the scan, and let the platform rank that board's
|
|
3
|
+
// open, UNASSIGNED bugs by impact against implementation complexity. Confirming a candidate
|
|
4
|
+
// adopts it as a bug task in the chosen container and starts the bug-fix pipeline.
|
|
5
|
+
//
|
|
6
|
+
// What SCOPES the scan depends on the tracker, and the source states which (`repoBacked`): a
|
|
7
|
+
// repo-backed one (GitHub Issues, GitLab Issues) hunts the repository the chosen service is
|
|
8
|
+
// linked to and offers NO board control, because its issues live in one repo per service and the
|
|
9
|
+
// only honest answer is the one the backend resolves. Every other tracker names a board of its
|
|
10
|
+
// own. Never a picker either way that could aim a hunt at a repository this board holds no
|
|
11
|
+
// service for.
|
|
5
12
|
//
|
|
6
13
|
// The interactive dual of the recurring bug-triage schedule: same reading and same pipeline,
|
|
7
14
|
// but a human picks the bug instead of the oldest match being claimed unattended.
|
|
@@ -37,11 +44,14 @@ import {
|
|
|
37
44
|
} from '~/utils/sourcePicker'
|
|
38
45
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
39
46
|
import { appliesIntakePredicate } from '~/utils/intakePredicates'
|
|
47
|
+
import { boardFromService as isBoardFromService, huntRequest } from './BugHuntModal.logic'
|
|
40
48
|
|
|
41
49
|
const { t, d, n } = useI18n()
|
|
42
50
|
const ui = useUiStore()
|
|
43
51
|
const tasks = useTasksStore()
|
|
44
52
|
const hunt = useBugHuntStore()
|
|
53
|
+
const board = useBoardStore()
|
|
54
|
+
const github = useGitHubStore()
|
|
45
55
|
const toast = useToast()
|
|
46
56
|
const { present } = usePipelineErrorToast()
|
|
47
57
|
|
|
@@ -130,6 +140,36 @@ const boardItems = computed(() =>
|
|
|
130
140
|
})),
|
|
131
141
|
)
|
|
132
142
|
|
|
143
|
+
/** This tracker's board is the chosen service's own repository (see the logic module). */
|
|
144
|
+
const boardFromService = computed(() => isBoardFromService(descriptor.value))
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* WHICH repository a repo-backed hunt will read, named before it runs.
|
|
148
|
+
*
|
|
149
|
+
* The premise of this whole branch is that the platform picks the board on the user's behalf, so
|
|
150
|
+
* withholding the value until the results block (`scannedBoard`) states it only after a billable
|
|
151
|
+
* scan has already been paid for. Resolved the way the backend resolves it — walk the chosen
|
|
152
|
+
* container up to its service frame, read that frame's repo link — so the two cannot name
|
|
153
|
+
* different repositories. Null while the projection is still loading or the service holds no
|
|
154
|
+
* link; the field then says what it always said, and the not-linked case stays the backend's to
|
|
155
|
+
* refuse (`boardNeedsRepo`), since an unloaded projection and an unlinked service look identical
|
|
156
|
+
* from here.
|
|
157
|
+
*/
|
|
158
|
+
const scopedRepo = computed(() => {
|
|
159
|
+
const container = containerId.value ? board.getBlock(containerId.value) : undefined
|
|
160
|
+
const frame = container ? board.serviceOf(container) : undefined
|
|
161
|
+
const repo = frame ? github.repoForBlock(frame.id) : undefined
|
|
162
|
+
return repo ? `${repo.owner}/${repo.name}` : null
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The service this hunt is scoped to has no repository linked, so it has no issues to read. The
|
|
167
|
+
* one scan failure worded here instead of in a toast: it names something to fix on this board,
|
|
168
|
+
* and it belongs beside the scope it invalidates.
|
|
169
|
+
*/
|
|
170
|
+
const REPO_NOT_LINKED: TaskSourceReadReason = 'repo_not_linked'
|
|
171
|
+
const huntNeedsRepo = computed(() => hunt.huntErrorReason === REPO_NOT_LINKED)
|
|
172
|
+
|
|
133
173
|
/**
|
|
134
174
|
* The tracker CANNOT enumerate boards, so the user types the scope in themselves. Keyed on the
|
|
135
175
|
* backend's reason code, not on "any error": an unreachable tracker or an expired token would
|
|
@@ -140,10 +180,30 @@ const boardIsFreeText = computed(
|
|
|
140
180
|
() => !hunt.boardsLoading && hunt.boardsErrorReason === BOARDS_UNSUPPORTED,
|
|
141
181
|
)
|
|
142
182
|
|
|
183
|
+
/**
|
|
184
|
+
* The refusals this surface words ITSELF, keyed on the backend's reason.
|
|
185
|
+
*
|
|
186
|
+
* The backend does not localize prose, so a reason with no entry here renders the server's
|
|
187
|
+
* untranslated English — the honest last resort for a cause this modal was not built to explain,
|
|
188
|
+
* and the wrong answer for one it was. Both entries are reachable only from a client that
|
|
189
|
+
* disagrees with the backend about which sources are repo-backed (a stale SPA build, a raced
|
|
190
|
+
* `repoBacked` read), which is exactly when a user is least served by raw backend prose.
|
|
191
|
+
* `repo_not_linked` is deliberately absent: it is not a message but a warning rendered beside the
|
|
192
|
+
* scope it invalidates.
|
|
193
|
+
*/
|
|
194
|
+
const REFUSAL_KEYS: Partial<Record<TaskSourceReadReason, string>> = {
|
|
195
|
+
board_from_service: 'bugHunt.refusal.boardFromService',
|
|
196
|
+
missing_board: 'bugHunt.refusal.missingBoard',
|
|
197
|
+
}
|
|
198
|
+
function refusalText(reason: string | null, fallback: string | null): string | null {
|
|
199
|
+
const key = reason ? REFUSAL_KEYS[reason as TaskSourceReadReason] : undefined
|
|
200
|
+
return key ? t(key) : fallback
|
|
201
|
+
}
|
|
202
|
+
|
|
143
203
|
/** A board read that failed for a reason the user has to fix — shown, never silently swallowed. */
|
|
144
204
|
const boardsFailure = computed(() =>
|
|
145
205
|
!hunt.boardsLoading && hunt.boardsError !== null && !boardIsFreeText.value
|
|
146
|
-
? hunt.boardsError
|
|
206
|
+
? refusalText(hunt.boardsErrorReason, hunt.boardsError)
|
|
147
207
|
: null,
|
|
148
208
|
)
|
|
149
209
|
|
|
@@ -157,7 +217,17 @@ function createdAtDate(createdAt: string): Date | null {
|
|
|
157
217
|
return Number.isNaN(parsed.getTime()) ? null : parsed
|
|
158
218
|
}
|
|
159
219
|
|
|
160
|
-
|
|
220
|
+
/** The scan this form currently describes, or null while it does not describe one. */
|
|
221
|
+
const request = computed(() =>
|
|
222
|
+
huntRequest({
|
|
223
|
+
source: descriptor.value,
|
|
224
|
+
containerId: containerId.value,
|
|
225
|
+
board: boardId.value,
|
|
226
|
+
issueType: issueType.value,
|
|
227
|
+
labels: labels.value,
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
230
|
+
const canHunt = computed(() => request.value !== null)
|
|
161
231
|
|
|
162
232
|
watch(open, (isOpen) => {
|
|
163
233
|
if (!isOpen) return
|
|
@@ -168,7 +238,7 @@ watch(open, (isOpen) => {
|
|
|
168
238
|
awaitingConnect.value = null
|
|
169
239
|
source.value = ui.bugHunt?.source ?? tasks.offeredSources[0]?.source ?? undefined
|
|
170
240
|
resetContainer()
|
|
171
|
-
|
|
241
|
+
loadBoardsFor(source.value)
|
|
172
242
|
})
|
|
173
243
|
|
|
174
244
|
// Switching tracker invalidates both the board list and any ranking already on screen: the
|
|
@@ -176,24 +246,40 @@ watch(open, (isOpen) => {
|
|
|
176
246
|
watch(source, (next) => {
|
|
177
247
|
boardId.value = ''
|
|
178
248
|
hunt.reset()
|
|
179
|
-
|
|
249
|
+
loadBoardsFor(next)
|
|
180
250
|
})
|
|
181
251
|
|
|
252
|
+
// For a repo-backed tracker the service IS the board, so moving the hunt to another service moves
|
|
253
|
+
// it to another repository: the shortlist on screen belongs to the old one and must go with it.
|
|
254
|
+
// A repo-less tracker keeps its results, since the container only decides where an adopted bug
|
|
255
|
+
// lands and the scan is still of the board that was asked for.
|
|
256
|
+
watch(containerId, () => {
|
|
257
|
+
if (boardFromService.value) hunt.reset()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Boards are listed only for a tracker that HAS a board to choose; asking otherwise is refused
|
|
262
|
+
* server-side. The other branch is not a no-op: the previous tracker's list (or the warning its
|
|
263
|
+
* failed listing left behind) has to go, or it renders under a tracker with no board field.
|
|
264
|
+
*/
|
|
265
|
+
function loadBoardsFor(next: TaskSourceKind | undefined) {
|
|
266
|
+
if (!next) return
|
|
267
|
+
if (isBoardFromService(tasks.descriptorFor(next))) {
|
|
268
|
+
hunt.dropBoards(next)
|
|
269
|
+
// The repo projection is lazy and nothing on the board opens it, so the field that names the
|
|
270
|
+
// repository this hunt will read asks for it here — only on the branch that has one.
|
|
271
|
+
void github.ensureLoaded().catch(() => {})
|
|
272
|
+
} else hunt.loadBoards(next)
|
|
273
|
+
}
|
|
274
|
+
|
|
182
275
|
async function runHunt() {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
.filter((l) => l.length > 0)
|
|
188
|
-
const ok = await hunt.hunt(source.value, {
|
|
189
|
-
board: boardId.value.trim(),
|
|
190
|
-
...(issueType.value.trim() ? { issueType: issueType.value.trim() } : {}),
|
|
191
|
-
...(parsedLabels.length ? { labels: parsedLabels } : {}),
|
|
192
|
-
})
|
|
193
|
-
if (!ok) {
|
|
276
|
+
const input = request.value
|
|
277
|
+
if (!source.value || !input) return
|
|
278
|
+
const ok = await hunt.hunt(source.value, input)
|
|
279
|
+
if (!ok && !huntNeedsRepo.value) {
|
|
194
280
|
toast.add({
|
|
195
281
|
title: t('bugHunt.huntFailed'),
|
|
196
|
-
description: hunt.huntError ?? undefined,
|
|
282
|
+
description: refusalText(hunt.huntErrorReason, hunt.huntError) ?? undefined,
|
|
197
283
|
icon: 'i-lucide-triangle-alert',
|
|
198
284
|
color: 'error',
|
|
199
285
|
})
|
|
@@ -306,10 +392,21 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
|
|
|
306
392
|
</UFormField>
|
|
307
393
|
|
|
308
394
|
<UFormField :label="t('bugHunt.board')">
|
|
395
|
+
<!-- This tracker's issues belong to one repository per service, so the board is
|
|
396
|
+
STATED rather than asked: the repository the service below is linked to. No
|
|
397
|
+
control at all, because every value one could offer here is either that repo
|
|
398
|
+
(nothing to choose) or another one this board holds no service for. -->
|
|
399
|
+
<p
|
|
400
|
+
v-if="boardFromService"
|
|
401
|
+
class="flex items-center gap-1.5 py-1 text-sm text-slate-300"
|
|
402
|
+
>
|
|
403
|
+
<UIcon name="i-lucide-folder-git-2" class="h-4 w-4 shrink-0" />
|
|
404
|
+
<span class="truncate">{{ scopedRepo ?? t('bugHunt.boardFromService') }}</span>
|
|
405
|
+
</p>
|
|
309
406
|
<!-- A tracker that can't enumerate its boards gets a free-text field rather than
|
|
310
407
|
an empty picker, so the hunt is still usable. -->
|
|
311
408
|
<UInput
|
|
312
|
-
v-if="boardIsFreeText"
|
|
409
|
+
v-else-if="boardIsFreeText"
|
|
313
410
|
v-model="boardId"
|
|
314
411
|
:placeholder="t('bugHunt.boardPlaceholder')"
|
|
315
412
|
class="w-full"
|
|
@@ -322,9 +419,14 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
|
|
|
322
419
|
:placeholder="t('bugHunt.pickBoard')"
|
|
323
420
|
class="w-full"
|
|
324
421
|
/>
|
|
422
|
+
<!-- The service holds no repository, so there are no issues to read. Said here
|
|
423
|
+
rather than in a toast: it invalidates the scope named right above it. -->
|
|
424
|
+
<p v-if="huntNeedsRepo" class="mt-1 text-xs text-amber-400">
|
|
425
|
+
{{ t('bugHunt.boardNeedsRepo') }}
|
|
426
|
+
</p>
|
|
325
427
|
<!-- A board read that failed for a fixable reason (unreachable site, expired
|
|
326
428
|
token): named, so the user isn't left with an empty picker and no cause. -->
|
|
327
|
-
<p v-if="boardsFailure" class="mt-1 text-xs text-amber-400">
|
|
429
|
+
<p v-else-if="boardsFailure" class="mt-1 text-xs text-amber-400">
|
|
328
430
|
{{ t('bugHunt.boardsFailed', { reason: boardsFailure }) }}
|
|
329
431
|
</p>
|
|
330
432
|
</UFormField>
|
|
@@ -347,16 +449,32 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
|
|
|
347
449
|
</UFormField>
|
|
348
450
|
</div>
|
|
349
451
|
|
|
350
|
-
<!-- Where an adopted bug lands
|
|
351
|
-
|
|
352
|
-
|
|
452
|
+
<!-- Where an adopted bug lands, and on a repo-backed tracker WHICH REPOSITORY is
|
|
453
|
+
scanned, so the wording says both rather than leaving the scope unexplained. Stated
|
|
454
|
+
when the frame this hunt was opened from is the only legal target; a choice (scoped
|
|
455
|
+
to that frame) when it has modules, or over the whole board when the hunt was opened
|
|
456
|
+
standalone. -->
|
|
457
|
+
<!-- Two blocks rather than one with a computed `keypath`: the i18n extractor reads a
|
|
458
|
+
bound keypath as the key itself, so a dynamic one is a key missing from every
|
|
459
|
+
locale. Every other `<i18n-t>` in the SPA names its key statically for that reason. -->
|
|
353
460
|
<p v-if="containerStated" class="text-xs text-slate-400">
|
|
354
|
-
<i18n-t keypath="bugHunt.
|
|
461
|
+
<i18n-t v-if="boardFromService" keypath="bugHunt.huntingIn" tag="span" scope="global">
|
|
462
|
+
<template #container>
|
|
463
|
+
<span class="font-medium text-slate-200">{{ pinnedContainer!.title }}</span>
|
|
464
|
+
</template>
|
|
465
|
+
</i18n-t>
|
|
466
|
+
<i18n-t v-else keypath="bugHunt.adoptingInto" tag="span" scope="global">
|
|
355
467
|
<template #container>
|
|
356
468
|
<span class="font-medium text-slate-200">{{ pinnedContainer!.title }}</span>
|
|
357
469
|
</template>
|
|
358
470
|
</i18n-t>
|
|
359
471
|
</p>
|
|
472
|
+
<!-- Two fields rather than one with a computed key, for the reason the two blocks above
|
|
473
|
+
are two: the i18n extractor reads a bound key as the key itself, so a dynamic one
|
|
474
|
+
leaves BOTH real keys unreferenced and a dead-key sweep prunes them. -->
|
|
475
|
+
<UFormField v-else-if="boardFromService" :label="t('bugHunt.huntIn')">
|
|
476
|
+
<USelect v-model="containerId" :items="containerItems" class="w-full" />
|
|
477
|
+
</UFormField>
|
|
360
478
|
<UFormField v-else :label="t('bugHunt.adoptInto')">
|
|
361
479
|
<USelect v-model="containerId" :items="containerItems" class="w-full" />
|
|
362
480
|
</UFormField>
|
|
@@ -379,6 +497,12 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
|
|
|
379
497
|
<!-- Results -->
|
|
380
498
|
<div v-if="hunt.hasResult" class="space-y-3 border-t border-slate-800 pt-3">
|
|
381
499
|
<p class="text-xs text-slate-400">
|
|
500
|
+
<!-- The board the scan actually ran against, named because on a repo-backed tracker
|
|
501
|
+
the platform resolved it: the person reading the shortlist should not have to
|
|
502
|
+
infer which repository it came out of. -->
|
|
503
|
+
<span class="text-slate-500">
|
|
504
|
+
{{ t('bugHunt.scannedBoard', { board: hunt.result!.board }) }}
|
|
505
|
+
</span>
|
|
382
506
|
{{ t(STATUS_KEYS[hunt.result!.analysisStatus]) }}
|
|
383
507
|
<span v-if="hunt.result!.model" class="text-slate-500">
|
|
384
508
|
{{ t('bugHunt.viaModel', { model: hunt.result!.model }) }}
|