@1mancompany/onemancompany 0.7.31 → 0.7.33
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/frontend/app.js +318 -15
- package/frontend/style.css +247 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/onemancompany/api/routes.py +36 -0
- package/src/onemancompany/core/product.py +21 -3
- package/src/onemancompany/core/product_triggers.py +22 -15
package/frontend/app.js
CHANGED
|
@@ -7368,13 +7368,13 @@ class AppController {
|
|
|
7368
7368
|
if (result.status === 'imported') {
|
|
7369
7369
|
this.updateProjectsPanel();
|
|
7370
7370
|
this._refreshProductSelector();
|
|
7371
|
-
|
|
7371
|
+
this._showToast(`Imported "${bundle.product.name}" — ${result.issues_created} issues, ${result.krs_created} KRs`, 'success', 5000);
|
|
7372
7372
|
} else {
|
|
7373
|
-
|
|
7373
|
+
this._showToast('Import failed: ' + (result.detail || 'Unknown error'), 'error');
|
|
7374
7374
|
}
|
|
7375
7375
|
} catch (err) {
|
|
7376
7376
|
console.error('Import failed:', err);
|
|
7377
|
-
|
|
7377
|
+
this._showToast('Import failed: ' + err.message, 'error');
|
|
7378
7378
|
}
|
|
7379
7379
|
});
|
|
7380
7380
|
input.click();
|
|
@@ -7419,7 +7419,7 @@ class AppController {
|
|
|
7419
7419
|
const ownerId = document.getElementById('create-product-owner')?.value || '';
|
|
7420
7420
|
|
|
7421
7421
|
if (!name) {
|
|
7422
|
-
|
|
7422
|
+
this._showToast('Product name is required', 'warning');
|
|
7423
7423
|
return;
|
|
7424
7424
|
}
|
|
7425
7425
|
|
|
@@ -7460,7 +7460,7 @@ class AppController {
|
|
|
7460
7460
|
this._refreshProductSelector();
|
|
7461
7461
|
} catch (e) {
|
|
7462
7462
|
console.error('Failed to create product:', e);
|
|
7463
|
-
|
|
7463
|
+
this._showToast('Failed to create product', 'error');
|
|
7464
7464
|
}
|
|
7465
7465
|
}
|
|
7466
7466
|
|
|
@@ -7502,6 +7502,31 @@ class AppController {
|
|
|
7502
7502
|
return card;
|
|
7503
7503
|
}
|
|
7504
7504
|
|
|
7505
|
+
// ===== Toast Notifications =====
|
|
7506
|
+
|
|
7507
|
+
_showToast(message, type = 'info', duration = 3000) {
|
|
7508
|
+
let container = document.querySelector('.app-toast-container');
|
|
7509
|
+
if (!container) {
|
|
7510
|
+
container = document.createElement('div');
|
|
7511
|
+
container.className = 'app-toast-container';
|
|
7512
|
+
document.body.appendChild(container);
|
|
7513
|
+
}
|
|
7514
|
+
const toast = document.createElement('div');
|
|
7515
|
+
toast.className = `app-toast toast-${type}`;
|
|
7516
|
+
toast.textContent = message;
|
|
7517
|
+
toast.addEventListener('click', () => {
|
|
7518
|
+
toast.classList.add('toast-out');
|
|
7519
|
+
setTimeout(() => toast.remove(), 200);
|
|
7520
|
+
});
|
|
7521
|
+
container.appendChild(toast);
|
|
7522
|
+
setTimeout(() => {
|
|
7523
|
+
if (toast.parentNode) {
|
|
7524
|
+
toast.classList.add('toast-out');
|
|
7525
|
+
setTimeout(() => toast.remove(), 200);
|
|
7526
|
+
}
|
|
7527
|
+
}, duration);
|
|
7528
|
+
}
|
|
7529
|
+
|
|
7505
7530
|
// ===== Product Detail Modal =====
|
|
7506
7531
|
|
|
7507
7532
|
_openProductDetail(slug) {
|
|
@@ -7670,7 +7695,7 @@ class AppController {
|
|
|
7670
7695
|
this._refreshProductSelector();
|
|
7671
7696
|
} catch (err) {
|
|
7672
7697
|
console.error('Delete failed:', err);
|
|
7673
|
-
|
|
7698
|
+
this._showToast('Delete failed: ' + err.message, 'error');
|
|
7674
7699
|
}
|
|
7675
7700
|
});
|
|
7676
7701
|
header.appendChild(deleteBtn);
|
|
@@ -7994,6 +8019,14 @@ class AppController {
|
|
|
7994
8019
|
priSel.addEventListener('change', () => renderFiltered());
|
|
7995
8020
|
toolbar.appendChild(priSel);
|
|
7996
8021
|
|
|
8022
|
+
// Text search
|
|
8023
|
+
const searchInput = document.createElement('input');
|
|
8024
|
+
searchInput.type = 'text';
|
|
8025
|
+
searchInput.className = 'issue-search-input';
|
|
8026
|
+
searchInput.placeholder = 'Search...';
|
|
8027
|
+
searchInput.addEventListener('input', () => renderFiltered());
|
|
8028
|
+
toolbar.appendChild(searchInput);
|
|
8029
|
+
|
|
7997
8030
|
container.appendChild(toolbar);
|
|
7998
8031
|
|
|
7999
8032
|
const issueList = document.createElement('div');
|
|
@@ -8005,9 +8038,15 @@ class AppController {
|
|
|
8005
8038
|
const renderFiltered = () => {
|
|
8006
8039
|
const sf = statusSel.value;
|
|
8007
8040
|
const pf = priSel.value;
|
|
8041
|
+
const q = (searchInput.value || '').toLowerCase().trim();
|
|
8008
8042
|
let filtered = issues;
|
|
8009
8043
|
if (sf) filtered = filtered.filter(i => i.status === sf);
|
|
8010
8044
|
if (pf) filtered = filtered.filter(i => i.priority === pf);
|
|
8045
|
+
if (q) filtered = filtered.filter(i =>
|
|
8046
|
+
(i.title || '').toLowerCase().includes(q) ||
|
|
8047
|
+
(i.description || '').toLowerCase().includes(q) ||
|
|
8048
|
+
(i.labels || []).some(l => l.toLowerCase().includes(q))
|
|
8049
|
+
);
|
|
8011
8050
|
filtered.sort((a, b) => {
|
|
8012
8051
|
const aDone = a.status === 'done' || a.status === 'released';
|
|
8013
8052
|
const bDone = b.status === 'done' || b.status === 'released';
|
|
@@ -8160,6 +8199,30 @@ class AppController {
|
|
|
8160
8199
|
body.appendChild(histEl);
|
|
8161
8200
|
}
|
|
8162
8201
|
|
|
8202
|
+
// Issue Links section
|
|
8203
|
+
this._renderIssueLinks(body, slug, issue, fullData);
|
|
8204
|
+
|
|
8205
|
+
// Delete button
|
|
8206
|
+
const deleteRow = document.createElement('div');
|
|
8207
|
+
deleteRow.style.marginTop = '8px';
|
|
8208
|
+
deleteRow.style.paddingTop = '6px';
|
|
8209
|
+
deleteRow.style.borderTop = '1px solid rgba(255,255,255,0.05)';
|
|
8210
|
+
const deleteBtn = document.createElement('button');
|
|
8211
|
+
deleteBtn.className = 'issue-delete-btn';
|
|
8212
|
+
deleteBtn.textContent = 'Delete Issue';
|
|
8213
|
+
deleteBtn.addEventListener('click', async (e) => {
|
|
8214
|
+
e.stopPropagation();
|
|
8215
|
+
if (!confirm(`Delete issue "${issue.title}"?`)) return;
|
|
8216
|
+
try {
|
|
8217
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/issue/${encodeURIComponent(issue.id)}`, { method: 'DELETE' });
|
|
8218
|
+
if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
|
|
8219
|
+
this._showToast('Issue deleted', 'success');
|
|
8220
|
+
this._openProductDetail(slug);
|
|
8221
|
+
} catch (err) { this._showToast(`Delete failed: ${err.message}`, 'error'); }
|
|
8222
|
+
});
|
|
8223
|
+
deleteRow.appendChild(deleteBtn);
|
|
8224
|
+
body.appendChild(deleteRow);
|
|
8225
|
+
|
|
8163
8226
|
card.appendChild(body);
|
|
8164
8227
|
|
|
8165
8228
|
header.addEventListener('click', () => {
|
|
@@ -8169,6 +8232,129 @@ class AppController {
|
|
|
8169
8232
|
return card;
|
|
8170
8233
|
}
|
|
8171
8234
|
|
|
8235
|
+
_renderIssueLinks(container, slug, issue, fullData) {
|
|
8236
|
+
const section = document.createElement('div');
|
|
8237
|
+
section.className = 'issue-links-section';
|
|
8238
|
+
|
|
8239
|
+
const hdr = document.createElement('div');
|
|
8240
|
+
hdr.className = 'issue-links-header';
|
|
8241
|
+
const title = document.createElement('span');
|
|
8242
|
+
title.className = 'issue-links-title';
|
|
8243
|
+
title.textContent = 'Links';
|
|
8244
|
+
hdr.appendChild(title);
|
|
8245
|
+
|
|
8246
|
+
const addBtn = document.createElement('button');
|
|
8247
|
+
addBtn.className = 'btn-small';
|
|
8248
|
+
addBtn.textContent = '+';
|
|
8249
|
+
addBtn.style.padding = '0 4px';
|
|
8250
|
+
addBtn.addEventListener('click', (e) => {
|
|
8251
|
+
e.stopPropagation();
|
|
8252
|
+
this._showAddLinkForm(section, slug, issue, fullData);
|
|
8253
|
+
});
|
|
8254
|
+
hdr.appendChild(addBtn);
|
|
8255
|
+
section.appendChild(hdr);
|
|
8256
|
+
|
|
8257
|
+
const list = document.createElement('div');
|
|
8258
|
+
list.className = 'issue-links-list';
|
|
8259
|
+
|
|
8260
|
+
const links = issue.issue_links || [];
|
|
8261
|
+
if (links.length === 0) {
|
|
8262
|
+
const empty = document.createElement('div');
|
|
8263
|
+
empty.className = 'task-empty';
|
|
8264
|
+
empty.style.fontSize = 'calc(5px + var(--font-boost))';
|
|
8265
|
+
empty.textContent = 'No links';
|
|
8266
|
+
list.appendChild(empty);
|
|
8267
|
+
} else {
|
|
8268
|
+
for (const link of links) {
|
|
8269
|
+
const row = document.createElement('div');
|
|
8270
|
+
row.className = 'issue-link-row';
|
|
8271
|
+
|
|
8272
|
+
const typeEl = document.createElement('span');
|
|
8273
|
+
typeEl.className = 'issue-link-type';
|
|
8274
|
+
typeEl.textContent = link.relation;
|
|
8275
|
+
row.appendChild(typeEl);
|
|
8276
|
+
|
|
8277
|
+
const targetEl = document.createElement('span');
|
|
8278
|
+
targetEl.className = 'issue-link-target';
|
|
8279
|
+
const targetIssue = (fullData.issues || []).find(i => i.id === link.issue_id);
|
|
8280
|
+
targetEl.textContent = targetIssue ? targetIssue.title : link.issue_id;
|
|
8281
|
+
row.appendChild(targetEl);
|
|
8282
|
+
|
|
8283
|
+
const removeBtn = document.createElement('button');
|
|
8284
|
+
removeBtn.className = 'issue-link-remove';
|
|
8285
|
+
removeBtn.textContent = '\u00d7';
|
|
8286
|
+
removeBtn.title = 'Remove link';
|
|
8287
|
+
removeBtn.addEventListener('click', async (e) => {
|
|
8288
|
+
e.stopPropagation();
|
|
8289
|
+
try {
|
|
8290
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/issue/${encodeURIComponent(issue.id)}/link/${encodeURIComponent(link.issue_id)}`, { method: 'DELETE' });
|
|
8291
|
+
if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
|
|
8292
|
+
this._showToast('Link removed', 'success');
|
|
8293
|
+
this._openProductDetail(slug);
|
|
8294
|
+
} catch (err) { this._showToast(`Remove link failed: ${err.message}`, 'error'); }
|
|
8295
|
+
});
|
|
8296
|
+
row.appendChild(removeBtn);
|
|
8297
|
+
list.appendChild(row);
|
|
8298
|
+
}
|
|
8299
|
+
}
|
|
8300
|
+
|
|
8301
|
+
section.appendChild(list);
|
|
8302
|
+
container.appendChild(section);
|
|
8303
|
+
}
|
|
8304
|
+
|
|
8305
|
+
_showAddLinkForm(container, slug, issue, fullData) {
|
|
8306
|
+
if (container.querySelector('.issue-link-add-row')) return;
|
|
8307
|
+
const row = document.createElement('div');
|
|
8308
|
+
row.className = 'issue-link-add-row';
|
|
8309
|
+
|
|
8310
|
+
const relSel = document.createElement('select');
|
|
8311
|
+
relSel.className = 'form-input';
|
|
8312
|
+
relSel.style.width = 'auto';
|
|
8313
|
+
relSel.innerHTML = '<option value="blocks">blocks</option><option value="blocked_by">blocked_by</option><option value="relates_to">relates_to</option>';
|
|
8314
|
+
row.appendChild(relSel);
|
|
8315
|
+
|
|
8316
|
+
const targetSel = document.createElement('select');
|
|
8317
|
+
targetSel.className = 'form-input';
|
|
8318
|
+
targetSel.style.width = 'auto';
|
|
8319
|
+
targetSel.innerHTML = '<option value="">Select issue...</option>';
|
|
8320
|
+
for (const i of (fullData.issues || [])) {
|
|
8321
|
+
if (i.id === issue.id) continue;
|
|
8322
|
+
const opt = document.createElement('option');
|
|
8323
|
+
opt.value = i.id;
|
|
8324
|
+
opt.textContent = `[${i.priority || 'P2'}] ${i.title}`;
|
|
8325
|
+
targetSel.appendChild(opt);
|
|
8326
|
+
}
|
|
8327
|
+
row.appendChild(targetSel);
|
|
8328
|
+
|
|
8329
|
+
const saveBtn = document.createElement('button');
|
|
8330
|
+
saveBtn.className = 'btn-small';
|
|
8331
|
+
saveBtn.textContent = 'Add';
|
|
8332
|
+
saveBtn.addEventListener('click', async (e) => {
|
|
8333
|
+
e.stopPropagation();
|
|
8334
|
+
if (!targetSel.value) return;
|
|
8335
|
+
try {
|
|
8336
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/issue/${encodeURIComponent(issue.id)}/link`, {
|
|
8337
|
+
method: 'POST',
|
|
8338
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8339
|
+
body: JSON.stringify({ target_id: targetSel.value, relation: relSel.value }),
|
|
8340
|
+
});
|
|
8341
|
+
if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
|
|
8342
|
+
this._showToast('Link added', 'success');
|
|
8343
|
+
this._openProductDetail(slug);
|
|
8344
|
+
} catch (err) { this._showToast(`Add link failed: ${err.message}`, 'error'); }
|
|
8345
|
+
});
|
|
8346
|
+
row.appendChild(saveBtn);
|
|
8347
|
+
|
|
8348
|
+
const cancelBtn = document.createElement('button');
|
|
8349
|
+
cancelBtn.className = 'kr-remove-btn';
|
|
8350
|
+
cancelBtn.textContent = '\u00d7';
|
|
8351
|
+
cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); row.remove(); });
|
|
8352
|
+
row.appendChild(cancelBtn);
|
|
8353
|
+
|
|
8354
|
+
container.appendChild(row);
|
|
8355
|
+
targetSel.focus();
|
|
8356
|
+
}
|
|
8357
|
+
|
|
8172
8358
|
_makeIssueFieldEditable(el, slug, issueId, fieldName, fullData) {
|
|
8173
8359
|
el.style.cursor = 'pointer';
|
|
8174
8360
|
el.title = 'Click to edit';
|
|
@@ -8374,29 +8560,94 @@ class AppController {
|
|
|
8374
8560
|
.then(data => {
|
|
8375
8561
|
container.innerHTML = '';
|
|
8376
8562
|
|
|
8377
|
-
|
|
8378
|
-
|
|
8379
|
-
return;
|
|
8380
|
-
}
|
|
8381
|
-
|
|
8382
|
-
// Sprints section
|
|
8383
|
-
if (data.sprints.length) {
|
|
8563
|
+
// Sprint section (always visible — has create button)
|
|
8564
|
+
{
|
|
8384
8565
|
const section = document.createElement('div');
|
|
8385
8566
|
section.className = 'roadmap-section';
|
|
8567
|
+
const hdr = document.createElement('div');
|
|
8568
|
+
hdr.style.display = 'flex';
|
|
8569
|
+
hdr.style.alignItems = 'center';
|
|
8570
|
+
hdr.style.justifyContent = 'space-between';
|
|
8386
8571
|
const h = document.createElement('h3');
|
|
8387
8572
|
h.textContent = 'Sprints';
|
|
8388
|
-
|
|
8573
|
+
h.style.margin = '0';
|
|
8574
|
+
hdr.appendChild(h);
|
|
8575
|
+
const newBtn = document.createElement('button');
|
|
8576
|
+
newBtn.className = 'btn-small';
|
|
8577
|
+
newBtn.textContent = '+ New Sprint';
|
|
8578
|
+
newBtn.addEventListener('click', () => this._showNewSprintForm(section, slug));
|
|
8579
|
+
hdr.appendChild(newBtn);
|
|
8580
|
+
section.appendChild(hdr);
|
|
8389
8581
|
|
|
8390
8582
|
const timeline = document.createElement('div');
|
|
8391
8583
|
timeline.className = 'roadmap-timeline';
|
|
8392
8584
|
|
|
8585
|
+
if (data.sprints.length === 0) {
|
|
8586
|
+
const empty = document.createElement('div');
|
|
8587
|
+
empty.className = 'task-empty';
|
|
8588
|
+
empty.textContent = 'No sprints yet. Click "+ New Sprint" to plan your first sprint.';
|
|
8589
|
+
timeline.appendChild(empty);
|
|
8590
|
+
}
|
|
8591
|
+
|
|
8393
8592
|
for (const s of data.sprints) {
|
|
8394
8593
|
const bar = document.createElement('div');
|
|
8395
8594
|
bar.className = `roadmap-sprint-bar roadmap-status-${s.status}`;
|
|
8396
8595
|
|
|
8596
|
+
const topRow = document.createElement('div');
|
|
8597
|
+
topRow.style.display = 'flex';
|
|
8598
|
+
topRow.style.alignItems = 'center';
|
|
8599
|
+
topRow.style.justifyContent = 'space-between';
|
|
8600
|
+
|
|
8397
8601
|
const label = document.createElement('div');
|
|
8398
8602
|
label.className = 'roadmap-bar-label';
|
|
8399
8603
|
label.textContent = `${s.name} (${s.issue_count} issues)`;
|
|
8604
|
+
topRow.appendChild(label);
|
|
8605
|
+
|
|
8606
|
+
// Sprint action buttons
|
|
8607
|
+
const actions = document.createElement('div');
|
|
8608
|
+
actions.className = 'sprint-actions';
|
|
8609
|
+
if (s.status === 'planning') {
|
|
8610
|
+
const startBtn = document.createElement('button');
|
|
8611
|
+
startBtn.className = 'sprint-action-btn';
|
|
8612
|
+
startBtn.textContent = 'Start';
|
|
8613
|
+
startBtn.addEventListener('click', async () => {
|
|
8614
|
+
try {
|
|
8615
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/sprint/${encodeURIComponent(s.id)}/start`, { method: 'POST' });
|
|
8616
|
+
if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
|
|
8617
|
+
this._showToast('Sprint started', 'success');
|
|
8618
|
+
this._renderProductRoadmap(slug, container);
|
|
8619
|
+
} catch (err) { this._showToast(`Start failed: ${err.message}`, 'error'); }
|
|
8620
|
+
});
|
|
8621
|
+
actions.appendChild(startBtn);
|
|
8622
|
+
const delBtn = document.createElement('button');
|
|
8623
|
+
delBtn.className = 'sprint-action-btn danger';
|
|
8624
|
+
delBtn.textContent = 'Delete';
|
|
8625
|
+
delBtn.addEventListener('click', async () => {
|
|
8626
|
+
if (!confirm(`Delete sprint "${s.name}"?`)) return;
|
|
8627
|
+
try {
|
|
8628
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/sprint/${encodeURIComponent(s.id)}`, { method: 'DELETE' });
|
|
8629
|
+
if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
|
|
8630
|
+
this._showToast('Sprint deleted', 'success');
|
|
8631
|
+
this._renderProductRoadmap(slug, container);
|
|
8632
|
+
} catch (err) { this._showToast(`Delete failed: ${err.message}`, 'error'); }
|
|
8633
|
+
});
|
|
8634
|
+
actions.appendChild(delBtn);
|
|
8635
|
+
} else if (s.status === 'active') {
|
|
8636
|
+
const closeBtn = document.createElement('button');
|
|
8637
|
+
closeBtn.className = 'sprint-action-btn';
|
|
8638
|
+
closeBtn.textContent = 'Close';
|
|
8639
|
+
closeBtn.addEventListener('click', async () => {
|
|
8640
|
+
try {
|
|
8641
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/sprint/${encodeURIComponent(s.id)}/close`, { method: 'POST' });
|
|
8642
|
+
if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
|
|
8643
|
+
this._showToast('Sprint closed', 'success');
|
|
8644
|
+
this._renderProductRoadmap(slug, container);
|
|
8645
|
+
} catch (err) { this._showToast(`Close failed: ${err.message}`, 'error'); }
|
|
8646
|
+
});
|
|
8647
|
+
actions.appendChild(closeBtn);
|
|
8648
|
+
}
|
|
8649
|
+
topRow.appendChild(actions);
|
|
8650
|
+
bar.appendChild(topRow);
|
|
8400
8651
|
|
|
8401
8652
|
const dates = document.createElement('div');
|
|
8402
8653
|
dates.className = 'roadmap-bar-dates';
|
|
@@ -8406,7 +8657,6 @@ class AppController {
|
|
|
8406
8657
|
statusBadge.className = `roadmap-status-badge roadmap-status-${s.status}`;
|
|
8407
8658
|
statusBadge.textContent = s.status;
|
|
8408
8659
|
|
|
8409
|
-
bar.appendChild(label);
|
|
8410
8660
|
bar.appendChild(dates);
|
|
8411
8661
|
bar.appendChild(statusBadge);
|
|
8412
8662
|
if (s.goal) {
|
|
@@ -8493,6 +8743,59 @@ class AppController {
|
|
|
8493
8743
|
.catch(err => { container.innerHTML = `<div class="error-text">Failed to load roadmap: ${err.message}</div>`; });
|
|
8494
8744
|
}
|
|
8495
8745
|
|
|
8746
|
+
_showNewSprintForm(section, slug) {
|
|
8747
|
+
if (section.querySelector('.sprint-inline-add')) return;
|
|
8748
|
+
const form = document.createElement('div');
|
|
8749
|
+
form.className = 'sprint-inline-add';
|
|
8750
|
+
form.innerHTML = `
|
|
8751
|
+
<input type="text" class="form-input sprint-new-name" placeholder="Sprint name" />
|
|
8752
|
+
<input type="text" class="form-input sprint-new-goal" placeholder="Goal (optional)" />
|
|
8753
|
+
<div class="sprint-form-row">
|
|
8754
|
+
<label style="color:var(--text-dim);font-size:calc(5px + var(--font-boost))">Start:</label>
|
|
8755
|
+
<input type="date" class="form-input sprint-new-start" style="width:auto" />
|
|
8756
|
+
<label style="color:var(--text-dim);font-size:calc(5px + var(--font-boost))">End:</label>
|
|
8757
|
+
<input type="date" class="form-input sprint-new-end" style="width:auto" />
|
|
8758
|
+
</div>
|
|
8759
|
+
<div class="sprint-form-row">
|
|
8760
|
+
<input type="number" class="form-input sprint-new-capacity" placeholder="Capacity (pts)" style="width:80px" />
|
|
8761
|
+
<button class="btn-small sprint-save-btn">Create</button>
|
|
8762
|
+
<button class="kr-remove-btn sprint-cancel-btn">×</button>
|
|
8763
|
+
</div>
|
|
8764
|
+
`;
|
|
8765
|
+
// Default dates: today → +14 days
|
|
8766
|
+
const today = new Date();
|
|
8767
|
+
const end = new Date(today);
|
|
8768
|
+
end.setDate(end.getDate() + 14);
|
|
8769
|
+
form.querySelector('.sprint-new-start').value = today.toISOString().split('T')[0];
|
|
8770
|
+
form.querySelector('.sprint-new-end').value = end.toISOString().split('T')[0];
|
|
8771
|
+
|
|
8772
|
+
form.querySelector('.sprint-cancel-btn').addEventListener('click', () => form.remove());
|
|
8773
|
+
form.querySelector('.sprint-save-btn').addEventListener('click', async () => {
|
|
8774
|
+
const name = form.querySelector('.sprint-new-name').value.trim();
|
|
8775
|
+
if (!name) { this._showToast('Sprint name is required', 'warning'); return; }
|
|
8776
|
+
const start_date = form.querySelector('.sprint-new-start').value;
|
|
8777
|
+
const end_date = form.querySelector('.sprint-new-end').value;
|
|
8778
|
+
if (!start_date || !end_date) { this._showToast('Dates are required', 'warning'); return; }
|
|
8779
|
+
const goal = form.querySelector('.sprint-new-goal').value.trim();
|
|
8780
|
+
const capacity = parseInt(form.querySelector('.sprint-new-capacity').value) || null;
|
|
8781
|
+
try {
|
|
8782
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/sprint`, {
|
|
8783
|
+
method: 'POST',
|
|
8784
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8785
|
+
body: JSON.stringify({ name, start_date, end_date, goal, capacity }),
|
|
8786
|
+
});
|
|
8787
|
+
if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
|
|
8788
|
+
this._showToast('Sprint created', 'success');
|
|
8789
|
+
this._openProductDetail(slug);
|
|
8790
|
+
} catch (err) { this._showToast(`Create failed: ${err.message}`, 'error'); }
|
|
8791
|
+
});
|
|
8792
|
+
|
|
8793
|
+
// Insert after the header
|
|
8794
|
+
const timeline = section.querySelector('.roadmap-timeline');
|
|
8795
|
+
section.insertBefore(form, timeline);
|
|
8796
|
+
form.querySelector('.sprint-new-name').focus();
|
|
8797
|
+
}
|
|
8798
|
+
|
|
8496
8799
|
// ---------------------------------------------------------------------------
|
|
8497
8800
|
// Activity Feed Tab
|
|
8498
8801
|
// ---------------------------------------------------------------------------
|
package/frontend/style.css
CHANGED
|
@@ -6539,3 +6539,250 @@ body.resize-dragging {
|
|
|
6539
6539
|
0%, 100% { opacity: 1; }
|
|
6540
6540
|
50% { opacity: 0.5; }
|
|
6541
6541
|
}
|
|
6542
|
+
|
|
6543
|
+
/* ================================================================
|
|
6544
|
+
B3: Toast Notifications
|
|
6545
|
+
================================================================ */
|
|
6546
|
+
|
|
6547
|
+
.app-toast-container {
|
|
6548
|
+
position: fixed;
|
|
6549
|
+
top: 12px;
|
|
6550
|
+
right: 12px;
|
|
6551
|
+
z-index: 99999;
|
|
6552
|
+
display: flex;
|
|
6553
|
+
flex-direction: column;
|
|
6554
|
+
gap: 6px;
|
|
6555
|
+
pointer-events: none;
|
|
6556
|
+
}
|
|
6557
|
+
|
|
6558
|
+
.app-toast {
|
|
6559
|
+
background: var(--bg-panel);
|
|
6560
|
+
border: 1px solid var(--border);
|
|
6561
|
+
border-left: 3px solid var(--pixel-cyan);
|
|
6562
|
+
border-radius: 2px;
|
|
6563
|
+
padding: 8px 12px;
|
|
6564
|
+
font-family: var(--font-pixel);
|
|
6565
|
+
font-size: calc(6px + var(--font-boost));
|
|
6566
|
+
color: var(--pixel-white);
|
|
6567
|
+
max-width: 320px;
|
|
6568
|
+
pointer-events: auto;
|
|
6569
|
+
animation: toast-in 0.2s ease-out;
|
|
6570
|
+
cursor: pointer;
|
|
6571
|
+
}
|
|
6572
|
+
|
|
6573
|
+
.app-toast.toast-error {
|
|
6574
|
+
border-left-color: #ff4444;
|
|
6575
|
+
}
|
|
6576
|
+
|
|
6577
|
+
.app-toast.toast-success {
|
|
6578
|
+
border-left-color: var(--pixel-green);
|
|
6579
|
+
}
|
|
6580
|
+
|
|
6581
|
+
.app-toast.toast-warning {
|
|
6582
|
+
border-left-color: var(--pixel-yellow);
|
|
6583
|
+
}
|
|
6584
|
+
|
|
6585
|
+
.app-toast.toast-out {
|
|
6586
|
+
animation: toast-out 0.2s ease-in forwards;
|
|
6587
|
+
}
|
|
6588
|
+
|
|
6589
|
+
@keyframes toast-in {
|
|
6590
|
+
from { opacity: 0; transform: translateX(40px); }
|
|
6591
|
+
to { opacity: 1; transform: translateX(0); }
|
|
6592
|
+
}
|
|
6593
|
+
|
|
6594
|
+
@keyframes toast-out {
|
|
6595
|
+
from { opacity: 1; transform: translateX(0); }
|
|
6596
|
+
to { opacity: 0; transform: translateX(40px); }
|
|
6597
|
+
}
|
|
6598
|
+
|
|
6599
|
+
/* ================================================================
|
|
6600
|
+
B3: Issue Links UI
|
|
6601
|
+
================================================================ */
|
|
6602
|
+
|
|
6603
|
+
.issue-links-section {
|
|
6604
|
+
margin-top: 8px;
|
|
6605
|
+
padding-top: 6px;
|
|
6606
|
+
border-top: 1px solid rgba(255,255,255,0.05);
|
|
6607
|
+
}
|
|
6608
|
+
|
|
6609
|
+
.issue-links-header {
|
|
6610
|
+
display: flex;
|
|
6611
|
+
align-items: center;
|
|
6612
|
+
gap: 6px;
|
|
6613
|
+
margin-bottom: 4px;
|
|
6614
|
+
}
|
|
6615
|
+
|
|
6616
|
+
.issue-links-title {
|
|
6617
|
+
color: var(--pixel-cyan);
|
|
6618
|
+
font-size: calc(5px + var(--font-boost));
|
|
6619
|
+
font-weight: bold;
|
|
6620
|
+
}
|
|
6621
|
+
|
|
6622
|
+
.issue-links-list {
|
|
6623
|
+
display: flex;
|
|
6624
|
+
flex-direction: column;
|
|
6625
|
+
gap: 2px;
|
|
6626
|
+
}
|
|
6627
|
+
|
|
6628
|
+
.issue-link-row {
|
|
6629
|
+
display: flex;
|
|
6630
|
+
align-items: center;
|
|
6631
|
+
gap: 6px;
|
|
6632
|
+
padding: 2px 4px;
|
|
6633
|
+
font-size: calc(5px + var(--font-boost));
|
|
6634
|
+
border-radius: 2px;
|
|
6635
|
+
}
|
|
6636
|
+
|
|
6637
|
+
.issue-link-row:hover {
|
|
6638
|
+
background: rgba(255,255,255,0.03);
|
|
6639
|
+
}
|
|
6640
|
+
|
|
6641
|
+
.issue-link-type {
|
|
6642
|
+
color: var(--text-dim);
|
|
6643
|
+
min-width: 70px;
|
|
6644
|
+
font-size: calc(4px + var(--font-boost));
|
|
6645
|
+
}
|
|
6646
|
+
|
|
6647
|
+
.issue-link-target {
|
|
6648
|
+
color: var(--pixel-white);
|
|
6649
|
+
flex: 1;
|
|
6650
|
+
}
|
|
6651
|
+
|
|
6652
|
+
.issue-link-remove {
|
|
6653
|
+
background: transparent;
|
|
6654
|
+
border: none;
|
|
6655
|
+
color: var(--text-dim);
|
|
6656
|
+
cursor: pointer;
|
|
6657
|
+
font-size: calc(6px + var(--font-boost));
|
|
6658
|
+
padding: 0 2px;
|
|
6659
|
+
font-family: var(--font-pixel);
|
|
6660
|
+
}
|
|
6661
|
+
|
|
6662
|
+
.issue-link-remove:hover {
|
|
6663
|
+
color: #ff4444;
|
|
6664
|
+
}
|
|
6665
|
+
|
|
6666
|
+
.issue-link-add-row {
|
|
6667
|
+
display: flex;
|
|
6668
|
+
gap: 4px;
|
|
6669
|
+
align-items: center;
|
|
6670
|
+
margin-top: 4px;
|
|
6671
|
+
}
|
|
6672
|
+
|
|
6673
|
+
.issue-link-add-row select,
|
|
6674
|
+
.issue-link-add-row input {
|
|
6675
|
+
font-size: calc(5px + var(--font-boost));
|
|
6676
|
+
}
|
|
6677
|
+
|
|
6678
|
+
/* ================================================================
|
|
6679
|
+
B3: Sprint Inline Form
|
|
6680
|
+
================================================================ */
|
|
6681
|
+
|
|
6682
|
+
.sprint-inline-add {
|
|
6683
|
+
padding: 8px;
|
|
6684
|
+
background: rgba(0,255,255,0.03);
|
|
6685
|
+
border: 1px solid var(--border);
|
|
6686
|
+
border-radius: 2px;
|
|
6687
|
+
margin-bottom: 8px;
|
|
6688
|
+
}
|
|
6689
|
+
|
|
6690
|
+
.sprint-inline-add .form-input {
|
|
6691
|
+
margin-bottom: 4px;
|
|
6692
|
+
}
|
|
6693
|
+
|
|
6694
|
+
.sprint-form-row {
|
|
6695
|
+
display: flex;
|
|
6696
|
+
gap: 4px;
|
|
6697
|
+
align-items: center;
|
|
6698
|
+
}
|
|
6699
|
+
|
|
6700
|
+
.sprint-actions {
|
|
6701
|
+
display: flex;
|
|
6702
|
+
gap: 4px;
|
|
6703
|
+
margin-top: 4px;
|
|
6704
|
+
}
|
|
6705
|
+
|
|
6706
|
+
.sprint-action-btn {
|
|
6707
|
+
background: transparent;
|
|
6708
|
+
border: 1px solid var(--border);
|
|
6709
|
+
color: var(--text-dim);
|
|
6710
|
+
font-family: var(--font-pixel);
|
|
6711
|
+
font-size: calc(5px + var(--font-boost));
|
|
6712
|
+
padding: 1px 6px;
|
|
6713
|
+
cursor: pointer;
|
|
6714
|
+
border-radius: 2px;
|
|
6715
|
+
}
|
|
6716
|
+
|
|
6717
|
+
.sprint-action-btn:hover {
|
|
6718
|
+
border-color: var(--pixel-cyan);
|
|
6719
|
+
color: var(--pixel-cyan);
|
|
6720
|
+
}
|
|
6721
|
+
|
|
6722
|
+
.sprint-action-btn.danger:hover {
|
|
6723
|
+
border-color: #ff4444;
|
|
6724
|
+
color: #ff4444;
|
|
6725
|
+
}
|
|
6726
|
+
|
|
6727
|
+
/* ================================================================
|
|
6728
|
+
B3: Search Input
|
|
6729
|
+
================================================================ */
|
|
6730
|
+
|
|
6731
|
+
.issue-search-input {
|
|
6732
|
+
background: var(--bg-dark);
|
|
6733
|
+
border: 1px solid var(--border);
|
|
6734
|
+
color: var(--pixel-white);
|
|
6735
|
+
font-family: var(--font-pixel);
|
|
6736
|
+
font-size: calc(5px + var(--font-boost));
|
|
6737
|
+
padding: 2px 6px;
|
|
6738
|
+
border-radius: 2px;
|
|
6739
|
+
width: 120px;
|
|
6740
|
+
}
|
|
6741
|
+
|
|
6742
|
+
.issue-search-input::placeholder {
|
|
6743
|
+
color: var(--text-dim);
|
|
6744
|
+
}
|
|
6745
|
+
|
|
6746
|
+
.issue-search-input:focus {
|
|
6747
|
+
border-color: var(--pixel-cyan);
|
|
6748
|
+
outline: none;
|
|
6749
|
+
}
|
|
6750
|
+
|
|
6751
|
+
/* ================================================================
|
|
6752
|
+
B3: Tab Overflow
|
|
6753
|
+
================================================================ */
|
|
6754
|
+
|
|
6755
|
+
.project-tabs {
|
|
6756
|
+
overflow-x: auto;
|
|
6757
|
+
flex-wrap: nowrap;
|
|
6758
|
+
scrollbar-width: none;
|
|
6759
|
+
}
|
|
6760
|
+
|
|
6761
|
+
.project-tabs::-webkit-scrollbar {
|
|
6762
|
+
display: none;
|
|
6763
|
+
}
|
|
6764
|
+
|
|
6765
|
+
.project-tab {
|
|
6766
|
+
white-space: nowrap;
|
|
6767
|
+
flex-shrink: 0;
|
|
6768
|
+
}
|
|
6769
|
+
|
|
6770
|
+
/* ================================================================
|
|
6771
|
+
B3: Delete Issue Button
|
|
6772
|
+
================================================================ */
|
|
6773
|
+
|
|
6774
|
+
.issue-delete-btn {
|
|
6775
|
+
background: transparent;
|
|
6776
|
+
border: 1px solid var(--border);
|
|
6777
|
+
color: var(--text-dim);
|
|
6778
|
+
font-family: var(--font-pixel);
|
|
6779
|
+
font-size: calc(5px + var(--font-boost));
|
|
6780
|
+
padding: 1px 6px;
|
|
6781
|
+
cursor: pointer;
|
|
6782
|
+
border-radius: 2px;
|
|
6783
|
+
}
|
|
6784
|
+
|
|
6785
|
+
.issue-delete-btn:hover {
|
|
6786
|
+
border-color: #ff4444;
|
|
6787
|
+
color: #ff4444;
|
|
6788
|
+
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -7294,6 +7294,18 @@ async def api_reopen_issue(slug: str, issue_id: str) -> dict:
|
|
|
7294
7294
|
return result
|
|
7295
7295
|
|
|
7296
7296
|
|
|
7297
|
+
@router.delete("/api/product/{slug}/issue/{issue_id}")
|
|
7298
|
+
async def api_delete_issue(slug: str, issue_id: str) -> dict:
|
|
7299
|
+
"""Delete an issue and clean up all links."""
|
|
7300
|
+
from onemancompany.core import product as prod
|
|
7301
|
+
|
|
7302
|
+
try:
|
|
7303
|
+
prod.delete_issue(slug, issue_id)
|
|
7304
|
+
except ValueError as exc:
|
|
7305
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
7306
|
+
return {"ok": True}
|
|
7307
|
+
|
|
7308
|
+
|
|
7297
7309
|
# ── Versions ────────────────────────────────────────────────────────────────
|
|
7298
7310
|
|
|
7299
7311
|
|
|
@@ -7522,6 +7534,30 @@ async def api_close_sprint(slug: str, sprint_id: str) -> dict:
|
|
|
7522
7534
|
return result
|
|
7523
7535
|
|
|
7524
7536
|
|
|
7537
|
+
@router.post("/api/product/{slug}/sprint/{sprint_id}/start")
|
|
7538
|
+
async def api_start_sprint(slug: str, sprint_id: str) -> dict:
|
|
7539
|
+
"""Start a sprint (set to active). Only one sprint can be active at a time."""
|
|
7540
|
+
from onemancompany.core import product as prod
|
|
7541
|
+
|
|
7542
|
+
try:
|
|
7543
|
+
result = prod.start_sprint(slug, sprint_id)
|
|
7544
|
+
except ValueError as exc:
|
|
7545
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
7546
|
+
return result
|
|
7547
|
+
|
|
7548
|
+
|
|
7549
|
+
@router.delete("/api/product/{slug}/sprint/{sprint_id}")
|
|
7550
|
+
async def api_delete_sprint(slug: str, sprint_id: str) -> dict:
|
|
7551
|
+
"""Delete a sprint. Cannot delete active sprints."""
|
|
7552
|
+
from onemancompany.core import product as prod
|
|
7553
|
+
|
|
7554
|
+
try:
|
|
7555
|
+
prod.delete_sprint(slug, sprint_id)
|
|
7556
|
+
except ValueError as exc:
|
|
7557
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
7558
|
+
return {"ok": True}
|
|
7559
|
+
|
|
7560
|
+
|
|
7525
7561
|
@router.get("/api/product/{slug}/sprint/suggest-capacity")
|
|
7526
7562
|
async def api_suggest_sprint_capacity(slug: str) -> dict:
|
|
7527
7563
|
"""Suggest sprint capacity based on historical velocity (sliding average of last 3)."""
|
|
@@ -37,6 +37,12 @@ from onemancompany.core.models import (
|
|
|
37
37
|
)
|
|
38
38
|
from onemancompany.core.store import _read_yaml, _write_yaml, mark_dirty
|
|
39
39
|
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Configurable constants
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
HISTORY_MAX_ENTRIES: int = 100
|
|
45
|
+
|
|
40
46
|
# ---------------------------------------------------------------------------
|
|
41
47
|
# Per-slug threading locks (same pattern as project_archive.py)
|
|
42
48
|
# ---------------------------------------------------------------------------
|
|
@@ -269,8 +275,8 @@ def _append_history(data: dict, field: str, old_value, new_value, changed_by: st
|
|
|
269
275
|
"new_value": str(new_value) if new_value is not None else None,
|
|
270
276
|
"changed_by": changed_by,
|
|
271
277
|
})
|
|
272
|
-
if len(data["history"]) >
|
|
273
|
-
data["history"] = data["history"][-
|
|
278
|
+
if len(data["history"]) > HISTORY_MAX_ENTRIES:
|
|
279
|
+
data["history"] = data["history"][-HISTORY_MAX_ENTRIES:]
|
|
274
280
|
|
|
275
281
|
|
|
276
282
|
def create_issue(
|
|
@@ -355,8 +361,12 @@ def list_issues(
|
|
|
355
361
|
priority: IssuePriority | None = None,
|
|
356
362
|
labels: list[str] | None = None,
|
|
357
363
|
sprint: str | None = None,
|
|
364
|
+
assignee_id: str | None = None,
|
|
358
365
|
) -> list[dict]:
|
|
359
|
-
"""List issues for a product, optionally filtered.
|
|
366
|
+
"""List issues for a product, optionally filtered.
|
|
367
|
+
|
|
368
|
+
assignee_id: filter by assignee. Empty string "" means unassigned.
|
|
369
|
+
"""
|
|
360
370
|
issues_path = _issues_dir(slug)
|
|
361
371
|
if not issues_path.exists():
|
|
362
372
|
return []
|
|
@@ -378,6 +388,14 @@ def list_issues(
|
|
|
378
388
|
continue
|
|
379
389
|
if sprint is not None and data.get("sprint") != sprint:
|
|
380
390
|
continue
|
|
391
|
+
if assignee_id is not None:
|
|
392
|
+
issue_assignee = data.get("assignee_id") or ""
|
|
393
|
+
if assignee_id == "":
|
|
394
|
+
# Filter for unassigned
|
|
395
|
+
if issue_assignee:
|
|
396
|
+
continue
|
|
397
|
+
elif issue_assignee != assignee_id:
|
|
398
|
+
continue
|
|
381
399
|
results.append(data)
|
|
382
400
|
return results
|
|
383
401
|
|
|
@@ -23,6 +23,16 @@ from onemancompany.core.system_cron import system_cron
|
|
|
23
23
|
# Priorities that auto-trigger project creation
|
|
24
24
|
_AUTO_PROJECT_PRIORITIES = {IssuePriority.P0.value, IssuePriority.P1.value}
|
|
25
25
|
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Configurable thresholds (B4 audit: extracted from inline magic numbers)
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
KR_LAGGING_THRESHOLD: int = 50 # KR progress % below which it's "lagging"
|
|
31
|
+
MAX_ACTIVE_PROJECTS: int = 3 # Max concurrent active projects per product
|
|
32
|
+
BACKLOG_GROOMING_THRESHOLD: int = 5 # P2/P3 unscheduled issues before grooming nudge
|
|
33
|
+
STALE_REVIEW_HOURS: int = 24 # Hours before an open review is considered stale
|
|
34
|
+
BLOCKED_DAYS_THRESHOLD: int = 7 # Days before a blocked issue is flagged
|
|
35
|
+
UNHANDLED_BACKLOG_THRESHOLD: int = 2 # Unhandled backlog issues before alert
|
|
26
36
|
|
|
27
37
|
# ---------------------------------------------------------------------------
|
|
28
38
|
# Trigger handlers
|
|
@@ -332,7 +342,7 @@ async def check_kr_progress(product_slug: str) -> list[dict]:
|
|
|
332
342
|
if target <= 0:
|
|
333
343
|
continue
|
|
334
344
|
progress_pct = current / target * 100
|
|
335
|
-
if progress_pct >=
|
|
345
|
+
if progress_pct >= KR_LAGGING_THRESHOLD:
|
|
336
346
|
continue
|
|
337
347
|
|
|
338
348
|
# Check if an open issue already exists for this KR
|
|
@@ -415,7 +425,7 @@ async def run_product_check(product_slug: str) -> dict:
|
|
|
415
425
|
|
|
416
426
|
# High priority + no active project → create project
|
|
417
427
|
if priority in _AUTO_PROJECT_PRIORITIES and not linked:
|
|
418
|
-
if len(active_for_product) >=
|
|
428
|
+
if len(active_for_product) >= MAX_ACTIVE_PROJECTS:
|
|
419
429
|
logger.debug("[PRODUCT_CHECK] Skipping project for issue {} — 3+ active projects", issue["id"])
|
|
420
430
|
continue
|
|
421
431
|
project_id = await _create_project_for_issue(product_slug, issue)
|
|
@@ -430,7 +440,7 @@ async def run_product_check(product_slug: str) -> dict:
|
|
|
430
440
|
|
|
431
441
|
# Has assignee but no project → create project
|
|
432
442
|
elif issue.get("assignee_id") and not linked:
|
|
433
|
-
if len(active_for_product) >=
|
|
443
|
+
if len(active_for_product) >= MAX_ACTIVE_PROJECTS:
|
|
434
444
|
continue
|
|
435
445
|
project_id = await _create_project_for_issue(product_slug, issue)
|
|
436
446
|
if project_id:
|
|
@@ -484,34 +494,31 @@ async def run_product_check(product_slug: str) -> dict:
|
|
|
484
494
|
logger.debug("[PRODUCT_CHECK] Invalid end_date '{}' on sprint {}", end_date_str, active_sprint.get("id"))
|
|
485
495
|
|
|
486
496
|
# --- Step 4: Backlog grooming reminder ---
|
|
487
|
-
_BACKLOG_GROOMING_THRESHOLD = 5
|
|
488
497
|
unscheduled_low = [
|
|
489
498
|
i for i in all_issues
|
|
490
499
|
if i.get("priority") in (IssuePriority.P2.value, IssuePriority.P3.value)
|
|
491
500
|
and not i.get("sprint")
|
|
492
501
|
and i.get("status") not in (IssueStatus.DONE.value, IssueStatus.RELEASED.value)
|
|
493
502
|
]
|
|
494
|
-
if len(unscheduled_low) >=
|
|
503
|
+
if len(unscheduled_low) >= BACKLOG_GROOMING_THRESHOLD:
|
|
495
504
|
actions_taken.append(f"{len(unscheduled_low)} P2/P3 issues unscheduled — backlog grooming needed")
|
|
496
505
|
|
|
497
|
-
# --- Step 5: Stale review check
|
|
506
|
+
# --- Step 5: Stale review check ---
|
|
498
507
|
from datetime import datetime as _datetime, timedelta as _timedelta
|
|
499
508
|
|
|
500
509
|
open_reviews = prod.list_reviews(product_slug, status="open")
|
|
501
|
-
_STALE_REVIEW_HOURS = 24
|
|
502
510
|
stale_reviews = []
|
|
503
511
|
for rev in open_reviews:
|
|
504
512
|
try:
|
|
505
513
|
created = _datetime.fromisoformat(rev.get("created_at", ""))
|
|
506
|
-
if _datetime.now() - created > _timedelta(hours=
|
|
514
|
+
if _datetime.now() - created > _timedelta(hours=STALE_REVIEW_HOURS):
|
|
507
515
|
stale_reviews.append(rev)
|
|
508
516
|
except (ValueError, TypeError):
|
|
509
517
|
logger.debug("[PRODUCT_CHECK] Invalid created_at on review {}", rev.get("id"))
|
|
510
518
|
if stale_reviews:
|
|
511
|
-
actions_taken.append(f"{len(stale_reviews)} stale review(s) open > {
|
|
519
|
+
actions_taken.append(f"{len(stale_reviews)} stale review(s) open > {STALE_REVIEW_HOURS}h")
|
|
512
520
|
|
|
513
|
-
# --- Step 6: Blocked issue check
|
|
514
|
-
_BLOCKED_DAYS_THRESHOLD = 7
|
|
521
|
+
# --- Step 6: Blocked issue check ---
|
|
515
522
|
for issue in all_issues:
|
|
516
523
|
if issue.get("status") in (IssueStatus.DONE.value, IssueStatus.RELEASED.value):
|
|
517
524
|
continue
|
|
@@ -532,9 +539,9 @@ async def run_product_check(product_slug: str) -> dict:
|
|
|
532
539
|
oldest_blocked_at = link_created
|
|
533
540
|
except (ValueError, TypeError):
|
|
534
541
|
logger.debug("[PRODUCT_CHECK] Invalid created_at on link in issue {}", issue.get("id"))
|
|
535
|
-
if oldest_blocked_at and _datetime.now() - oldest_blocked_at > _timedelta(days=
|
|
542
|
+
if oldest_blocked_at and _datetime.now() - oldest_blocked_at > _timedelta(days=BLOCKED_DAYS_THRESHOLD):
|
|
536
543
|
actions_taken.append(
|
|
537
|
-
f"Issue '{issue['title']}' blocked for >{
|
|
544
|
+
f"Issue '{issue['title']}' blocked for >{BLOCKED_DAYS_THRESHOLD} days"
|
|
538
545
|
)
|
|
539
546
|
|
|
540
547
|
# --- Step 7: Check if owner review is needed ---
|
|
@@ -546,7 +553,7 @@ async def run_product_check(product_slug: str) -> dict:
|
|
|
546
553
|
i for i in all_issues
|
|
547
554
|
if i.get("status") == IssueStatus.BACKLOG.value and not i.get("linked_task_ids")
|
|
548
555
|
]
|
|
549
|
-
if len(unhandled_backlog) >
|
|
556
|
+
if len(unhandled_backlog) > UNHANDLED_BACKLOG_THRESHOLD:
|
|
550
557
|
needs_review = True
|
|
551
558
|
review_reasons.append(f"{len(unhandled_backlog)} unhandled backlog issues")
|
|
552
559
|
|
|
@@ -570,7 +577,7 @@ async def run_product_check(product_slug: str) -> dict:
|
|
|
570
577
|
logger.debug("[PRODUCT_CHECK] Invalid end_date on sprint {} for review check", active_sprint.get("id"))
|
|
571
578
|
|
|
572
579
|
# Backlog grooming threshold → needs owner review
|
|
573
|
-
if len(unscheduled_low) >=
|
|
580
|
+
if len(unscheduled_low) >= BACKLOG_GROOMING_THRESHOLD:
|
|
574
581
|
needs_review = True
|
|
575
582
|
review_reasons.append(f"{len(unscheduled_low)} P2/P3 issues need sprint assignment")
|
|
576
583
|
|