issue-flow 0.7.7 → 0.9.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/README.md +16 -0
- package/dist/{analyze-2RVGY5OK.js → analyze-VSWAEVN3.js} +6 -6
- package/dist/{chunk-WO6ERABE.js → chunk-57MH6RYJ.js} +106 -7
- package/dist/chunk-57MH6RYJ.js.map +1 -0
- package/dist/{chunk-6UTVPKNF.js → chunk-66ZGYXTH.js} +53 -9
- package/dist/chunk-66ZGYXTH.js.map +1 -0
- package/dist/{chunk-WGDLUPSF.js → chunk-DP43WJY4.js} +75 -11
- package/dist/chunk-DP43WJY4.js.map +1 -0
- package/dist/{chunk-ZYBYMEWG.js → chunk-DTNM3LTO.js} +33 -10
- package/dist/chunk-DTNM3LTO.js.map +1 -0
- package/dist/{chunk-KHTLN54U.js → chunk-JGKPSWOA.js} +2 -2
- package/dist/{chunk-EYVXLRRD.js → chunk-KXAG24DD.js} +219 -3
- package/dist/chunk-KXAG24DD.js.map +1 -0
- package/dist/{chunk-XPW46TI5.js → chunk-M37YZYDO.js} +3 -3
- package/dist/{chunk-G37JNQKB.js → chunk-M72ZEPWD.js} +2 -2
- package/dist/{chunk-RUTDK6ZF.js → chunk-MTJQOOMG.js} +105 -4
- package/dist/chunk-MTJQOOMG.js.map +1 -0
- package/dist/{chunk-OPIDR2DC.js → chunk-NB6WKTBL.js} +2 -2
- package/dist/chunk-TJMZWRS6.js +265 -0
- package/dist/chunk-TJMZWRS6.js.map +1 -0
- package/dist/{chunk-6IOPYFUM.js → chunk-TLSIK656.js} +6 -6
- package/dist/{chunk-JOK7VXRV.js → chunk-VNKSMWDN.js} +17 -3
- package/dist/chunk-VNKSMWDN.js.map +1 -0
- package/dist/{chunk-6L2BPOE3.js → chunk-WHEU55ZP.js} +7 -7
- package/dist/{chunk-EOTULFWV.js → chunk-X7XBF5TS.js} +6 -6
- package/dist/cli.js +91 -29
- package/dist/cli.js.map +1 -1
- package/dist/{config-OAZG7KIK.js → config-J5DW6HSY.js} +3 -3
- package/dist/execute-Z2DKGVLR.js +14 -0
- package/dist/{generate-BEJF3SVJ.js → generate-ERDPOTKX.js} +6 -6
- package/dist/init-UY6ZCIFT.js +9 -0
- package/dist/plan-REMAMCX4.js +16 -0
- package/dist/pr-KERP52TI.js +20 -0
- package/dist/pr-review-IU4N3TEK.js +16 -0
- package/dist/prd-SHVQW6RM.js +16 -0
- package/dist/review-L5F77QAC.js +14 -0
- package/dist/run-BRBFHVUO.js +1860 -0
- package/dist/run-BRBFHVUO.js.map +1 -0
- package/dist/{web-3SBYL7QJ.js → web-WQISLO66.js} +4 -4
- package/package.json +1 -1
- package/prompts/execute.md +2 -2
- package/prompts/plan.md +10 -1
- package/prompts/pr.md +9 -4
- package/web/CLAUDE.md +70 -8
- package/web/public/app.css +444 -0
- package/web/public/app.js +573 -17
- package/web/public/index.html +50 -0
- package/dist/chunk-3E6KASSE.js +0 -123
- package/dist/chunk-3E6KASSE.js.map +0 -1
- package/dist/chunk-6UTVPKNF.js.map +0 -1
- package/dist/chunk-EYVXLRRD.js.map +0 -1
- package/dist/chunk-JOK7VXRV.js.map +0 -1
- package/dist/chunk-RUTDK6ZF.js.map +0 -1
- package/dist/chunk-WGDLUPSF.js.map +0 -1
- package/dist/chunk-WO6ERABE.js.map +0 -1
- package/dist/chunk-ZYBYMEWG.js.map +0 -1
- package/dist/execute-AOEM52CD.js +0 -14
- package/dist/init-TBC3WI74.js +0 -9
- package/dist/plan-YDZYB2BO.js +0 -16
- package/dist/pr-SIXMJIZF.js +0 -16
- package/dist/pr-review-AY56ZPJR.js +0 -16
- package/dist/prd-MBSSBLGO.js +0 -16
- package/dist/review-2MDDHZ4X.js +0 -14
- package/dist/run-SEB6F3W4.js +0 -733
- package/dist/run-SEB6F3W4.js.map +0 -1
- /package/dist/{analyze-2RVGY5OK.js.map → analyze-VSWAEVN3.js.map} +0 -0
- /package/dist/{chunk-KHTLN54U.js.map → chunk-JGKPSWOA.js.map} +0 -0
- /package/dist/{chunk-XPW46TI5.js.map → chunk-M37YZYDO.js.map} +0 -0
- /package/dist/{chunk-G37JNQKB.js.map → chunk-M72ZEPWD.js.map} +0 -0
- /package/dist/{chunk-OPIDR2DC.js.map → chunk-NB6WKTBL.js.map} +0 -0
- /package/dist/{chunk-6IOPYFUM.js.map → chunk-TLSIK656.js.map} +0 -0
- /package/dist/{chunk-6L2BPOE3.js.map → chunk-WHEU55ZP.js.map} +0 -0
- /package/dist/{chunk-EOTULFWV.js.map → chunk-X7XBF5TS.js.map} +0 -0
- /package/dist/{config-OAZG7KIK.js.map → config-J5DW6HSY.js.map} +0 -0
- /package/dist/{execute-AOEM52CD.js.map → execute-Z2DKGVLR.js.map} +0 -0
- /package/dist/{generate-BEJF3SVJ.js.map → generate-ERDPOTKX.js.map} +0 -0
- /package/dist/{init-TBC3WI74.js.map → init-UY6ZCIFT.js.map} +0 -0
- /package/dist/{plan-YDZYB2BO.js.map → plan-REMAMCX4.js.map} +0 -0
- /package/dist/{pr-SIXMJIZF.js.map → pr-KERP52TI.js.map} +0 -0
- /package/dist/{pr-review-AY56ZPJR.js.map → pr-review-IU4N3TEK.js.map} +0 -0
- /package/dist/{prd-MBSSBLGO.js.map → prd-SHVQW6RM.js.map} +0 -0
- /package/dist/{review-2MDDHZ4X.js.map → review-L5F77QAC.js.map} +0 -0
- /package/dist/{web-3SBYL7QJ.js.map → web-WQISLO66.js.map} +0 -0
package/web/public/app.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Interface do monitoramento web do issue-flow.
|
|
2
2
|
// JS puro, sem framework e sem recursos externos — funciona offline.
|
|
3
|
-
// Consome
|
|
4
|
-
// dinâmico entra via textContent (nunca innerHTML com dados
|
|
3
|
+
// Consome GET api/sessions, GET api/status (ETag/304) e GET api/health;
|
|
4
|
+
// todo texto dinâmico entra via textContent (nunca innerHTML com dados
|
|
5
|
+
// do snapshot).
|
|
5
6
|
(() => {
|
|
6
7
|
'use strict';
|
|
7
8
|
|
|
@@ -10,6 +11,7 @@
|
|
|
10
11
|
const STORAGE_KEY = 'issue-flow:refresh-seconds';
|
|
11
12
|
const MAX_BACKOFF_MS = 60000;
|
|
12
13
|
const ALERT_PREVIEW = 3;
|
|
14
|
+
const DESCRIPTION_PREVIEW = 140;
|
|
13
15
|
|
|
14
16
|
const STATUS_LABELS = {
|
|
15
17
|
idle: 'aguardando',
|
|
@@ -32,14 +34,43 @@
|
|
|
32
34
|
done: 'concluída',
|
|
33
35
|
};
|
|
34
36
|
|
|
37
|
+
// Rótulos do `stage` granular (issue 38) — mais fino que STORY_STATUS_LABELS,
|
|
38
|
+
// deriva de eventos reais do pipeline (iteration:start, stories:update,
|
|
39
|
+
// phase:start/phase:end da review, correction:cycle). Ver README, seção
|
|
40
|
+
// "Story stage".
|
|
41
|
+
const STORY_STAGE_LABELS = {
|
|
42
|
+
pending: 'aguardando',
|
|
43
|
+
executing: 'em execução',
|
|
44
|
+
awaiting_review: 'aguardando revisão',
|
|
45
|
+
in_review: 'em revisão',
|
|
46
|
+
in_correction: 'em correção',
|
|
47
|
+
done: 'concluída',
|
|
48
|
+
failed: 'falhou',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Colunas do Kanban, na ordem em que a execução avança. Os títulos são os das
|
|
52
|
+
// colunas, não os rótulos dos badges (STORY_STATUS_LABELS), que seguem em minúsculas.
|
|
53
|
+
const KANBAN_COLUMNS = [
|
|
54
|
+
{ status: 'backlog', title: 'Backlog' },
|
|
55
|
+
{ status: 'in_progress', title: 'Em andamento' },
|
|
56
|
+
{ status: 'in_review', title: 'Em revisão' },
|
|
57
|
+
{ status: 'done', title: 'Concluído' },
|
|
58
|
+
];
|
|
59
|
+
|
|
35
60
|
const els = {
|
|
36
61
|
banner: document.getElementById('banner-disconnected'),
|
|
62
|
+
viewDashboard: document.getElementById('view-dashboard'),
|
|
63
|
+
viewDetail: document.getElementById('view-detail'),
|
|
64
|
+
dashboard: document.getElementById('dashboard'),
|
|
65
|
+
dashboardMeta: document.getElementById('dashboard-meta'),
|
|
66
|
+
backToDashboard: document.getElementById('back-to-dashboard'),
|
|
37
67
|
issueLink: document.getElementById('issue-link'),
|
|
38
68
|
branchLine: document.getElementById('branch-line'),
|
|
39
69
|
statusBadge: document.getElementById('status-badge'),
|
|
40
70
|
elapsed: document.getElementById('elapsed'),
|
|
41
71
|
estimate: document.getElementById('estimate'),
|
|
42
72
|
refreshSelect: document.getElementById('refresh-select'),
|
|
73
|
+
refreshSelectDashboard: document.getElementById('refresh-select-dashboard'),
|
|
43
74
|
alerts: document.getElementById('alerts'),
|
|
44
75
|
alertsBody: document.getElementById('alerts-body'),
|
|
45
76
|
issueSummary: document.getElementById('issue-summary'),
|
|
@@ -56,6 +87,13 @@
|
|
|
56
87
|
logFilter: document.getElementById('log-filter'),
|
|
57
88
|
logs: document.getElementById('logs'),
|
|
58
89
|
sessionMeta: document.getElementById('session-meta'),
|
|
90
|
+
tabs: Array.prototype.slice.call(document.querySelectorAll('[role="tab"]')),
|
|
91
|
+
kanban: document.getElementById('kanban'),
|
|
92
|
+
drawer: document.getElementById('drawer'),
|
|
93
|
+
drawerOverlay: document.getElementById('drawer-overlay'),
|
|
94
|
+
drawerClose: document.getElementById('drawer-close'),
|
|
95
|
+
drawerTitle: document.getElementById('drawer-title'),
|
|
96
|
+
drawerBody: document.getElementById('drawer-body'),
|
|
59
97
|
};
|
|
60
98
|
|
|
61
99
|
const state = {
|
|
@@ -66,6 +104,20 @@
|
|
|
66
104
|
timer: null,
|
|
67
105
|
polling: false,
|
|
68
106
|
logFilter: 'all',
|
|
107
|
+
activeTab: 'tab-execution',
|
|
108
|
+
// Só o id: o card que abriu o drawer é destruído no próximo render, então
|
|
109
|
+
// guardar o nó levaria a uma referência morta.
|
|
110
|
+
selectedStoryId: null,
|
|
111
|
+
// Multi-sessão (#35): lista de /api/sessions e seleção explícita do usuário.
|
|
112
|
+
// selectedSessionId null = modo automático (1 sessão → detalhe; 2+ → dashboard).
|
|
113
|
+
sessions: [],
|
|
114
|
+
selectedSessionId: null,
|
|
115
|
+
// sessionId cujo snapshot/detalhe está na tela (evita flash ao trocar).
|
|
116
|
+
detailSessionId: null,
|
|
117
|
+
viewMode: 'detail', // 'detail' | 'dashboard'
|
|
118
|
+
statusUrl: 'api/status',
|
|
119
|
+
// Se selectSession/clearSessionSelection chega durante um poll, reexecuta.
|
|
120
|
+
pollAgain: false,
|
|
69
121
|
};
|
|
70
122
|
|
|
71
123
|
// ---- Utilitários ----------------------------------------------------------
|
|
@@ -189,6 +241,66 @@
|
|
|
189
241
|
return match ? match[1] : null;
|
|
190
242
|
}
|
|
191
243
|
|
|
244
|
+
// ---- Camada de leitura das stories ----------------------------------------
|
|
245
|
+
// Único ponto de acesso a uma story individual: o Kanban e o drawer nunca
|
|
246
|
+
// varrem snapshot.stories por conta própria. Quando a escrita existir, é aqui
|
|
247
|
+
// que a leitura passa a conversar com ela, sem tocar na UI.
|
|
248
|
+
|
|
249
|
+
function text(value) {
|
|
250
|
+
return typeof value === 'string' ? value : '';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function list(value) {
|
|
254
|
+
return Array.isArray(value) ? value : [];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Normaliza os campos que um session.json anterior pode não ter gravado, para
|
|
258
|
+
// que nenhum consumidor precise repetir a checagem de ausência.
|
|
259
|
+
function normalizeStory(story) {
|
|
260
|
+
const status = story.status !== null && story.status !== undefined ? story.status : 'backlog';
|
|
261
|
+
const stage = story.stage !== null && story.stage !== undefined ? story.stage : 'pending';
|
|
262
|
+
return {
|
|
263
|
+
...story,
|
|
264
|
+
status: STORY_STATUS_LABELS[status] !== undefined ? status : 'backlog',
|
|
265
|
+
stage: STORY_STAGE_LABELS[stage] !== undefined ? stage : 'pending',
|
|
266
|
+
stageSince: story.stageSince !== null && story.stageSince !== undefined ? story.stageSince : null,
|
|
267
|
+
stageDetail:
|
|
268
|
+
story.stageDetail !== null && story.stageDetail !== undefined ? story.stageDetail : null,
|
|
269
|
+
dependencies: list(story.dependencies),
|
|
270
|
+
description: text(story.description),
|
|
271
|
+
acceptanceCriteria: list(story.acceptanceCriteria),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function getStoryById(snapshot, id) {
|
|
276
|
+
if (!snapshot) return null;
|
|
277
|
+
const stories = list(snapshot.stories);
|
|
278
|
+
for (const story of stories) {
|
|
279
|
+
if (story.id === id) return normalizeStory(story);
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getStories(snapshot) {
|
|
285
|
+
if (!snapshot) return [];
|
|
286
|
+
return list(snapshot.stories).map(normalizeStory);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---- Abas -----------------------------------------------------------------
|
|
290
|
+
// Troca apenas visibilidade e estado ARIA. Não toca no polling: render()
|
|
291
|
+
// segue atualizando os dois painéis, então a aba inativa nunca fica defasada.
|
|
292
|
+
|
|
293
|
+
function setActiveTab(tabId) {
|
|
294
|
+
state.activeTab = tabId;
|
|
295
|
+
for (const tab of els.tabs) {
|
|
296
|
+
const active = tab.id === tabId;
|
|
297
|
+
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
298
|
+
tab.classList.toggle('is-active', active);
|
|
299
|
+
const panel = document.getElementById(tab.getAttribute('aria-controls'));
|
|
300
|
+
if (panel) panel.hidden = !active;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
192
304
|
// ---- Polling: intervalo configurável, aba oculta e backoff ----------------
|
|
193
305
|
|
|
194
306
|
function readStoredRefresh() {
|
|
@@ -211,22 +323,28 @@
|
|
|
211
323
|
}
|
|
212
324
|
}
|
|
213
325
|
|
|
214
|
-
function
|
|
326
|
+
function fillRefreshSelect(select) {
|
|
327
|
+
if (!select) return;
|
|
215
328
|
const values = REFRESH_OPTIONS.slice();
|
|
216
329
|
if (state.refreshSeconds !== PAUSED && values.indexOf(state.refreshSeconds) === -1) {
|
|
217
330
|
values.push(state.refreshSeconds);
|
|
218
331
|
values.sort((a, b) => a - b);
|
|
219
332
|
}
|
|
220
|
-
clear(
|
|
333
|
+
clear(select);
|
|
221
334
|
for (const value of values) {
|
|
222
335
|
const option = el('option', null, value + 's');
|
|
223
336
|
option.value = String(value);
|
|
224
|
-
|
|
337
|
+
select.appendChild(option);
|
|
225
338
|
}
|
|
226
339
|
const pause = el('option', null, 'pausar');
|
|
227
340
|
pause.value = String(PAUSED);
|
|
228
|
-
|
|
229
|
-
|
|
341
|
+
select.appendChild(pause);
|
|
342
|
+
select.value = String(state.refreshSeconds);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function buildRefreshSelect() {
|
|
346
|
+
fillRefreshSelect(els.refreshSelect);
|
|
347
|
+
fillRefreshSelect(els.refreshSelectDashboard);
|
|
230
348
|
}
|
|
231
349
|
|
|
232
350
|
function clearTimer() {
|
|
@@ -244,19 +362,161 @@
|
|
|
244
362
|
state.timer = window.setTimeout(poll, Math.min(backoff, MAX_BACKOFF_MS));
|
|
245
363
|
}
|
|
246
364
|
|
|
365
|
+
// Decide dashboard vs detalhe a partir de /api/sessions. selectedSessionId
|
|
366
|
+
// null = automático; string = escolha explícita do usuário (card).
|
|
367
|
+
function resolveView(sessions) {
|
|
368
|
+
state.sessions = sessions;
|
|
369
|
+
|
|
370
|
+
if (state.selectedSessionId !== null) {
|
|
371
|
+
const stillThere = sessions.some((s) => s.sessionId === state.selectedSessionId);
|
|
372
|
+
if (!stillThere) {
|
|
373
|
+
state.selectedSessionId = null;
|
|
374
|
+
state.etag = null;
|
|
375
|
+
} else if (sessions.length === 1) {
|
|
376
|
+
// Seleção explícita deixa de fazer sentido com uma única sessão.
|
|
377
|
+
state.selectedSessionId = null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (state.selectedSessionId !== null) {
|
|
382
|
+
const selected = sessions.find((s) => s.sessionId === state.selectedSessionId);
|
|
383
|
+
return { mode: 'detail', session: selected || null };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (sessions.length === 0) {
|
|
387
|
+
return { mode: 'detail', session: null };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (sessions.length === 1) {
|
|
391
|
+
return { mode: 'detail', session: sessions[0] };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return { mode: 'dashboard', session: null };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function setViewMode(mode) {
|
|
398
|
+
state.viewMode = mode;
|
|
399
|
+
els.viewDashboard.hidden = mode !== 'dashboard';
|
|
400
|
+
els.viewDetail.hidden = mode !== 'detail';
|
|
401
|
+
// Back só quando o usuário escolheu um card e ainda há dashboard para voltar.
|
|
402
|
+
const showBack =
|
|
403
|
+
mode === 'detail' && state.selectedSessionId !== null && state.sessions.length >= 2;
|
|
404
|
+
els.backToDashboard.hidden = !showBack;
|
|
405
|
+
if (mode === 'dashboard' && state.selectedStoryId !== null) {
|
|
406
|
+
closeDrawer();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function statusUrlFor(session) {
|
|
411
|
+
if (!session || !session.statusUrl) return 'api/status';
|
|
412
|
+
// statusUrl vem com barra inicial (/api/status?session=…); o fetch relativo
|
|
413
|
+
// ao path do painel precisa da forma sem a barra absoluta do host.
|
|
414
|
+
return session.statusUrl.replace(/^\//, '');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function clearDetailState() {
|
|
418
|
+
state.etag = null;
|
|
419
|
+
state.snapshot = null;
|
|
420
|
+
state.detailSessionId = null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function requestPoll() {
|
|
424
|
+
clearTimer();
|
|
425
|
+
if (state.polling) {
|
|
426
|
+
state.pollAgain = true;
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
poll();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function selectSession(sessionId) {
|
|
433
|
+
if (state.selectedSessionId !== sessionId) {
|
|
434
|
+
clearDetailState();
|
|
435
|
+
}
|
|
436
|
+
state.selectedSessionId = sessionId;
|
|
437
|
+
requestPoll();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function clearSessionSelection() {
|
|
441
|
+
state.selectedSessionId = null;
|
|
442
|
+
clearDetailState();
|
|
443
|
+
requestPoll();
|
|
444
|
+
}
|
|
445
|
+
|
|
247
446
|
async function poll() {
|
|
248
|
-
if (state.polling)
|
|
447
|
+
if (state.polling) {
|
|
448
|
+
state.pollAgain = true;
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
249
451
|
state.polling = true;
|
|
452
|
+
state.pollAgain = false;
|
|
250
453
|
try {
|
|
454
|
+
const sessionsRes = await fetch('api/sessions', { cache: 'no-store' });
|
|
455
|
+
if (!sessionsRes.ok) throw new Error('HTTP ' + sessionsRes.status);
|
|
456
|
+
const sessions = await sessionsRes.json();
|
|
457
|
+
if (!Array.isArray(sessions)) throw new Error('sessions payload invalid');
|
|
458
|
+
|
|
459
|
+
const resolved = resolveView(sessions);
|
|
460
|
+
|
|
461
|
+
if (resolved.mode === 'dashboard') {
|
|
462
|
+
clearDetailState();
|
|
463
|
+
setViewMode('dashboard');
|
|
464
|
+
renderDashboard(sessions);
|
|
465
|
+
state.failures = 0;
|
|
466
|
+
els.banner.hidden = true;
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const nextId =
|
|
471
|
+
resolved.session && resolved.session.sessionId != null
|
|
472
|
+
? resolved.session.sessionId
|
|
473
|
+
: null;
|
|
474
|
+
const sessionChanged = state.detailSessionId !== nextId;
|
|
475
|
+
if (sessionChanged) {
|
|
476
|
+
clearDetailState();
|
|
477
|
+
state.detailSessionId = nextId;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (!resolved.session) {
|
|
481
|
+
setViewMode('detail');
|
|
482
|
+
// Zero sessões: mantém a última tela de detalhe se houver, sem explodir.
|
|
483
|
+
renderEmptyDetail();
|
|
484
|
+
state.failures = 0;
|
|
485
|
+
els.banner.hidden = true;
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const nextUrl = statusUrlFor(resolved.session);
|
|
490
|
+
if (nextUrl !== state.statusUrl) {
|
|
491
|
+
state.statusUrl = nextUrl;
|
|
492
|
+
state.etag = null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Evita flash da sessão anterior enquanto o status novo carrega.
|
|
496
|
+
if (sessionChanged || !state.snapshot) {
|
|
497
|
+
els.viewDashboard.hidden = true;
|
|
498
|
+
els.viewDetail.hidden = true;
|
|
499
|
+
} else {
|
|
500
|
+
setViewMode('detail');
|
|
501
|
+
}
|
|
502
|
+
|
|
251
503
|
const headers = {};
|
|
252
504
|
if (state.etag) headers['If-None-Match'] = state.etag;
|
|
253
|
-
const res = await fetch(
|
|
505
|
+
const res = await fetch(state.statusUrl, { headers, cache: 'no-store' });
|
|
506
|
+
// Se selectSession/clear pediu outro poll no meio do fetch, descarta este
|
|
507
|
+
// status — o ciclo seguinte aplica a seleção atual.
|
|
508
|
+
if (state.pollAgain) {
|
|
509
|
+
state.failures = 0;
|
|
510
|
+
els.banner.hidden = true;
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
254
513
|
if (res.status !== 304) {
|
|
255
514
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
256
515
|
state.etag = res.headers.get('ETag');
|
|
257
516
|
state.snapshot = await res.json();
|
|
258
517
|
render();
|
|
259
518
|
}
|
|
519
|
+
setViewMode('detail');
|
|
260
520
|
state.failures = 0;
|
|
261
521
|
els.banner.hidden = true;
|
|
262
522
|
} catch (err) {
|
|
@@ -264,7 +524,124 @@
|
|
|
264
524
|
els.banner.hidden = false;
|
|
265
525
|
} finally {
|
|
266
526
|
state.polling = false;
|
|
267
|
-
|
|
527
|
+
if (state.pollAgain) {
|
|
528
|
+
state.pollAgain = false;
|
|
529
|
+
poll();
|
|
530
|
+
} else {
|
|
531
|
+
schedule();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function renderEmptyDetail() {
|
|
537
|
+
if (state.snapshot) return;
|
|
538
|
+
document.title = 'issue-flow';
|
|
539
|
+
els.statusBadge.textContent = STATUS_LABELS.idle;
|
|
540
|
+
els.statusBadge.className = 'badge status-idle';
|
|
541
|
+
els.elapsed.textContent = '—';
|
|
542
|
+
els.estimate.hidden = true;
|
|
543
|
+
els.sessionMeta.textContent = 'nenhuma execução ativa';
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function truncateText(text, max) {
|
|
547
|
+
if (!text) return '';
|
|
548
|
+
const normalized = String(text).replace(/\s+/g, ' ').trim();
|
|
549
|
+
if (normalized.length <= max) return normalized;
|
|
550
|
+
return normalized.slice(0, max - 1).trimEnd() + '…';
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function renderDashboard(sessions) {
|
|
554
|
+
const active = document.activeElement;
|
|
555
|
+
const focusedId =
|
|
556
|
+
active &&
|
|
557
|
+
active.dataset &&
|
|
558
|
+
els.dashboard.contains(active) &&
|
|
559
|
+
active.dataset.sessionId
|
|
560
|
+
? active.dataset.sessionId
|
|
561
|
+
: null;
|
|
562
|
+
|
|
563
|
+
clear(els.dashboard);
|
|
564
|
+
|
|
565
|
+
if (sessions.length === 0) {
|
|
566
|
+
els.dashboard.appendChild(el('p', 'empty', 'Nenhuma execução ativa.'));
|
|
567
|
+
els.dashboardMeta.textContent = '0 execuções';
|
|
568
|
+
document.title = 'issue-flow';
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
for (const session of sessions) {
|
|
573
|
+
// <button> só aceita phrasing content — mesmos spans do Kanban.
|
|
574
|
+
const card = el('button', 'dashboard-card');
|
|
575
|
+
card.type = 'button';
|
|
576
|
+
if (session.sessionId) card.dataset.sessionId = session.sessionId;
|
|
577
|
+
if (session.status === 'running') card.classList.add('is-live');
|
|
578
|
+
|
|
579
|
+
const head = el('span', 'dashboard-card-head');
|
|
580
|
+
const project = el(
|
|
581
|
+
'span',
|
|
582
|
+
'dashboard-project',
|
|
583
|
+
session.repositoryName || 'Projeto desconhecido',
|
|
584
|
+
);
|
|
585
|
+
const badge = el('span', 'badge status-' + (session.status || 'idle'));
|
|
586
|
+
badge.textContent = STATUS_LABELS[session.status] || session.status || '—';
|
|
587
|
+
head.appendChild(project);
|
|
588
|
+
head.appendChild(badge);
|
|
589
|
+
card.appendChild(head);
|
|
590
|
+
|
|
591
|
+
const titleRow = el('span', 'dashboard-title-row');
|
|
592
|
+
if (session.issueNumber !== null && session.issueNumber !== undefined) {
|
|
593
|
+
titleRow.appendChild(el('span', 'dashboard-issue', '#' + session.issueNumber));
|
|
594
|
+
}
|
|
595
|
+
titleRow.appendChild(
|
|
596
|
+
el('span', 'dashboard-title', session.issueTitle || 'Sem título'),
|
|
597
|
+
);
|
|
598
|
+
card.appendChild(titleRow);
|
|
599
|
+
|
|
600
|
+
const summary = truncateText(session.issueDescription, DESCRIPTION_PREVIEW);
|
|
601
|
+
card.appendChild(el('span', 'dashboard-summary muted', summary || 'Sem descrição'));
|
|
602
|
+
|
|
603
|
+
const meta = el('span', 'dashboard-meta-row');
|
|
604
|
+
meta.appendChild(
|
|
605
|
+
el('span', null, 'Fase: ' + (session.currentPhase || '—')),
|
|
606
|
+
);
|
|
607
|
+
const percent =
|
|
608
|
+
typeof session.progressPercent === 'number' ? session.progressPercent : 0;
|
|
609
|
+
meta.appendChild(el('span', null, percent + '%'));
|
|
610
|
+
const elapsed =
|
|
611
|
+
typeof session.elapsedSeconds === 'number'
|
|
612
|
+
? formatDuration(session.elapsedSeconds)
|
|
613
|
+
: session.startedAt
|
|
614
|
+
? formatAgo(session.startedAt)
|
|
615
|
+
: '—';
|
|
616
|
+
meta.appendChild(el('span', null, elapsed));
|
|
617
|
+
card.appendChild(meta);
|
|
618
|
+
|
|
619
|
+
const progress = el('span', 'dashboard-progress');
|
|
620
|
+
const bar = el('span', 'dashboard-progress-bar');
|
|
621
|
+
bar.style.width = Math.max(0, Math.min(100, percent)) + '%';
|
|
622
|
+
progress.appendChild(bar);
|
|
623
|
+
card.appendChild(progress);
|
|
624
|
+
|
|
625
|
+
if (session.status === 'running') {
|
|
626
|
+
card.appendChild(el('span', 'dashboard-live-dot', 'ao vivo'));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
card.addEventListener('click', () => {
|
|
630
|
+
if (session.sessionId) selectSession(session.sessionId);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
els.dashboard.appendChild(card);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
els.dashboardMeta.textContent =
|
|
637
|
+
sessions.length + (sessions.length === 1 ? ' execução' : ' execuções');
|
|
638
|
+
document.title = sessions.length + ' execuções · issue-flow';
|
|
639
|
+
|
|
640
|
+
if (focusedId) {
|
|
641
|
+
const card = els.dashboard.querySelector(
|
|
642
|
+
'[data-session-id="' + focusedId.replace(/"/g, '') + '"]',
|
|
643
|
+
);
|
|
644
|
+
if (card) card.focus();
|
|
268
645
|
}
|
|
269
646
|
}
|
|
270
647
|
|
|
@@ -282,6 +659,9 @@
|
|
|
282
659
|
renderPhases(snapshot);
|
|
283
660
|
renderNextSteps(snapshot);
|
|
284
661
|
renderStories(snapshot);
|
|
662
|
+
// Incondicional: a aba inativa não pode ficar defasada até ser aberta.
|
|
663
|
+
renderKanban(snapshot);
|
|
664
|
+
renderDrawer(snapshot);
|
|
285
665
|
renderGit(snapshot);
|
|
286
666
|
renderLogs(snapshot);
|
|
287
667
|
renderMeta(snapshot);
|
|
@@ -510,7 +890,10 @@
|
|
|
510
890
|
return;
|
|
511
891
|
}
|
|
512
892
|
for (const story of snapshot.stories) {
|
|
513
|
-
|
|
893
|
+
// stage vem com default no schema ('pending'), mas session.json gravado
|
|
894
|
+
// antes da issue 38 pode chegar sem ele.
|
|
895
|
+
const storyStage = story.stage !== null && story.stage !== undefined ? story.stage : 'pending';
|
|
896
|
+
const item = el('li', storyStage === 'executing' ? 'story-executing' : null);
|
|
514
897
|
const status = story.passes ? 'completed' : 'pending';
|
|
515
898
|
item.appendChild(el('span', 'item-icon icon-' + status, story.passes ? '✓' : '○'));
|
|
516
899
|
const main = el('div', 'item-main');
|
|
@@ -534,7 +917,12 @@
|
|
|
534
917
|
|
|
535
918
|
item.appendChild(main);
|
|
536
919
|
const duration = metric(story.durationSeconds);
|
|
920
|
+
const stageLabel = STORY_STAGE_LABELS[storyStage] || storyStage;
|
|
921
|
+
const stageText = story.stageSince
|
|
922
|
+
? stageLabel + ' ' + formatAgo(story.stageSince)
|
|
923
|
+
: stageLabel;
|
|
537
924
|
const side = itemSideText([
|
|
925
|
+
stageText,
|
|
538
926
|
story.completedAt ? 'concluída ' + formatClock(story.completedAt) : '',
|
|
539
927
|
duration !== null ? formatDuration(duration) : '',
|
|
540
928
|
formatUsage(story),
|
|
@@ -544,6 +932,157 @@
|
|
|
544
932
|
}
|
|
545
933
|
}
|
|
546
934
|
|
|
935
|
+
// As stories chegam já normalizadas por getStories(), então aqui nenhum campo
|
|
936
|
+
// precisa de checagem de ausência.
|
|
937
|
+
// <button> em vez de <div role="button">: acionamento por Enter/Espaço e foco
|
|
938
|
+
// saem de graça. Por isso todo o conteúdo é <span> — <p>/<div> não são
|
|
939
|
+
// conteúdo válido dentro de um botão.
|
|
940
|
+
function storyCard(story) {
|
|
941
|
+
const card = el('button', 'kanban-card');
|
|
942
|
+
card.type = 'button';
|
|
943
|
+
card.dataset.storyId = story.id;
|
|
944
|
+
|
|
945
|
+
const head = el('span', 'kanban-card-head');
|
|
946
|
+
head.appendChild(
|
|
947
|
+
el('span', 'item-icon icon-' + (story.passes ? 'completed' : 'pending'), story.passes ? '✓' : '○'),
|
|
948
|
+
);
|
|
949
|
+
head.appendChild(el('span', 'story-id', story.id));
|
|
950
|
+
card.appendChild(head);
|
|
951
|
+
|
|
952
|
+
card.appendChild(el('span', 'kanban-card-title', story.title));
|
|
953
|
+
if (story.description) card.appendChild(el('span', 'kanban-card-desc', story.description));
|
|
954
|
+
card.appendChild(
|
|
955
|
+
el('span', 'badge story-status-' + story.status, STORY_STATUS_LABELS[story.status]),
|
|
956
|
+
);
|
|
957
|
+
card.addEventListener('click', () => openDrawer(story.id));
|
|
958
|
+
return card;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function renderKanban(snapshot) {
|
|
962
|
+
clear(els.kanban);
|
|
963
|
+
const stories = getStories(snapshot);
|
|
964
|
+
for (const column of KANBAN_COLUMNS) {
|
|
965
|
+
const entries = stories.filter((story) => story.status === column.status);
|
|
966
|
+
|
|
967
|
+
const node = el('section', 'kanban-column');
|
|
968
|
+
const head = el('div', 'kanban-column-head');
|
|
969
|
+
head.appendChild(el('h3', 'kanban-column-title', column.title));
|
|
970
|
+
head.appendChild(el('span', 'kanban-column-count', String(entries.length)));
|
|
971
|
+
node.appendChild(head);
|
|
972
|
+
|
|
973
|
+
if (entries.length === 0) {
|
|
974
|
+
node.appendChild(el('p', 'empty kanban-empty', 'Nenhuma story.'));
|
|
975
|
+
} else {
|
|
976
|
+
for (const story of entries) node.appendChild(storyCard(story));
|
|
977
|
+
}
|
|
978
|
+
els.kanban.appendChild(node);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// ---- Drawer de detalhes da story ------------------------------------------
|
|
983
|
+
|
|
984
|
+
function onDrawerKeydown(event) {
|
|
985
|
+
if (event.key === 'Escape') closeDrawer();
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Reidrata a partir do id a cada render: o drawer aberto sobrevive ao poll.
|
|
989
|
+
function renderDrawer(snapshot) {
|
|
990
|
+
if (state.selectedStoryId === null) return;
|
|
991
|
+
const story = getStoryById(snapshot, state.selectedStoryId);
|
|
992
|
+
// A story saiu do plano: manter o drawer aberto exibiria dados obsoletos.
|
|
993
|
+
if (story === null) {
|
|
994
|
+
closeDrawer();
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
els.drawerTitle.textContent = story.id + ' · ' + story.title;
|
|
998
|
+
|
|
999
|
+
clear(els.drawerBody);
|
|
1000
|
+
els.drawerBody.appendChild(
|
|
1001
|
+
el('span', 'badge story-status-' + story.status, STORY_STATUS_LABELS[story.status]),
|
|
1002
|
+
);
|
|
1003
|
+
|
|
1004
|
+
const timing = itemSideText([
|
|
1005
|
+
story.completedAt ? 'concluída ' + formatClock(story.completedAt) : '',
|
|
1006
|
+
metric(story.durationSeconds) !== null ? formatDuration(story.durationSeconds) : '',
|
|
1007
|
+
]);
|
|
1008
|
+
if (timing) els.drawerBody.appendChild(el('p', 'muted drawer-timing', timing));
|
|
1009
|
+
|
|
1010
|
+
drawerSection('Descrição', (body) => {
|
|
1011
|
+
body.appendChild(
|
|
1012
|
+
story.description
|
|
1013
|
+
? el('p', 'drawer-text', story.description)
|
|
1014
|
+
: el('p', 'empty', 'Sem descrição.'),
|
|
1015
|
+
);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
drawerSection('Critérios de aceite', (body) => {
|
|
1019
|
+
if (story.acceptanceCriteria.length === 0) {
|
|
1020
|
+
body.appendChild(el('p', 'empty', 'Nenhum critério declarado.'));
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
const items = el('ul', 'drawer-list');
|
|
1024
|
+
for (const criterion of story.acceptanceCriteria) items.appendChild(el('li', null, criterion));
|
|
1025
|
+
body.appendChild(items);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
drawerSection('Dependências', (body) => {
|
|
1029
|
+
if (story.dependencies.length === 0) {
|
|
1030
|
+
body.appendChild(el('p', 'empty', 'Nenhuma dependência.'));
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const row = el('div', 'badge-row');
|
|
1034
|
+
for (const dependency of story.dependencies) {
|
|
1035
|
+
row.appendChild(el('span', 'badge label-badge', dependency));
|
|
1036
|
+
}
|
|
1037
|
+
body.appendChild(row);
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
// Nenhum campo de histórico existe no snapshot hoje; a seção só aparece se
|
|
1041
|
+
// uma versão futura publicar um.
|
|
1042
|
+
const history = list(story.history);
|
|
1043
|
+
if (history.length > 0) {
|
|
1044
|
+
drawerSection('Histórico', (body) => {
|
|
1045
|
+
const entries = el('ol', 'drawer-list');
|
|
1046
|
+
for (const entry of history) {
|
|
1047
|
+
const item = el('li');
|
|
1048
|
+
if (entry.at) item.appendChild(el('span', 'log-time', formatClock(entry.at)));
|
|
1049
|
+
item.appendChild(document.createTextNode(text(entry.message)));
|
|
1050
|
+
entries.appendChild(item);
|
|
1051
|
+
}
|
|
1052
|
+
body.appendChild(entries);
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function drawerSection(title, fill) {
|
|
1058
|
+
const section = el('section', 'drawer-section');
|
|
1059
|
+
section.appendChild(el('h3', 'drawer-section-title', title));
|
|
1060
|
+
fill(section);
|
|
1061
|
+
els.drawerBody.appendChild(section);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function openDrawer(id) {
|
|
1065
|
+
state.selectedStoryId = id;
|
|
1066
|
+
els.drawer.hidden = false;
|
|
1067
|
+
els.drawerOverlay.hidden = false;
|
|
1068
|
+
document.addEventListener('keydown', onDrawerKeydown);
|
|
1069
|
+
renderDrawer(state.snapshot);
|
|
1070
|
+
els.drawer.focus();
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function closeDrawer() {
|
|
1074
|
+
const id = state.selectedStoryId;
|
|
1075
|
+
state.selectedStoryId = null;
|
|
1076
|
+
els.drawer.hidden = true;
|
|
1077
|
+
els.drawerOverlay.hidden = true;
|
|
1078
|
+
document.removeEventListener('keydown', onDrawerKeydown);
|
|
1079
|
+
clear(els.drawerBody);
|
|
1080
|
+
// O card é recriado a cada render, então o foco volta pelo id, não por uma
|
|
1081
|
+
// referência guardada na abertura.
|
|
1082
|
+
const card = id ? els.kanban.querySelector('[data-story-id="' + id + '"]') : null;
|
|
1083
|
+
if (card) card.focus();
|
|
1084
|
+
}
|
|
1085
|
+
|
|
547
1086
|
function renderGit(snapshot) {
|
|
548
1087
|
const repo = repoUrl(snapshot);
|
|
549
1088
|
|
|
@@ -633,13 +1172,30 @@
|
|
|
633
1172
|
|
|
634
1173
|
// ---- Inicialização --------------------------------------------------------
|
|
635
1174
|
|
|
1175
|
+
function onRefreshChange(select) {
|
|
1176
|
+
state.refreshSeconds = Number(select.value);
|
|
1177
|
+
storeRefresh(state.refreshSeconds);
|
|
1178
|
+
buildRefreshSelect();
|
|
1179
|
+
clearTimer();
|
|
1180
|
+
if (state.refreshSeconds !== PAUSED) poll();
|
|
1181
|
+
}
|
|
1182
|
+
|
|
636
1183
|
async function init() {
|
|
637
|
-
els.
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
1184
|
+
for (const tab of els.tabs) {
|
|
1185
|
+
tab.addEventListener('click', () => setActiveTab(tab.id));
|
|
1186
|
+
}
|
|
1187
|
+
setActiveTab(state.activeTab);
|
|
1188
|
+
|
|
1189
|
+
els.drawerClose.addEventListener('click', closeDrawer);
|
|
1190
|
+
els.drawerOverlay.addEventListener('click', closeDrawer);
|
|
1191
|
+
els.backToDashboard.addEventListener('click', clearSessionSelection);
|
|
1192
|
+
|
|
1193
|
+
els.refreshSelect.addEventListener('change', () => onRefreshChange(els.refreshSelect));
|
|
1194
|
+
if (els.refreshSelectDashboard) {
|
|
1195
|
+
els.refreshSelectDashboard.addEventListener('change', () =>
|
|
1196
|
+
onRefreshChange(els.refreshSelectDashboard),
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
643
1199
|
|
|
644
1200
|
els.logFilter.addEventListener('change', () => {
|
|
645
1201
|
state.logFilter = els.logFilter.value;
|