Skip to content

Commit 49879b2

Browse files
committed
feat(buds): edit impacted_repos from BUD detail
The tech-arch agent's initial guess at which repos a BUD touches is often close but not perfect — and scope sometimes drifts mid-build. This adds an Edit affordance next to the impacted-repos chip row on ``BUDDevelopmentPanel.vue`` so a PM can correct the list without re-running the agent or hand-editing JSON. UI - ``BUDImpactedReposDialog.vue`` multi-select bound to the org's ``active`` tracked repos. Filter-by-name field, in-flight loader, per-row checkbox, count chip with ``aria-live`` so screen readers announce selection changes. - Repos already on the BUD but no longer ``active`` in the tracked list render as separate "retired" rows, pre-checked. The user consciously preserves or drops them — never silently erased. - Always refreshes the tracked-repo list on open (one cheap GET) so a repo added in another tab shows up in the picker before driving a destructive PATCH. In-flight ``fetchRepos`` is memoised so reopen-during-load is safe. - Save → ``PATCH /v1/buds/{id}`` with the full new list. Clearing every row when the BUD previously had repos requires a confirm prompt — release-stage tabs and code-review status both go quiet on empty, so the consequence is surfaced before the write lands. - Empty-state copy on the chip row is reworded to read in any phase (the previous imperative read as tech_arch-only). Plumbing - ``BUDDevelopmentPanel`` emits ``refresh-bud`` after a successful save; ``BUDDetail`` calls ``budStore.fetchBUD`` so the chip row updates everywhere and any downstream panel relying on ``bud.impacted_repos`` picks up the new list. - Dialog is non-persistent so Esc and backdrop-click work; the ``persistent`` flag only activates during the in-flight save. Backend already accepts ``impacted_repos`` on the ``BUDUpdate`` PATCH schema and logs ``bud_impacted_repos_edited_post_planning`` for mid-development edits — both landed in the per-BUD branch override PR this stacks on. Signed-off-by: Arun Rajkumar <mickyarunr@gmail.com>
1 parent 9429daa commit 49879b2

3 files changed

Lines changed: 344 additions & 3 deletions

File tree

frontend/src/components/buds/BUDDevelopmentPanel.vue

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,23 @@
3434
<MCPSetupHint purpose="development" class="mb-4" />
3535

3636
<!-- Impacted repos hint -->
37-
<div v-if="impactedRepos && impactedRepos.length" class="mb-4">
38-
<div class="text-caption text-medium-emphasis mb-1">Impacted repositories</div>
39-
<div class="d-flex ga-2 flex-wrap justify-center">
37+
<div class="mb-4">
38+
<div class="d-flex align-center justify-center ga-2 mb-1">
39+
<span class="text-caption text-medium-emphasis">Impacted repositories</span>
40+
<v-btn
41+
size="x-small"
42+
variant="text"
43+
density="compact"
44+
prepend-icon="mdi-pencil-outline"
45+
@click="editReposOpen = true"
46+
>
47+
Edit
48+
</v-btn>
49+
</div>
50+
<div
51+
v-if="impactedRepos && impactedRepos.length"
52+
class="d-flex ga-2 flex-wrap justify-center"
53+
>
4054
<v-chip
4155
v-for="r in impactedRepos"
4256
:key="r.repo_id || r.repo_name"
@@ -47,6 +61,10 @@
4761
{{ r.repo_name }}
4862
</v-chip>
4963
</div>
64+
<div v-else class="text-caption text-medium-emphasis text-center">
65+
No impacted repositories yet. Add the repos this BUD touches
66+
so PRs and merges link back here.
67+
</div>
5068
</div>
5169

5270
<div class="d-flex ga-2 justify-center">
@@ -232,11 +250,19 @@
232250
</v-expansion-panel>
233251
</v-expansion-panels>
234252
</template>
253+
254+
<BUDImpactedReposDialog
255+
v-model="editReposOpen"
256+
:bud-id="budId"
257+
:current="impactedRepos ?? null"
258+
@saved="$emit('refresh-bud')"
259+
/>
235260
</div>
236261
</template>
237262

238263
<script setup lang="ts">
239264
import { computed, onMounted, ref, watch } from 'vue'
265+
import BUDImpactedReposDialog from '@/components/buds/BUDImpactedReposDialog.vue'
240266
import { useBudActivity } from '@/composables/useBudActivity'
241267
import { useSettingsStore } from '@/stores/settings'
242268
import { fileColor, fileIcon, parseFiles, timeAgo } from '@/utils/dev-activity-helpers'
@@ -252,8 +278,13 @@ const props = defineProps<{
252278
253279
defineEmits<{
254280
(e: 'download-tech-spec'): void
281+
/** Fired after the user saves an impacted_repos edit. Parent reloads
282+
* the BUD so the chip row updates everywhere. */
283+
(e: 'refresh-bud'): void
255284
}>()
256285
286+
const editReposOpen = ref(false)
287+
257288
// When QA automation is enabled for the org, the testing tab pulls
258289
// commits from QA-role users into its own activity stream. We exclude
259290
// those rows from the dev tab so the same commit doesn't appear twice.
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
<!--
2+
* Copyright 2025-2026 Arun Rajkumar
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
-->
16+
17+
<!--
18+
Edit the impacted_repos array on a BUD. The tech-arch agent's
19+
initial guess is often close but not perfect; this dialog lets the
20+
user correct or extend the set without having to re-run the agent.
21+
22+
Persistence is a single PATCH /v1/buds/{id} with the full new list
23+
(the column has no merge key, so partial edits don't make sense).
24+
Removing a repo only hides its PRs from the release-stage tabs —
25+
the PR rows themselves keep their bud_id so the backend log
26+
``bud_impacted_repos_edited_post_planning`` captures the audit trail.
27+
-->
28+
29+
<template>
30+
<v-dialog
31+
v-model="open"
32+
max-width="560"
33+
:persistent="saving"
34+
>
35+
<v-card>
36+
<v-card-title class="d-flex align-center ga-2">
37+
<v-icon icon="mdi-source-repository-multiple" />
38+
Impacted repositories
39+
</v-card-title>
40+
<v-card-text>
41+
<p class="text-body-2 text-medium-emphasis mb-3">
42+
Pick every repo this BUD touches. The release-stage tabs filter
43+
their open-PR list by this set, and the code-review status feed
44+
watches these repos for merges.
45+
</p>
46+
<v-progress-linear
47+
v-if="trackedReposLoading"
48+
indeterminate
49+
color="primary"
50+
class="mb-3"
51+
/>
52+
<v-text-field
53+
v-model.trim="filter"
54+
density="compact"
55+
variant="outlined"
56+
prepend-inner-icon="mdi-magnify"
57+
placeholder="Filter by name or path"
58+
hide-details
59+
class="mb-3"
60+
:disabled="saving"
61+
/>
62+
<div
63+
v-if="!visibleRepos.length && !retiredRows.length && !trackedReposLoading"
64+
class="text-caption text-medium-emphasis text-center py-6"
65+
>
66+
No tracked repos match
67+
<span v-if="filter">"<code>{{ filter }}</code>"</span>.
68+
<span v-else>Add a repo in Settings first.</span>
69+
</div>
70+
<div v-else class="bud-impacted-repos__list">
71+
<v-checkbox
72+
v-for="repo in visibleRepos"
73+
:key="repo.id"
74+
v-model="selectedIds"
75+
:value="repo.id"
76+
density="compact"
77+
hide-details
78+
:disabled="saving"
79+
>
80+
<template #label>
81+
<div class="d-flex align-center ga-2 flex-grow-1 min-w-0">
82+
<v-icon icon="mdi-source-repository" size="16" />
83+
<span class="text-body-2 flex-shrink-0">{{ repo.name }}</span>
84+
<span class="text-caption text-medium-emphasis text-truncate">{{ repo.path }}</span>
85+
</div>
86+
</template>
87+
</v-checkbox>
88+
89+
<!-- Retired-but-still-on-the-BUD rows: keep them checkable so
90+
the user can preserve the legacy assignment, but mark them
91+
clearly so accidentally re-saving with the checkbox off
92+
(an effective remove) is a conscious choice. -->
93+
<v-checkbox
94+
v-for="retired in retiredRows"
95+
:key="`retired:${retired.id}`"
96+
v-model="selectedIds"
97+
:value="retired.id"
98+
density="compact"
99+
hide-details
100+
:disabled="saving"
101+
>
102+
<template #label>
103+
<div class="d-flex align-center ga-2 flex-grow-1 min-w-0">
104+
<v-icon icon="mdi-source-repository" size="16" color="warning" />
105+
<span class="text-body-2 flex-shrink-0">{{ retired.name }}</span>
106+
<v-chip size="x-small" variant="tonal" color="warning">retired</v-chip>
107+
</div>
108+
</template>
109+
</v-checkbox>
110+
</div>
111+
<p v-if="error" class="text-caption text-error mt-2">{{ error }}</p>
112+
</v-card-text>
113+
<v-card-actions class="px-4 pb-4">
114+
<v-btn
115+
variant="text"
116+
:disabled="saving"
117+
@click="cancel"
118+
>
119+
Cancel
120+
</v-btn>
121+
<v-spacer />
122+
<span
123+
class="text-caption text-medium-emphasis mr-3"
124+
aria-live="polite"
125+
>
126+
{{ selectedIds.length }} selected
127+
</span>
128+
<v-btn
129+
color="primary"
130+
variant="flat"
131+
:loading="saving"
132+
@click="save"
133+
>
134+
Save
135+
</v-btn>
136+
</v-card-actions>
137+
</v-card>
138+
</v-dialog>
139+
</template>
140+
141+
<script setup lang="ts">
142+
import { computed, ref, watch } from 'vue'
143+
import api from '@/services/api'
144+
import { useSettingsStore } from '@/stores/settings'
145+
146+
interface ImpactedRepo {
147+
repo_id?: string
148+
repo_name: string
149+
}
150+
151+
const props = defineProps<{
152+
modelValue: boolean
153+
budId: string
154+
/** Current impacted_repos array — the dialog initialises selection
155+
* from this on open and PATCHes the replacement on save. */
156+
current: ImpactedRepo[] | null
157+
}>()
158+
159+
const emit = defineEmits<{
160+
(e: 'update:modelValue', value: boolean): void
161+
/** Fired after a successful PATCH. Parent should re-fetch the BUD. */
162+
(e: 'saved'): void
163+
}>()
164+
165+
const settingsStore = useSettingsStore()
166+
const trackedReposLoading = ref(false)
167+
const filter = ref('')
168+
const selectedIds = ref<string[]>([])
169+
const saving = ref(false)
170+
const error = ref<string | null>(null)
171+
172+
// In-flight ``fetchRepos`` promise. Subsequent opens reuse the same
173+
// promise instead of spawning a second GET, so reopen-during-load is
174+
// safe and ``trackedReposLoading`` stays accurate.
175+
let inFlightFetch: Promise<void> | null = null
176+
177+
// Rows that ARE on the BUD but are no longer ``active`` in the tracked
178+
// repo list. Rendered separately with a "retired" chip so the user can
179+
// either keep the legacy assignment by leaving them checked, or
180+
// consciously drop them by unchecking — neither path is silent.
181+
interface RetiredRow { id: string; name: string }
182+
const retiredRows = ref<RetiredRow[]>([])
183+
184+
const open = computed({
185+
get: () => props.modelValue,
186+
set: (v: boolean) => emit('update:modelValue', v),
187+
})
188+
189+
// Only ``active`` tracked repos can be chosen. ``ignored`` / ``removed``
190+
// rows are excluded so the user cannot accidentally re-introduce a repo
191+
// the workspace admin has retired.
192+
const activeRepos = computed(() =>
193+
settingsStore.repos.filter((r) => r.status === 'active'),
194+
)
195+
196+
const visibleRepos = computed(() => {
197+
const term = filter.value.toLowerCase()
198+
if (!term) return activeRepos.value
199+
return activeRepos.value.filter(
200+
(r) =>
201+
r.name.toLowerCase().includes(term)
202+
|| (r.path?.toLowerCase().includes(term) ?? false),
203+
)
204+
})
205+
206+
async function refreshRepos(): Promise<void> {
207+
// Always refresh on open so a repo added in another tab shows up in
208+
// the picker before driving a destructive PATCH. Re-uses the in-flight
209+
// promise if another open is mid-fetch.
210+
if (inFlightFetch) {
211+
await inFlightFetch
212+
return
213+
}
214+
trackedReposLoading.value = true
215+
inFlightFetch = settingsStore.fetchRepos()
216+
try {
217+
await inFlightFetch
218+
} finally {
219+
trackedReposLoading.value = false
220+
inFlightFetch = null
221+
}
222+
}
223+
224+
watch(open, async (isOpen) => {
225+
if (!isOpen) return
226+
error.value = null
227+
filter.value = ''
228+
await refreshRepos()
229+
230+
// Partition the BUD's current rows against the freshly-loaded active
231+
// repo set: matches go into the selection (auto-checked), misses
232+
// surface as retired-but-still-on-the-BUD rows so the user can keep
233+
// them checked or consciously drop them.
234+
const activeIds = new Set(activeRepos.value.map((r) => r.id))
235+
const seedSelected: string[] = []
236+
const seedRetired: RetiredRow[] = []
237+
for (const r of props.current ?? []) {
238+
if (typeof r.repo_id !== 'string') continue
239+
if (activeIds.has(r.repo_id)) {
240+
seedSelected.push(r.repo_id)
241+
} else {
242+
seedRetired.push({ id: r.repo_id, name: r.repo_name })
243+
}
244+
}
245+
selectedIds.value = [...seedSelected, ...seedRetired.map((r) => r.id)]
246+
retiredRows.value = seedRetired
247+
})
248+
249+
function cancel(): void {
250+
if (saving.value) return
251+
open.value = false
252+
}
253+
254+
async function save(): Promise<void> {
255+
// Conscious-clear gate: dropping every repo from a BUD that previously
256+
// had some hides its PRs from the release-stage tabs and removes the
257+
// signal that smart-assignment + code-review status depend on. Surface
258+
// the consequence before letting the user save the empty list.
259+
const previouslyHadRepos = (props.current ?? []).length > 0
260+
if (selectedIds.value.length === 0 && previouslyHadRepos) {
261+
const ok = window.confirm(
262+
'Clear all impacted repositories?\n\n'
263+
+ 'The release-stage tabs will no longer surface open PRs for this BUD, '
264+
+ 'and code-review status will go quiet until at least one repo is added back.',
265+
)
266+
if (!ok) return
267+
}
268+
269+
saving.value = true
270+
error.value = null
271+
try {
272+
// Send the full new list, not a diff — the column has no per-row
273+
// merge identity. Retired rows the user kept checked are preserved
274+
// with their original repo_name (the active repos list does not
275+
// know them) so closing then reopening the dialog shows the same
276+
// state. The PATCH validator accepts the exact JSONB shape the
277+
// tech-arch agent originally wrote.
278+
const retiredById = new Map(retiredRows.value.map((r) => [r.id, r]))
279+
const next: ImpactedRepo[] = selectedIds.value.map((id) => {
280+
const retired = retiredById.get(id)
281+
if (retired) return { repo_id: id, repo_name: retired.name }
282+
const repo = activeRepos.value.find((r) => r.id === id)
283+
return { repo_id: id, repo_name: repo?.name ?? '' }
284+
})
285+
await api.patch(`/v1/buds/${props.budId}`, { impacted_repos: next })
286+
emit('saved')
287+
open.value = false
288+
} catch (e: unknown) {
289+
const msg = (e as { response?: { data?: { detail?: string } } })
290+
?.response?.data?.detail
291+
error.value = msg ?? 'Failed to update impacted repos. Please retry.'
292+
} finally {
293+
saving.value = false
294+
}
295+
}
296+
</script>
297+
298+
<style scoped>
299+
.bud-impacted-repos__list {
300+
max-height: 280px;
301+
overflow-y: auto;
302+
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
303+
border-radius: 8px;
304+
padding: 4px 8px;
305+
}
306+
.min-w-0 {
307+
min-width: 0;
308+
}
309+
</style>

frontend/src/views/buds/BUDDetail.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@
324324
:has-tech-spec="!!bud.tech_spec_md"
325325
:impacted-repos="bud.impacted_repos"
326326
@download-tech-spec="downloadSection('tech_spec_md')"
327+
@refresh-bud="budStore.fetchBUD(bud.id)"
327328
/>
328329
</v-tabs-window-item>
329330

0 commit comments

Comments
 (0)