@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.
@@ -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
- the shared iteration-cap decision (one more round / proceed with the current
497
- output / stop & reset). One prompt, two headings — the choices are the same but
498
- the reason is not, and the spent-limit wording is untrue of a stalled loop. -->
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
- companionStalled
517
- ? t('panels.stepDetail.companionStalledDetail')
518
- : t('panels.stepDetail.companionCapDetail')
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?.passed ? '≥' : '<' }} {{ pctOf(latestVerdict!.threshold) }}
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
- challenge as rendered markdown. The feedback used to trail the score inside the same
288
- line, which turned a multi-point review into one unreadable run of text. -->
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 companionVerdicts"
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.passed ? '≥' : '<' }} {{ pctOf(v.threshold) }}
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, pick one of its boards, and let the platform rank that
3
- // board's open, UNASSIGNED bugs by impact against implementation complexity. Confirming a
4
- // candidate adopts it as a bug task in the chosen container and starts the bug-fix pipeline.
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
- const canHunt = computed(() => !!source.value && boardId.value.trim().length > 0)
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
- if (source.value) hunt.loadBoards(source.value)
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
- if (next) hunt.loadBoards(next)
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
- if (!source.value || !canHunt.value) return
184
- const parsedLabels = labels.value
185
- .split(',')
186
- .map((l) => l.trim())
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. Stated when the frame this hunt was opened from is the
351
- only legal target; a choice (scoped to that frame) when it has modules, or over the
352
- whole board when the hunt was opened standalone. -->
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.adoptingInto" tag="span" scope="global">
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 }) }}