@cat-factory/app 0.87.4 → 0.88.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/board/RecurringPipelineModal.vue +162 -2
- package/app/components/initiative/InitiativeTrackerWindow.vue +249 -11
- package/app/components/panels/MergerResultView.vue +1 -0
- package/app/components/panels/inspector/ServiceFragments.vue +11 -15
- package/app/components/panels/inspector/TaskStructure.vue +15 -15
- package/app/components/settings/ServiceFragmentDefaultsPanel.vue +3 -9
- package/app/composables/api/initiative.ts +51 -0
- package/app/stores/initiative.ts +82 -1
- package/app/types/initiative.ts +3 -0
- package/app/types/recurring.ts +1 -0
- package/app/utils/catalog.ts +6 -4
- package/app/utils/fragmentPicker.spec.ts +60 -0
- package/app/utils/fragmentPicker.ts +32 -0
- package/app/utils/initiative.ts +27 -4
- package/i18n/locales/en.json +33 -2
- package/i18n/locales/es.json +33 -2
- package/i18n/locales/fr.json +33 -2
- package/i18n/locales/he.json +33 -2
- package/i18n/locales/ja.json +33 -2
- package/i18n/locales/pl.json +33 -2
- package/i18n/locales/tr.json +33 -2
- package/i18n/locales/uk.json +33 -2
- package/package.json +2 -2
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// task block inside the frame that the schedule re-runs. When the Tech-debt
|
|
6
6
|
// pipeline is picked, the workspace issue-tracker choice is surfaced inline (it is
|
|
7
7
|
// where that pipeline files its ticket) and saved alongside.
|
|
8
|
-
import type { Recurrence, ScheduleTemplate } from '~/types/recurring'
|
|
8
|
+
import type { IssueIntakeConfig, Recurrence, ScheduleTemplate } from '~/types/recurring'
|
|
9
|
+
import type { TaskSourceKind } from '~/types/domain'
|
|
9
10
|
import { pipelineAllowedForSchedule } from '~/utils/pipeline'
|
|
10
11
|
|
|
11
12
|
const ui = useUiStore()
|
|
@@ -13,6 +14,7 @@ const board = useBoardStore()
|
|
|
13
14
|
const pipelines = usePipelinesStore()
|
|
14
15
|
const recurring = useRecurringPipelinesStore()
|
|
15
16
|
const tracker = useTrackerStore()
|
|
17
|
+
const tasks = useTasksStore()
|
|
16
18
|
const toast = useToast()
|
|
17
19
|
const { t } = useI18n()
|
|
18
20
|
|
|
@@ -41,6 +43,17 @@ const trackerKind = ref<'github' | 'jira' | 'linear' | null>(null)
|
|
|
41
43
|
const jiraProjectKey = ref('')
|
|
42
44
|
const linearTeamId = ref('')
|
|
43
45
|
|
|
46
|
+
// Issue-intake config (only relevant when the picked pipeline has a `bug-intake` step). Which
|
|
47
|
+
// tracker board + predicates a recurring bug-triage run pulls its one issue from, per-schedule.
|
|
48
|
+
const intakeSource = ref<TaskSourceKind | null>(null)
|
|
49
|
+
const intakeJiraProjectKey = ref('')
|
|
50
|
+
const intakeLinearTeamId = ref('')
|
|
51
|
+
const intakeGithubRepo = ref('')
|
|
52
|
+
const intakeTitleFragment = ref('')
|
|
53
|
+
const intakeLabels = ref('') // comma-separated in the UI, sent as an array
|
|
54
|
+
const intakeIssueType = ref('')
|
|
55
|
+
const intakeInProgressLabel = ref('')
|
|
56
|
+
|
|
44
57
|
function defaultRecurrence(): Recurrence {
|
|
45
58
|
return {
|
|
46
59
|
intervalHours: 168, // weekly
|
|
@@ -77,6 +90,19 @@ const template = computed<ScheduleTemplate>(() => {
|
|
|
77
90
|
})
|
|
78
91
|
const isTechDebt = computed(() => template.value === 'tech-debt')
|
|
79
92
|
|
|
93
|
+
// A pipeline whose ENABLED steps include `bug-intake` pulls its work from the tracker board, so
|
|
94
|
+
// the intake config is surfaced + required. Mirrors the backend `pipelineHasEnabledBugIntake`
|
|
95
|
+
// (a disabled step imposes nothing), so the modal doesn't demand config for a step that won't run.
|
|
96
|
+
const isBugIntake = computed(() => {
|
|
97
|
+
const pipeline = selectedPipeline.value
|
|
98
|
+
if (!pipeline) return false
|
|
99
|
+
return pipeline.agentKinds.some(
|
|
100
|
+
(kind, i) => kind === 'bug-intake' && pipeline.enabled?.[i] !== false,
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
// Sources that can back intake right now (connected / App-installed AND enabled).
|
|
104
|
+
const intakeSources = computed(() => tasks.offeredSources)
|
|
105
|
+
|
|
80
106
|
watch(open, (isOpen) => {
|
|
81
107
|
if (!isOpen) return
|
|
82
108
|
name.value = ''
|
|
@@ -92,9 +118,62 @@ watch(open, (isOpen) => {
|
|
|
92
118
|
trackerKind.value = tracker.settings.tracker
|
|
93
119
|
jiraProjectKey.value = tracker.settings.jiraProjectKey ?? ''
|
|
94
120
|
linearTeamId.value = tracker.settings.linearTeamId ?? ''
|
|
121
|
+
intakeSource.value = null
|
|
122
|
+
intakeJiraProjectKey.value = ''
|
|
123
|
+
intakeLinearTeamId.value = ''
|
|
124
|
+
intakeGithubRepo.value = ''
|
|
125
|
+
intakeTitleFragment.value = ''
|
|
126
|
+
intakeLabels.value = ''
|
|
127
|
+
intakeIssueType.value = ''
|
|
128
|
+
intakeInProgressLabel.value = ''
|
|
129
|
+
// Load the connected task sources so the intake source picker is populated.
|
|
130
|
+
void tasks.probe()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// The board field required for the picked source must be filled before a bug-intake schedule saves.
|
|
134
|
+
const intakeReady = computed(() => {
|
|
135
|
+
if (!isBugIntake.value) return true
|
|
136
|
+
if (intakeSource.value === 'jira') return intakeJiraProjectKey.value.trim().length > 0
|
|
137
|
+
if (intakeSource.value === 'linear') return intakeLinearTeamId.value.trim().length > 0
|
|
138
|
+
if (intakeSource.value === 'github') return intakeGithubRepo.value.trim().length > 0
|
|
139
|
+
return false
|
|
95
140
|
})
|
|
96
141
|
|
|
97
|
-
|
|
142
|
+
function buildIssueIntake(): IssueIntakeConfig {
|
|
143
|
+
const source = intakeSource.value as TaskSourceKind
|
|
144
|
+
const labels = intakeLabels.value
|
|
145
|
+
.split(',')
|
|
146
|
+
.map((l) => l.trim())
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
return {
|
|
149
|
+
source,
|
|
150
|
+
board: {
|
|
151
|
+
...(source === 'jira' && intakeJiraProjectKey.value.trim()
|
|
152
|
+
? { jiraProjectKey: intakeJiraProjectKey.value.trim() }
|
|
153
|
+
: {}),
|
|
154
|
+
...(source === 'linear' && intakeLinearTeamId.value.trim()
|
|
155
|
+
? { linearTeamId: intakeLinearTeamId.value.trim() }
|
|
156
|
+
: {}),
|
|
157
|
+
...(source === 'github' && intakeGithubRepo.value.trim()
|
|
158
|
+
? { githubRepo: intakeGithubRepo.value.trim() }
|
|
159
|
+
: {}),
|
|
160
|
+
},
|
|
161
|
+
predicates: {
|
|
162
|
+
...(intakeTitleFragment.value.trim()
|
|
163
|
+
? { titleFragment: intakeTitleFragment.value.trim() }
|
|
164
|
+
: {}),
|
|
165
|
+
...(labels.length ? { labels } : {}),
|
|
166
|
+
...(intakeIssueType.value.trim() ? { issueType: intakeIssueType.value.trim() } : {}),
|
|
167
|
+
},
|
|
168
|
+
...(source === 'github' && intakeInProgressLabel.value.trim()
|
|
169
|
+
? { inProgressLabel: intakeInProgressLabel.value.trim() }
|
|
170
|
+
: {}),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const canAdd = computed(
|
|
175
|
+
() => name.value.trim().length > 0 && pipelineId.value.length > 0 && intakeReady.value,
|
|
176
|
+
)
|
|
98
177
|
|
|
99
178
|
async function add() {
|
|
100
179
|
const frameId = ui.addRecurringFrameId
|
|
@@ -119,6 +198,7 @@ async function add() {
|
|
|
119
198
|
onDemand: onDemand.value,
|
|
120
199
|
...(onDemand.value ? {} : { recurrence: recurrence.value }),
|
|
121
200
|
...(description.value.trim() ? { description: description.value.trim() } : {}),
|
|
201
|
+
...(isBugIntake.value ? { issueIntake: buildIssueIntake() } : {}),
|
|
122
202
|
})
|
|
123
203
|
ui.closeAddRecurring()
|
|
124
204
|
} catch (e) {
|
|
@@ -238,6 +318,86 @@ async function add() {
|
|
|
238
318
|
</UFormField>
|
|
239
319
|
</div>
|
|
240
320
|
|
|
321
|
+
<div v-if="isBugIntake" class="space-y-3 rounded-lg border border-slate-800 p-3">
|
|
322
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
323
|
+
{{ t('board.recurring.intake') }}
|
|
324
|
+
</p>
|
|
325
|
+
<p class="text-[11px] text-slate-500">
|
|
326
|
+
{{ t('board.recurring.intakeHint') }}
|
|
327
|
+
</p>
|
|
328
|
+
<p v-if="intakeSources.length === 0" class="text-[11px] text-amber-500">
|
|
329
|
+
{{ t('board.recurring.intakeNoSources') }}
|
|
330
|
+
</p>
|
|
331
|
+
<div v-else class="flex flex-wrap gap-1">
|
|
332
|
+
<UButton
|
|
333
|
+
v-for="s in intakeSources"
|
|
334
|
+
:key="s.source"
|
|
335
|
+
size="xs"
|
|
336
|
+
:color="intakeSource === s.source ? 'primary' : 'neutral'"
|
|
337
|
+
:variant="intakeSource === s.source ? 'solid' : 'subtle'"
|
|
338
|
+
:icon="s.icon"
|
|
339
|
+
@click="intakeSource = s.source"
|
|
340
|
+
>
|
|
341
|
+
{{ s.label }}
|
|
342
|
+
</UButton>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<UFormField
|
|
346
|
+
v-if="intakeSource === 'jira'"
|
|
347
|
+
:label="t('board.recurring.jiraProjectKey')"
|
|
348
|
+
required
|
|
349
|
+
>
|
|
350
|
+
<UInput
|
|
351
|
+
v-model="intakeJiraProjectKey"
|
|
352
|
+
:placeholder="t('board.recurring.jiraProjectKeyPlaceholder')"
|
|
353
|
+
class="w-full"
|
|
354
|
+
/>
|
|
355
|
+
</UFormField>
|
|
356
|
+
<UFormField
|
|
357
|
+
v-if="intakeSource === 'linear'"
|
|
358
|
+
:label="t('board.recurring.linearTeamId')"
|
|
359
|
+
required
|
|
360
|
+
>
|
|
361
|
+
<UInput v-model="intakeLinearTeamId" placeholder="team_…" class="w-full" />
|
|
362
|
+
</UFormField>
|
|
363
|
+
<UFormField
|
|
364
|
+
v-if="intakeSource === 'github'"
|
|
365
|
+
:label="t('board.recurring.intakeGithubRepo')"
|
|
366
|
+
required
|
|
367
|
+
>
|
|
368
|
+
<!-- A GitHub repo ref is always the literal `owner/name` path, never localized. -->
|
|
369
|
+
<UInput v-model="intakeGithubRepo" placeholder="owner/name" class="w-full" />
|
|
370
|
+
</UFormField>
|
|
371
|
+
|
|
372
|
+
<template v-if="intakeSource">
|
|
373
|
+
<UFormField :label="t('board.recurring.intakeTitleFragment')">
|
|
374
|
+
<UInput
|
|
375
|
+
v-model="intakeTitleFragment"
|
|
376
|
+
:placeholder="t('board.recurring.intakeTitleFragmentPlaceholder')"
|
|
377
|
+
class="w-full"
|
|
378
|
+
/>
|
|
379
|
+
</UFormField>
|
|
380
|
+
<UFormField :label="t('board.recurring.intakeLabels')">
|
|
381
|
+
<UInput
|
|
382
|
+
v-model="intakeLabels"
|
|
383
|
+
:placeholder="t('board.recurring.intakeLabelsPlaceholder')"
|
|
384
|
+
class="w-full"
|
|
385
|
+
/>
|
|
386
|
+
</UFormField>
|
|
387
|
+
<UFormField :label="t('board.recurring.intakeIssueType')">
|
|
388
|
+
<!-- A literal issue-type example (tracker vocabulary), kept verbatim across locales. -->
|
|
389
|
+
<UInput v-model="intakeIssueType" placeholder="bug" class="w-full" />
|
|
390
|
+
</UFormField>
|
|
391
|
+
<UFormField
|
|
392
|
+
v-if="intakeSource === 'github'"
|
|
393
|
+
:label="t('board.recurring.intakeInProgressLabel')"
|
|
394
|
+
>
|
|
395
|
+
<!-- A literal label example, kept verbatim across locales. -->
|
|
396
|
+
<UInput v-model="intakeInProgressLabel" placeholder="in-progress" class="w-full" />
|
|
397
|
+
</UFormField>
|
|
398
|
+
</template>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
241
401
|
<p class="text-[11px] text-slate-500">
|
|
242
402
|
{{ t('board.recurring.footerHint') }}
|
|
243
403
|
</p>
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// result-view host: from the board card / inspector (`ui.openInitiativeTracker`) or
|
|
8
8
|
// as the planner step's result view. Live `initiative` stream events patch the
|
|
9
9
|
// store, so an open window follows the plan as it is ingested and later executed.
|
|
10
|
-
import { computed } from 'vue'
|
|
11
|
-
import type { InitiativeItem } from '~/types/domain'
|
|
10
|
+
import { computed, reactive, ref } from 'vue'
|
|
11
|
+
import type { InitiativeFollowUp, InitiativeItem } from '~/types/domain'
|
|
12
12
|
import {
|
|
13
|
+
INITIATIVE_FOLLOWUP_STATUS_CHIPS,
|
|
14
|
+
INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS,
|
|
13
15
|
INITIATIVE_ITEM_STATUS_CHIPS,
|
|
14
16
|
INITIATIVE_ITEM_STATUS_LABEL_KEYS,
|
|
15
17
|
INITIATIVE_STATUS_LABEL_KEYS,
|
|
@@ -19,6 +21,7 @@ import {
|
|
|
19
21
|
const board = useBoardStore()
|
|
20
22
|
const initiatives = useInitiativesStore()
|
|
21
23
|
const { t } = useI18n()
|
|
24
|
+
const toast = useToast()
|
|
22
25
|
|
|
23
26
|
const { open, blockId, close } = useResultView('initiative-tracker', {
|
|
24
27
|
onOpen: (id) => void initiatives.load(id),
|
|
@@ -52,6 +55,93 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
|
|
|
52
55
|
].filter((a): a is string => a !== null)
|
|
53
56
|
return axes.length ? axes.join(' · ') : t('initiative.tracker.axisNever')
|
|
54
57
|
}
|
|
58
|
+
|
|
59
|
+
// ---- Curation (slice 4): only meaningful while the initiative is still executing ----
|
|
60
|
+
const editable = computed(() => initiative.value?.status === 'executing')
|
|
61
|
+
|
|
62
|
+
/** Report a failed curation call as a toast (a stale-rev CAS conflict, an illegal edit, …). */
|
|
63
|
+
function reportError(error: unknown) {
|
|
64
|
+
const message = error instanceof Error ? error.message : t('initiative.curation.failed')
|
|
65
|
+
toast.add({ title: t('initiative.curation.failed'), description: message, color: 'error' })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Follow-up promotion: an inline per-follow-up form (phase + optional title override).
|
|
69
|
+
const promotingId = ref<string | null>(null)
|
|
70
|
+
const promoteForm = reactive<{ phaseId: string; title: string }>({ phaseId: '', title: '' })
|
|
71
|
+
|
|
72
|
+
function startPromote(followUp: InitiativeFollowUp) {
|
|
73
|
+
const sourcePhase = (initiative.value?.items ?? []).find(
|
|
74
|
+
(i) => i.id === followUp.sourceItemId,
|
|
75
|
+
)?.phaseId
|
|
76
|
+
promoteForm.phaseId = sourcePhase ?? phases.value[0]?.id ?? ''
|
|
77
|
+
promoteForm.title = followUp.title
|
|
78
|
+
promotingId.value = followUp.id
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function submitPromote(followUp: InitiativeFollowUp) {
|
|
82
|
+
if (!initiative.value || !promoteForm.phaseId) return
|
|
83
|
+
try {
|
|
84
|
+
await initiatives.promoteFollowUp(initiative.value.id, followUp.id, {
|
|
85
|
+
phaseId: promoteForm.phaseId,
|
|
86
|
+
...(promoteForm.title.trim() && promoteForm.title.trim() !== followUp.title
|
|
87
|
+
? { title: promoteForm.title.trim() }
|
|
88
|
+
: {}),
|
|
89
|
+
})
|
|
90
|
+
promotingId.value = null
|
|
91
|
+
} catch (error) {
|
|
92
|
+
reportError(error)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function dismissFollowUp(followUp: InitiativeFollowUp) {
|
|
97
|
+
if (!initiative.value) return
|
|
98
|
+
try {
|
|
99
|
+
await initiatives.dismissFollowUp(initiative.value.id, followUp.id)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
reportError(error)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Item status control: retry a blocked item, or skip a pending/blocked one.
|
|
106
|
+
async function itemAction(item: InitiativeItem, action: 'retry' | 'skip') {
|
|
107
|
+
if (!initiative.value) return
|
|
108
|
+
try {
|
|
109
|
+
await initiatives.updateItem(initiative.value.id, item.id, { action })
|
|
110
|
+
} catch (error) {
|
|
111
|
+
reportError(error)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Policy editing: retune the two scalar knobs (concurrency + default pipeline) while preserving
|
|
116
|
+
// the planner-authored rules. A full rule editor stays out of scope — re-plan to reshape rules.
|
|
117
|
+
const editingPolicy = ref(false)
|
|
118
|
+
const policyForm = reactive<{ maxConcurrent: number; defaultPipelineId: string }>({
|
|
119
|
+
maxConcurrent: 1,
|
|
120
|
+
defaultPipelineId: '',
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
function startEditPolicy() {
|
|
124
|
+
const policy = initiative.value?.policy
|
|
125
|
+
if (!policy) return
|
|
126
|
+
policyForm.maxConcurrent = policy.maxConcurrent
|
|
127
|
+
policyForm.defaultPipelineId = policy.defaultPipelineId
|
|
128
|
+
editingPolicy.value = true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function savePolicy() {
|
|
132
|
+
const policy = initiative.value?.policy
|
|
133
|
+
if (!initiative.value || !policy) return
|
|
134
|
+
try {
|
|
135
|
+
await initiatives.updatePolicy(initiative.value.id, {
|
|
136
|
+
...policy,
|
|
137
|
+
maxConcurrent: policyForm.maxConcurrent,
|
|
138
|
+
defaultPipelineId: policyForm.defaultPipelineId.trim() || policy.defaultPipelineId,
|
|
139
|
+
})
|
|
140
|
+
editingPolicy.value = false
|
|
141
|
+
} catch (error) {
|
|
142
|
+
reportError(error)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
55
145
|
</script>
|
|
56
146
|
|
|
57
147
|
<template>
|
|
@@ -193,10 +283,34 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
|
|
|
193
283
|
<div v-if="item.note" class="mt-0.5 text-[10px] text-amber-300/80">
|
|
194
284
|
{{ item.note }}
|
|
195
285
|
</div>
|
|
286
|
+
<div
|
|
287
|
+
v-if="
|
|
288
|
+
editable && (item.status === 'blocked' || item.status === 'pending')
|
|
289
|
+
"
|
|
290
|
+
class="mt-1 flex gap-1.5"
|
|
291
|
+
>
|
|
292
|
+
<button
|
|
293
|
+
v-if="item.status === 'blocked'"
|
|
294
|
+
class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800 disabled:opacity-50"
|
|
295
|
+
:disabled="initiatives.curating"
|
|
296
|
+
:data-testid="`initiative-item-retry-${item.id}`"
|
|
297
|
+
@click="itemAction(item, 'retry')"
|
|
298
|
+
>
|
|
299
|
+
{{ t('initiative.curation.retry') }}
|
|
300
|
+
</button>
|
|
301
|
+
<button
|
|
302
|
+
class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800 disabled:opacity-50"
|
|
303
|
+
:disabled="initiatives.curating"
|
|
304
|
+
:data-testid="`initiative-item-skip-${item.id}`"
|
|
305
|
+
@click="itemAction(item, 'skip')"
|
|
306
|
+
>
|
|
307
|
+
{{ t('initiative.curation.skip') }}
|
|
308
|
+
</button>
|
|
309
|
+
</div>
|
|
196
310
|
</td>
|
|
197
311
|
<td class="px-3 py-2 align-top">
|
|
198
312
|
<UBadge
|
|
199
|
-
:color="INITIATIVE_ITEM_STATUS_CHIPS[item.status]
|
|
313
|
+
:color="INITIATIVE_ITEM_STATUS_CHIPS[item.status]"
|
|
200
314
|
variant="subtle"
|
|
201
315
|
size="sm"
|
|
202
316
|
>
|
|
@@ -225,10 +339,20 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
|
|
|
225
339
|
|
|
226
340
|
<!-- Execution policy -->
|
|
227
341
|
<section v-if="initiative.policy" class="mb-4">
|
|
228
|
-
<
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
342
|
+
<div class="mb-1 flex items-center gap-2">
|
|
343
|
+
<h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
344
|
+
{{ t('initiative.tracker.policy') }}
|
|
345
|
+
</h3>
|
|
346
|
+
<button
|
|
347
|
+
v-if="editable && !editingPolicy"
|
|
348
|
+
class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800"
|
|
349
|
+
data-testid="initiative-policy-edit"
|
|
350
|
+
@click="startEditPolicy"
|
|
351
|
+
>
|
|
352
|
+
{{ t('initiative.curation.edit') }}
|
|
353
|
+
</button>
|
|
354
|
+
</div>
|
|
355
|
+
<ul v-if="!editingPolicy" class="text-[12px] text-slate-300">
|
|
232
356
|
<li>
|
|
233
357
|
{{
|
|
234
358
|
t('initiative.tracker.maxConcurrent', {
|
|
@@ -245,6 +369,45 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
|
|
|
245
369
|
<code class="text-sky-300">{{ initiative.policy.defaultPipelineId }}</code>
|
|
246
370
|
</li>
|
|
247
371
|
</ul>
|
|
372
|
+
<!-- Edit form: the two scalar knobs; planner-authored rules are preserved. -->
|
|
373
|
+
<div v-else class="flex flex-col gap-2 rounded-lg border border-slate-800 p-3">
|
|
374
|
+
<label class="flex items-center gap-2 text-[12px] text-slate-300">
|
|
375
|
+
<span class="w-40">{{ t('initiative.curation.maxConcurrentField') }}</span>
|
|
376
|
+
<input
|
|
377
|
+
v-model.number="policyForm.maxConcurrent"
|
|
378
|
+
type="number"
|
|
379
|
+
min="1"
|
|
380
|
+
max="20"
|
|
381
|
+
class="w-20 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-slate-200"
|
|
382
|
+
data-testid="initiative-policy-max-concurrent"
|
|
383
|
+
/>
|
|
384
|
+
</label>
|
|
385
|
+
<label class="flex items-center gap-2 text-[12px] text-slate-300">
|
|
386
|
+
<span class="w-40">{{ t('initiative.curation.defaultPipelineField') }}</span>
|
|
387
|
+
<input
|
|
388
|
+
v-model="policyForm.defaultPipelineId"
|
|
389
|
+
type="text"
|
|
390
|
+
class="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 font-mono text-[11px] text-slate-200"
|
|
391
|
+
data-testid="initiative-policy-default-pipeline"
|
|
392
|
+
/>
|
|
393
|
+
</label>
|
|
394
|
+
<div class="flex gap-2">
|
|
395
|
+
<button
|
|
396
|
+
class="rounded bg-indigo-600 px-2 py-1 text-[11px] text-white hover:bg-indigo-500 disabled:opacity-50"
|
|
397
|
+
:disabled="initiatives.curating"
|
|
398
|
+
data-testid="initiative-policy-save"
|
|
399
|
+
@click="savePolicy"
|
|
400
|
+
>
|
|
401
|
+
{{ t('initiative.curation.save') }}
|
|
402
|
+
</button>
|
|
403
|
+
<button
|
|
404
|
+
class="rounded border border-slate-700 px-2 py-1 text-[11px] text-slate-300 hover:bg-slate-800"
|
|
405
|
+
@click="editingPolicy = false"
|
|
406
|
+
>
|
|
407
|
+
{{ t('initiative.curation.cancel') }}
|
|
408
|
+
</button>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
248
411
|
</section>
|
|
249
412
|
|
|
250
413
|
<!-- Logs -->
|
|
@@ -275,10 +438,85 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
|
|
|
275
438
|
<h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
276
439
|
{{ t('initiative.tracker.followUps') }}
|
|
277
440
|
</h3>
|
|
278
|
-
<ul class="
|
|
279
|
-
<li
|
|
280
|
-
|
|
281
|
-
|
|
441
|
+
<ul class="flex flex-col gap-2 text-[13px] text-slate-300">
|
|
442
|
+
<li
|
|
443
|
+
v-for="f in initiative.followUps"
|
|
444
|
+
:key="f.id"
|
|
445
|
+
class="rounded-lg border border-slate-800 p-2.5"
|
|
446
|
+
:data-testid="`initiative-followup-${f.id}`"
|
|
447
|
+
>
|
|
448
|
+
<div class="flex items-start gap-2">
|
|
449
|
+
<div class="min-w-0 flex-1">
|
|
450
|
+
<span class="font-medium">{{ f.title }}</span>
|
|
451
|
+
<span v-if="f.detail" class="text-slate-400"> — {{ f.detail }}</span>
|
|
452
|
+
</div>
|
|
453
|
+
<UBadge
|
|
454
|
+
:color="INITIATIVE_FOLLOWUP_STATUS_CHIPS[f.status]"
|
|
455
|
+
variant="subtle"
|
|
456
|
+
size="sm"
|
|
457
|
+
>
|
|
458
|
+
{{ t(INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS[f.status]) }}
|
|
459
|
+
</UBadge>
|
|
460
|
+
</div>
|
|
461
|
+
<!-- Triage actions for an open follow-up (only while executing) -->
|
|
462
|
+
<div v-if="editable && f.status === 'open'" class="mt-2">
|
|
463
|
+
<div v-if="promotingId === f.id" class="flex flex-col gap-2">
|
|
464
|
+
<label class="flex items-center gap-2 text-[12px]">
|
|
465
|
+
<span class="text-slate-400">{{
|
|
466
|
+
t('initiative.curation.phaseField')
|
|
467
|
+
}}</span>
|
|
468
|
+
<select
|
|
469
|
+
v-model="promoteForm.phaseId"
|
|
470
|
+
class="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-slate-200"
|
|
471
|
+
data-testid="initiative-promote-phase"
|
|
472
|
+
>
|
|
473
|
+
<option v-for="p in phases" :key="p.id" :value="p.id">
|
|
474
|
+
{{ p.title }}
|
|
475
|
+
</option>
|
|
476
|
+
</select>
|
|
477
|
+
</label>
|
|
478
|
+
<input
|
|
479
|
+
v-model="promoteForm.title"
|
|
480
|
+
type="text"
|
|
481
|
+
class="rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[12px] text-slate-200"
|
|
482
|
+
:placeholder="t('initiative.curation.itemTitlePlaceholder')"
|
|
483
|
+
data-testid="initiative-promote-title"
|
|
484
|
+
/>
|
|
485
|
+
<div class="flex gap-2">
|
|
486
|
+
<button
|
|
487
|
+
class="rounded bg-indigo-600 px-2 py-1 text-[11px] text-white hover:bg-indigo-500 disabled:opacity-50"
|
|
488
|
+
:disabled="initiatives.curating || !promoteForm.phaseId"
|
|
489
|
+
data-testid="initiative-promote-submit"
|
|
490
|
+
@click="submitPromote(f)"
|
|
491
|
+
>
|
|
492
|
+
{{ t('initiative.curation.promoteConfirm') }}
|
|
493
|
+
</button>
|
|
494
|
+
<button
|
|
495
|
+
class="rounded border border-slate-700 px-2 py-1 text-[11px] text-slate-300 hover:bg-slate-800"
|
|
496
|
+
@click="promotingId = null"
|
|
497
|
+
>
|
|
498
|
+
{{ t('initiative.curation.cancel') }}
|
|
499
|
+
</button>
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
<div v-else class="flex gap-1.5">
|
|
503
|
+
<button
|
|
504
|
+
class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800"
|
|
505
|
+
data-testid="initiative-followup-promote"
|
|
506
|
+
@click="startPromote(f)"
|
|
507
|
+
>
|
|
508
|
+
{{ t('initiative.curation.promote') }}
|
|
509
|
+
</button>
|
|
510
|
+
<button
|
|
511
|
+
class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800 disabled:opacity-50"
|
|
512
|
+
:disabled="initiatives.curating"
|
|
513
|
+
data-testid="initiative-followup-dismiss"
|
|
514
|
+
@click="dismissFollowUp(f)"
|
|
515
|
+
>
|
|
516
|
+
{{ t('initiative.curation.dismiss') }}
|
|
517
|
+
</button>
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
282
520
|
</li>
|
|
283
521
|
</ul>
|
|
284
522
|
</section>
|
|
@@ -66,6 +66,7 @@ const REASON_KEYS: Record<MergeDecision['reason'], string> = {
|
|
|
66
66
|
no_rationale: 'panels.mergerResult.reason.no_rationale',
|
|
67
67
|
no_assessment: 'panels.mergerResult.reason.no_assessment',
|
|
68
68
|
merge_failed: 'panels.mergerResult.reason.merge_failed',
|
|
69
|
+
merge_partial: 'panels.mergerResult.reason.merge_partial',
|
|
69
70
|
}
|
|
70
71
|
const OUTCOME_KEYS: Record<MergeDecision['outcome'], string> = {
|
|
71
72
|
auto_merged: 'panels.mergerResult.outcome.auto_merged',
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
2
3
|
import type { Block } from '~/types/domain'
|
|
3
4
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
5
|
+
import { buildFragmentPickerGroups } from '~/utils/fragmentPicker'
|
|
4
6
|
|
|
5
7
|
// Service-level best-practice fragments (frame blocks). These are the programming
|
|
6
8
|
// standards/guidelines for the whole service; at run time their bodies are folded
|
|
@@ -19,8 +21,6 @@ const { t } = useI18n()
|
|
|
19
21
|
|
|
20
22
|
onMounted(() => fragments.ensureLoaded())
|
|
21
23
|
|
|
22
|
-
type MenuItem = { label: string; icon?: string; onSelect: () => void }
|
|
23
|
-
|
|
24
24
|
// An id the catalog no longer resolves (removed/suppressed after selection) still
|
|
25
25
|
// renders — labelled by its raw id — so it stays visible and removable.
|
|
26
26
|
const selectedFragments = computed(() =>
|
|
@@ -32,8 +32,8 @@ const selectedFragments = computed(() =>
|
|
|
32
32
|
// A trailing group that jumps from "attach a fragment" to authoring/editing the
|
|
33
33
|
// library itself (board tier always; account tier when accounts are enabled).
|
|
34
34
|
// Open to every member — managing fragments is not an admin-only action.
|
|
35
|
-
const manageItems = computed<
|
|
36
|
-
const items:
|
|
35
|
+
const manageItems = computed<DropdownMenuItem[]>(() => {
|
|
36
|
+
const items: DropdownMenuItem[] = [
|
|
37
37
|
{
|
|
38
38
|
label: t('inspector.fragments.manageBoard'),
|
|
39
39
|
icon: 'i-lucide-book-marked',
|
|
@@ -50,18 +50,14 @@ const manageItems = computed<MenuItem[]>(() => {
|
|
|
50
50
|
return items
|
|
51
51
|
})
|
|
52
52
|
|
|
53
|
-
// Picker menu: every pool fragment not already selected, grouped
|
|
54
|
-
// the management links appended as the final group.
|
|
55
|
-
const fragmentMenu = computed<
|
|
53
|
+
// Picker menu: every pool fragment not already selected, grouped into labelled
|
|
54
|
+
// per-category sections, with the management links appended as the final group.
|
|
55
|
+
const fragmentMenu = computed<DropdownMenuItem[][]>(() => {
|
|
56
56
|
const selected = new Set(props.block.serviceFragmentIds ?? [])
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
items.push({ label: f.title, onSelect: () => addFragment(f.id) })
|
|
62
|
-
groups.set(f.category, items)
|
|
63
|
-
}
|
|
64
|
-
return [...groups.values(), manageItems.value]
|
|
57
|
+
return [
|
|
58
|
+
...buildFragmentPickerGroups(fragments.fragments, (id) => selected.has(id), addFragment),
|
|
59
|
+
manageItems.value,
|
|
60
|
+
]
|
|
65
61
|
})
|
|
66
62
|
|
|
67
63
|
function addFragment(id: string) {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
2
3
|
import type { Block } from '~/types/domain'
|
|
3
4
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
5
|
+
import { buildFragmentPickerGroups } from '~/utils/fragmentPicker'
|
|
4
6
|
|
|
5
7
|
const props = defineProps<{ block: Block }>()
|
|
6
8
|
|
|
@@ -14,8 +16,6 @@ const { t } = useI18n()
|
|
|
14
16
|
// task inspector mounts — mirrors ServiceFragments; ensureLoaded is a no-op while current.
|
|
15
17
|
onMounted(() => fragments.ensureLoaded())
|
|
16
18
|
|
|
17
|
-
type MenuItem = { label: string; icon?: string; onSelect: () => void }
|
|
18
|
-
|
|
19
19
|
// ---- best-practice prompt fragments ----------------------------------------
|
|
20
20
|
// Selected fragments, resolved against the catalog. An id the catalog no longer
|
|
21
21
|
// resolves (removed/suppressed after selection) still renders — labelled by its
|
|
@@ -29,8 +29,8 @@ const selectedFragments = computed(() =>
|
|
|
29
29
|
// A trailing group that jumps from "attach a fragment" to authoring/editing the
|
|
30
30
|
// library itself (board tier always; account tier when accounts are enabled).
|
|
31
31
|
// Open to every member — managing fragments is not an admin-only action.
|
|
32
|
-
const manageItems = computed<
|
|
33
|
-
const items:
|
|
32
|
+
const manageItems = computed<DropdownMenuItem[]>(() => {
|
|
33
|
+
const items: DropdownMenuItem[] = [
|
|
34
34
|
{
|
|
35
35
|
label: t('inspector.fragments.manageBoard'),
|
|
36
36
|
icon: 'i-lucide-book-marked',
|
|
@@ -48,18 +48,18 @@ const manageItems = computed<MenuItem[]>(() => {
|
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
// Picker menu: fragments suitable for this block's type, not already selected,
|
|
51
|
-
// grouped
|
|
52
|
-
// links appended as the final group.
|
|
53
|
-
const fragmentMenu = computed<
|
|
51
|
+
// grouped into labelled per-category sections so the dropdown reads like the catalog,
|
|
52
|
+
// with the management links appended as the final group.
|
|
53
|
+
const fragmentMenu = computed<DropdownMenuItem[][]>(() => {
|
|
54
54
|
const selected = new Set(props.block.fragmentIds ?? [])
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
55
|
+
return [
|
|
56
|
+
...buildFragmentPickerGroups(
|
|
57
|
+
fragments.forBlockType(props.block.type),
|
|
58
|
+
(id) => selected.has(id),
|
|
59
|
+
addFragment,
|
|
60
|
+
),
|
|
61
|
+
manageItems.value,
|
|
62
|
+
]
|
|
63
63
|
})
|
|
64
64
|
|
|
65
65
|
function addFragment(id: string) {
|