@1mancompany/onemancompany 0.7.25 → 0.7.30
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 +325 -0
- package/frontend/style.css +285 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/onemancompany/api/routes.py +73 -15
- package/src/onemancompany/core/config.py +1 -0
- package/src/onemancompany/core/product.py +186 -14
- package/src/onemancompany/core/product_triggers.py +48 -0
package/frontend/app.js
CHANGED
|
@@ -7528,6 +7528,9 @@ class AppController {
|
|
|
7528
7528
|
const tabDefs = [
|
|
7529
7529
|
{ id: 'overview', label: 'Overview' },
|
|
7530
7530
|
{ id: 'issues', label: `Issues (${issues.length})` },
|
|
7531
|
+
{ id: 'kanban', label: 'Kanban' },
|
|
7532
|
+
{ id: 'roadmap', label: 'Roadmap' },
|
|
7533
|
+
{ id: 'activity', label: 'Activity' },
|
|
7531
7534
|
{ id: 'projects', label: `Projects (${projects.length})` },
|
|
7532
7535
|
];
|
|
7533
7536
|
const tabContent = document.createElement('div');
|
|
@@ -7559,6 +7562,12 @@ class AppController {
|
|
|
7559
7562
|
this._renderProductOverview(product, versions, slug, container);
|
|
7560
7563
|
} else if (tabId === 'issues') {
|
|
7561
7564
|
this._renderProductIssues(issues, slug, container, data);
|
|
7565
|
+
} else if (tabId === 'kanban') {
|
|
7566
|
+
this._renderProductKanban(slug, container, data);
|
|
7567
|
+
} else if (tabId === 'roadmap') {
|
|
7568
|
+
this._renderProductRoadmap(slug, container);
|
|
7569
|
+
} else if (tabId === 'activity') {
|
|
7570
|
+
this._renderProductActivity(slug, container);
|
|
7562
7571
|
} else if (tabId === 'projects') {
|
|
7563
7572
|
this._renderProductProjects(projects, container);
|
|
7564
7573
|
}
|
|
@@ -8246,6 +8255,322 @@ class AppController {
|
|
|
8246
8255
|
}
|
|
8247
8256
|
}
|
|
8248
8257
|
|
|
8258
|
+
// ---------------------------------------------------------------------------
|
|
8259
|
+
// Kanban Board Tab
|
|
8260
|
+
// ---------------------------------------------------------------------------
|
|
8261
|
+
|
|
8262
|
+
_renderProductKanban(slug, container, fullData) {
|
|
8263
|
+
container.innerHTML = '<div class="loading-text">Loading kanban...</div>';
|
|
8264
|
+
fetch(`/api/product/${encodeURIComponent(slug)}/kanban`)
|
|
8265
|
+
.then(r => r.json())
|
|
8266
|
+
.then(data => {
|
|
8267
|
+
container.innerHTML = '';
|
|
8268
|
+
const board = document.createElement('div');
|
|
8269
|
+
board.className = 'kanban-board';
|
|
8270
|
+
|
|
8271
|
+
const statusLabels = {
|
|
8272
|
+
backlog: 'Backlog',
|
|
8273
|
+
planned: 'Planned',
|
|
8274
|
+
in_progress: 'In Progress',
|
|
8275
|
+
in_review: 'In Review',
|
|
8276
|
+
done: 'Done',
|
|
8277
|
+
released: 'Released',
|
|
8278
|
+
};
|
|
8279
|
+
const blockedSet = new Set(data.blocked_ids || []);
|
|
8280
|
+
|
|
8281
|
+
for (const [status, label] of Object.entries(statusLabels)) {
|
|
8282
|
+
const col = document.createElement('div');
|
|
8283
|
+
col.className = 'kanban-column';
|
|
8284
|
+
col.dataset.status = status;
|
|
8285
|
+
|
|
8286
|
+
const colHeader = document.createElement('div');
|
|
8287
|
+
colHeader.className = 'kanban-column-header';
|
|
8288
|
+
const items = data.columns[status] || [];
|
|
8289
|
+
colHeader.textContent = `${label} (${items.length})`;
|
|
8290
|
+
col.appendChild(colHeader);
|
|
8291
|
+
|
|
8292
|
+
const cardList = document.createElement('div');
|
|
8293
|
+
cardList.className = 'kanban-card-list';
|
|
8294
|
+
|
|
8295
|
+
// Drag-drop: allow dropping on column
|
|
8296
|
+
cardList.addEventListener('dragover', (e) => { e.preventDefault(); cardList.classList.add('kanban-drop-target'); });
|
|
8297
|
+
cardList.addEventListener('dragleave', () => cardList.classList.remove('kanban-drop-target'));
|
|
8298
|
+
cardList.addEventListener('drop', (e) => {
|
|
8299
|
+
e.preventDefault();
|
|
8300
|
+
cardList.classList.remove('kanban-drop-target');
|
|
8301
|
+
const issueId = e.dataTransfer.getData('text/plain');
|
|
8302
|
+
if (issueId) {
|
|
8303
|
+
fetch(`/api/product/${encodeURIComponent(slug)}/issue/${encodeURIComponent(issueId)}`, {
|
|
8304
|
+
method: 'PUT',
|
|
8305
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8306
|
+
body: JSON.stringify({ status }),
|
|
8307
|
+
}).then(() => this._renderProductKanban(slug, container, fullData));
|
|
8308
|
+
}
|
|
8309
|
+
});
|
|
8310
|
+
|
|
8311
|
+
for (const issue of items) {
|
|
8312
|
+
const card = document.createElement('div');
|
|
8313
|
+
card.className = `kanban-card priority-${(issue.priority || 'p2').toLowerCase()}`;
|
|
8314
|
+
card.draggable = true;
|
|
8315
|
+
card.dataset.issueId = issue.id;
|
|
8316
|
+
if (blockedSet.has(issue.id)) card.classList.add('kanban-blocked');
|
|
8317
|
+
|
|
8318
|
+
card.addEventListener('dragstart', (e) => {
|
|
8319
|
+
e.dataTransfer.setData('text/plain', issue.id);
|
|
8320
|
+
card.classList.add('dragging');
|
|
8321
|
+
});
|
|
8322
|
+
card.addEventListener('dragend', () => card.classList.remove('dragging'));
|
|
8323
|
+
|
|
8324
|
+
const priTag = document.createElement('span');
|
|
8325
|
+
priTag.className = `kanban-priority priority-${(issue.priority || 'p2').toLowerCase()}`;
|
|
8326
|
+
priTag.textContent = issue.priority || 'P2';
|
|
8327
|
+
|
|
8328
|
+
const title = document.createElement('span');
|
|
8329
|
+
title.className = 'kanban-card-title';
|
|
8330
|
+
title.textContent = issue.title;
|
|
8331
|
+
|
|
8332
|
+
const meta = document.createElement('div');
|
|
8333
|
+
meta.className = 'kanban-card-meta';
|
|
8334
|
+
if (issue.assignee_id) {
|
|
8335
|
+
meta.textContent = issue.assignee_id;
|
|
8336
|
+
}
|
|
8337
|
+
if (issue.story_points) {
|
|
8338
|
+
const sp = document.createElement('span');
|
|
8339
|
+
sp.className = 'kanban-sp';
|
|
8340
|
+
sp.textContent = `${issue.story_points}sp`;
|
|
8341
|
+
meta.appendChild(sp);
|
|
8342
|
+
}
|
|
8343
|
+
if (blockedSet.has(issue.id)) {
|
|
8344
|
+
const lock = document.createElement('span');
|
|
8345
|
+
lock.className = 'kanban-blocked-icon';
|
|
8346
|
+
lock.textContent = '🔒';
|
|
8347
|
+
lock.title = 'Blocked by dependency';
|
|
8348
|
+
meta.appendChild(lock);
|
|
8349
|
+
}
|
|
8350
|
+
|
|
8351
|
+
card.appendChild(priTag);
|
|
8352
|
+
card.appendChild(title);
|
|
8353
|
+
card.appendChild(meta);
|
|
8354
|
+
cardList.appendChild(card);
|
|
8355
|
+
}
|
|
8356
|
+
|
|
8357
|
+
col.appendChild(cardList);
|
|
8358
|
+
board.appendChild(col);
|
|
8359
|
+
}
|
|
8360
|
+
|
|
8361
|
+
container.appendChild(board);
|
|
8362
|
+
})
|
|
8363
|
+
.catch(err => { container.innerHTML = `<div class="error-text">Failed to load kanban: ${err.message}</div>`; });
|
|
8364
|
+
}
|
|
8365
|
+
|
|
8366
|
+
// ---------------------------------------------------------------------------
|
|
8367
|
+
// Roadmap Timeline Tab
|
|
8368
|
+
// ---------------------------------------------------------------------------
|
|
8369
|
+
|
|
8370
|
+
_renderProductRoadmap(slug, container) {
|
|
8371
|
+
container.innerHTML = '<div class="loading-text">Loading roadmap...</div>';
|
|
8372
|
+
fetch(`/api/product/${encodeURIComponent(slug)}/roadmap`)
|
|
8373
|
+
.then(r => r.json())
|
|
8374
|
+
.then(data => {
|
|
8375
|
+
container.innerHTML = '';
|
|
8376
|
+
|
|
8377
|
+
if (!data.sprints.length && !data.versions.length && !data.milestoned_issues.length) {
|
|
8378
|
+
container.innerHTML = '<div class="task-empty">No sprints, versions, or milestoned issues yet.</div>';
|
|
8379
|
+
return;
|
|
8380
|
+
}
|
|
8381
|
+
|
|
8382
|
+
// Sprints section
|
|
8383
|
+
if (data.sprints.length) {
|
|
8384
|
+
const section = document.createElement('div');
|
|
8385
|
+
section.className = 'roadmap-section';
|
|
8386
|
+
const h = document.createElement('h3');
|
|
8387
|
+
h.textContent = 'Sprints';
|
|
8388
|
+
section.appendChild(h);
|
|
8389
|
+
|
|
8390
|
+
const timeline = document.createElement('div');
|
|
8391
|
+
timeline.className = 'roadmap-timeline';
|
|
8392
|
+
|
|
8393
|
+
for (const s of data.sprints) {
|
|
8394
|
+
const bar = document.createElement('div');
|
|
8395
|
+
bar.className = `roadmap-sprint-bar roadmap-status-${s.status}`;
|
|
8396
|
+
|
|
8397
|
+
const label = document.createElement('div');
|
|
8398
|
+
label.className = 'roadmap-bar-label';
|
|
8399
|
+
label.textContent = `${s.name} (${s.issue_count} issues)`;
|
|
8400
|
+
|
|
8401
|
+
const dates = document.createElement('div');
|
|
8402
|
+
dates.className = 'roadmap-bar-dates';
|
|
8403
|
+
dates.textContent = `${s.start_date} → ${s.end_date}`;
|
|
8404
|
+
|
|
8405
|
+
const statusBadge = document.createElement('span');
|
|
8406
|
+
statusBadge.className = `roadmap-status-badge roadmap-status-${s.status}`;
|
|
8407
|
+
statusBadge.textContent = s.status;
|
|
8408
|
+
|
|
8409
|
+
bar.appendChild(label);
|
|
8410
|
+
bar.appendChild(dates);
|
|
8411
|
+
bar.appendChild(statusBadge);
|
|
8412
|
+
if (s.goal) {
|
|
8413
|
+
const goal = document.createElement('div');
|
|
8414
|
+
goal.className = 'roadmap-goal';
|
|
8415
|
+
goal.textContent = s.goal;
|
|
8416
|
+
bar.appendChild(goal);
|
|
8417
|
+
}
|
|
8418
|
+
timeline.appendChild(bar);
|
|
8419
|
+
}
|
|
8420
|
+
section.appendChild(timeline);
|
|
8421
|
+
container.appendChild(section);
|
|
8422
|
+
}
|
|
8423
|
+
|
|
8424
|
+
// Versions section
|
|
8425
|
+
if (data.versions.length) {
|
|
8426
|
+
const section = document.createElement('div');
|
|
8427
|
+
section.className = 'roadmap-section';
|
|
8428
|
+
const h = document.createElement('h3');
|
|
8429
|
+
h.textContent = 'Releases';
|
|
8430
|
+
section.appendChild(h);
|
|
8431
|
+
|
|
8432
|
+
for (const v of data.versions) {
|
|
8433
|
+
const row = document.createElement('div');
|
|
8434
|
+
row.className = 'roadmap-version-row';
|
|
8435
|
+
|
|
8436
|
+
const ver = document.createElement('span');
|
|
8437
|
+
ver.className = 'roadmap-version-tag';
|
|
8438
|
+
ver.textContent = `v${v.version}`;
|
|
8439
|
+
|
|
8440
|
+
const date = document.createElement('span');
|
|
8441
|
+
date.className = 'roadmap-version-date';
|
|
8442
|
+
date.textContent = v.released_at ? v.released_at.split('T')[0] : '';
|
|
8443
|
+
|
|
8444
|
+
const count = document.createElement('span');
|
|
8445
|
+
count.className = 'roadmap-version-count';
|
|
8446
|
+
count.textContent = `${v.resolved_count} issues resolved`;
|
|
8447
|
+
|
|
8448
|
+
row.appendChild(ver);
|
|
8449
|
+
row.appendChild(date);
|
|
8450
|
+
row.appendChild(count);
|
|
8451
|
+
section.appendChild(row);
|
|
8452
|
+
}
|
|
8453
|
+
container.appendChild(section);
|
|
8454
|
+
}
|
|
8455
|
+
|
|
8456
|
+
// Milestoned issues
|
|
8457
|
+
if (data.milestoned_issues.length) {
|
|
8458
|
+
const section = document.createElement('div');
|
|
8459
|
+
section.className = 'roadmap-section';
|
|
8460
|
+
const h = document.createElement('h3');
|
|
8461
|
+
h.textContent = 'Milestoned Issues';
|
|
8462
|
+
section.appendChild(h);
|
|
8463
|
+
|
|
8464
|
+
// Group by milestone_version
|
|
8465
|
+
const groups = {};
|
|
8466
|
+
for (const i of data.milestoned_issues) {
|
|
8467
|
+
const mv = i.milestone_version;
|
|
8468
|
+
if (!groups[mv]) groups[mv] = [];
|
|
8469
|
+
groups[mv].push(i);
|
|
8470
|
+
}
|
|
8471
|
+
|
|
8472
|
+
for (const [ver, items] of Object.entries(groups).sort()) {
|
|
8473
|
+
const group = document.createElement('div');
|
|
8474
|
+
group.className = 'roadmap-milestone-group';
|
|
8475
|
+
|
|
8476
|
+
const gh = document.createElement('div');
|
|
8477
|
+
gh.className = 'roadmap-milestone-header';
|
|
8478
|
+
gh.textContent = `v${ver} (${items.length} issues)`;
|
|
8479
|
+
group.appendChild(gh);
|
|
8480
|
+
|
|
8481
|
+
for (const item of items) {
|
|
8482
|
+
const row = document.createElement('div');
|
|
8483
|
+
row.className = `roadmap-issue-row priority-${(item.priority || 'p2').toLowerCase()}`;
|
|
8484
|
+
row.innerHTML = `<span class="roadmap-issue-pri">[${item.priority}]</span> ${item.title} <span class="roadmap-issue-status">${item.status}</span>`;
|
|
8485
|
+
group.appendChild(row);
|
|
8486
|
+
}
|
|
8487
|
+
|
|
8488
|
+
section.appendChild(group);
|
|
8489
|
+
}
|
|
8490
|
+
container.appendChild(section);
|
|
8491
|
+
}
|
|
8492
|
+
})
|
|
8493
|
+
.catch(err => { container.innerHTML = `<div class="error-text">Failed to load roadmap: ${err.message}</div>`; });
|
|
8494
|
+
}
|
|
8495
|
+
|
|
8496
|
+
// ---------------------------------------------------------------------------
|
|
8497
|
+
// Activity Feed Tab
|
|
8498
|
+
// ---------------------------------------------------------------------------
|
|
8499
|
+
|
|
8500
|
+
_renderProductActivity(slug, container) {
|
|
8501
|
+
container.innerHTML = '<div class="loading-text">Loading activity...</div>';
|
|
8502
|
+
fetch(`/api/product/${encodeURIComponent(slug)}/activity?limit=100`)
|
|
8503
|
+
.then(r => r.json())
|
|
8504
|
+
.then(entries => {
|
|
8505
|
+
container.innerHTML = '';
|
|
8506
|
+
|
|
8507
|
+
if (!entries.length) {
|
|
8508
|
+
container.innerHTML = '<div class="task-empty">No activity recorded yet.</div>';
|
|
8509
|
+
return;
|
|
8510
|
+
}
|
|
8511
|
+
|
|
8512
|
+
const feed = document.createElement('div');
|
|
8513
|
+
feed.className = 'activity-feed';
|
|
8514
|
+
|
|
8515
|
+
const eventIcons = {
|
|
8516
|
+
issue_created: '📋',
|
|
8517
|
+
issue_closed: '✅',
|
|
8518
|
+
issue_assigned: '👤',
|
|
8519
|
+
sprint_created: '🏃',
|
|
8520
|
+
sprint_closed: '🏁',
|
|
8521
|
+
version_released: '🚀',
|
|
8522
|
+
review_created: '📝',
|
|
8523
|
+
review_completed: '☑️',
|
|
8524
|
+
kr_updated: '📊',
|
|
8525
|
+
};
|
|
8526
|
+
|
|
8527
|
+
for (const entry of entries) {
|
|
8528
|
+
const item = document.createElement('div');
|
|
8529
|
+
item.className = 'activity-item';
|
|
8530
|
+
|
|
8531
|
+
const icon = document.createElement('span');
|
|
8532
|
+
icon.className = 'activity-icon';
|
|
8533
|
+
icon.textContent = eventIcons[entry.event_type] || '•';
|
|
8534
|
+
|
|
8535
|
+
const content = document.createElement('div');
|
|
8536
|
+
content.className = 'activity-content';
|
|
8537
|
+
|
|
8538
|
+
const headerLine = document.createElement('div');
|
|
8539
|
+
headerLine.className = 'activity-header';
|
|
8540
|
+
const typeLabel = document.createElement('span');
|
|
8541
|
+
typeLabel.className = 'activity-type';
|
|
8542
|
+
typeLabel.textContent = (entry.event_type || '').replace(/_/g, ' ');
|
|
8543
|
+
const actor = document.createElement('span');
|
|
8544
|
+
actor.className = 'activity-actor';
|
|
8545
|
+
actor.textContent = entry.actor || '';
|
|
8546
|
+
headerLine.appendChild(typeLabel);
|
|
8547
|
+
headerLine.appendChild(actor);
|
|
8548
|
+
|
|
8549
|
+
const detail = document.createElement('div');
|
|
8550
|
+
detail.className = 'activity-detail';
|
|
8551
|
+
detail.textContent = entry.detail || '';
|
|
8552
|
+
|
|
8553
|
+
const ts = document.createElement('div');
|
|
8554
|
+
ts.className = 'activity-ts';
|
|
8555
|
+
if (entry.ts) {
|
|
8556
|
+
const d = new Date(entry.ts);
|
|
8557
|
+
ts.textContent = d.toLocaleString();
|
|
8558
|
+
}
|
|
8559
|
+
|
|
8560
|
+
content.appendChild(headerLine);
|
|
8561
|
+
content.appendChild(detail);
|
|
8562
|
+
content.appendChild(ts);
|
|
8563
|
+
|
|
8564
|
+
item.appendChild(icon);
|
|
8565
|
+
item.appendChild(content);
|
|
8566
|
+
feed.appendChild(item);
|
|
8567
|
+
}
|
|
8568
|
+
|
|
8569
|
+
container.appendChild(feed);
|
|
8570
|
+
})
|
|
8571
|
+
.catch(err => { container.innerHTML = `<div class="error-text">Failed to load activity: ${err.message}</div>`; });
|
|
8572
|
+
}
|
|
8573
|
+
|
|
8249
8574
|
_doUpdateProjectsPanel() {
|
|
8250
8575
|
const panel = document.getElementById('projects-panel-list');
|
|
8251
8576
|
if (!panel) return;
|
package/frontend/style.css
CHANGED
|
@@ -6173,6 +6173,291 @@ body.resize-dragging {
|
|
|
6173
6173
|
align-items: center;
|
|
6174
6174
|
}
|
|
6175
6175
|
|
|
6176
|
+
/* ================================================================
|
|
6177
|
+
Kanban Board
|
|
6178
|
+
================================================================ */
|
|
6179
|
+
|
|
6180
|
+
.kanban-board {
|
|
6181
|
+
display: flex;
|
|
6182
|
+
gap: 8px;
|
|
6183
|
+
overflow-x: auto;
|
|
6184
|
+
padding: 4px 0;
|
|
6185
|
+
min-height: 200px;
|
|
6186
|
+
}
|
|
6187
|
+
|
|
6188
|
+
.kanban-column {
|
|
6189
|
+
flex: 1;
|
|
6190
|
+
min-width: 130px;
|
|
6191
|
+
max-width: 200px;
|
|
6192
|
+
background: rgba(255,255,255,0.02);
|
|
6193
|
+
border: 1px solid var(--border);
|
|
6194
|
+
border-radius: 2px;
|
|
6195
|
+
}
|
|
6196
|
+
|
|
6197
|
+
.kanban-column-header {
|
|
6198
|
+
padding: 6px 8px;
|
|
6199
|
+
font-size: calc(6px + var(--font-boost));
|
|
6200
|
+
color: var(--pixel-cyan);
|
|
6201
|
+
border-bottom: 1px solid var(--border);
|
|
6202
|
+
text-align: center;
|
|
6203
|
+
font-weight: bold;
|
|
6204
|
+
}
|
|
6205
|
+
|
|
6206
|
+
.kanban-card-list {
|
|
6207
|
+
padding: 4px;
|
|
6208
|
+
min-height: 40px;
|
|
6209
|
+
}
|
|
6210
|
+
|
|
6211
|
+
.kanban-card-list.kanban-drop-target {
|
|
6212
|
+
background: rgba(0,255,255,0.05);
|
|
6213
|
+
outline: 1px dashed var(--pixel-cyan);
|
|
6214
|
+
}
|
|
6215
|
+
|
|
6216
|
+
.kanban-card {
|
|
6217
|
+
background: rgba(0,0,0,0.3);
|
|
6218
|
+
border: 1px solid var(--border);
|
|
6219
|
+
border-left: 3px solid var(--text-dim);
|
|
6220
|
+
border-radius: 2px;
|
|
6221
|
+
padding: 4px 6px;
|
|
6222
|
+
margin-bottom: 4px;
|
|
6223
|
+
cursor: grab;
|
|
6224
|
+
font-size: calc(5px + var(--font-boost));
|
|
6225
|
+
transition: opacity 0.15s;
|
|
6226
|
+
}
|
|
6227
|
+
|
|
6228
|
+
.kanban-card.dragging {
|
|
6229
|
+
opacity: 0.4;
|
|
6230
|
+
}
|
|
6231
|
+
|
|
6232
|
+
.kanban-card.priority-p0 { border-left-color: #ff4444; }
|
|
6233
|
+
.kanban-card.priority-p1 { border-left-color: #ff8800; }
|
|
6234
|
+
.kanban-card.priority-p2 { border-left-color: var(--pixel-yellow); }
|
|
6235
|
+
.kanban-card.priority-p3 { border-left-color: var(--text-dim); }
|
|
6236
|
+
|
|
6237
|
+
.kanban-card.kanban-blocked {
|
|
6238
|
+
background: rgba(255,68,68,0.08);
|
|
6239
|
+
}
|
|
6240
|
+
|
|
6241
|
+
.kanban-priority {
|
|
6242
|
+
font-size: calc(4px + var(--font-boost));
|
|
6243
|
+
padding: 0 3px;
|
|
6244
|
+
border-radius: 1px;
|
|
6245
|
+
margin-right: 4px;
|
|
6246
|
+
}
|
|
6247
|
+
|
|
6248
|
+
.kanban-card-title {
|
|
6249
|
+
color: var(--pixel-white);
|
|
6250
|
+
word-break: break-word;
|
|
6251
|
+
}
|
|
6252
|
+
|
|
6253
|
+
.kanban-card-meta {
|
|
6254
|
+
color: var(--text-dim);
|
|
6255
|
+
font-size: calc(4px + var(--font-boost));
|
|
6256
|
+
margin-top: 3px;
|
|
6257
|
+
display: flex;
|
|
6258
|
+
gap: 4px;
|
|
6259
|
+
align-items: center;
|
|
6260
|
+
}
|
|
6261
|
+
|
|
6262
|
+
.kanban-sp {
|
|
6263
|
+
background: rgba(255,255,255,0.08);
|
|
6264
|
+
padding: 0 3px;
|
|
6265
|
+
border-radius: 1px;
|
|
6266
|
+
}
|
|
6267
|
+
|
|
6268
|
+
.kanban-blocked-icon {
|
|
6269
|
+
font-size: calc(5px + var(--font-boost));
|
|
6270
|
+
}
|
|
6271
|
+
|
|
6272
|
+
/* ================================================================
|
|
6273
|
+
Roadmap Timeline
|
|
6274
|
+
================================================================ */
|
|
6275
|
+
|
|
6276
|
+
.roadmap-section {
|
|
6277
|
+
margin-bottom: 16px;
|
|
6278
|
+
}
|
|
6279
|
+
|
|
6280
|
+
.roadmap-section h3 {
|
|
6281
|
+
color: var(--pixel-cyan);
|
|
6282
|
+
font-size: calc(7px + var(--font-boost));
|
|
6283
|
+
margin: 0 0 8px 0;
|
|
6284
|
+
border-bottom: 1px solid var(--border);
|
|
6285
|
+
padding-bottom: 4px;
|
|
6286
|
+
}
|
|
6287
|
+
|
|
6288
|
+
.roadmap-timeline {
|
|
6289
|
+
display: flex;
|
|
6290
|
+
flex-direction: column;
|
|
6291
|
+
gap: 6px;
|
|
6292
|
+
}
|
|
6293
|
+
|
|
6294
|
+
.roadmap-sprint-bar {
|
|
6295
|
+
background: rgba(0,255,255,0.05);
|
|
6296
|
+
border: 1px solid var(--border);
|
|
6297
|
+
border-left: 3px solid var(--pixel-cyan);
|
|
6298
|
+
border-radius: 2px;
|
|
6299
|
+
padding: 6px 8px;
|
|
6300
|
+
}
|
|
6301
|
+
|
|
6302
|
+
.roadmap-sprint-bar.roadmap-status-active {
|
|
6303
|
+
border-left-color: var(--pixel-green);
|
|
6304
|
+
background: rgba(0,255,0,0.05);
|
|
6305
|
+
}
|
|
6306
|
+
|
|
6307
|
+
.roadmap-sprint-bar.roadmap-status-closed {
|
|
6308
|
+
border-left-color: var(--text-dim);
|
|
6309
|
+
opacity: 0.7;
|
|
6310
|
+
}
|
|
6311
|
+
|
|
6312
|
+
.roadmap-bar-label {
|
|
6313
|
+
color: var(--pixel-white);
|
|
6314
|
+
font-size: calc(6px + var(--font-boost));
|
|
6315
|
+
font-weight: bold;
|
|
6316
|
+
}
|
|
6317
|
+
|
|
6318
|
+
.roadmap-bar-dates {
|
|
6319
|
+
color: var(--text-dim);
|
|
6320
|
+
font-size: calc(5px + var(--font-boost));
|
|
6321
|
+
margin-top: 2px;
|
|
6322
|
+
}
|
|
6323
|
+
|
|
6324
|
+
.roadmap-status-badge {
|
|
6325
|
+
display: inline-block;
|
|
6326
|
+
font-size: calc(4px + var(--font-boost));
|
|
6327
|
+
padding: 0 4px;
|
|
6328
|
+
border-radius: 2px;
|
|
6329
|
+
margin-top: 3px;
|
|
6330
|
+
color: var(--pixel-cyan);
|
|
6331
|
+
border: 1px solid var(--border);
|
|
6332
|
+
}
|
|
6333
|
+
|
|
6334
|
+
.roadmap-status-badge.roadmap-status-active { color: var(--pixel-green); border-color: var(--pixel-green); }
|
|
6335
|
+
.roadmap-status-badge.roadmap-status-closed { color: var(--text-dim); border-color: var(--text-dim); }
|
|
6336
|
+
|
|
6337
|
+
.roadmap-goal {
|
|
6338
|
+
color: var(--text-dim);
|
|
6339
|
+
font-size: calc(5px + var(--font-boost));
|
|
6340
|
+
font-style: italic;
|
|
6341
|
+
margin-top: 2px;
|
|
6342
|
+
}
|
|
6343
|
+
|
|
6344
|
+
.roadmap-version-row {
|
|
6345
|
+
display: flex;
|
|
6346
|
+
align-items: center;
|
|
6347
|
+
gap: 10px;
|
|
6348
|
+
padding: 4px 0;
|
|
6349
|
+
border-bottom: 1px solid rgba(255,255,255,0.03);
|
|
6350
|
+
font-size: calc(6px + var(--font-boost));
|
|
6351
|
+
}
|
|
6352
|
+
|
|
6353
|
+
.roadmap-version-tag {
|
|
6354
|
+
color: var(--pixel-green);
|
|
6355
|
+
font-weight: bold;
|
|
6356
|
+
min-width: 60px;
|
|
6357
|
+
}
|
|
6358
|
+
|
|
6359
|
+
.roadmap-version-date {
|
|
6360
|
+
color: var(--text-dim);
|
|
6361
|
+
min-width: 80px;
|
|
6362
|
+
}
|
|
6363
|
+
|
|
6364
|
+
.roadmap-version-count {
|
|
6365
|
+
color: var(--pixel-white);
|
|
6366
|
+
}
|
|
6367
|
+
|
|
6368
|
+
.roadmap-milestone-group {
|
|
6369
|
+
margin-bottom: 10px;
|
|
6370
|
+
}
|
|
6371
|
+
|
|
6372
|
+
.roadmap-milestone-header {
|
|
6373
|
+
color: var(--pixel-yellow);
|
|
6374
|
+
font-size: calc(6px + var(--font-boost));
|
|
6375
|
+
font-weight: bold;
|
|
6376
|
+
margin-bottom: 4px;
|
|
6377
|
+
}
|
|
6378
|
+
|
|
6379
|
+
.roadmap-issue-row {
|
|
6380
|
+
padding: 2px 8px;
|
|
6381
|
+
font-size: calc(5px + var(--font-boost));
|
|
6382
|
+
color: var(--pixel-white);
|
|
6383
|
+
border-left: 2px solid var(--text-dim);
|
|
6384
|
+
margin-left: 8px;
|
|
6385
|
+
}
|
|
6386
|
+
|
|
6387
|
+
.roadmap-issue-row.priority-p0 { border-left-color: #ff4444; }
|
|
6388
|
+
.roadmap-issue-row.priority-p1 { border-left-color: #ff8800; }
|
|
6389
|
+
|
|
6390
|
+
.roadmap-issue-pri {
|
|
6391
|
+
color: var(--text-dim);
|
|
6392
|
+
margin-right: 4px;
|
|
6393
|
+
}
|
|
6394
|
+
|
|
6395
|
+
.roadmap-issue-status {
|
|
6396
|
+
color: var(--text-dim);
|
|
6397
|
+
font-size: calc(4px + var(--font-boost));
|
|
6398
|
+
margin-left: 6px;
|
|
6399
|
+
}
|
|
6400
|
+
|
|
6401
|
+
/* ================================================================
|
|
6402
|
+
Activity Feed
|
|
6403
|
+
================================================================ */
|
|
6404
|
+
|
|
6405
|
+
.activity-feed {
|
|
6406
|
+
display: flex;
|
|
6407
|
+
flex-direction: column;
|
|
6408
|
+
gap: 2px;
|
|
6409
|
+
}
|
|
6410
|
+
|
|
6411
|
+
.activity-item {
|
|
6412
|
+
display: flex;
|
|
6413
|
+
gap: 8px;
|
|
6414
|
+
padding: 6px 8px;
|
|
6415
|
+
border-bottom: 1px solid rgba(255,255,255,0.03);
|
|
6416
|
+
}
|
|
6417
|
+
|
|
6418
|
+
.activity-icon {
|
|
6419
|
+
font-size: calc(7px + var(--font-boost));
|
|
6420
|
+
flex-shrink: 0;
|
|
6421
|
+
width: 20px;
|
|
6422
|
+
text-align: center;
|
|
6423
|
+
}
|
|
6424
|
+
|
|
6425
|
+
.activity-content {
|
|
6426
|
+
flex: 1;
|
|
6427
|
+
min-width: 0;
|
|
6428
|
+
}
|
|
6429
|
+
|
|
6430
|
+
.activity-header {
|
|
6431
|
+
display: flex;
|
|
6432
|
+
gap: 8px;
|
|
6433
|
+
align-items: center;
|
|
6434
|
+
}
|
|
6435
|
+
|
|
6436
|
+
.activity-type {
|
|
6437
|
+
color: var(--pixel-cyan);
|
|
6438
|
+
font-size: calc(5px + var(--font-boost));
|
|
6439
|
+
font-weight: bold;
|
|
6440
|
+
text-transform: capitalize;
|
|
6441
|
+
}
|
|
6442
|
+
|
|
6443
|
+
.activity-actor {
|
|
6444
|
+
color: var(--text-dim);
|
|
6445
|
+
font-size: calc(4px + var(--font-boost));
|
|
6446
|
+
}
|
|
6447
|
+
|
|
6448
|
+
.activity-detail {
|
|
6449
|
+
color: var(--pixel-white);
|
|
6450
|
+
font-size: calc(5px + var(--font-boost));
|
|
6451
|
+
margin-top: 1px;
|
|
6452
|
+
word-break: break-word;
|
|
6453
|
+
}
|
|
6454
|
+
|
|
6455
|
+
.activity-ts {
|
|
6456
|
+
color: var(--text-dim);
|
|
6457
|
+
font-size: calc(4px + var(--font-boost));
|
|
6458
|
+
margin-top: 1px;
|
|
6459
|
+
}
|
|
6460
|
+
|
|
6176
6461
|
/* Product Detail Button in panel */
|
|
6177
6462
|
.product-detail-btn {
|
|
6178
6463
|
margin-left: auto;
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -7197,8 +7197,11 @@ async def api_list_issues(slug: str, status: str = "", priority: str = "") -> li
|
|
|
7197
7197
|
from onemancompany.core import product as prod
|
|
7198
7198
|
from onemancompany.core.models import IssuePriority, IssueStatus
|
|
7199
7199
|
|
|
7200
|
-
|
|
7201
|
-
|
|
7200
|
+
try:
|
|
7201
|
+
status_filter = IssueStatus(status) if status else None
|
|
7202
|
+
priority_filter = IssuePriority(priority) if priority else None
|
|
7203
|
+
except ValueError as exc:
|
|
7204
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
7202
7205
|
return prod.list_issues(slug, status=status_filter, priority=priority_filter)
|
|
7203
7206
|
|
|
7204
7207
|
|
|
@@ -7223,13 +7226,17 @@ async def api_update_issue(slug: str, issue_id: str, request: Request) -> dict:
|
|
|
7223
7226
|
ISSUE_MUTABLE_FIELDS = {"title", "status", "priority", "assignee_id", "labels", "milestone_version", "description", "story_points", "sprint"}
|
|
7224
7227
|
body = await request.json()
|
|
7225
7228
|
filtered = {k: v for k, v in body.items() if k in ISSUE_MUTABLE_FIELDS}
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
raise HTTPException(status_code=
|
|
7229
|
+
try:
|
|
7230
|
+
if "status" in filtered:
|
|
7231
|
+
filtered["status"] = _IS(filtered["status"]).value
|
|
7232
|
+
if "priority" in filtered:
|
|
7233
|
+
filtered["priority"] = _IP2(filtered["priority"]).value
|
|
7234
|
+
except ValueError as exc:
|
|
7235
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
7236
|
+
try:
|
|
7237
|
+
result = prod.update_issue(slug, issue_id, **filtered)
|
|
7238
|
+
except ValueError as exc:
|
|
7239
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
7233
7240
|
|
|
7234
7241
|
# Publish ISSUE_ASSIGNED event when assignee changes
|
|
7235
7242
|
if "assignee_id" in filtered and filtered["assignee_id"]:
|
|
@@ -7257,9 +7264,14 @@ async def api_close_issue(slug: str, issue_id: str, request: Request) -> dict:
|
|
|
7257
7264
|
|
|
7258
7265
|
body = await request.json() if request.headers.get("content-length", "0") != "0" else {}
|
|
7259
7266
|
resolution_str = body.get("resolution", "fixed")
|
|
7260
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
7267
|
+
try:
|
|
7268
|
+
resolution = IssueResolution(resolution_str)
|
|
7269
|
+
except ValueError as exc:
|
|
7270
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
7271
|
+
try:
|
|
7272
|
+
result = prod.close_issue(slug, issue_id, resolution=resolution)
|
|
7273
|
+
except ValueError as exc:
|
|
7274
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
7263
7275
|
await event_bus.publish(
|
|
7264
7276
|
CompanyEvent(
|
|
7265
7277
|
type=EventType.ISSUE_CLOSED,
|
|
@@ -7275,9 +7287,10 @@ async def api_reopen_issue(slug: str, issue_id: str) -> dict:
|
|
|
7275
7287
|
"""Reopen a closed issue."""
|
|
7276
7288
|
from onemancompany.core import product as prod
|
|
7277
7289
|
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
7290
|
+
try:
|
|
7291
|
+
result = prod.reopen_issue(slug, issue_id)
|
|
7292
|
+
except ValueError as exc:
|
|
7293
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
7281
7294
|
return result
|
|
7282
7295
|
|
|
7283
7296
|
|
|
@@ -7645,3 +7658,48 @@ async def api_complete_review(slug: str, review_id: str) -> dict:
|
|
|
7645
7658
|
return prod.complete_review(slug, review_id)
|
|
7646
7659
|
except ValueError as exc:
|
|
7647
7660
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
7661
|
+
|
|
7662
|
+
|
|
7663
|
+
# ---------------------------------------------------------------------------
|
|
7664
|
+
# Kanban Board
|
|
7665
|
+
# ---------------------------------------------------------------------------
|
|
7666
|
+
|
|
7667
|
+
|
|
7668
|
+
@router.get("/api/product/{slug}/kanban")
|
|
7669
|
+
async def api_kanban_board(slug: str) -> dict:
|
|
7670
|
+
"""Return issues grouped by status columns for kanban view."""
|
|
7671
|
+
from onemancompany.core import product as prod
|
|
7672
|
+
|
|
7673
|
+
try:
|
|
7674
|
+
return prod.kanban_board(slug)
|
|
7675
|
+
except ValueError as exc:
|
|
7676
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
7677
|
+
|
|
7678
|
+
|
|
7679
|
+
# ---------------------------------------------------------------------------
|
|
7680
|
+
# Roadmap Timeline
|
|
7681
|
+
# ---------------------------------------------------------------------------
|
|
7682
|
+
|
|
7683
|
+
|
|
7684
|
+
@router.get("/api/product/{slug}/roadmap")
|
|
7685
|
+
async def api_roadmap_timeline(slug: str) -> dict:
|
|
7686
|
+
"""Return sprints, versions, and milestoned issues for timeline view."""
|
|
7687
|
+
from onemancompany.core import product as prod
|
|
7688
|
+
|
|
7689
|
+
try:
|
|
7690
|
+
return prod.roadmap_timeline(slug)
|
|
7691
|
+
except ValueError as exc:
|
|
7692
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
7693
|
+
|
|
7694
|
+
|
|
7695
|
+
# ---------------------------------------------------------------------------
|
|
7696
|
+
# Product Activity Feed
|
|
7697
|
+
# ---------------------------------------------------------------------------
|
|
7698
|
+
|
|
7699
|
+
|
|
7700
|
+
@router.get("/api/product/{slug}/activity")
|
|
7701
|
+
async def api_product_activity(slug: str, limit: int = 50) -> list[dict]:
|
|
7702
|
+
"""Return product-scoped activity feed, newest first."""
|
|
7703
|
+
from onemancompany.core import product as prod
|
|
7704
|
+
|
|
7705
|
+
return prod.list_product_activity(slug, limit=limit)
|
|
@@ -63,6 +63,7 @@ ISSUES_DIR_NAME = "issues"
|
|
|
63
63
|
VERSIONS_DIR_NAME = "versions"
|
|
64
64
|
SPRINTS_DIR_NAME = "sprints"
|
|
65
65
|
REVIEWS_DIR_NAME = "reviews"
|
|
66
|
+
ACTIVITY_LOG_DIR_NAME = "activity"
|
|
66
67
|
TALENT_PERSONA_FILENAME = "talent_persona.md"
|
|
67
68
|
MCP_CONFIG_FILENAME = "mcp_config.json"
|
|
68
69
|
CONVERSATIONS_DIR_NAME = "conversations"
|
|
@@ -18,6 +18,7 @@ from pathlib import Path
|
|
|
18
18
|
from loguru import logger
|
|
19
19
|
|
|
20
20
|
from onemancompany.core.config import (
|
|
21
|
+
ACTIVITY_LOG_DIR_NAME,
|
|
21
22
|
ISSUES_DIR_NAME,
|
|
22
23
|
PRODUCT_YAML_FILENAME,
|
|
23
24
|
PRODUCTS_DIR,
|
|
@@ -286,9 +287,11 @@ def create_issue(
|
|
|
286
287
|
sprint: str | None = None,
|
|
287
288
|
) -> dict:
|
|
288
289
|
"""Create an issue for a product. Returns the issue dict."""
|
|
289
|
-
issue_id = _gen_id("issue_")
|
|
290
290
|
product = load_product(slug)
|
|
291
|
-
|
|
291
|
+
if not product:
|
|
292
|
+
raise ValueError(f"Product '{slug}' not found")
|
|
293
|
+
issue_id = _gen_id("issue_")
|
|
294
|
+
product_id = product["id"]
|
|
292
295
|
now = datetime.now().isoformat()
|
|
293
296
|
|
|
294
297
|
data = {
|
|
@@ -379,14 +382,13 @@ def list_issues(
|
|
|
379
382
|
return results
|
|
380
383
|
|
|
381
384
|
|
|
382
|
-
def update_issue(slug: str, issue_id: str, **fields) -> dict
|
|
383
|
-
"""Update issue fields. Returns updated dict
|
|
385
|
+
def update_issue(slug: str, issue_id: str, **fields) -> dict:
|
|
386
|
+
"""Update issue fields. Returns updated dict. Raises ValueError if not found."""
|
|
384
387
|
with _get_slug_lock(slug):
|
|
385
388
|
path = _issues_dir(slug) / f"{issue_id}.yaml"
|
|
386
389
|
data = _read_yaml(path)
|
|
387
390
|
if not data:
|
|
388
|
-
|
|
389
|
-
return None
|
|
391
|
+
raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
|
|
390
392
|
for key, value in fields.items():
|
|
391
393
|
if value is not None:
|
|
392
394
|
old_value = data.get(key)
|
|
@@ -403,14 +405,13 @@ def close_issue(
|
|
|
403
405
|
issue_id: str,
|
|
404
406
|
*,
|
|
405
407
|
resolution: IssueResolution = IssueResolution.FIXED,
|
|
406
|
-
) -> dict
|
|
407
|
-
"""Close an issue with a resolution. Returns updated dict
|
|
408
|
+
) -> dict:
|
|
409
|
+
"""Close an issue with a resolution. Returns updated dict. Raises ValueError if not found."""
|
|
408
410
|
with _get_slug_lock(slug):
|
|
409
411
|
path = _issues_dir(slug) / f"{issue_id}.yaml"
|
|
410
412
|
data = _read_yaml(path)
|
|
411
413
|
if not data:
|
|
412
|
-
|
|
413
|
-
return None
|
|
414
|
+
raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
|
|
414
415
|
old_status = data.get("status")
|
|
415
416
|
_append_history(data, "status", old_status, IssueStatus.DONE.value, changed_by="system")
|
|
416
417
|
data["status"] = IssueStatus.DONE.value
|
|
@@ -422,14 +423,13 @@ def close_issue(
|
|
|
422
423
|
return data
|
|
423
424
|
|
|
424
425
|
|
|
425
|
-
def reopen_issue(slug: str, issue_id: str) -> dict
|
|
426
|
-
"""Reopen a closed issue. Increments reopened_count. Returns updated dict
|
|
426
|
+
def reopen_issue(slug: str, issue_id: str) -> dict:
|
|
427
|
+
"""Reopen a closed issue. Increments reopened_count. Returns updated dict. Raises ValueError if not found."""
|
|
427
428
|
with _get_slug_lock(slug):
|
|
428
429
|
path = _issues_dir(slug) / f"{issue_id}.yaml"
|
|
429
430
|
data = _read_yaml(path)
|
|
430
431
|
if not data:
|
|
431
|
-
|
|
432
|
-
return None
|
|
432
|
+
raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
|
|
433
433
|
old_status = data.get("status")
|
|
434
434
|
_append_history(data, "status", old_status, IssueStatus.BACKLOG.value, changed_by="system")
|
|
435
435
|
data["status"] = IssueStatus.BACKLOG.value
|
|
@@ -477,6 +477,13 @@ def add_issue_link(
|
|
|
477
477
|
rel_value = relation.value if hasattr(relation, "value") else relation
|
|
478
478
|
reverse_rel = _REVERSE_RELATION[rel_value]
|
|
479
479
|
|
|
480
|
+
# Circular dependency check for blocking relations
|
|
481
|
+
if rel_value == IssueRelation.BLOCKS.value:
|
|
482
|
+
_check_block_cycle(slug, issue_id, target_id)
|
|
483
|
+
elif rel_value == IssueRelation.BLOCKED_BY.value:
|
|
484
|
+
# blocked_by is the reverse: target blocks issue
|
|
485
|
+
_check_block_cycle(slug, target_id, issue_id)
|
|
486
|
+
|
|
480
487
|
# Add forward link (idempotent)
|
|
481
488
|
_add_link_entry(slug, issue_id, target_id, rel_value)
|
|
482
489
|
# Add reverse link
|
|
@@ -486,6 +493,34 @@ def add_issue_link(
|
|
|
486
493
|
logger.debug("Linked {} —{}→ {}", issue_id, rel_value, target_id)
|
|
487
494
|
|
|
488
495
|
|
|
496
|
+
def _check_block_cycle(slug: str, blocker_id: str, blocked_id: str) -> None:
|
|
497
|
+
"""Raise ValueError if adding 'blocker_id blocks blocked_id' would create a cycle.
|
|
498
|
+
|
|
499
|
+
Walks the existing 'blocks' graph starting from blocked_id to see if
|
|
500
|
+
blocker_id is reachable (meaning blocked_id already transitively blocks blocker_id).
|
|
501
|
+
"""
|
|
502
|
+
visited: set[str] = set()
|
|
503
|
+
queue = [blocked_id]
|
|
504
|
+
while queue:
|
|
505
|
+
current = queue.pop()
|
|
506
|
+
if current in visited:
|
|
507
|
+
continue
|
|
508
|
+
visited.add(current)
|
|
509
|
+
issue = load_issue(slug, current)
|
|
510
|
+
if not issue:
|
|
511
|
+
continue
|
|
512
|
+
for link in issue.get("issue_links", []):
|
|
513
|
+
if link["relation"] != IssueRelation.BLOCKS.value:
|
|
514
|
+
continue
|
|
515
|
+
downstream = link["issue_id"]
|
|
516
|
+
if downstream == blocker_id:
|
|
517
|
+
raise ValueError(
|
|
518
|
+
f"Circular dependency: {blocked_id} already transitively "
|
|
519
|
+
f"blocks {blocker_id}"
|
|
520
|
+
)
|
|
521
|
+
queue.append(downstream)
|
|
522
|
+
|
|
523
|
+
|
|
489
524
|
def _add_link_entry(slug: str, issue_id: str, target_id: str, relation: str) -> None:
|
|
490
525
|
"""Add a single link entry to an issue (idempotent)."""
|
|
491
526
|
with _get_slug_lock(slug):
|
|
@@ -662,6 +697,143 @@ def complete_review(slug: str, review_id: str) -> dict:
|
|
|
662
697
|
return data
|
|
663
698
|
|
|
664
699
|
|
|
700
|
+
# ---------------------------------------------------------------------------
|
|
701
|
+
# Kanban Board
|
|
702
|
+
# ---------------------------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def kanban_board(slug: str) -> dict:
|
|
706
|
+
"""Return issues grouped by IssueStatus columns + blocked IDs.
|
|
707
|
+
|
|
708
|
+
Raises ValueError if product not found.
|
|
709
|
+
"""
|
|
710
|
+
product = load_product(slug)
|
|
711
|
+
if not product:
|
|
712
|
+
raise ValueError(f"Product '{slug}' not found")
|
|
713
|
+
|
|
714
|
+
all_issues = list_issues(slug)
|
|
715
|
+
columns: dict[str, list[dict]] = {s.value: [] for s in IssueStatus}
|
|
716
|
+
blocked_ids: list[str] = []
|
|
717
|
+
|
|
718
|
+
for issue in all_issues:
|
|
719
|
+
status = issue.get("status", IssueStatus.BACKLOG.value)
|
|
720
|
+
if status in columns:
|
|
721
|
+
columns[status].append(issue)
|
|
722
|
+
if is_blocked(slug, issue["id"]):
|
|
723
|
+
blocked_ids.append(issue["id"])
|
|
724
|
+
|
|
725
|
+
return {"columns": columns, "blocked_ids": blocked_ids}
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
# ---------------------------------------------------------------------------
|
|
729
|
+
# Roadmap Timeline
|
|
730
|
+
# ---------------------------------------------------------------------------
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def roadmap_timeline(slug: str) -> dict:
|
|
734
|
+
"""Return sprints, versions, and milestoned issues for timeline display.
|
|
735
|
+
|
|
736
|
+
Raises ValueError if product not found.
|
|
737
|
+
"""
|
|
738
|
+
product = load_product(slug)
|
|
739
|
+
if not product:
|
|
740
|
+
raise ValueError(f"Product '{slug}' not found")
|
|
741
|
+
|
|
742
|
+
sprints = list_sprints(slug)
|
|
743
|
+
sprint_summaries = []
|
|
744
|
+
for s in sprints:
|
|
745
|
+
issue_count = len(list_issues(slug, sprint=s["id"]))
|
|
746
|
+
sprint_summaries.append({
|
|
747
|
+
"id": s["id"],
|
|
748
|
+
"name": s["name"],
|
|
749
|
+
"start_date": s["start_date"],
|
|
750
|
+
"end_date": s["end_date"],
|
|
751
|
+
"status": s["status"],
|
|
752
|
+
"goal": s.get("goal", ""),
|
|
753
|
+
"issue_count": issue_count,
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
versions = list_versions(slug)
|
|
757
|
+
version_summaries = []
|
|
758
|
+
for v in versions:
|
|
759
|
+
version_summaries.append({
|
|
760
|
+
"version": v["version"],
|
|
761
|
+
"released_at": v["released_at"],
|
|
762
|
+
"resolved_count": len(v.get("resolved_issue_ids", [])),
|
|
763
|
+
"changelog": v.get("changelog", ""),
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
all_issues = list_issues(slug)
|
|
767
|
+
milestoned = [
|
|
768
|
+
{
|
|
769
|
+
"issue_id": i["id"],
|
|
770
|
+
"title": i["title"],
|
|
771
|
+
"priority": i.get("priority", "P2"),
|
|
772
|
+
"milestone_version": i["milestone_version"],
|
|
773
|
+
"status": i.get("status", "backlog"),
|
|
774
|
+
}
|
|
775
|
+
for i in all_issues
|
|
776
|
+
if i.get("milestone_version")
|
|
777
|
+
]
|
|
778
|
+
|
|
779
|
+
return {
|
|
780
|
+
"sprints": sprint_summaries,
|
|
781
|
+
"versions": version_summaries,
|
|
782
|
+
"milestoned_issues": milestoned,
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
# ---------------------------------------------------------------------------
|
|
787
|
+
# Product Activity Log
|
|
788
|
+
# ---------------------------------------------------------------------------
|
|
789
|
+
|
|
790
|
+
_MAX_ACTIVITY_ENTRIES = 500
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _activity_dir(slug: str) -> Path:
|
|
794
|
+
return _product_dir(slug) / ACTIVITY_LOG_DIR_NAME
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def append_product_activity(
|
|
798
|
+
slug: str,
|
|
799
|
+
*,
|
|
800
|
+
event_type: str,
|
|
801
|
+
actor: str,
|
|
802
|
+
detail: str,
|
|
803
|
+
) -> None:
|
|
804
|
+
"""Append an activity entry to the product's activity log."""
|
|
805
|
+
adir = _activity_dir(slug)
|
|
806
|
+
adir.mkdir(parents=True, exist_ok=True)
|
|
807
|
+
log_path = adir / "log.yaml"
|
|
808
|
+
|
|
809
|
+
entry = {
|
|
810
|
+
"ts": datetime.now().isoformat(),
|
|
811
|
+
"event_type": event_type,
|
|
812
|
+
"actor": actor,
|
|
813
|
+
"detail": detail,
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
with _get_slug_lock(slug):
|
|
817
|
+
log = _read_yaml(log_path) or []
|
|
818
|
+
if not isinstance(log, list):
|
|
819
|
+
log = []
|
|
820
|
+
log.append(entry)
|
|
821
|
+
if len(log) > _MAX_ACTIVITY_ENTRIES:
|
|
822
|
+
log = log[-_MAX_ACTIVITY_ENTRIES:]
|
|
823
|
+
_write_yaml(log_path, log)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def list_product_activity(slug: str, *, limit: int = 50) -> list[dict]:
|
|
827
|
+
"""Return product activity entries, newest first."""
|
|
828
|
+
log_path = _activity_dir(slug) / "log.yaml"
|
|
829
|
+
log = _read_yaml(log_path)
|
|
830
|
+
if not log or not isinstance(log, list):
|
|
831
|
+
return []
|
|
832
|
+
# Newest first
|
|
833
|
+
log.reverse()
|
|
834
|
+
return log[:limit]
|
|
835
|
+
|
|
836
|
+
|
|
665
837
|
# ---------------------------------------------------------------------------
|
|
666
838
|
# Product Versioning
|
|
667
839
|
# ---------------------------------------------------------------------------
|
|
@@ -701,6 +701,37 @@ async def handle_sprint_closed(event: CompanyEvent) -> None:
|
|
|
701
701
|
logger.info("[PRODUCT_TRIGGER] Auto-created review for sprint {} in {}", sprint_id, slug)
|
|
702
702
|
|
|
703
703
|
|
|
704
|
+
def _log_product_activity(event: CompanyEvent) -> None:
|
|
705
|
+
"""Log a product event to the product-scoped activity feed."""
|
|
706
|
+
slug = event.payload.get("product_slug", "")
|
|
707
|
+
if not slug:
|
|
708
|
+
return
|
|
709
|
+
detail = event.payload.get("detail", "")
|
|
710
|
+
if not detail:
|
|
711
|
+
# Build a default detail string from event type + payload
|
|
712
|
+
etype = event.type.value
|
|
713
|
+
title = event.payload.get("title", "")
|
|
714
|
+
issue_id = event.payload.get("issue_id", "")
|
|
715
|
+
sprint_id = event.payload.get("sprint_id", "")
|
|
716
|
+
if title:
|
|
717
|
+
detail = f"{etype}: {title}"
|
|
718
|
+
elif issue_id:
|
|
719
|
+
detail = f"{etype}: {issue_id}"
|
|
720
|
+
elif sprint_id:
|
|
721
|
+
detail = f"{etype}: {sprint_id}"
|
|
722
|
+
else:
|
|
723
|
+
detail = etype
|
|
724
|
+
try:
|
|
725
|
+
prod.append_product_activity(
|
|
726
|
+
slug,
|
|
727
|
+
event_type=event.type.value,
|
|
728
|
+
actor=event.agent,
|
|
729
|
+
detail=detail,
|
|
730
|
+
)
|
|
731
|
+
except Exception:
|
|
732
|
+
logger.debug("[PRODUCT_TRIGGER] Failed to log activity for {}", slug)
|
|
733
|
+
|
|
734
|
+
|
|
704
735
|
# ---------------------------------------------------------------------------
|
|
705
736
|
# Registration
|
|
706
737
|
# ---------------------------------------------------------------------------
|
|
@@ -718,10 +749,27 @@ def register_product_triggers() -> "asyncio.Task":
|
|
|
718
749
|
|
|
719
750
|
queue = event_bus.subscribe()
|
|
720
751
|
|
|
752
|
+
# Event types that should be auto-logged to product activity feed
|
|
753
|
+
_ACTIVITY_EVENT_TYPES = {
|
|
754
|
+
EventType.ISSUE_CREATED,
|
|
755
|
+
EventType.ISSUE_CLOSED,
|
|
756
|
+
EventType.ISSUE_ASSIGNED,
|
|
757
|
+
EventType.SPRINT_CREATED,
|
|
758
|
+
EventType.SPRINT_CLOSED,
|
|
759
|
+
EventType.VERSION_RELEASED,
|
|
760
|
+
EventType.REVIEW_CREATED,
|
|
761
|
+
EventType.REVIEW_COMPLETED,
|
|
762
|
+
EventType.KR_UPDATED,
|
|
763
|
+
}
|
|
764
|
+
|
|
721
765
|
async def _dispatch_loop() -> None:
|
|
722
766
|
while True:
|
|
723
767
|
event = await queue.get()
|
|
724
768
|
try:
|
|
769
|
+
# Auto-log product events to activity feed
|
|
770
|
+
if event.type in _ACTIVITY_EVENT_TYPES:
|
|
771
|
+
_log_product_activity(event)
|
|
772
|
+
|
|
725
773
|
if event.type == EventType.ISSUE_CREATED:
|
|
726
774
|
await handle_issue_created(event)
|
|
727
775
|
elif event.type == EventType.ISSUE_ASSIGNED:
|