|
| 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> |
0 commit comments