@slorenzot/mcp-azure 2.4.5 → 2.6.1
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/CODIFICACION_URL.md +115 -0
- package/PRD_CONCURRENCIA_ADD_ATTACHMENT.md +512 -0
- package/RELEASE_BUG_FIXES.md +164 -0
- package/RELEASE_HISTORICAL.md +85 -0
- package/RELEASE_V2_4_0.md +63 -0
- package/RELEASE_V2_6_0.md +423 -0
- package/RELEASE_V2_6_1.md +350 -0
- package/dist/index.js +616 -250
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Guía de Codificación URL para Azure DevOps MCP
|
|
2
|
+
|
|
3
|
+
## ¿Cuándo se necesita codificar el nombre del proyecto?
|
|
4
|
+
|
|
5
|
+
El MCP maneja automáticamente la codificación URL. Aquí están las reglas:
|
|
6
|
+
|
|
7
|
+
### Operaciones que REQUIEREN codificación (automática):
|
|
8
|
+
|
|
9
|
+
1. **📎 Adjuntos de archivos**
|
|
10
|
+
- `ado_upload_attachment()`
|
|
11
|
+
- `ado_add_attachment()`
|
|
12
|
+
- Razón: Estas funciones construyen URLs REST manualmente
|
|
13
|
+
|
|
14
|
+
### Operaciones que NO requieren codificación (automática):
|
|
15
|
+
|
|
16
|
+
1. **📋 Work Items**
|
|
17
|
+
- Crear/actualizar Work Items
|
|
18
|
+
- Consultas WIQL
|
|
19
|
+
- Tipos de Work Item
|
|
20
|
+
|
|
21
|
+
2. **🌐 Git Operations**
|
|
22
|
+
- Pull Requests
|
|
23
|
+
- Repositorios
|
|
24
|
+
- Branches
|
|
25
|
+
|
|
26
|
+
3. **📅 Proyecto Configuration**
|
|
27
|
+
- Iteraciones/Sprints
|
|
28
|
+
- Áreas
|
|
29
|
+
|
|
30
|
+
## Ejemplos de Nombres de Proyecto
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"azure-devops": {
|
|
36
|
+
"env": {
|
|
37
|
+
"AZURE_DEVOPS_PROJECT": "Mi Proyecto" // Se codificará automáticamente a "Mi%20Proyecto"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Mensajes del Sistema
|
|
45
|
+
|
|
46
|
+
Cuando el MCP se inicia, verás mensajes como:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
✅ Conexión establecida exitosamente:
|
|
50
|
+
- Organización: https://dev.azure.com/mi-org
|
|
51
|
+
- Proyecto: "Mi Proyecto"
|
|
52
|
+
- Fuente: .mcp.json
|
|
53
|
+
- Requiere codificación URL: SÍ
|
|
54
|
+
- Versión codificada: "Mi%20Proyecto"
|
|
55
|
+
- NOTA: Solo se usa en operaciones REST manuales (adjuntos)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Configuración Recomendada
|
|
59
|
+
|
|
60
|
+
### Opción 1: Archivo .mcp.json (Recomendado)
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"azure-devops": {
|
|
66
|
+
"command": "npx",
|
|
67
|
+
"args": ["-y", "@slorenzot/mcp-azure"],
|
|
68
|
+
"env": {
|
|
69
|
+
"AZURE_DEVOPS_ORG": "https://dev.azure.com/tu-organizacion",
|
|
70
|
+
"AZURE_DEVOPS_PAT": "tu-pat-aqui",
|
|
71
|
+
"AZURE_DEVOPS_PROJECT": "Nombre con espacios"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Opción 2: Variables de Entorno (Fallback)
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
export AZURE_DEVOPS_ORG="https://dev.azure.com/tu-organizacion"
|
|
82
|
+
export AZURE_DEVOPS_PAT="tu-pat-aqui"
|
|
83
|
+
export AZURE_DEVOPS_PROJECT="Nombre con espacios"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Solución de Problemas
|
|
87
|
+
|
|
88
|
+
### Errores Comunes
|
|
89
|
+
|
|
90
|
+
1. **"No se encontró configuración"**
|
|
91
|
+
- Crea un archivo .mcp.json o configura variables de entorno
|
|
92
|
+
|
|
93
|
+
2. **"Proyecto requiere codificación URL"**
|
|
94
|
+
- Es normal, el MCP lo maneja automáticamente
|
|
95
|
+
|
|
96
|
+
3. **"Timeout de conexión"**
|
|
97
|
+
- Verifica tu conexión a internet y el PAT
|
|
98
|
+
|
|
99
|
+
### Jerarquía de Configuración
|
|
100
|
+
|
|
101
|
+
El MCP busca configuración en este orden:
|
|
102
|
+
|
|
103
|
+
1. **.mcp.json** en directorio actual
|
|
104
|
+
2. **.mcp.json** en directorio padre
|
|
105
|
+
3. **.mcp.json** en directorio del script
|
|
106
|
+
4. **.mcp.json** en home del usuario
|
|
107
|
+
5. **Variables de entorno** (fallback)
|
|
108
|
+
6. **Error** si nada está disponible
|
|
109
|
+
|
|
110
|
+
## Notas Técnicas
|
|
111
|
+
|
|
112
|
+
- La codificación URL solo se aplica a operaciones REST manuales
|
|
113
|
+
- El SDK de Azure DevOps maneja la codificación automáticamente
|
|
114
|
+
- Los nombres de proyecto con espacios, #, %, u otros caracteres especiales se codifican automáticamente
|
|
115
|
+
- El logging muestra exactamente cuándo y cómo se aplica la codificación
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
# PRD: Corrección de Concurrencia en add_attachment
|
|
2
|
+
|
|
3
|
+
## 📋 Resumen Ejecutivo
|
|
4
|
+
|
|
5
|
+
Corregir el problema de concurrencia en `ado_add_attachment` que causa el error **TF26071** cuando múltiples intentos de agregar adjuntos ocurren simultáneamente al mismo Work Item en Azure DevOps.
|
|
6
|
+
|
|
7
|
+
## 🐛 Problema
|
|
8
|
+
|
|
9
|
+
### Error Reportado
|
|
10
|
+
```
|
|
11
|
+
TF26071: This work item has been changed by someone else since you opened it.
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
### Causa Raíz
|
|
15
|
+
Cuando múltiples llamadas a `ado_add_attachment` golpean el mismo `workItemId` en paralelo, Azure DevOps rechaza los cambios debido a conflicto de revisión. Cada `add_attachment` incrementa la revisión del Work Item.
|
|
16
|
+
|
|
17
|
+
### Flujo del Problema
|
|
18
|
+
1. **Usuario A** ejecuta `add_attachment` para WI #123 → Recupera WI versión 10
|
|
19
|
+
2. **Usuario B** ejecuta `add_attachment` para WI #123 → Recupera WI versión 10
|
|
20
|
+
3. **Usuario A** intenta actualizar WI #123 con adjunto → Azure DevOps acepta (versión 10→11)
|
|
21
|
+
4. **Usuario B** intenta actualizar WI #123 con adjunto → **TF26071**: Versión 10 ya no es la actual
|
|
22
|
+
5. **Resultado**: El segundo adjunto falla con error confuso para el usuario
|
|
23
|
+
|
|
24
|
+
### Impacto
|
|
25
|
+
- ❌ **Pérdida de funcionalidad**: Los segundos intentos fallan
|
|
26
|
+
- ❌ **Experiencia de usuario**: Errores técnicos no comprensibles
|
|
27
|
+
- ❌ **Inconsistencia**: A veces funciona, a veces falla (race condition)
|
|
28
|
+
- ❌ **Sin workaround documentado**: Los usuarios no saben cómo manejarlo
|
|
29
|
+
|
|
30
|
+
## ✅ Solución Propuesta
|
|
31
|
+
|
|
32
|
+
### Estrategia Principal: Sistema de Cola por Work Item
|
|
33
|
+
|
|
34
|
+
Implementar una cola interna por Work Item que asegure que las operaciones de adjuntos sean **secuenciales** por WI, permitiendo paralelismo entre diferentes Work Items.
|
|
35
|
+
|
|
36
|
+
### Arquitectura de la Solución
|
|
37
|
+
|
|
38
|
+
#### 1. Sistema de Cola por Work Item
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
interface AttachmentQueue {
|
|
42
|
+
// Mapa de colas por Work Item ID
|
|
43
|
+
queues: Map<number, Promise<AttachmentResult>>;
|
|
44
|
+
|
|
45
|
+
// Agregar operación a la cola del WI
|
|
46
|
+
addToQueue(workItemId: number, operation: () => Promise<AttachmentResult>): Promise<AttachmentResult>;
|
|
47
|
+
|
|
48
|
+
// Verificar si hay cola activa para el WI
|
|
49
|
+
hasActiveQueue(workItemId: number): boolean;
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
#### 2. Retry Automático con Re-fetch
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
async function addAttachmentWithRetry(
|
|
57
|
+
workItemId: number,
|
|
58
|
+
attachmentData: AttachmentData,
|
|
59
|
+
maxRetries: number = 3
|
|
60
|
+
): Promise<AttachmentResult> {
|
|
61
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
62
|
+
try {
|
|
63
|
+
// 1. Recuperar versión actual del WI (importante para cada intento)
|
|
64
|
+
const currentWI = await getWorkItem(workItemId);
|
|
65
|
+
|
|
66
|
+
// 2. Agregar adjunto usando la versión actual
|
|
67
|
+
return await addAttachment(workItemId, attachmentData);
|
|
68
|
+
|
|
69
|
+
} catch (error: any) {
|
|
70
|
+
// 3. Verificar si es error de concurrencia TF26071
|
|
71
|
+
if (isConcurrencyError(error) && attempt < maxRetries) {
|
|
72
|
+
// 4. Esperar backoff exponencial
|
|
73
|
+
await sleep(Math.pow(2, attempt) * 1000); // 2s, 4s, 8s
|
|
74
|
+
|
|
75
|
+
// 5. Re-fetch del WI para obtener la nueva versión
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 6. Si no es TF26071 o se agotaron reintentos, lanzar error
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
#### 3. Integración en `ado_add_attachment`
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
server.tool(
|
|
90
|
+
"ado_add_attachment",
|
|
91
|
+
"Agrega un adjunto a un Work Item existente con manejo de concurrencia",
|
|
92
|
+
// ... parámetros existentes ...
|
|
93
|
+
async ({ workItemId, filePath, attachmentUrl, comment, name }) => {
|
|
94
|
+
validateConnection();
|
|
95
|
+
validateProject();
|
|
96
|
+
|
|
97
|
+
// Verificar si hay cola activa para este WI
|
|
98
|
+
if (attachmentQueue.hasActiveQueue(workItemId)) {
|
|
99
|
+
console.error(`⏳ Encolando adjunto para WI #${workItemId} (ya hay operación en curso)`);
|
|
100
|
+
return await attachmentQueue.addToQueue(workItemId, () =>
|
|
101
|
+
addAttachmentWithRetry(workItemId, { filePath, attachmentUrl, comment, name })
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// No hay cola activa, ejecutar directamente con retry
|
|
106
|
+
console.error(`🚀 Ejecutando adjunto para WI #${workItemId}`);
|
|
107
|
+
return await addAttachmentWithRetry(workItemId, { filePath, attachmentUrl, comment, name });
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## 🔧 Implementación Detallada
|
|
113
|
+
|
|
114
|
+
### Paso 1: Funciones de Utilidad
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// Detectar si el error es TF26071 (concurrencia)
|
|
118
|
+
function isConcurrencyError(error: any): boolean {
|
|
119
|
+
const errorString = error?.message || error?.toString() || '';
|
|
120
|
+
return errorString.includes('TF26071') ||
|
|
121
|
+
errorString.includes('changed by someone else');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Función de sleep para backoff
|
|
125
|
+
function sleep(ms: number): Promise<void> {
|
|
126
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Obtener Work Item actualizado (para re-fetch en retry)
|
|
130
|
+
async function getWorkItem(workItemId: number): Promise<witInterfaces.WorkItem> {
|
|
131
|
+
const api = await getWitApi();
|
|
132
|
+
return await safeApiCall(
|
|
133
|
+
() => api.getWorkItem(workItemId, undefined, undefined, witInterfaces.WorkItemExpand.All),
|
|
134
|
+
`Error al obtener Work Item #${workItemId}`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Paso 2: Implementación de Cola
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
// Sistema de cola por Work Item
|
|
143
|
+
class AttachmentQueueManager {
|
|
144
|
+
private queues: Map<number, Promise<any>> = new Map();
|
|
145
|
+
|
|
146
|
+
async addToQueue<T>(
|
|
147
|
+
workItemId: number,
|
|
148
|
+
operation: () => Promise<T>
|
|
149
|
+
): Promise<T> {
|
|
150
|
+
// Si ya hay cola para este WI, esperar
|
|
151
|
+
const existingQueue = this.queues.get(workItemId);
|
|
152
|
+
if (existingQueue) {
|
|
153
|
+
console.error(`⏳ Esperando cola para WI #${workItemId}...`);
|
|
154
|
+
try {
|
|
155
|
+
await existingQueue;
|
|
156
|
+
} catch {
|
|
157
|
+
// Si la operación anterior falló, proceder con la nuestra
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Crear nueva cola para este WI
|
|
162
|
+
const queue = operation().finally(() => {
|
|
163
|
+
// Limpiar cola cuando termine
|
|
164
|
+
this.queues.delete(workItemId);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
this.queues.set(workItemId, queue);
|
|
168
|
+
return queue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
hasActiveQueue(workItemId: number): boolean {
|
|
172
|
+
return this.queues.has(workItemId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Instancia global del gestor de colas
|
|
177
|
+
const attachmentQueue = new AttachmentQueueManager();
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Paso 3: Función Principal con Retry
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
// Función principal con retry y re-fetch
|
|
184
|
+
async function addAttachmentWithRetry(
|
|
185
|
+
api: witApi.IWorkItemTrackingApi,
|
|
186
|
+
workItemId: number,
|
|
187
|
+
attachmentData: {
|
|
188
|
+
filePath?: string;
|
|
189
|
+
attachmentUrl?: string;
|
|
190
|
+
comment?: string;
|
|
191
|
+
name?: string;
|
|
192
|
+
},
|
|
193
|
+
maxRetries: number = 3
|
|
194
|
+
): Promise<{ content: any }> {
|
|
195
|
+
let lastError: any = null;
|
|
196
|
+
|
|
197
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
198
|
+
try {
|
|
199
|
+
console.error(`🔄 Intento ${attempt}/${maxRetries} para adjuntar a WI #${workItemId}`);
|
|
200
|
+
|
|
201
|
+
// 1. Recuperar versión actual del WI (CRÍTICO para cada intento)
|
|
202
|
+
const currentWI = await getWorkItem(workItemId);
|
|
203
|
+
console.error(`📄 WI #${workItemId} versión actual: ${currentWI.rev}`);
|
|
204
|
+
|
|
205
|
+
// 2. Procesar adjunto
|
|
206
|
+
let attachmentId: string | undefined;
|
|
207
|
+
let fileName: string | undefined;
|
|
208
|
+
let attachmentLinkUrl: string | undefined;
|
|
209
|
+
|
|
210
|
+
if (attachmentData.filePath) {
|
|
211
|
+
// Subir archivo nuevo
|
|
212
|
+
fileName = attachmentData.name || path.basename(attachmentData.filePath);
|
|
213
|
+
const attachment = await uploadAttachmentRest(attachmentData.filePath, fileName);
|
|
214
|
+
attachmentId = attachment.id;
|
|
215
|
+
attachmentLinkUrl = attachment.url;
|
|
216
|
+
} else if (attachmentData.attachmentUrl) {
|
|
217
|
+
// Usar adjunto existente
|
|
218
|
+
attachmentId = attachmentData.attachmentUrl.split('/attachments/')[1]?.split('?')[0];
|
|
219
|
+
fileName = attachmentData.name || "Archivo adjunto";
|
|
220
|
+
const baseUrl = currentOrg.endsWith("/") ? currentOrg.slice(0, -1) : currentOrg;
|
|
221
|
+
const encodedProject = getEncodedProject(currentProject);
|
|
222
|
+
attachmentLinkUrl = `${baseUrl}/${encodedProject}/_apis/wit/attachments/${attachmentId}`;
|
|
223
|
+
} else {
|
|
224
|
+
throw new Error("Debe proporcionar filePath o attachmentUrl");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 3. Vincular adjunto al WI usando la versión ACTUAL
|
|
228
|
+
const patchDocument: VSSInterfaces.JsonPatchOperation[] = [
|
|
229
|
+
{
|
|
230
|
+
op: VSSInterfaces.Operation.Add,
|
|
231
|
+
path: "/relations/-",
|
|
232
|
+
value: {
|
|
233
|
+
rel: "AttachedFile",
|
|
234
|
+
url: attachmentLinkUrl,
|
|
235
|
+
attributes: {
|
|
236
|
+
name: fileName,
|
|
237
|
+
comment: attachmentData.comment || "",
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
// 4. Actualizar WI con la versión actual
|
|
244
|
+
const updatedWI = await api.updateWorkItem(
|
|
245
|
+
null, // project
|
|
246
|
+
patchDocument,
|
|
247
|
+
workItemId
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
console.error(`✅ Adjunto agregado exitosamente a WI #${workItemId}`);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
content: [{
|
|
254
|
+
type: "text",
|
|
255
|
+
text: `Adjunto agregado exitosamente al Work Item #${workItemId}\n- Nombre: ${fileName}\n- URL: ${attachmentLinkUrl}`,
|
|
256
|
+
}],
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
} catch (error: any) {
|
|
260
|
+
lastError = error;
|
|
261
|
+
console.error(`❌ Error en intento ${attempt}:`, error.message);
|
|
262
|
+
|
|
263
|
+
// Verificar si es error de concurrencia y quedan reintentos
|
|
264
|
+
if (isConcurrencyError(error) && attempt < maxRetries) {
|
|
265
|
+
const backoffTime = Math.pow(2, attempt) * 1000;
|
|
266
|
+
console.error(`⏳ Error TF26071 detectado. Esperando ${backoffTime}ms antes de reintentar...`);
|
|
267
|
+
await sleep(backoffTime);
|
|
268
|
+
console.error(`🔄 Reintentando con nueva versión del WI...`);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Si no es TF26071 o se agotaron reintentos, lanzar error
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Si se agotaron todos los reintentos, lanzar el último error
|
|
278
|
+
throw lastError;
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Paso 4: Actualización de `ado_add_attachment`
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
server.tool(
|
|
286
|
+
"ado_add_attachment",
|
|
287
|
+
"Agrega un adjunto a un Work Item existente. Maneja automáticamente concurrencia cuando múltiples adjuntos se agregan al mismo WI.",
|
|
288
|
+
{
|
|
289
|
+
workItemId: z.number().describe("ID del Work Item"),
|
|
290
|
+
filePath: z.string().optional().describe("Ruta del archivo a subir (opcional si se usa attachmentUrl)"),
|
|
291
|
+
attachmentUrl: z.string().optional().describe("URL de un adjunto ya subido (opcional si se usa filePath)"),
|
|
292
|
+
comment: z.string().optional().describe("Comentario para el adjunto"),
|
|
293
|
+
name: z.string().optional().describe("Nombre del archivo (si no se especifica, usa el nombre del archivo original)"),
|
|
294
|
+
},
|
|
295
|
+
async ({ workItemId, filePath, attachmentUrl, comment, name }) => {
|
|
296
|
+
validateConnection();
|
|
297
|
+
validateProject();
|
|
298
|
+
|
|
299
|
+
const api = await getWitApi();
|
|
300
|
+
|
|
301
|
+
// VERIFICAR CONCURRENCIA: Usar sistema de cola
|
|
302
|
+
if (attachmentQueue.hasActiveQueue(workItemId)) {
|
|
303
|
+
console.error(`⏳ Encolando adjunto para WI #${workItemId} (ya hay operación en curso)`);
|
|
304
|
+
return await attachmentQueue.addToQueue(workItemId, () =>
|
|
305
|
+
addAttachmentWithRetry(api, workItemId, { filePath, attachmentUrl, comment, name })
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Sin concurrencia: ejecutar directamente
|
|
310
|
+
console.error(`🚀 Ejecutando adjunto para WI #${workItemId}`);
|
|
311
|
+
return await attachmentQueue.addToQueue(workItemId, () =>
|
|
312
|
+
addAttachmentWithRetry(api, workItemId, { filePath, attachmentUrl, comment, name })
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## 📊 Casos de Uso
|
|
319
|
+
|
|
320
|
+
### Caso 1: Adjunto Único (sin concurrencia)
|
|
321
|
+
```
|
|
322
|
+
Usuario: add_attachment(123, "archivo.pdf")
|
|
323
|
+
Resultado: ✅ Adjunto agregado en primer intento
|
|
324
|
+
Logs:
|
|
325
|
+
🚀 Ejecutando adjunto para WI #123
|
|
326
|
+
📄 WI #123 versión actual: 10
|
|
327
|
+
✅ Adjunto agregado exitosamente a WI #123
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Caso 2: Concurrencia (2 adjuntos simultáneos)
|
|
331
|
+
```
|
|
332
|
+
Usuario A (hilo 1): add_attachment(123, "archivo1.pdf")
|
|
333
|
+
Usuario B (hilo 2): add_attachment(123, "archivo2.pdf")
|
|
334
|
+
|
|
335
|
+
Resultado:
|
|
336
|
+
Hilo 1: ✅ Adjunto agregado en primer intento
|
|
337
|
+
Hilo 2: ⏳ Encolado, espera a que termine hilo 1
|
|
338
|
+
Hilo 2: ✅ Adjunto agregado después de que hilo 1 terminó
|
|
339
|
+
|
|
340
|
+
Logs:
|
|
341
|
+
Hilo 1: 🚀 Ejecutando adjunto para WI #123
|
|
342
|
+
Hilo 2: ⏳ Encolando adjunto para WI #123 (ya hay operación en curso)
|
|
343
|
+
Hilo 1: 📄 WI #123 versión actual: 10
|
|
344
|
+
Hilo 1: ✅ Adjunto agregado exitosamente a WI #123
|
|
345
|
+
Hilo 2: 📄 WI #123 versión actual: 11
|
|
346
|
+
Hilo 2: ✅ Adjunto agregado exitosamente a WI #123
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Caso 3: Concurrencia con TF26071 (3 adjuntos simultáneos)
|
|
350
|
+
```
|
|
351
|
+
Usuario A, B, C: add_attachment(123, "archivoX.pdf")
|
|
352
|
+
|
|
353
|
+
Resultado:
|
|
354
|
+
Hilo 1: ✅ Adjunto agregado (versión 10→11)
|
|
355
|
+
Hilo 2: ⏳ Encolado, espera hilo 1
|
|
356
|
+
Hilo 3: ⏳ Encolado, espera hilo 1
|
|
357
|
+
Hilo 2: 🔄 Intento 1 → TF26071 → Reintenta
|
|
358
|
+
Hilo 2: 📄 WI #123 versión actual: 11
|
|
359
|
+
Hilo 2: ✅ Adjunto agregado (versión 11→12)
|
|
360
|
+
Hilo 3: 🔄 Intento 1 → TF26071 → Reintenta
|
|
361
|
+
Hilo 3: 📄 WI #123 versión actual: 12
|
|
362
|
+
Hilo 3: ✅ Adjunto agregado (versión 12→13)
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## ✅ Beneficios
|
|
366
|
+
|
|
367
|
+
### Para Usuarios
|
|
368
|
+
- ✅ **Sin errores TF26071**: Manejo automático de concurrencia
|
|
369
|
+
- ✅ **Transparencia**: El sistema maneja los reintentos automáticamente
|
|
370
|
+
- ✅ **Predictibilidad**: Los adjuntos siempre se agregan, eventualmente
|
|
371
|
+
- ✅ **Mejor experiencia**: Sin necesidad de reintentar manualmente
|
|
372
|
+
|
|
373
|
+
### Para Desarrolladores
|
|
374
|
+
- ✅ **Código limpio**: Lógica de concurrencia encapsulada
|
|
375
|
+
- ✅ **Reutilizable**: Sistema de cola puede usarse para otras operaciones
|
|
376
|
+
- ✅ **Debuggable**: Logs claros de lo que está pasando
|
|
377
|
+
- ✅ **Testable**: Lógica separada en funciones pequeñas
|
|
378
|
+
|
|
379
|
+
## 🧪 Testing
|
|
380
|
+
|
|
381
|
+
### Test Unitarios
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
// Test: Detección de error TF26071
|
|
385
|
+
describe('isConcurrencyError', () => {
|
|
386
|
+
it('debería detectar error TF26071', () => {
|
|
387
|
+
const error = new Error('TF26071: This work item has been changed by someone else since you opened it.');
|
|
388
|
+
expect(isConcurrencyError(error)).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('no debería detectar otros errores', () => {
|
|
392
|
+
const error = new Error('Network error');
|
|
393
|
+
expect(isConcurrencyError(error)).toBe(false);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// Test: Sistema de cola
|
|
398
|
+
describe('AttachmentQueueManager', () => {
|
|
399
|
+
it('debería encolar operaciones concurrentes al mismo WI', async () => {
|
|
400
|
+
const queue = new AttachmentQueueManager();
|
|
401
|
+
const results: string[] = [];
|
|
402
|
+
|
|
403
|
+
// Simular 3 operaciones concurrentes al mismo WI
|
|
404
|
+
const op1 = queue.addToQueue(123, async () => { await sleep(100); return 'A'; });
|
|
405
|
+
const op2 = queue.addToQueue(123, async () => { await sleep(50); return 'B'; });
|
|
406
|
+
const op3 = queue.addToQueue(123, async () => { await sleep(50); return 'C'; });
|
|
407
|
+
|
|
408
|
+
results.push(await op1, await op2, await op3);
|
|
409
|
+
expect(results).toEqual(['A', 'B', 'C']);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### Test de Integración
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
// Test: Retry automático con concurrencia simulada
|
|
418
|
+
describe('addAttachmentWithRetry', () => {
|
|
419
|
+
it('debería reintentar automáticamente al recibir TF26071', async () => {
|
|
420
|
+
let callCount = 0;
|
|
421
|
+
|
|
422
|
+
// Mock de API que falla 2 veces con TF26071, luego tiene éxito
|
|
423
|
+
const mockApi = {
|
|
424
|
+
updateWorkItem: jest.fn().mockImplementation(async () => {
|
|
425
|
+
callCount++;
|
|
426
|
+
if (callCount <= 2) {
|
|
427
|
+
throw new Error('TF26071: This work item has been changed by someone else since you opened it.');
|
|
428
|
+
}
|
|
429
|
+
return { id: 123, rev: 10 + callCount };
|
|
430
|
+
})
|
|
431
|
+
} as any;
|
|
432
|
+
|
|
433
|
+
const result = await addAttachmentWithRetry(mockApi, 123, { filePath: 'test.pdf' });
|
|
434
|
+
|
|
435
|
+
expect(mockApi.updateWorkItem).toHaveBeenCalledTimes(3);
|
|
436
|
+
expect(callCount).toBe(3);
|
|
437
|
+
expect(result).toBeDefined();
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
## 🚀 Plan de Implementación
|
|
443
|
+
|
|
444
|
+
### Fase 1: Implementación Core (Sprint 1)
|
|
445
|
+
- [ ] Funciones de utilidad: `isConcurrencyError`, `sleep`, `getWorkItem`
|
|
446
|
+
- [ ] Clase `AttachmentQueueManager`
|
|
447
|
+
- [ ] Función `addAttachmentWithRetry` con lógica de retry
|
|
448
|
+
- [ ] Tests unitarios para nuevas funciones
|
|
449
|
+
|
|
450
|
+
### Fase 2: Integración (Sprint 1)
|
|
451
|
+
- [ ] Actualizar `ado_add_attachment` para usar sistema de cola
|
|
452
|
+
- [ ] Agregar logs informativos de concurrencia
|
|
453
|
+
- [ ] Tests de integración end-to-end
|
|
454
|
+
|
|
455
|
+
### Fase 3: Documentación (Sprint 1)
|
|
456
|
+
- [ ] Actualizar README.md con manejo de concurrencia
|
|
457
|
+
- [ ] Agregar ejemplos de uso con concurrencia
|
|
458
|
+
- [ ] Documentar comportamientos esperados
|
|
459
|
+
|
|
460
|
+
## 📝 Notas Técnicas
|
|
461
|
+
|
|
462
|
+
### Consideraciones de Performance
|
|
463
|
+
- **Cola por WI**: Solo serializa operaciones del mismo WI, permite paralelismo entre diferentes WIs
|
|
464
|
+
- **Backoff exponencial**: Evita sobrecargar Azure DevOps con reintentos agresivos
|
|
465
|
+
- **Re-fetch eficiente**: Solo recupera datos necesarios del WI, no campos completos
|
|
466
|
+
|
|
467
|
+
### Compatibilidad hacia atrás
|
|
468
|
+
- ✅ **Sin breaking changes**: Comportamiento automático y transparente
|
|
469
|
+
- ✅ **Opcional**: Si no hay concurrencia, funciona igual que antes
|
|
470
|
+
- ✅ **Configurable**: `maxRetries` puede ajustarse por usuario o config
|
|
471
|
+
|
|
472
|
+
### Manejo de Edge Cases
|
|
473
|
+
- ✅ **Timeout de colas**: Las colas no deben quedarse esperando para siempre
|
|
474
|
+
- ✅ **Error en operación anterior**: Si la op anterior falló, la siguiente debe proceder
|
|
475
|
+
- ✅ **Limpieza de colas**: Las colas se limpian automáticamente cuando terminan
|
|
476
|
+
|
|
477
|
+
## 🎯 Criterios de Éxito
|
|
478
|
+
|
|
479
|
+
### Funcionales
|
|
480
|
+
- ✅ Los adjuntos se agregan exitosamente incluso con concurrencia
|
|
481
|
+
- ✅ No se observan más errores TF26071 en producción
|
|
482
|
+
- ✅ El sistema maneja automáticamente los reintentos
|
|
483
|
+
- ✅ Las operaciones a diferentes WIs se ejecutan en paralelo
|
|
484
|
+
|
|
485
|
+
### No Funcionales
|
|
486
|
+
- ✅ Latencia aceptable (< 5s adicional con retry)
|
|
487
|
+
- ✅ Sin memory leaks en sistema de cola
|
|
488
|
+
- ✅ Logs claros y accionables
|
|
489
|
+
- ✅ Tests con > 80% de cobertura
|
|
490
|
+
|
|
491
|
+
## 📞 Risk Assessment
|
|
492
|
+
|
|
493
|
+
### Riesgos
|
|
494
|
+
| Riesgo | Probabilidad | Impacto | Mitigación |
|
|
495
|
+
|---------|-------------|-----------|-------------|
|
|
496
|
+
| Retry infinito con TF26071 persistente | Baja | Alta | Límite de 3 reintentos por defecto |
|
|
497
|
+
| Memory leak en colas no limpiadas | Baja | Media | Limpieza automática en `finally()` |
|
|
498
|
+
| Performance degradation con muchos retries | Media | Baja | Backoff exponencial y límite de reintentos |
|
|
499
|
+
|
|
500
|
+
## 📊 Estimación de Esfuerzo
|
|
501
|
+
|
|
502
|
+
### Complejidad Técnica: Media
|
|
503
|
+
- Implementación de sistema de cola: 2-3 días
|
|
504
|
+
- Lógica de retry con re-fetch: 1-2 días
|
|
505
|
+
- Integración y testing: 2-3 días
|
|
506
|
+
- Documentación: 1 día
|
|
507
|
+
|
|
508
|
+
**Total estimado**: 6-9 días de desarrollo
|
|
509
|
+
|
|
510
|
+
---
|
|
511
|
+
|
|
512
|
+
*Este PRD establece la solución completa para el problema de concurrencia en `ado_add_attachment`, eliminando los errores TF26071 y proporcionando una experiencia de usuario robusta y transparente.*
|