Sistema de códigos jerárquicos 2-niveles + asignación determinista post-IA
- hierarchy.ts: Spike (S) agregado, buildHierarchyPath genera [E01-F04] (2 niveles)
Legacy [E05-F04-U01] preservado (regex opcional 3er segmento)
- hierarchy-generator.ts (nuevo): analyzeExisting() computa contadores por épica+tipo
assignEpicCodes() asigna E{max+1} secuencial
assignItemCodes() asigna {epic}-{tipo}{n+1} a cada HU dentro de su épica
- project-analyzer.ts: post-procesa épicas y HUs con generador de códigos
saveEpicDrafts usa epicCode en metadata y título con [E01]
- prompts-db.ts: prompt FASE 2 instruye a la IA a no generar códigos
- workitems.ts: EnrichedEpic._epicCode, EnrichedUserStory._epicCode/_itemCode
- DashboardView: muestra códigos en drafts y tabla de épicas
This commit is contained in:
@@ -0,0 +1,144 @@
|
|||||||
|
import { parseHierarchy, buildFullTitle, buildHierarchyPath, type ItemType } from '@/services/hierarchy'
|
||||||
|
import type { AnalysisEpic, AnalysisHU } from '@/services/project-analyzer'
|
||||||
|
import type { EnrichedEpic, EnrichedUserStory } from '@/stores/workitems'
|
||||||
|
|
||||||
|
export interface MaxCounters {
|
||||||
|
epicMax: number
|
||||||
|
byEpic: Map<string, Record<string, number>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITEM_TYPE_MAP: Record<string, ItemType> = {
|
||||||
|
feature: 'F',
|
||||||
|
task: 'T',
|
||||||
|
us: 'U',
|
||||||
|
bug: 'B',
|
||||||
|
spike: 'S',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analiza épicas e ítems existentes para determinar el próximo número disponible
|
||||||
|
* para cada nivel jerárquico.
|
||||||
|
*/
|
||||||
|
export function analyzeExisting(
|
||||||
|
epics: EnrichedEpic[],
|
||||||
|
items: EnrichedUserStory[],
|
||||||
|
): MaxCounters {
|
||||||
|
let epicMax = 0
|
||||||
|
const byEpic = new Map<string, Record<string, number>>()
|
||||||
|
|
||||||
|
function updateCounter(epicCode: string, type: string, num: number) {
|
||||||
|
if (!byEpic.has(epicCode)) byEpic.set(epicCode, {})
|
||||||
|
const map = byEpic.get(epicCode)!
|
||||||
|
const current = map[type] || 0
|
||||||
|
if (num > current) map[type] = num
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const epic of epics) {
|
||||||
|
const h = parseHierarchy(epic._cleanName || epic.name || epic.title || '')
|
||||||
|
if (!h) {
|
||||||
|
// Si no tiene código jerárquico, asumimos que usa el id numérico de KAPPA
|
||||||
|
const fakeH = parseHierarchy(buildFullTitle('', Math.max(epicMax, 1)))
|
||||||
|
if (fakeH) {
|
||||||
|
const code = fakeH.epicCode
|
||||||
|
if (code) {
|
||||||
|
const num = parseInt(code.slice(1), 10)
|
||||||
|
if (num > epicMax) epicMax = num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const epicItem = h.items.find(i => i.type === 'E')
|
||||||
|
if (epicItem) {
|
||||||
|
const code = `E${String(epicItem.number).padStart(2, '0')}`
|
||||||
|
if (epicItem.number > epicMax) epicMax = epicItem.number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const h = parseHierarchy(item._cleanTitle || item.title || '')
|
||||||
|
if (!h) continue
|
||||||
|
const epicItem = h.items.find(i => i.type === 'E')
|
||||||
|
if (!epicItem) continue
|
||||||
|
const epicCode = `E${String(epicItem.number).padStart(2, '0')}`
|
||||||
|
|
||||||
|
for (const seg of h.items) {
|
||||||
|
if (seg.type === 'E') continue
|
||||||
|
updateCounter(epicCode, seg.type, seg.number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { epicMax, byEpic }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asigna códigos jerárquicos a épicas propuestas y devuelve el array
|
||||||
|
* enriquecido con epicCode en metadata.
|
||||||
|
*/
|
||||||
|
export function assignEpicCodes(
|
||||||
|
proposedEpics: AnalysisEpic[],
|
||||||
|
counters: MaxCounters,
|
||||||
|
): (AnalysisEpic & { epicCode: string })[] {
|
||||||
|
let nextEpicNum = counters.epicMax + 1
|
||||||
|
|
||||||
|
return proposedEpics.map(epic => {
|
||||||
|
const epicCode = `E${String(nextEpicNum).padStart(2, '0')}`
|
||||||
|
nextEpicNum++
|
||||||
|
return { ...epic, epicCode }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asigna códigos jerárquicos a ítems propuestos dentro de épicas confirmadas.
|
||||||
|
* Requiere que cada HU tenga epicName (nombre de la épica) y type.
|
||||||
|
* Devuelve HUs con title y description enriquecidos con el código.
|
||||||
|
*/
|
||||||
|
export function assignItemCodes(
|
||||||
|
proposedHUs: AnalysisHU[],
|
||||||
|
confirmedEpics: { name: string; epicCode: string }[],
|
||||||
|
counters: MaxCounters,
|
||||||
|
): AnalysisHU[] {
|
||||||
|
const epicNameToCode = new Map<string, string>()
|
||||||
|
for (const e of confirmedEpics) {
|
||||||
|
epicNameToCode.set(e.name.toLowerCase().trim(), e.epicCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clonar counters para no mutar el original
|
||||||
|
const localCounters = new Map<string, Record<string, number>>()
|
||||||
|
for (const [key, val] of counters.byEpic) {
|
||||||
|
localCounters.set(key, { ...val })
|
||||||
|
}
|
||||||
|
|
||||||
|
return proposedHUs.map(hu => {
|
||||||
|
const epicName = (hu.epicName || '').toLowerCase().trim()
|
||||||
|
const epicCode = epicNameToCode.get(epicName) || ''
|
||||||
|
const epicNum = epicCode ? parseInt(epicCode.slice(1), 10) : 0
|
||||||
|
|
||||||
|
const itemType = ITEM_TYPE_MAP[(hu.type || 'feature').toLowerCase()] || 'F'
|
||||||
|
|
||||||
|
// Obtener próximo número para este tipo dentro de la épica
|
||||||
|
const epicMap = localCounters.get(epicCode) || {}
|
||||||
|
const nextNum = (epicMap[itemType] || 0) + 1
|
||||||
|
if (!localCounters.has(epicCode)) localCounters.set(epicCode, {})
|
||||||
|
localCounters.get(epicCode)![itemType] = nextNum
|
||||||
|
|
||||||
|
// Construir título y descripción con código
|
||||||
|
const fullTitle = buildFullTitle(hu.title, epicNum || undefined, itemType, nextNum)
|
||||||
|
const codeTag = buildHierarchyPath(epicNum || undefined, itemType, nextNum)
|
||||||
|
|
||||||
|
const typeLabel: Record<string, string> = {
|
||||||
|
feature: 'Feature', task: 'Tarea', us: 'HU', bug: 'Bug', spike: 'Spike',
|
||||||
|
}
|
||||||
|
|
||||||
|
const huType = (hu.type || 'feature').toLowerCase()
|
||||||
|
const descPrefix = `[Tipo: ${typeLabel[huType] || 'HU'} | Épica: ${epicCode} | Código: ${codeTag}]<br>`
|
||||||
|
const enrichedDesc = hu.description
|
||||||
|
? `${descPrefix}${hu.description}`
|
||||||
|
: descPrefix
|
||||||
|
|
||||||
|
return {
|
||||||
|
...hu,
|
||||||
|
title: fullTitle,
|
||||||
|
description: enrichedDesc,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+13
-15
@@ -1,10 +1,9 @@
|
|||||||
export type ItemType = 'E' | 'F' | 'U' | 'T' | 'B'
|
export type ItemType = 'E' | 'F' | 'U' | 'T' | 'B' | 'S'
|
||||||
|
|
||||||
export interface HierarchyInfo {
|
export interface HierarchyInfo {
|
||||||
fullPath: string
|
fullPath: string
|
||||||
items: { type: ItemType; number: number }[]
|
items: { type: ItemType; number: number }[]
|
||||||
epicCode: string | null
|
epicCode: string | null
|
||||||
featureCode: string | null
|
|
||||||
itemCode: string | null
|
itemCode: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,6 +13,7 @@ const TYPE_LABELS: Record<ItemType, string> = {
|
|||||||
U: 'HU',
|
U: 'HU',
|
||||||
T: 'Tarea',
|
T: 'Tarea',
|
||||||
B: 'Bug',
|
B: 'Bug',
|
||||||
|
S: 'Spike',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_COLORS: Record<ItemType, string> = {
|
const TYPE_COLORS: Record<ItemType, string> = {
|
||||||
@@ -22,6 +22,7 @@ const TYPE_COLORS: Record<ItemType, string> = {
|
|||||||
U: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
|
U: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
|
||||||
T: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
T: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||||
B: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400',
|
B: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400',
|
||||||
|
S: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_ICONS: Record<ItemType, string> = {
|
const TYPE_ICONS: Record<ItemType, string> = {
|
||||||
@@ -30,6 +31,7 @@ const TYPE_ICONS: Record<ItemType, string> = {
|
|||||||
U: '📋',
|
U: '📋',
|
||||||
T: '⚙️',
|
T: '⚙️',
|
||||||
B: '🐛',
|
B: '🐛',
|
||||||
|
S: '🔬',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTypeLabel(type: ItemType): string {
|
export function getTypeLabel(type: ItemType): string {
|
||||||
@@ -44,7 +46,7 @@ export function getTypeIcon(type: ItemType): string {
|
|||||||
return TYPE_ICONS[type] || '📄'
|
return TYPE_ICONS[type] || '📄'
|
||||||
}
|
}
|
||||||
|
|
||||||
const HIERARCHY_REGEX = /\[(([EFUTB]\d+)(?:-([EFUTB]\d+))?(?:-([EFUTB]\d+))?)\]/i
|
const HIERARCHY_REGEX = /\[(([EFUTBS]\d+)(?:-([EFUTBS]\d+))?(?:-([EFUTBS]\d+))?)\]/i
|
||||||
|
|
||||||
export function parseHierarchy(title: string): HierarchyInfo | null {
|
export function parseHierarchy(title: string): HierarchyInfo | null {
|
||||||
const match = title.match(HIERARCHY_REGEX)
|
const match = title.match(HIERARCHY_REGEX)
|
||||||
@@ -61,15 +63,12 @@ export function parseHierarchy(title: string): HierarchyInfo | null {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const epic = items.find(i => i.type === 'E')
|
const epic = items.find(i => i.type === 'E')
|
||||||
const feature = items.find(i => i.type === 'F')
|
|
||||||
const item = items[items.length - 1]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fullPath,
|
fullPath,
|
||||||
items,
|
items,
|
||||||
epicCode: epic ? `E${String(epic.number).padStart(2, '0')}` : null,
|
epicCode: epic ? `E${String(epic.number).padStart(2, '0')}` : null,
|
||||||
featureCode: feature ? `F${String(feature.number).padStart(2, '0')}` : null,
|
itemCode: items.length > 1 ? `${items[1].type}${String(items[1].number).padStart(2, '0')}` : null,
|
||||||
itemCode: item ? `${item.type}${String(item.number).padStart(2, '0')}` : null,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,25 +84,24 @@ export function getItemType(title: string): ItemType {
|
|||||||
|
|
||||||
export function buildHierarchyPath(
|
export function buildHierarchyPath(
|
||||||
epicNum?: number,
|
epicNum?: number,
|
||||||
featureNum?: number,
|
|
||||||
itemType?: ItemType,
|
itemType?: ItemType,
|
||||||
itemNum?: number,
|
itemNum?: number,
|
||||||
): string {
|
): string {
|
||||||
const parts: string[] = []
|
if (epicNum === undefined) return ''
|
||||||
if (epicNum !== undefined) parts.push(`E${String(epicNum).padStart(2, '0')}`)
|
const parts: string[] = [`E${String(epicNum).padStart(2, '0')}`]
|
||||||
if (featureNum !== undefined) parts.push(`F${String(featureNum).padStart(2, '0')}`)
|
if (itemType && itemNum !== undefined) {
|
||||||
if (itemType && itemNum !== undefined) parts.push(`${itemType}${String(itemNum).padStart(2, '0')}`)
|
parts.push(`${itemType}${String(itemNum).padStart(2, '0')}`)
|
||||||
return parts.length ? `[${parts.join('-')}]` : ''
|
}
|
||||||
|
return `[${parts.join('-')}]`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFullTitle(
|
export function buildFullTitle(
|
||||||
name: string,
|
name: string,
|
||||||
epicNum?: number,
|
epicNum?: number,
|
||||||
featureNum?: number,
|
|
||||||
itemType?: ItemType,
|
itemType?: ItemType,
|
||||||
itemNum?: number,
|
itemNum?: number,
|
||||||
): string {
|
): string {
|
||||||
const path = buildHierarchyPath(epicNum, featureNum, itemType, itemNum)
|
const path = buildHierarchyPath(epicNum, itemType, itemNum)
|
||||||
return path ? `${path} ${name}` : name
|
return path ? `${path} ${name}` : name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { callAI } from '@/services/ai'
|
|||||||
import { getPrompt } from '@/services/prompts-db'
|
import { getPrompt } from '@/services/prompts-db'
|
||||||
import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db'
|
import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db'
|
||||||
import { saveDraft, createDraftId } from '@/services/hu-drafts-db'
|
import { saveDraft, createDraftId } from '@/services/hu-drafts-db'
|
||||||
import type { EnrichedUserStory } from '@/stores/workitems'
|
import { analyzeExisting, assignEpicCodes, assignItemCodes } from '@/services/hierarchy-generator'
|
||||||
|
import type { EnrichedEpic, EnrichedUserStory } from '@/stores/workitems'
|
||||||
|
|
||||||
export interface AnalysisHU {
|
export interface AnalysisHU {
|
||||||
title: string
|
title: string
|
||||||
@@ -70,8 +71,9 @@ export async function analyzeProjectEpics(
|
|||||||
projectId: number,
|
projectId: number,
|
||||||
projectName: string,
|
projectName: string,
|
||||||
existingHUs: EnrichedUserStory[],
|
existingHUs: EnrichedUserStory[],
|
||||||
existingEpics: string[], // épicas que ya están en KAPPA
|
existingEpics: string[], // nombres de épicas que ya están en KAPPA
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
existingEpicItems?: EnrichedEpic[], // épicas enriquecidas para contar códigos
|
||||||
): Promise<AnalysisEpicsResult> {
|
): Promise<AnalysisEpicsResult> {
|
||||||
const context = await buildProjectContext(projectId, projectName, existingHUs)
|
const context = await buildProjectContext(projectId, projectName, existingHUs)
|
||||||
|
|
||||||
@@ -101,8 +103,14 @@ IMPORTANTE: Respondé SOLO con épicas en esta fase. NO generes HUs aún.`
|
|||||||
try {
|
try {
|
||||||
const jsonStr = extractJSON(content)
|
const jsonStr = extractJSON(content)
|
||||||
const parsed = JSON.parse(jsonStr)
|
const parsed = JSON.parse(jsonStr)
|
||||||
|
const rawEpics: AnalysisEpic[] = parsed.epics || parsed.epic || []
|
||||||
|
|
||||||
|
// Asignar códigos jerárquicos (E06, E07...) a las épicas propuestas
|
||||||
|
const counters = analyzeExisting(existingEpicItems || [], existingHUs)
|
||||||
|
const epicsWithCodes = assignEpicCodes(rawEpics, counters)
|
||||||
|
|
||||||
const result: AnalysisEpicsResult = {
|
const result: AnalysisEpicsResult = {
|
||||||
epics: parsed.epics || parsed.epic || [],
|
epics: epicsWithCodes,
|
||||||
summary: parsed.summary || '',
|
summary: parsed.summary || '',
|
||||||
rationale: parsed.rationale || parsed.summary || '',
|
rationale: parsed.rationale || parsed.summary || '',
|
||||||
}
|
}
|
||||||
@@ -153,11 +161,21 @@ IMPORTANTE: Respondé SOLO con HUs en esta fase. NO generes épicas nuevas.`
|
|||||||
try {
|
try {
|
||||||
const jsonStr = extractJSON(content)
|
const jsonStr = extractJSON(content)
|
||||||
const parsed = JSON.parse(jsonStr)
|
const parsed = JSON.parse(jsonStr)
|
||||||
|
const rawHUs: AnalysisHU[] = parsed.hus || []
|
||||||
|
|
||||||
|
// Asignar códigos jerárquicos (E01-F04, E01-T01...) a las HUs propuestas
|
||||||
|
const counters = analyzeExisting([], existingHUs)
|
||||||
|
const epicsWithCodes = confirmedEpics.map(e => ({
|
||||||
|
name: e.name,
|
||||||
|
epicCode: (e as any).epicCode || '',
|
||||||
|
}))
|
||||||
|
const husWithCodes = assignItemCodes(rawHUs, epicsWithCodes, counters)
|
||||||
|
|
||||||
const result: AnalysisHUsResult = {
|
const result: AnalysisHUsResult = {
|
||||||
hus: parsed.hus || [],
|
hus: husWithCodes,
|
||||||
summary: parsed.summary || '',
|
summary: parsed.summary || '',
|
||||||
}
|
}
|
||||||
console.log(`[Alpha] Phase 2 complete: ${result.hus.length} HUs generadas`)
|
console.log(`[Alpha] Phase 2 complete: ${result.hus.length} HUs generadas con códigos`)
|
||||||
return result
|
return result
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Alpha] Failed to parse HUs analysis. Raw:', content)
|
console.error('[Alpha] Failed to parse HUs analysis. Raw:', content)
|
||||||
@@ -176,18 +194,23 @@ export async function saveEpicDrafts(
|
|||||||
let skipped = 0
|
let skipped = 0
|
||||||
|
|
||||||
for (const epic of epics) {
|
for (const epic of epics) {
|
||||||
|
const epicWithCode = epic as any
|
||||||
|
const epicCode = epicWithCode.epicCode || ''
|
||||||
|
const fullTitle = epicCode ? `[${epicCode}] ${epic.name}` : epic.name
|
||||||
|
|
||||||
const normalizedName = epic.name.toLowerCase().trim()
|
const normalizedName = epic.name.toLowerCase().trim()
|
||||||
const isDuplicate = existingHUs.some(ex => (ex._cleanTitle || ex.title).toLowerCase().trim() === normalizedName)
|
const isDuplicate = existingHUs.some(ex => (ex._cleanTitle || ex.title).toLowerCase().trim() === normalizedName)
|
||||||
if (isDuplicate) { skipped++; continue }
|
if (isDuplicate) { skipped++; continue }
|
||||||
|
|
||||||
await saveDraft({
|
await saveDraft({
|
||||||
id: createDraftId(), projectId, title: epic.name,
|
id: createDraftId(), projectId, title: fullTitle,
|
||||||
description: epic.description, acceptanceCriteria: '',
|
description: epic.description, acceptanceCriteria: '',
|
||||||
priority: 'Media', type: 'E',
|
priority: 'Media', type: 'E',
|
||||||
metadata: JSON.stringify({
|
metadata: JSON.stringify({
|
||||||
linkedHuTitles: epic.linkedHuTitles,
|
linkedHuTitles: epic.linkedHuTitles,
|
||||||
estimatedStart: epic.estimatedStart,
|
estimatedStart: epic.estimatedStart,
|
||||||
estimatedEnd: epic.estimatedEnd,
|
estimatedEnd: epic.estimatedEnd,
|
||||||
|
epicCode,
|
||||||
}),
|
}),
|
||||||
syncStatus: 'draft', createdAt: new Date().toISOString(),
|
syncStatus: 'draft', createdAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ Formato de respuesta FASE 1:
|
|||||||
--- FASE 2: Generar HUs dentro de Épicas ---
|
--- FASE 2: Generar HUs dentro de Épicas ---
|
||||||
Instrucciones específicas:
|
Instrucciones específicas:
|
||||||
- Las épicas ya están definidas. Generá las HUs que pertenecen a cada una.
|
- Las épicas ya están definidas. Generá las HUs que pertenecen a cada una.
|
||||||
- Cada HU debe tener un campo "epicName" indicando a qué épica pertenece
|
- NO generes códigos jerárquicos en los títulos. El sistema los asigna después.
|
||||||
|
- Proponé solo el nombre del ítem sin prefijos como [E01-F04].
|
||||||
|
- Cada HU debe tener un campo "epicName" con el NOMBRE de la épica a la que pertenece.
|
||||||
- Tipos de HU: feature, task, US (historia de usuario), bug, spike
|
- Tipos de HU: feature, task, US (historia de usuario), bug, spike
|
||||||
- Incluí: título, descripción, criterios de aceptación, prioridad, story points, tipo, feature, sprint estimado
|
- Incluí: título, descripción, criterios de aceptación, prioridad, story points, tipo, feature, sprint estimado
|
||||||
- No generes HUs duplicadas con las existentes
|
- No generes HUs duplicadas con las existentes
|
||||||
@@ -87,7 +89,7 @@ Formato de respuesta FASE 2:
|
|||||||
{
|
{
|
||||||
"hus": [
|
"hus": [
|
||||||
{
|
{
|
||||||
"title": "Título de la HU",
|
"title": "Nombre del ítem (sin código jerárquico)",
|
||||||
"description": "Descripción detallada",
|
"description": "Descripción detallada",
|
||||||
"acceptance_criteria": ["Criterio 1", "Criterio 2"],
|
"acceptance_criteria": ["Criterio 1", "Criterio 2"],
|
||||||
"priority": "Alta|Media|Baja",
|
"priority": "Alta|Media|Baja",
|
||||||
@@ -95,7 +97,7 @@ Formato de respuesta FASE 2:
|
|||||||
"type": "feature|task|bug|spike",
|
"type": "feature|task|bug|spike",
|
||||||
"feature": "Nombre de la feature",
|
"feature": "Nombre de la feature",
|
||||||
"sprint": 12,
|
"sprint": 12,
|
||||||
"epicName": "Nombre de la épica a la que pertenece"
|
"epicName": "Nombre exacto de la épica a la que pertenece"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"summary": "Resumen de las HUs generadas"
|
"summary": "Resumen de las HUs generadas"
|
||||||
|
|||||||
@@ -19,12 +19,15 @@ export interface EnrichedUserStory extends KappaUserStory {
|
|||||||
_assignedName: string
|
_assignedName: string
|
||||||
_statusName: string
|
_statusName: string
|
||||||
_assignedEmployeeId: number | null
|
_assignedEmployeeId: number | null
|
||||||
|
_epicCode: string | null
|
||||||
|
_itemCode: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnrichedEpic extends KappaEpicDevelopment {
|
export interface EnrichedEpic extends KappaEpicDevelopment {
|
||||||
_itemType: ItemType
|
_itemType: ItemType
|
||||||
_hierarchyPath: string | null
|
_hierarchyPath: string | null
|
||||||
_cleanName: string
|
_cleanName: string
|
||||||
|
_epicCode: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWorkItemsStore = defineStore('workitems', () => {
|
export const useWorkItemsStore = defineStore('workitems', () => {
|
||||||
@@ -61,6 +64,7 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
_itemType: h?.items[h.items.length - 1]?.type || 'E',
|
_itemType: h?.items[h.items.length - 1]?.type || 'E',
|
||||||
_hierarchyPath: h?.fullPath ?? null,
|
_hierarchyPath: h?.fullPath ?? null,
|
||||||
_cleanName: h ? stripHierarchy(title) : title,
|
_cleanName: h ? stripHierarchy(title) : title,
|
||||||
|
_epicCode: h?.epicCode ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +117,8 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
_assignedName: assignedName,
|
_assignedName: assignedName,
|
||||||
_statusName: hu.status_name || resolveStatusName(hu.status),
|
_statusName: hu.status_name || resolveStatusName(hu.status),
|
||||||
_assignedEmployeeId: employeeId,
|
_assignedEmployeeId: employeeId,
|
||||||
|
_epicCode: h?.epicCode ?? null,
|
||||||
|
_itemCode: h?.itemCode ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,22 @@ function getEpicLinkedHUs(d: HuDraftRecord): string[] {
|
|||||||
return meta.linkedHuTitles || []
|
return meta.linkedHuTitles || []
|
||||||
} catch { return [] }
|
} catch { return [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEpicCode(d: HuDraftRecord): string {
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(d.metadata || '{}')
|
||||||
|
return meta.epicCode || ''
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemCode(d: HuDraftRecord): string {
|
||||||
|
const match = d.title.match(/\[([^\]]+)\]/)
|
||||||
|
return match ? match[1] : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripEpicTitle(title: string): string {
|
||||||
|
return title.replace(/^\[[^\]]+\]\s*/, '')
|
||||||
|
}
|
||||||
const drafts = ref<HuDraftRecord[]>([])
|
const drafts = ref<HuDraftRecord[]>([])
|
||||||
const pushingDraftId = ref<string | null>(null)
|
const pushingDraftId = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -427,6 +443,7 @@ async function runPhaseEpics() {
|
|||||||
project.value.id, project.value.name || '',
|
project.value.id, project.value.name || '',
|
||||||
workItems.userStories, existingEpics,
|
workItems.userStories, existingEpics,
|
||||||
analysisAbort.value?.signal,
|
analysisAbort.value?.signal,
|
||||||
|
workItems.epics,
|
||||||
)
|
)
|
||||||
analysisMessage.value = result.rationale
|
analysisMessage.value = result.rationale
|
||||||
|
|
||||||
@@ -699,7 +716,9 @@ const statusLabel = (status: unknown) => {
|
|||||||
<Badge variant="outline" class="text-[10px]" :class="d.type === 'E' ? 'border-purple-300 text-purple-600' : ''">
|
<Badge variant="outline" class="text-[10px]" :class="d.type === 'E' ? 'border-purple-300 text-purple-600' : ''">
|
||||||
{{ d.type === 'E' ? 'Épica' : 'HU' }}
|
{{ d.type === 'E' ? 'Épica' : 'HU' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<p class="font-medium">{{ d.title }}</p>
|
<span v-if="d.type === 'E'" class="font-mono text-[10px] text-muted-foreground shrink-0">[{{ getEpicCode(d) }}]</span>
|
||||||
|
<span v-else class="font-mono text-[10px] text-muted-foreground shrink-0">{{ getItemCode(d) }}</span>
|
||||||
|
<p class="font-medium">{{ d.type === 'E' ? stripEpicTitle(d.title) : d.title }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="d.type === 'E' && d.metadata" class="text-xs text-muted-foreground mt-0.5">
|
<div v-if="d.type === 'E' && d.metadata" class="text-xs text-muted-foreground mt-0.5">
|
||||||
<span v-if="getEpicLinkedHUs(d).length > 0">
|
<span v-if="getEpicLinkedHUs(d).length > 0">
|
||||||
@@ -758,7 +777,7 @@ const statusLabel = (status: unknown) => {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-for="epic in workItems.epics" :key="epic.id">
|
<TableRow v-for="epic in workItems.epics" :key="epic.id">
|
||||||
<TableCell class="font-mono text-xs text-muted-foreground">{{ epic.code || `EP-${epic.id}` }}</TableCell>
|
<TableCell class="font-mono text-xs text-muted-foreground">{{ epic._epicCode || epic.code || `EP-${epic.id}` }}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span class="inline-flex items-center rounded px-1 py-0.5 text-[10px] font-bold" :class="getTypeColor(epic._itemType)">{{ getTypeLabel(epic._itemType) }}</span>
|
<span class="inline-flex items-center rounded px-1 py-0.5 text-[10px] font-bold" :class="getTypeColor(epic._itemType)">{{ getTypeLabel(epic._itemType) }}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
Reference in New Issue
Block a user