@cat-factory/app 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/board/AddTaskModal.vue +21 -33
- package/app/components/layout/IntegrationsHub.vue +15 -5
- package/app/components/settings/IssueTrackerPanel.vue +393 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +9 -9
- package/app/components/tasks/ContextIssuePicker.vue +243 -0
- package/app/components/tasks/TaskImportModal.vue +139 -20
- package/app/composables/api/tasks.ts +8 -0
- package/app/stores/tasks.ts +44 -2
- package/app/types/tasks.ts +25 -0
- package/package.json +1 -1
- package/app/components/settings/IssueTrackerWritebackPanel.vue +0 -91
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// (see useContextLinking) — the same context the agents see for every step of the run.
|
|
13
13
|
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
14
14
|
import type { CreateTaskType, TaskTypeFields } from '~/types/domain'
|
|
15
|
+
import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
|
|
15
16
|
|
|
16
17
|
const ui = useUiStore()
|
|
17
18
|
const board = useBoardStore()
|
|
@@ -230,32 +231,12 @@ const docAttachMenu = computed<DropdownMenuItem[][]>(() => {
|
|
|
230
231
|
return [items]
|
|
231
232
|
})
|
|
232
233
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
.map((t) => ({
|
|
240
|
-
label: `${t.externalId} · ${t.title}`,
|
|
241
|
-
icon: tasks.descriptorFor(t.source)?.icon ?? 'i-lucide-square-check',
|
|
242
|
-
onSelect: () =>
|
|
243
|
-
addPending({
|
|
244
|
-
kind: 'task',
|
|
245
|
-
source: t.source,
|
|
246
|
-
externalId: t.externalId,
|
|
247
|
-
title: `${t.externalId} · ${t.title}`,
|
|
248
|
-
icon: tasks.descriptorFor(t.source)?.icon ?? 'i-lucide-square-check',
|
|
249
|
-
needsImport: false,
|
|
250
|
-
}),
|
|
251
|
-
}))
|
|
252
|
-
items.push({
|
|
253
|
-
label: 'Import an issue…',
|
|
254
|
-
icon: 'i-lucide-file-down',
|
|
255
|
-
onSelect: () => ui.openTaskImport(),
|
|
256
|
-
})
|
|
257
|
-
return [items]
|
|
258
|
-
})
|
|
234
|
+
// Context issues are picked through an inline search picker (ContextIssuePicker)
|
|
235
|
+
// rather than a dropdown that opens a second modal — stacked page-level modals
|
|
236
|
+
// don't interact here, which is why the old "Import an issue…" path appeared to
|
|
237
|
+
// do nothing. The "Attach" button toggles the picker open.
|
|
238
|
+
const showIssuePicker = ref(false)
|
|
239
|
+
const chosenIssueKeys = computed(() => pendingIssues.value.map(contextKey))
|
|
259
240
|
|
|
260
241
|
// Reset the form whenever the modal opens for a (new) container, and refresh the
|
|
261
242
|
// imported docs/issues so the quick-pick list is current.
|
|
@@ -274,6 +255,7 @@ watch(open, (isOpen) => {
|
|
|
274
255
|
pipelineId.value = ''
|
|
275
256
|
agentConfigValues.value = {}
|
|
276
257
|
pendingContext.value = []
|
|
258
|
+
showIssuePicker.value = false
|
|
277
259
|
documents.loadDocuments().catch(() => {})
|
|
278
260
|
tasks.loadTasks().catch(() => {})
|
|
279
261
|
})
|
|
@@ -591,15 +573,16 @@ async function add() {
|
|
|
591
573
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
592
574
|
Context issues
|
|
593
575
|
</span>
|
|
594
|
-
<
|
|
576
|
+
<UButton
|
|
595
577
|
v-if="issuesConnected"
|
|
596
|
-
|
|
597
|
-
|
|
578
|
+
color="neutral"
|
|
579
|
+
variant="soft"
|
|
580
|
+
size="xs"
|
|
581
|
+
:icon="showIssuePicker ? 'i-lucide-x' : 'i-lucide-plus'"
|
|
582
|
+
@click="showIssuePicker = !showIssuePicker"
|
|
598
583
|
>
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
</UButton>
|
|
602
|
-
</UDropdownMenu>
|
|
584
|
+
{{ showIssuePicker ? 'Done' : 'Attach' }}
|
|
585
|
+
</UButton>
|
|
603
586
|
<UButton
|
|
604
587
|
v-else
|
|
605
588
|
color="neutral"
|
|
@@ -616,6 +599,11 @@ async function add() {
|
|
|
616
599
|
Attach
|
|
617
600
|
</UButton>
|
|
618
601
|
</div>
|
|
602
|
+
<ContextIssuePicker
|
|
603
|
+
v-if="showIssuePicker && issuesConnected"
|
|
604
|
+
:chosen-keys="chosenIssueKeys"
|
|
605
|
+
@pick="addPending"
|
|
606
|
+
/>
|
|
619
607
|
<div v-if="pendingIssues.length" class="space-y-1">
|
|
620
608
|
<div
|
|
621
609
|
v-for="item in pendingIssues"
|
|
@@ -12,8 +12,16 @@ const github = useGitHubStore()
|
|
|
12
12
|
const slack = useSlackStore()
|
|
13
13
|
const documents = useDocumentsStore()
|
|
14
14
|
const tasks = useTasksStore()
|
|
15
|
+
const tracker = useTrackerStore()
|
|
15
16
|
const releaseHealth = useReleaseHealthStore()
|
|
16
17
|
|
|
18
|
+
// The selected filing tracker, as a badge label ("GitHub Issues" / "Jira").
|
|
19
|
+
const trackerLabel = computed(() => {
|
|
20
|
+
if (tracker.settings.tracker === 'github') return 'GitHub Issues'
|
|
21
|
+
if (tracker.settings.tracker === 'jira') return 'Jira'
|
|
22
|
+
return undefined
|
|
23
|
+
})
|
|
24
|
+
|
|
17
25
|
// The observability connection status drives the hub's connected badge. Load it
|
|
18
26
|
// lazily when the hub opens (the secret-less connection view is cheap).
|
|
19
27
|
watch(
|
|
@@ -130,11 +138,13 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
130
138
|
})
|
|
131
139
|
}
|
|
132
140
|
trackers.push({
|
|
133
|
-
key: 'task:
|
|
134
|
-
icon: 'i-lucide-
|
|
135
|
-
label: 'Issue tracker
|
|
136
|
-
description: '
|
|
137
|
-
|
|
141
|
+
key: 'task:tracker',
|
|
142
|
+
icon: 'i-lucide-list-checks',
|
|
143
|
+
label: 'Issue tracker settings',
|
|
144
|
+
description: 'Choose the filing tracker, enable linking sources, and configure writeback.',
|
|
145
|
+
status: trackerLabel.value,
|
|
146
|
+
connected: tracker.settings.tracker !== null,
|
|
147
|
+
onClick: () => go(() => ui.openWorkspaceSettings('tracker')),
|
|
138
148
|
})
|
|
139
149
|
out.push({ title: 'Task trackers', items: trackers })
|
|
140
150
|
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Workspace settings: a single, first-class home for issue tracking. It gathers
|
|
3
|
+
// the three things that used to be scattered (and, for the filing tracker, were
|
|
4
|
+
// only reachable buried inside the tech-debt recurring-pipeline modal):
|
|
5
|
+
//
|
|
6
|
+
// 1. Filing tracker — which tracker the tech-debt pipeline's `tracker` step
|
|
7
|
+
// files its ticket in (GitHub Issues / Jira / none).
|
|
8
|
+
// 2. Linking sources — the per-workspace on/off toggle for each source
|
|
9
|
+
// (task_source_settings) that governs whether issues can be imported and
|
|
10
|
+
// linked to tasks as agent context.
|
|
11
|
+
// 3. Writeback — comment on a task's linked issue when its PR opens, and
|
|
12
|
+
// comment + close it when the PR merges.
|
|
13
|
+
//
|
|
14
|
+
// Filing and linking are independent (filing rides the App / Jira connection
|
|
15
|
+
// directly; linking is the source toggle), so both are shown explicitly to undo
|
|
16
|
+
// the common confusion that "I have the GitHub App, why is nothing surfaced?".
|
|
17
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
18
|
+
import type { TaskSourceDiagnosticStatus, TaskSourceKind, TrackerKind } from '~/types/domain'
|
|
19
|
+
|
|
20
|
+
const tracker = useTrackerStore()
|
|
21
|
+
const tasks = useTasksStore()
|
|
22
|
+
const ui = useUiStore()
|
|
23
|
+
const toast = useToast()
|
|
24
|
+
|
|
25
|
+
// --- filing tracker + writeback (one Save, persisted on tracker settings) -----
|
|
26
|
+
const trackerKind = ref<TrackerKind | null>(null)
|
|
27
|
+
const jiraProjectKey = ref('')
|
|
28
|
+
const commentOnPrOpen = ref(false)
|
|
29
|
+
const resolveOnMerge = ref(false)
|
|
30
|
+
const saving = ref(false)
|
|
31
|
+
|
|
32
|
+
function hydrate() {
|
|
33
|
+
trackerKind.value = tracker.settings.tracker
|
|
34
|
+
jiraProjectKey.value = tracker.settings.jiraProjectKey ?? ''
|
|
35
|
+
commentOnPrOpen.value = tracker.settings.writebackCommentOnPrOpen
|
|
36
|
+
resolveOnMerge.value = tracker.settings.writebackResolveOnMerge
|
|
37
|
+
}
|
|
38
|
+
onMounted(() => {
|
|
39
|
+
hydrate()
|
|
40
|
+
// The descriptors (availability + enable state) come from the task-source probe;
|
|
41
|
+
// probe on open if the navbar hasn't already, so the toggles below reflect reality.
|
|
42
|
+
if (tasks.available === null) void tasks.probe()
|
|
43
|
+
})
|
|
44
|
+
watch(() => tracker.settings, hydrate, { deep: true })
|
|
45
|
+
|
|
46
|
+
// Per-source live state (available = usable now; enabled = offered to the workspace).
|
|
47
|
+
const github = computed(() => tasks.descriptorFor('github'))
|
|
48
|
+
const jira = computed(() => tasks.descriptorFor('jira'))
|
|
49
|
+
|
|
50
|
+
// A tracker can only file where it can authenticate: GitHub rides the installed
|
|
51
|
+
// App, Jira needs a connection. Selecting an unusable tracker is allowed (it just
|
|
52
|
+
// won't file until set up), but we surface the gap inline.
|
|
53
|
+
const githubAvailable = computed(() => github.value?.available ?? false)
|
|
54
|
+
const jiraConnected = computed(() => tasks.isConnected('jira'))
|
|
55
|
+
|
|
56
|
+
// Jira needs a project key to file into; block Save on an empty one when picked.
|
|
57
|
+
const canSave = computed(
|
|
58
|
+
() => trackerKind.value !== 'jira' || jiraProjectKey.value.trim().length > 0,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async function save() {
|
|
62
|
+
if (!canSave.value) return
|
|
63
|
+
saving.value = true
|
|
64
|
+
try {
|
|
65
|
+
await tracker.save({
|
|
66
|
+
tracker: trackerKind.value,
|
|
67
|
+
jiraProjectKey: trackerKind.value === 'jira' ? jiraProjectKey.value.trim() : null,
|
|
68
|
+
writebackCommentOnPrOpen: commentOnPrOpen.value,
|
|
69
|
+
writebackResolveOnMerge: resolveOnMerge.value,
|
|
70
|
+
})
|
|
71
|
+
toast.add({ title: 'Issue tracker saved', icon: 'i-lucide-check', color: 'success' })
|
|
72
|
+
} catch (e) {
|
|
73
|
+
toast.add({
|
|
74
|
+
title: 'Could not save settings',
|
|
75
|
+
description: e instanceof Error ? e.message : String(e),
|
|
76
|
+
icon: 'i-lucide-triangle-alert',
|
|
77
|
+
color: 'error',
|
|
78
|
+
})
|
|
79
|
+
} finally {
|
|
80
|
+
saving.value = false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- linking sources (per-source toggle, saved immediately) -------------------
|
|
85
|
+
const togglingSource = ref<TaskSourceKind | null>(null)
|
|
86
|
+
|
|
87
|
+
async function toggleSource(source: TaskSourceKind, enabled: boolean) {
|
|
88
|
+
togglingSource.value = source
|
|
89
|
+
try {
|
|
90
|
+
await tasks.setEnabled(source, enabled)
|
|
91
|
+
} catch (e) {
|
|
92
|
+
toast.add({
|
|
93
|
+
title: 'Could not update',
|
|
94
|
+
description: e instanceof Error ? e.message : String(e),
|
|
95
|
+
icon: 'i-lucide-triangle-alert',
|
|
96
|
+
color: 'error',
|
|
97
|
+
})
|
|
98
|
+
} finally {
|
|
99
|
+
togglingSource.value = null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- live "check setup" -------------------------------------------------------
|
|
104
|
+
// The probe failed entirely (not a per-source state): the whole integration is
|
|
105
|
+
// off or the backend errored, so the panel can't show real source state. We
|
|
106
|
+
// translate the captured status into a plain explanation + next step.
|
|
107
|
+
const probeFailureHint = computed(() => {
|
|
108
|
+
const err = tasks.probeError
|
|
109
|
+
if (tasks.available !== false || !err) return null
|
|
110
|
+
if (err.status === 503) {
|
|
111
|
+
return 'The task-source integration is turned off on this deployment (its encryption key is not configured). Set ENCRYPTION_KEY on the backend to enable issue tracking.'
|
|
112
|
+
}
|
|
113
|
+
if (err.status && err.status >= 500) {
|
|
114
|
+
return `The issue-tracker service returned an error (HTTP ${err.status}): ${err.message}. This usually means the backend isn't fully migrated/configured.`
|
|
115
|
+
}
|
|
116
|
+
return `Couldn't load issue-tracker settings${err.status ? ` (HTTP ${err.status})` : ''}: ${err.message}`
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
async function checkSetup(source: TaskSourceKind) {
|
|
120
|
+
try {
|
|
121
|
+
await tasks.checkSetup(source)
|
|
122
|
+
} catch (e) {
|
|
123
|
+
toast.add({
|
|
124
|
+
title: 'Check failed',
|
|
125
|
+
description: e instanceof Error ? e.message : String(e),
|
|
126
|
+
icon: 'i-lucide-triangle-alert',
|
|
127
|
+
color: 'error',
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Status → presentation for a setup-check verdict.
|
|
133
|
+
const STATUS_UI: Record<
|
|
134
|
+
TaskSourceDiagnosticStatus,
|
|
135
|
+
{ color: 'success' | 'warning' | 'error' | 'neutral'; icon: string }
|
|
136
|
+
> = {
|
|
137
|
+
ready: { color: 'success', icon: 'i-lucide-circle-check' },
|
|
138
|
+
not_installed: { color: 'warning', icon: 'i-lucide-download' },
|
|
139
|
+
not_connected: { color: 'warning', icon: 'i-lucide-plug' },
|
|
140
|
+
auth_failed: { color: 'error', icon: 'i-lucide-key-round' },
|
|
141
|
+
forbidden: { color: 'error', icon: 'i-lucide-shield-x' },
|
|
142
|
+
unreachable: { color: 'error', icon: 'i-lucide-wifi-off' },
|
|
143
|
+
error: { color: 'error', icon: 'i-lucide-triangle-alert' },
|
|
144
|
+
}
|
|
145
|
+
</script>
|
|
146
|
+
|
|
147
|
+
<template>
|
|
148
|
+
<div class="space-y-7">
|
|
149
|
+
<!-- Whole-integration failure: explain WHY nothing is surfaced, instead of the
|
|
150
|
+
passive per-source "install first" hints (which would be misleading here). -->
|
|
151
|
+
<UAlert
|
|
152
|
+
v-if="probeFailureHint"
|
|
153
|
+
color="error"
|
|
154
|
+
variant="subtle"
|
|
155
|
+
icon="i-lucide-triangle-alert"
|
|
156
|
+
title="Issue tracking isn't available"
|
|
157
|
+
:description="probeFailureHint"
|
|
158
|
+
/>
|
|
159
|
+
|
|
160
|
+
<!-- 1. Filing tracker ----------------------------------------------------->
|
|
161
|
+
<section class="space-y-3">
|
|
162
|
+
<div>
|
|
163
|
+
<h3 class="text-sm font-semibold text-slate-200">Where tickets are filed</h3>
|
|
164
|
+
<p class="mt-1 text-[11px] text-slate-400">
|
|
165
|
+
The tech-debt recurring pipeline raises an issue before implementation and files it in
|
|
166
|
+
this tracker. Choose <span class="text-slate-300">None</span> to skip filing.
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div class="flex flex-wrap gap-2">
|
|
171
|
+
<UButton
|
|
172
|
+
icon="i-lucide-circle-slash"
|
|
173
|
+
size="sm"
|
|
174
|
+
:color="trackerKind === null ? 'primary' : 'neutral'"
|
|
175
|
+
:variant="trackerKind === null ? 'solid' : 'subtle'"
|
|
176
|
+
@click="trackerKind = null"
|
|
177
|
+
>
|
|
178
|
+
None
|
|
179
|
+
</UButton>
|
|
180
|
+
<UButton
|
|
181
|
+
icon="i-lucide-github"
|
|
182
|
+
size="sm"
|
|
183
|
+
:color="trackerKind === 'github' ? 'primary' : 'neutral'"
|
|
184
|
+
:variant="trackerKind === 'github' ? 'solid' : 'subtle'"
|
|
185
|
+
@click="trackerKind = 'github'"
|
|
186
|
+
>
|
|
187
|
+
GitHub Issues
|
|
188
|
+
</UButton>
|
|
189
|
+
<UButton
|
|
190
|
+
icon="i-lucide-trello"
|
|
191
|
+
size="sm"
|
|
192
|
+
:color="trackerKind === 'jira' ? 'primary' : 'neutral'"
|
|
193
|
+
:variant="trackerKind === 'jira' ? 'solid' : 'subtle'"
|
|
194
|
+
@click="trackerKind = 'jira'"
|
|
195
|
+
>
|
|
196
|
+
Jira
|
|
197
|
+
</UButton>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<!-- Inline readiness hints for the picked tracker. -->
|
|
201
|
+
<p v-if="trackerKind === 'github' && !githubAvailable" class="text-[11px] text-amber-400">
|
|
202
|
+
GitHub Issues rides your installed GitHub App, which isn't connected yet. Install it under
|
|
203
|
+
<button class="underline" @click="ui.openGitHub()">Integrations → GitHub</button> — filing
|
|
204
|
+
stays off until then.
|
|
205
|
+
</p>
|
|
206
|
+
<p v-else-if="trackerKind === 'jira' && !jiraConnected" class="text-[11px] text-amber-400">
|
|
207
|
+
Jira isn't connected yet.
|
|
208
|
+
<button class="underline" @click="ui.openTaskConnect('jira')">Connect it</button> to file
|
|
209
|
+
and link issues.
|
|
210
|
+
</p>
|
|
211
|
+
|
|
212
|
+
<UFormField v-if="trackerKind === 'jira'" label="Jira project key" class="w-48">
|
|
213
|
+
<UInput v-model="jiraProjectKey" placeholder="ENG" size="sm" class="w-full" />
|
|
214
|
+
<template #help>
|
|
215
|
+
<span class="text-[11px] text-slate-500">New tickets are filed under this project.</span>
|
|
216
|
+
</template>
|
|
217
|
+
</UFormField>
|
|
218
|
+
</section>
|
|
219
|
+
|
|
220
|
+
<!-- 2. Linking sources ---------------------------------------------------->
|
|
221
|
+
<section class="space-y-3">
|
|
222
|
+
<div>
|
|
223
|
+
<h3 class="text-sm font-semibold text-slate-200">Link issues as context</h3>
|
|
224
|
+
<p class="mt-1 text-[11px] text-slate-400">
|
|
225
|
+
When a source is offered you can import its issues and attach them to a task, so agents
|
|
226
|
+
see the issue description and comments while implementing. This is independent of the
|
|
227
|
+
filing tracker above.
|
|
228
|
+
</p>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<!-- GitHub Issues source -->
|
|
232
|
+
<div class="rounded-lg border border-slate-800 bg-slate-800/40 px-3 py-2.5">
|
|
233
|
+
<div class="flex items-center justify-between gap-2">
|
|
234
|
+
<div class="flex min-w-0 items-center gap-2.5">
|
|
235
|
+
<UIcon name="i-lucide-github" class="h-5 w-5 shrink-0 text-slate-300" />
|
|
236
|
+
<div class="min-w-0">
|
|
237
|
+
<div class="text-sm font-medium text-slate-200">GitHub Issues</div>
|
|
238
|
+
<div class="text-[11px] text-slate-500">
|
|
239
|
+
{{
|
|
240
|
+
githubAvailable
|
|
241
|
+
? 'Rides your installed GitHub App — no credentials needed.'
|
|
242
|
+
: 'Install the GitHub App (Integrations → GitHub) to offer this source.'
|
|
243
|
+
}}
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
248
|
+
<UButton
|
|
249
|
+
size="xs"
|
|
250
|
+
color="neutral"
|
|
251
|
+
variant="ghost"
|
|
252
|
+
icon="i-lucide-stethoscope"
|
|
253
|
+
:loading="tasks.checking === 'github'"
|
|
254
|
+
@click="checkSetup('github')"
|
|
255
|
+
>
|
|
256
|
+
Check setup
|
|
257
|
+
</UButton>
|
|
258
|
+
<USwitch
|
|
259
|
+
v-if="githubAvailable"
|
|
260
|
+
:model-value="github?.enabled ?? false"
|
|
261
|
+
:loading="togglingSource === 'github'"
|
|
262
|
+
@update:model-value="(v: boolean) => toggleSource('github', v)"
|
|
263
|
+
/>
|
|
264
|
+
<UButton
|
|
265
|
+
v-else
|
|
266
|
+
size="xs"
|
|
267
|
+
color="neutral"
|
|
268
|
+
variant="soft"
|
|
269
|
+
icon="i-lucide-github"
|
|
270
|
+
@click="ui.openGitHub()"
|
|
271
|
+
>
|
|
272
|
+
Install
|
|
273
|
+
</UButton>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
<UAlert
|
|
277
|
+
v-if="tasks.diagnostics.github"
|
|
278
|
+
class="mt-2.5"
|
|
279
|
+
:color="STATUS_UI[tasks.diagnostics.github.status].color"
|
|
280
|
+
variant="subtle"
|
|
281
|
+
:icon="STATUS_UI[tasks.diagnostics.github.status].icon"
|
|
282
|
+
:description="
|
|
283
|
+
tasks.diagnostics.github.message +
|
|
284
|
+
(tasks.diagnostics.github.detail ? ` ${tasks.diagnostics.github.detail}` : '')
|
|
285
|
+
"
|
|
286
|
+
:ui="{ description: 'text-[11px]' }"
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<!-- Jira source -->
|
|
291
|
+
<div class="rounded-lg border border-slate-800 bg-slate-800/40 px-3 py-2.5">
|
|
292
|
+
<div class="flex items-center justify-between gap-2">
|
|
293
|
+
<div class="flex min-w-0 items-center gap-2.5">
|
|
294
|
+
<UIcon name="i-lucide-trello" class="h-5 w-5 shrink-0 text-slate-300" />
|
|
295
|
+
<div class="min-w-0">
|
|
296
|
+
<div class="text-sm font-medium text-slate-200">Jira</div>
|
|
297
|
+
<div class="text-[11px] text-slate-500">
|
|
298
|
+
{{ jiraConnected ? 'Connected.' : 'Connect with a Jira account and API token.' }}
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
303
|
+
<UButton
|
|
304
|
+
v-if="jiraConnected"
|
|
305
|
+
size="xs"
|
|
306
|
+
color="neutral"
|
|
307
|
+
variant="ghost"
|
|
308
|
+
icon="i-lucide-stethoscope"
|
|
309
|
+
:loading="tasks.checking === 'jira'"
|
|
310
|
+
@click="checkSetup('jira')"
|
|
311
|
+
>
|
|
312
|
+
Check setup
|
|
313
|
+
</UButton>
|
|
314
|
+
<USwitch
|
|
315
|
+
v-if="jira?.available"
|
|
316
|
+
:model-value="jira?.enabled ?? false"
|
|
317
|
+
:loading="togglingSource === 'jira'"
|
|
318
|
+
@update:model-value="(v: boolean) => toggleSource('jira', v)"
|
|
319
|
+
/>
|
|
320
|
+
<UButton
|
|
321
|
+
v-else
|
|
322
|
+
size="xs"
|
|
323
|
+
color="neutral"
|
|
324
|
+
variant="soft"
|
|
325
|
+
icon="i-lucide-plug"
|
|
326
|
+
@click="ui.openTaskConnect('jira')"
|
|
327
|
+
>
|
|
328
|
+
Connect
|
|
329
|
+
</UButton>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
<UAlert
|
|
333
|
+
v-if="tasks.diagnostics.jira"
|
|
334
|
+
class="mt-2.5"
|
|
335
|
+
:color="STATUS_UI[tasks.diagnostics.jira.status].color"
|
|
336
|
+
variant="subtle"
|
|
337
|
+
:icon="STATUS_UI[tasks.diagnostics.jira.status].icon"
|
|
338
|
+
:description="
|
|
339
|
+
tasks.diagnostics.jira.message +
|
|
340
|
+
(tasks.diagnostics.jira.detail ? ` ${tasks.diagnostics.jira.detail}` : '')
|
|
341
|
+
"
|
|
342
|
+
:ui="{ description: 'text-[11px]' }"
|
|
343
|
+
/>
|
|
344
|
+
</div>
|
|
345
|
+
</section>
|
|
346
|
+
|
|
347
|
+
<!-- 3. Writeback ---------------------------------------------------------->
|
|
348
|
+
<section class="space-y-3">
|
|
349
|
+
<div>
|
|
350
|
+
<h3 class="text-sm font-semibold text-slate-200">Writeback</h3>
|
|
351
|
+
<p class="mt-1 text-[11px] text-slate-400">
|
|
352
|
+
Write back to a task's linked issue(s) as its pull request progresses. Each toggle is the
|
|
353
|
+
workspace default and can be overridden per task in the inspector. GitHub issues close
|
|
354
|
+
natively; Jira issues transition to the first status in their
|
|
355
|
+
<span class="text-slate-300">Done</span> category.
|
|
356
|
+
</p>
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<label class="flex items-start gap-3 rounded-lg border border-slate-700 bg-slate-800/40 p-3">
|
|
360
|
+
<USwitch v-model="commentOnPrOpen" />
|
|
361
|
+
<span class="text-sm">
|
|
362
|
+
<span class="block text-slate-200">Comment when a PR opens</span>
|
|
363
|
+
<span class="block text-xs text-slate-500">
|
|
364
|
+
Post a comment on the linked issue with the new pull request's link.
|
|
365
|
+
</span>
|
|
366
|
+
</span>
|
|
367
|
+
</label>
|
|
368
|
+
|
|
369
|
+
<label class="flex items-start gap-3 rounded-lg border border-slate-700 bg-slate-800/40 p-3">
|
|
370
|
+
<USwitch v-model="resolveOnMerge" />
|
|
371
|
+
<span class="text-sm">
|
|
372
|
+
<span class="block text-slate-200">Close as resolved when a PR merges</span>
|
|
373
|
+
<span class="block text-xs text-slate-500">
|
|
374
|
+
Comment that the PR merged, then close / resolve the linked issue.
|
|
375
|
+
</span>
|
|
376
|
+
</span>
|
|
377
|
+
</label>
|
|
378
|
+
</section>
|
|
379
|
+
|
|
380
|
+
<div class="flex justify-end">
|
|
381
|
+
<UButton
|
|
382
|
+
color="primary"
|
|
383
|
+
icon="i-lucide-save"
|
|
384
|
+
size="sm"
|
|
385
|
+
:loading="saving"
|
|
386
|
+
:disabled="!canSave"
|
|
387
|
+
@click="save"
|
|
388
|
+
>
|
|
389
|
+
Save
|
|
390
|
+
</UButton>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
</template>
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
// configuration that used to live in separate windows:
|
|
4
4
|
// - Workspace: the run-timing escalation threshold + per-service running-task limit.
|
|
5
5
|
// - Merge thresholds: the auto-merge preset library.
|
|
6
|
-
// - Issue
|
|
6
|
+
// - Issue tracker: filing-tracker selection + linking sources + writeback.
|
|
7
7
|
// - Service best practices: the default fragments new services inherit.
|
|
8
8
|
// The latter three are body-only section components rendered in tabs here (no longer
|
|
9
9
|
// standalone modals).
|
|
10
10
|
import { reactive, ref, watch } from 'vue'
|
|
11
11
|
import type { CreateTaskType, TaskLimitMode } from '~/types/domain'
|
|
12
12
|
import MergeThresholdsPanel from '~/components/settings/MergeThresholdsPanel.vue'
|
|
13
|
-
import
|
|
13
|
+
import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
|
|
14
14
|
import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
|
|
15
15
|
|
|
16
16
|
const ui = useUiStore()
|
|
@@ -38,10 +38,10 @@ const tabs = [
|
|
|
38
38
|
},
|
|
39
39
|
{ value: 'merge', label: 'Merge thresholds', icon: 'i-lucide-git-merge', slot: 'merge' },
|
|
40
40
|
{
|
|
41
|
-
value: '
|
|
42
|
-
label: 'Issue
|
|
43
|
-
icon: 'i-lucide-
|
|
44
|
-
slot: '
|
|
41
|
+
value: 'tracker',
|
|
42
|
+
label: 'Issue tracker',
|
|
43
|
+
icon: 'i-lucide-list-checks',
|
|
44
|
+
slot: 'tracker',
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
value: 'fragments',
|
|
@@ -200,9 +200,9 @@ async function save() {
|
|
|
200
200
|
<MergeThresholdsPanel />
|
|
201
201
|
</template>
|
|
202
202
|
|
|
203
|
-
<!-- Issue
|
|
204
|
-
<template #
|
|
205
|
-
<
|
|
203
|
+
<!-- Issue tracker -->
|
|
204
|
+
<template #tracker>
|
|
205
|
+
<IssueTrackerPanel />
|
|
206
206
|
</template>
|
|
207
207
|
|
|
208
208
|
<!-- Service best practices -->
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Inline picker for attaching a tracker issue as task context. It searches the
|
|
3
|
+
// connected tracker (GitHub Issues / Jira) by free text, lists already-imported
|
|
4
|
+
// issues for quick re-use, and accepts a pasted URL/key as a reference — all
|
|
5
|
+
// inline, with NO second modal (stacked page-level modals don't interact here).
|
|
6
|
+
// It only *stages* a choice: the caller collects PendingContext items and links
|
|
7
|
+
// them once the block exists (see useContextLinking). A search hit / pasted ref
|
|
8
|
+
// carries `needsImport: true` so it's fetched + persisted before linking.
|
|
9
|
+
import type { TaskSearchResult, TaskSourceKind } from '~/types/domain'
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
/** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
|
|
13
|
+
chosenKeys?: string[]
|
|
14
|
+
}>()
|
|
15
|
+
const emit = defineEmits<{ pick: [item: PendingContext] }>()
|
|
16
|
+
|
|
17
|
+
const tasks = useTasksStore()
|
|
18
|
+
|
|
19
|
+
const chosen = computed(() => new Set(props.chosenKeys ?? []))
|
|
20
|
+
|
|
21
|
+
// Source: default to the first offered tracker; a selector appears only when more
|
|
22
|
+
// than one is offered (the common case is a single source).
|
|
23
|
+
const source = ref<TaskSourceKind | undefined>(tasks.offeredSources[0]?.source)
|
|
24
|
+
const sourceItems = computed(() =>
|
|
25
|
+
tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
|
|
26
|
+
)
|
|
27
|
+
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
28
|
+
const searchable = computed(() => descriptor.value?.searchable ?? false)
|
|
29
|
+
|
|
30
|
+
const query = ref('')
|
|
31
|
+
const results = ref<TaskSearchResult[]>([])
|
|
32
|
+
const searching = ref(false)
|
|
33
|
+
const searchError = ref<string | null>(null)
|
|
34
|
+
|
|
35
|
+
// Debounced search: free text hits the tracker; a query that's clearly a URL/key
|
|
36
|
+
// is left to the explicit "by reference" row below (search won't surface it).
|
|
37
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
38
|
+
watch([query, source], () => {
|
|
39
|
+
if (timer) clearTimeout(timer)
|
|
40
|
+
results.value = []
|
|
41
|
+
searchError.value = null
|
|
42
|
+
const q = query.value.trim()
|
|
43
|
+
if (!q || !searchable.value) return
|
|
44
|
+
timer = setTimeout(runSearch, 300)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
async function runSearch() {
|
|
48
|
+
const q = query.value.trim()
|
|
49
|
+
if (!q || !source.value) return
|
|
50
|
+
searching.value = true
|
|
51
|
+
searchError.value = null
|
|
52
|
+
try {
|
|
53
|
+
results.value = await tasks.search(source.value, q)
|
|
54
|
+
} catch (e) {
|
|
55
|
+
results.value = []
|
|
56
|
+
searchError.value = e instanceof Error ? e.message : String(e)
|
|
57
|
+
} finally {
|
|
58
|
+
searching.value = false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const icon = computed(() => descriptor.value?.icon ?? 'i-lucide-square-check')
|
|
63
|
+
|
|
64
|
+
function keyFor(externalId: string): string {
|
|
65
|
+
return source.value
|
|
66
|
+
? contextKey({ kind: 'task', source: source.value, externalId })
|
|
67
|
+
: `task::${externalId}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Already-imported issues for this source, filtered by the query and never
|
|
71
|
+
// re-offering one the caller already staged.
|
|
72
|
+
const importedRows = computed(() => {
|
|
73
|
+
if (!source.value) return []
|
|
74
|
+
const q = query.value.trim().toLowerCase()
|
|
75
|
+
return tasks.tasks
|
|
76
|
+
.filter((t) => t.source === source.value)
|
|
77
|
+
.filter((t) => !chosen.value.has(keyFor(t.externalId)))
|
|
78
|
+
.filter(
|
|
79
|
+
(t) => !q || t.externalId.toLowerCase().includes(q) || t.title.toLowerCase().includes(q),
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// Search hits not already imported (those show in importedRows) and not staged.
|
|
84
|
+
const searchRows = computed(() => {
|
|
85
|
+
if (!source.value) return []
|
|
86
|
+
const importedIds = new Set(
|
|
87
|
+
tasks.tasks.filter((t) => t.source === source.value).map((t) => t.externalId),
|
|
88
|
+
)
|
|
89
|
+
return results.value
|
|
90
|
+
.filter((r) => !importedIds.has(r.externalId))
|
|
91
|
+
.filter((r) => !chosen.value.has(keyFor(r.externalId)))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// A pasted URL / key the search won't match: offer it as an explicit reference.
|
|
95
|
+
const refRow = computed(() => {
|
|
96
|
+
const q = query.value.trim()
|
|
97
|
+
if (!q || !source.value) return null
|
|
98
|
+
const known =
|
|
99
|
+
importedRows.value.some((t) => t.externalId === q) ||
|
|
100
|
+
searchRows.value.some((r) => r.externalId === q) ||
|
|
101
|
+
chosen.value.has(keyFor(q))
|
|
102
|
+
if (known) return null
|
|
103
|
+
// Only worth offering when it looks like a reference, not a search phrase.
|
|
104
|
+
const looksLikeRef = q.includes('#') || q.includes('/') || /^https?:\/\//i.test(q)
|
|
105
|
+
return looksLikeRef ? q : null
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const empty = computed(
|
|
109
|
+
() =>
|
|
110
|
+
!searching.value &&
|
|
111
|
+
!searchError.value &&
|
|
112
|
+
importedRows.value.length === 0 &&
|
|
113
|
+
searchRows.value.length === 0 &&
|
|
114
|
+
refRow.value === null,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
function pickImported(externalId: string, title: string, status: string) {
|
|
118
|
+
if (!source.value) return
|
|
119
|
+
emit('pick', {
|
|
120
|
+
kind: 'task',
|
|
121
|
+
source: source.value,
|
|
122
|
+
externalId,
|
|
123
|
+
title: `${externalId} · ${title}`,
|
|
124
|
+
subtitle: status || undefined,
|
|
125
|
+
icon: icon.value,
|
|
126
|
+
needsImport: false,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function pickSearch(r: TaskSearchResult) {
|
|
131
|
+
emit('pick', {
|
|
132
|
+
kind: 'task',
|
|
133
|
+
source: r.source,
|
|
134
|
+
externalId: r.externalId,
|
|
135
|
+
title: `${r.externalId} · ${r.title}`,
|
|
136
|
+
subtitle: r.status || undefined,
|
|
137
|
+
icon: icon.value,
|
|
138
|
+
needsImport: true,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pickRef(q: string) {
|
|
143
|
+
if (!source.value) return
|
|
144
|
+
emit('pick', {
|
|
145
|
+
kind: 'task',
|
|
146
|
+
source: source.value,
|
|
147
|
+
externalId: q,
|
|
148
|
+
title: q,
|
|
149
|
+
subtitle: descriptor.value?.label,
|
|
150
|
+
icon: icon.value,
|
|
151
|
+
needsImport: true,
|
|
152
|
+
})
|
|
153
|
+
query.value = ''
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
onMounted(() => {
|
|
157
|
+
// Keep the quick-pick list current (cheap; the store dedupes).
|
|
158
|
+
tasks.loadTasks().catch(() => {})
|
|
159
|
+
})
|
|
160
|
+
</script>
|
|
161
|
+
|
|
162
|
+
<template>
|
|
163
|
+
<div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2">
|
|
164
|
+
<USelect
|
|
165
|
+
v-if="sourceItems.length > 1"
|
|
166
|
+
v-model="source"
|
|
167
|
+
:items="sourceItems"
|
|
168
|
+
size="xs"
|
|
169
|
+
class="w-full"
|
|
170
|
+
/>
|
|
171
|
+
|
|
172
|
+
<UInput
|
|
173
|
+
v-model="query"
|
|
174
|
+
:icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
|
|
175
|
+
:ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
|
|
176
|
+
size="sm"
|
|
177
|
+
class="w-full"
|
|
178
|
+
:placeholder="
|
|
179
|
+
searchable
|
|
180
|
+
? 'Search issues or paste a URL/key…'
|
|
181
|
+
: (descriptor?.refPlaceholder ?? 'Paste an issue URL or key…')
|
|
182
|
+
"
|
|
183
|
+
@keydown.enter="refRow && pickRef(refRow)"
|
|
184
|
+
/>
|
|
185
|
+
|
|
186
|
+
<p v-if="searchError" class="px-1 text-[11px] text-amber-400">
|
|
187
|
+
Search failed: {{ searchError }}
|
|
188
|
+
</p>
|
|
189
|
+
|
|
190
|
+
<div class="max-h-56 space-y-0.5 overflow-y-auto">
|
|
191
|
+
<!-- Already-imported issues (linked directly, no re-fetch). -->
|
|
192
|
+
<button
|
|
193
|
+
v-for="t in importedRows"
|
|
194
|
+
:key="`imp:${t.externalId}`"
|
|
195
|
+
type="button"
|
|
196
|
+
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/70"
|
|
197
|
+
@click="pickImported(t.externalId, t.title, t.status)"
|
|
198
|
+
>
|
|
199
|
+
<UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
|
|
200
|
+
<span class="truncate">{{ t.externalId }} · {{ t.title }}</span>
|
|
201
|
+
<UBadge color="neutral" variant="soft" size="xs" class="ml-auto shrink-0">imported</UBadge>
|
|
202
|
+
</button>
|
|
203
|
+
|
|
204
|
+
<!-- Tracker search hits (imported on add). -->
|
|
205
|
+
<button
|
|
206
|
+
v-for="r in searchRows"
|
|
207
|
+
:key="`hit:${r.externalId}`"
|
|
208
|
+
type="button"
|
|
209
|
+
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/70"
|
|
210
|
+
@click="pickSearch(r)"
|
|
211
|
+
>
|
|
212
|
+
<UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
|
213
|
+
<span class="truncate">{{ r.externalId }} · {{ r.title }}</span>
|
|
214
|
+
<UBadge v-if="r.status" color="neutral" variant="soft" size="xs" class="ml-auto shrink-0">
|
|
215
|
+
{{ r.status }}
|
|
216
|
+
</UBadge>
|
|
217
|
+
</button>
|
|
218
|
+
|
|
219
|
+
<!-- Explicit URL/key reference (imported on add). -->
|
|
220
|
+
<button
|
|
221
|
+
v-if="refRow"
|
|
222
|
+
type="button"
|
|
223
|
+
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/70"
|
|
224
|
+
@click="pickRef(refRow)"
|
|
225
|
+
>
|
|
226
|
+
<UIcon name="i-lucide-link" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
|
227
|
+
<span class="truncate"
|
|
228
|
+
>Attach <span class="text-slate-200">{{ refRow }}</span> by reference</span
|
|
229
|
+
>
|
|
230
|
+
</button>
|
|
231
|
+
|
|
232
|
+
<p v-if="empty" class="px-2 py-1.5 text-[11px] text-slate-500">
|
|
233
|
+
{{
|
|
234
|
+
query.trim()
|
|
235
|
+
? 'No matching issues.'
|
|
236
|
+
: searchable
|
|
237
|
+
? 'Search by title, or pick an imported issue.'
|
|
238
|
+
: 'Paste an issue URL or key to attach it.'
|
|
239
|
+
}}
|
|
240
|
+
</p>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
</template>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// or turned directly into a new board task here — pick a container (service frame
|
|
6
6
|
// or module) and "Create task", which seeds a leaf block from the issue and links
|
|
7
7
|
// the issue to it for context.
|
|
8
|
-
import type { Block, TaskSourceKind } from '~/types/domain'
|
|
8
|
+
import type { Block, TaskSearchResult, TaskSourceKind } from '~/types/domain'
|
|
9
9
|
|
|
10
10
|
const ui = useUiStore()
|
|
11
11
|
const tasks = useTasksStore()
|
|
@@ -27,6 +27,47 @@ const sourceItems = computed(() =>
|
|
|
27
27
|
tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
|
|
28
28
|
)
|
|
29
29
|
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
30
|
+
const searchable = computed(() => descriptor.value?.searchable ?? false)
|
|
31
|
+
|
|
32
|
+
// Browse the tracker by free text so an issue can be turned into a task without
|
|
33
|
+
// knowing its key. Debounced; a created/imported hit also lands in the list below.
|
|
34
|
+
const searchQuery = ref('')
|
|
35
|
+
const searchResults = ref<TaskSearchResult[]>([])
|
|
36
|
+
const searching = ref(false)
|
|
37
|
+
const searchError = ref<string | null>(null)
|
|
38
|
+
|
|
39
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
40
|
+
watch([searchQuery, source], () => {
|
|
41
|
+
if (searchTimer) clearTimeout(searchTimer)
|
|
42
|
+
searchResults.value = []
|
|
43
|
+
searchError.value = null
|
|
44
|
+
const q = searchQuery.value.trim()
|
|
45
|
+
if (!q || !searchable.value) return
|
|
46
|
+
searchTimer = setTimeout(runSearch, 300)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
async function runSearch() {
|
|
50
|
+
const q = searchQuery.value.trim()
|
|
51
|
+
if (!q || !source.value) return
|
|
52
|
+
searching.value = true
|
|
53
|
+
searchError.value = null
|
|
54
|
+
try {
|
|
55
|
+
searchResults.value = await tasks.search(source.value, q)
|
|
56
|
+
} catch (e) {
|
|
57
|
+
searchResults.value = []
|
|
58
|
+
searchError.value = e instanceof Error ? e.message : String(e)
|
|
59
|
+
} finally {
|
|
60
|
+
searching.value = false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Search hits not yet imported (imported ones already render in the list below).
|
|
65
|
+
const importedIds = computed(
|
|
66
|
+
() => new Set(tasks.tasks.filter((t) => t.source === source.value).map((t) => t.externalId)),
|
|
67
|
+
)
|
|
68
|
+
const freshHits = computed(() =>
|
|
69
|
+
searchResults.value.filter((r) => !importedIds.value.has(r.externalId)),
|
|
70
|
+
)
|
|
30
71
|
|
|
31
72
|
const sourceTasks = computed(() =>
|
|
32
73
|
source.value ? tasks.tasks.filter((t) => t.source === source.value) : [],
|
|
@@ -52,6 +93,9 @@ const creatingId = ref<string | null>(null)
|
|
|
52
93
|
watch(open, (isOpen) => {
|
|
53
94
|
if (isOpen) {
|
|
54
95
|
ref_.value = ''
|
|
96
|
+
searchQuery.value = ''
|
|
97
|
+
searchResults.value = []
|
|
98
|
+
searchError.value = null
|
|
55
99
|
source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
|
|
56
100
|
containerId.value = containerItems.value[0]?.value
|
|
57
101
|
creatingId.value = null
|
|
@@ -59,10 +103,14 @@ watch(open, (isOpen) => {
|
|
|
59
103
|
}
|
|
60
104
|
})
|
|
61
105
|
|
|
62
|
-
|
|
106
|
+
// Create a board task from an issue, seeding its title/description from the issue
|
|
107
|
+
// and linking it back for writeback. A search hit isn't projected locally yet, so
|
|
108
|
+
// `needsImport` fetches + persists it first (create-block requires it imported).
|
|
109
|
+
async function createTask(externalId: string, needsImport = false) {
|
|
63
110
|
if (!source.value || !containerId.value) return
|
|
64
111
|
creatingId.value = externalId
|
|
65
112
|
try {
|
|
113
|
+
if (needsImport) await tasks.importTask(source.value, externalId)
|
|
66
114
|
const { block } = await tasks.createTaskFromIssue(source.value, externalId, containerId.value)
|
|
67
115
|
board.upsert(block as Block)
|
|
68
116
|
toast.add({ title: `Created task "${block.title}"`, icon: 'i-lucide-square-check' })
|
|
@@ -100,7 +148,7 @@ async function doImport() {
|
|
|
100
148
|
</script>
|
|
101
149
|
|
|
102
150
|
<template>
|
|
103
|
-
<UModal v-model:open="open" title="
|
|
151
|
+
<UModal v-model:open="open" title="Tracker issues">
|
|
104
152
|
<template #body>
|
|
105
153
|
<!-- Empty state: no source offered (none connected/installed, or all disabled) -->
|
|
106
154
|
<div v-if="!tasks.anyOffered" class="space-y-3 text-center">
|
|
@@ -146,21 +194,90 @@ async function doImport() {
|
|
|
146
194
|
</UButton>
|
|
147
195
|
</div>
|
|
148
196
|
|
|
197
|
+
<!-- Browse: search the tracker by title so an issue can be turned into a
|
|
198
|
+
task without knowing its key. -->
|
|
199
|
+
<UFormField v-if="searchable" label="Search issues">
|
|
200
|
+
<UInput
|
|
201
|
+
v-model="searchQuery"
|
|
202
|
+
:icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
|
|
203
|
+
:ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
|
|
204
|
+
placeholder="Search by title…"
|
|
205
|
+
class="w-full"
|
|
206
|
+
/>
|
|
207
|
+
</UFormField>
|
|
208
|
+
|
|
209
|
+
<!-- Shared target container for every "Create task" action below. -->
|
|
210
|
+
<UFormField
|
|
211
|
+
v-if="containerItems.length && (freshHits.length || sourceTasks.length)"
|
|
212
|
+
label="Create tasks in"
|
|
213
|
+
class="w-72"
|
|
214
|
+
>
|
|
215
|
+
<USelect
|
|
216
|
+
v-model="containerId"
|
|
217
|
+
:items="containerItems"
|
|
218
|
+
placeholder="Pick a frame or module"
|
|
219
|
+
class="w-full"
|
|
220
|
+
/>
|
|
221
|
+
</UFormField>
|
|
222
|
+
<p
|
|
223
|
+
v-else-if="!containerItems.length && (freshHits.length || sourceTasks.length)"
|
|
224
|
+
class="text-[11px] text-slate-500"
|
|
225
|
+
>
|
|
226
|
+
Add a service frame to the board first to create tasks from issues.
|
|
227
|
+
</p>
|
|
228
|
+
|
|
229
|
+
<!-- Search results (not yet imported): create a task directly from a hit. -->
|
|
230
|
+
<div v-if="searchError" class="text-[11px] text-amber-400">
|
|
231
|
+
Search failed: {{ searchError }}
|
|
232
|
+
</div>
|
|
233
|
+
<div v-if="freshHits.length" class="space-y-2">
|
|
234
|
+
<h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
235
|
+
Search results
|
|
236
|
+
</h3>
|
|
237
|
+
<div
|
|
238
|
+
v-for="hit in freshHits"
|
|
239
|
+
:key="`hit:${hit.source}:${hit.externalId}`"
|
|
240
|
+
class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
|
241
|
+
>
|
|
242
|
+
<div class="flex items-start justify-between gap-2">
|
|
243
|
+
<div class="min-w-0">
|
|
244
|
+
<a
|
|
245
|
+
:href="hit.url"
|
|
246
|
+
target="_blank"
|
|
247
|
+
rel="noopener"
|
|
248
|
+
class="truncate text-sm font-medium text-white hover:underline"
|
|
249
|
+
>
|
|
250
|
+
{{ hit.externalId }} · {{ hit.title }}
|
|
251
|
+
</a>
|
|
252
|
+
<p v-if="hit.excerpt" class="mt-0.5 line-clamp-2 text-xs text-slate-500">
|
|
253
|
+
{{ hit.excerpt }}
|
|
254
|
+
</p>
|
|
255
|
+
</div>
|
|
256
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
257
|
+
<UBadge v-if="hit.status" color="neutral" variant="soft" size="xs">
|
|
258
|
+
{{ hit.status }}
|
|
259
|
+
</UBadge>
|
|
260
|
+
<UButton
|
|
261
|
+
color="primary"
|
|
262
|
+
variant="soft"
|
|
263
|
+
size="xs"
|
|
264
|
+
icon="i-lucide-square-check"
|
|
265
|
+
:loading="creatingId === hit.externalId"
|
|
266
|
+
:disabled="!containerId || creatingId !== null"
|
|
267
|
+
@click="createTask(hit.externalId, true)"
|
|
268
|
+
>
|
|
269
|
+
Create task
|
|
270
|
+
</UButton>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
149
276
|
<!-- List of already-imported issues -->
|
|
150
277
|
<div v-if="sourceTasks.length" class="space-y-2">
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
</h3>
|
|
155
|
-
<UFormField v-if="containerItems.length" label="Create tasks in" size="xs" class="w-56">
|
|
156
|
-
<USelect
|
|
157
|
-
v-model="containerId"
|
|
158
|
-
:items="containerItems"
|
|
159
|
-
placeholder="Pick a frame or module"
|
|
160
|
-
class="w-full"
|
|
161
|
-
/>
|
|
162
|
-
</UFormField>
|
|
163
|
-
</div>
|
|
278
|
+
<h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
279
|
+
Imported issues
|
|
280
|
+
</h3>
|
|
164
281
|
<div
|
|
165
282
|
v-for="task in sourceTasks"
|
|
166
283
|
:key="`${task.source}:${task.externalId}`"
|
|
@@ -196,11 +313,13 @@ async function doImport() {
|
|
|
196
313
|
</div>
|
|
197
314
|
</div>
|
|
198
315
|
</div>
|
|
199
|
-
<p v-if="!containerItems.length" class="text-[11px] text-slate-500">
|
|
200
|
-
Add a service frame to the board first to create tasks from issues.
|
|
201
|
-
</p>
|
|
202
316
|
</div>
|
|
203
|
-
<p
|
|
317
|
+
<p
|
|
318
|
+
v-else-if="!freshHits.length && !searchQuery.trim()"
|
|
319
|
+
class="text-center text-xs text-slate-500"
|
|
320
|
+
>
|
|
321
|
+
No issues imported yet. Search above, or paste an issue URL/key to import one.
|
|
322
|
+
</p>
|
|
204
323
|
</div>
|
|
205
324
|
</template>
|
|
206
325
|
</UModal>
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
SourceTask,
|
|
4
4
|
TaskConnection,
|
|
5
5
|
TaskSearchResult,
|
|
6
|
+
TaskSourceDiagnostic,
|
|
6
7
|
TaskSourceKind,
|
|
7
8
|
TaskSourceState,
|
|
8
9
|
} from '~/types/domain'
|
|
@@ -41,6 +42,13 @@ export function tasksApi({ http, ws }: ApiContext) {
|
|
|
41
42
|
disconnectTaskSource: (workspaceId: string, source: TaskSourceKind) =>
|
|
42
43
|
http(`${ws(workspaceId)}/task-sources/${source}/connection`, { method: 'DELETE' }),
|
|
43
44
|
|
|
45
|
+
// Live "check setup" probe: authenticates against the source and reads a slice
|
|
46
|
+
// of its issues API, returning a classified verdict the panel renders verbatim.
|
|
47
|
+
checkTaskSource: (workspaceId: string, source: TaskSourceKind) =>
|
|
48
|
+
http<TaskSourceDiagnostic>(`${ws(workspaceId)}/task-sources/${source}/diagnostics`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
}),
|
|
51
|
+
|
|
44
52
|
listTasks: (workspaceId: string) => http<SourceTask[]>(`${ws(workspaceId)}/tasks`),
|
|
45
53
|
|
|
46
54
|
importTask: (workspaceId: string, source: TaskSourceKind, body: { ref: string }) =>
|
package/app/stores/tasks.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
SourceTask,
|
|
5
5
|
TaskConnection,
|
|
6
6
|
TaskSearchResult,
|
|
7
|
+
TaskSourceDiagnostic,
|
|
7
8
|
TaskSourceKind,
|
|
8
9
|
TaskSourceState,
|
|
9
10
|
} from '~/types/domain'
|
|
@@ -27,11 +28,21 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
27
28
|
|
|
28
29
|
/** null = unknown (not probed yet), true/false = integration on/off. */
|
|
29
30
|
const available = ref<boolean | null>(null)
|
|
31
|
+
/**
|
|
32
|
+
* Why the last probe failed, when it did — captured (rather than swallowed) so
|
|
33
|
+
* the settings panel can explain *why* nothing is surfaced (integration disabled
|
|
34
|
+
* vs a server/backend error) instead of a blanket "install integration first".
|
|
35
|
+
*/
|
|
36
|
+
const probeError = ref<{ status: number | null; message: string } | null>(null)
|
|
30
37
|
/** The configured sources, each with its descriptor + per-workspace state (available + enabled). */
|
|
31
38
|
const sources = ref<TaskSourceState[]>([])
|
|
32
39
|
/** Live connections, one per connected (credentialed) source. */
|
|
33
40
|
const connections = ref<TaskConnection[]>([])
|
|
34
41
|
const tasks = ref<SourceTask[]>([])
|
|
42
|
+
/** The last live setup-check verdict per source (from `checkSetup`). */
|
|
43
|
+
const diagnostics = ref<Partial<Record<TaskSourceKind, TaskSourceDiagnostic>>>({})
|
|
44
|
+
/** The source currently running a setup check, if any. */
|
|
45
|
+
const checking = ref<TaskSourceKind | null>(null)
|
|
35
46
|
const loading = ref(false)
|
|
36
47
|
|
|
37
48
|
/** Sources the workspace currently has a live connection to. */
|
|
@@ -85,16 +96,43 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
85
96
|
api.listTaskConnections(workspace.requireId()),
|
|
86
97
|
])
|
|
87
98
|
available.value = true
|
|
99
|
+
probeError.value = null
|
|
88
100
|
sources.value = srcs
|
|
89
101
|
connections.value = conns
|
|
90
|
-
} catch {
|
|
91
|
-
// 503 (integration disabled) or any error → hide the UI entry points
|
|
102
|
+
} catch (e) {
|
|
103
|
+
// 503 (integration disabled) or any error → hide the UI entry points, but keep
|
|
104
|
+
// the reason so the settings panel can explain it (a 503 is "turned off on this
|
|
105
|
+
// deployment"; a 500 is "the backend errored — e.g. a migration isn't applied").
|
|
92
106
|
available.value = false
|
|
107
|
+
const err = e as { statusCode?: number; data?: { error?: { message?: string } } }
|
|
108
|
+
const serverMessage = err?.data?.error?.message
|
|
109
|
+
probeError.value = {
|
|
110
|
+
status: err?.statusCode ?? null,
|
|
111
|
+
message: serverMessage || (e instanceof Error ? e.message : String(e)),
|
|
112
|
+
}
|
|
93
113
|
sources.value = []
|
|
94
114
|
connections.value = []
|
|
95
115
|
}
|
|
96
116
|
}
|
|
97
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Run a live setup check for a source (authenticate + read), caching the verdict
|
|
120
|
+
* so the panel can show exactly what's wrong (missing App / wrong token / lacking
|
|
121
|
+
* the Issues permission) and how to fix it. Re-probes on success so a
|
|
122
|
+
* just-fixed source flips `available`/`enabled` without a manual reload.
|
|
123
|
+
*/
|
|
124
|
+
async function checkSetup(source: TaskSourceKind): Promise<TaskSourceDiagnostic> {
|
|
125
|
+
checking.value = source
|
|
126
|
+
try {
|
|
127
|
+
const result = await api.checkTaskSource(workspace.requireId(), source)
|
|
128
|
+
diagnostics.value = { ...diagnostics.value, [source]: result }
|
|
129
|
+
if (result.ok) await probe()
|
|
130
|
+
return result
|
|
131
|
+
} finally {
|
|
132
|
+
checking.value = null
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
98
136
|
/** Connect the workspace to a source with its credential bag. */
|
|
99
137
|
async function connect(source: TaskSourceKind, credentials: Record<string, string>) {
|
|
100
138
|
const conn = await api.connectTaskSource(workspace.requireId(), source, credentials)
|
|
@@ -165,9 +203,12 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
165
203
|
|
|
166
204
|
return {
|
|
167
205
|
available,
|
|
206
|
+
probeError,
|
|
168
207
|
sources,
|
|
169
208
|
connections,
|
|
170
209
|
tasks,
|
|
210
|
+
diagnostics,
|
|
211
|
+
checking,
|
|
171
212
|
loading,
|
|
172
213
|
connectedSources,
|
|
173
214
|
anyConnected,
|
|
@@ -178,6 +219,7 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
178
219
|
isConnected,
|
|
179
220
|
tasksForBlock,
|
|
180
221
|
probe,
|
|
222
|
+
checkSetup,
|
|
181
223
|
connect,
|
|
182
224
|
disconnect,
|
|
183
225
|
setEnabled,
|
package/app/types/tasks.ts
CHANGED
|
@@ -37,6 +37,31 @@ export interface TaskSourceState extends TaskSourceDescriptor {
|
|
|
37
37
|
enabled: boolean
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The verdict of a live "check setup" probe against a source (mirrors
|
|
42
|
+
* `@cat-factory/contracts`). Unlike `available` (a passive row-exists flag) this
|
|
43
|
+
* is the result of actually authenticating + reading, so it distinguishes a
|
|
44
|
+
* configured-but-broken source from a working one.
|
|
45
|
+
*/
|
|
46
|
+
export type TaskSourceDiagnosticStatus =
|
|
47
|
+
| 'ready'
|
|
48
|
+
| 'not_installed'
|
|
49
|
+
| 'not_connected'
|
|
50
|
+
| 'auth_failed'
|
|
51
|
+
| 'forbidden'
|
|
52
|
+
| 'unreachable'
|
|
53
|
+
| 'error'
|
|
54
|
+
|
|
55
|
+
export interface TaskSourceDiagnostic {
|
|
56
|
+
source: TaskSourceKind
|
|
57
|
+
ok: boolean
|
|
58
|
+
status: TaskSourceDiagnosticStatus
|
|
59
|
+
/** A one-line, actionable explanation shown verbatim in the panel. */
|
|
60
|
+
message: string
|
|
61
|
+
/** Optional extra context (account login, repo count, signed-in user). */
|
|
62
|
+
detail?: string | null
|
|
63
|
+
}
|
|
64
|
+
|
|
40
65
|
/** A workspace's connection to a task source (never carries credentials). */
|
|
41
66
|
export interface TaskConnection {
|
|
42
67
|
source: TaskSourceKind
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
// Workspace settings: issue-tracker writeback. Two independent toggles that govern
|
|
3
|
-
// whether the engine writes back to a task's linked tracker issue(s) as its PR
|
|
4
|
-
// progresses — comment when the PR opens, and comment + close as resolved when it
|
|
5
|
-
// merges. Each is overridable per task in the inspector. Persisted on the workspace
|
|
6
|
-
// tracker settings (the selection + Jira project key are preserved on save).
|
|
7
|
-
import { onMounted, ref, watch } from 'vue'
|
|
8
|
-
|
|
9
|
-
const tracker = useTrackerStore()
|
|
10
|
-
const toast = useToast()
|
|
11
|
-
|
|
12
|
-
const commentOnPrOpen = ref(false)
|
|
13
|
-
const resolveOnMerge = ref(false)
|
|
14
|
-
const saving = ref(false)
|
|
15
|
-
|
|
16
|
-
// Sync the local toggles from the store on mount (the tab renders when Workspace
|
|
17
|
-
// settings opens) and whenever the stored settings change underneath.
|
|
18
|
-
function hydrate() {
|
|
19
|
-
commentOnPrOpen.value = tracker.settings.writebackCommentOnPrOpen
|
|
20
|
-
resolveOnMerge.value = tracker.settings.writebackResolveOnMerge
|
|
21
|
-
}
|
|
22
|
-
onMounted(hydrate)
|
|
23
|
-
watch(() => tracker.settings, hydrate, { deep: true })
|
|
24
|
-
|
|
25
|
-
async function save() {
|
|
26
|
-
saving.value = true
|
|
27
|
-
try {
|
|
28
|
-
// Preserve the tracker selection + Jira project key; only the writeback flags change.
|
|
29
|
-
await tracker.save({
|
|
30
|
-
tracker: tracker.settings.tracker,
|
|
31
|
-
jiraProjectKey: tracker.settings.jiraProjectKey,
|
|
32
|
-
writebackCommentOnPrOpen: commentOnPrOpen.value,
|
|
33
|
-
writebackResolveOnMerge: resolveOnMerge.value,
|
|
34
|
-
})
|
|
35
|
-
toast.add({ title: 'Writeback settings saved', icon: 'i-lucide-check', color: 'success' })
|
|
36
|
-
} catch (e) {
|
|
37
|
-
toast.add({
|
|
38
|
-
title: 'Could not save settings',
|
|
39
|
-
description: e instanceof Error ? e.message : String(e),
|
|
40
|
-
icon: 'i-lucide-triangle-alert',
|
|
41
|
-
color: 'error',
|
|
42
|
-
})
|
|
43
|
-
} finally {
|
|
44
|
-
saving.value = false
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
</script>
|
|
48
|
-
|
|
49
|
-
<template>
|
|
50
|
-
<div class="space-y-4">
|
|
51
|
-
<p class="text-xs text-slate-400">
|
|
52
|
-
When a task is linked to a tracker issue (GitHub Issues or Jira), write back to it as the
|
|
53
|
-
task's pull request progresses. Each toggle is the workspace default and can be overridden per
|
|
54
|
-
task in the inspector. GitHub issues close natively; Jira issues transition to the first
|
|
55
|
-
status in their <span class="text-slate-300">Done</span> category.
|
|
56
|
-
</p>
|
|
57
|
-
|
|
58
|
-
<label class="flex items-start gap-3 rounded-lg border border-slate-700 bg-slate-800/40 p-3">
|
|
59
|
-
<USwitch v-model="commentOnPrOpen" />
|
|
60
|
-
<span class="text-sm">
|
|
61
|
-
<span class="block text-slate-200">Comment when a PR opens</span>
|
|
62
|
-
<span class="block text-xs text-slate-500">
|
|
63
|
-
Post a comment on the linked issue with the new pull request's link.
|
|
64
|
-
</span>
|
|
65
|
-
</span>
|
|
66
|
-
</label>
|
|
67
|
-
|
|
68
|
-
<label class="flex items-start gap-3 rounded-lg border border-slate-700 bg-slate-800/40 p-3">
|
|
69
|
-
<USwitch v-model="resolveOnMerge" />
|
|
70
|
-
<span class="text-sm">
|
|
71
|
-
<span class="block text-slate-200">Close as resolved when a PR merges</span>
|
|
72
|
-
<span class="block text-xs text-slate-500">
|
|
73
|
-
Comment that the PR merged, then close / resolve the linked issue.
|
|
74
|
-
</span>
|
|
75
|
-
</span>
|
|
76
|
-
</label>
|
|
77
|
-
|
|
78
|
-
<div class="flex justify-end">
|
|
79
|
-
<UButton
|
|
80
|
-
color="primary"
|
|
81
|
-
variant="soft"
|
|
82
|
-
size="sm"
|
|
83
|
-
icon="i-lucide-save"
|
|
84
|
-
:loading="saving"
|
|
85
|
-
@click="save"
|
|
86
|
-
>
|
|
87
|
-
Save
|
|
88
|
-
</UButton>
|
|
89
|
-
</div>
|
|
90
|
-
</div>
|
|
91
|
-
</template>
|