@1mancompany/onemancompany 0.7.32 → 0.7.39
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 +345 -6
- package/frontend/style.css +50 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/onemancompany/agents/product_tools.py +54 -0
- package/src/onemancompany/api/routes.py +49 -1
- package/src/onemancompany/core/models.py +1 -0
- package/src/onemancompany/core/product.py +53 -11
- package/src/onemancompany/core/product_triggers.py +23 -15
package/frontend/app.js
CHANGED
|
@@ -7555,6 +7555,7 @@ class AppController {
|
|
|
7555
7555
|
{ id: 'issues', label: `Issues (${issues.length})` },
|
|
7556
7556
|
{ id: 'kanban', label: 'Kanban' },
|
|
7557
7557
|
{ id: 'roadmap', label: 'Roadmap' },
|
|
7558
|
+
{ id: 'reviews', label: `Reviews (${(data.reviews || []).length})` },
|
|
7558
7559
|
{ id: 'activity', label: 'Activity' },
|
|
7559
7560
|
{ id: 'projects', label: `Projects (${projects.length})` },
|
|
7560
7561
|
];
|
|
@@ -7591,6 +7592,8 @@ class AppController {
|
|
|
7591
7592
|
this._renderProductKanban(slug, container, data);
|
|
7592
7593
|
} else if (tabId === 'roadmap') {
|
|
7593
7594
|
this._renderProductRoadmap(slug, container);
|
|
7595
|
+
} else if (tabId === 'reviews') {
|
|
7596
|
+
this._renderProductReviews(data.reviews || [], slug, container);
|
|
7594
7597
|
} else if (tabId === 'activity') {
|
|
7595
7598
|
this._renderProductActivity(slug, container);
|
|
7596
7599
|
} else if (tabId === 'projects') {
|
|
@@ -7783,6 +7786,21 @@ class AppController {
|
|
|
7783
7786
|
unitEl.textContent = unit;
|
|
7784
7787
|
this._makeKrFieldEditable(unitEl, slug, kr.id, 'unit');
|
|
7785
7788
|
|
|
7789
|
+
// Delete KR button
|
|
7790
|
+
const delKrBtn = document.createElement('button');
|
|
7791
|
+
delKrBtn.className = 'kr-remove-btn';
|
|
7792
|
+
delKrBtn.innerHTML = '×';
|
|
7793
|
+
delKrBtn.title = 'Delete KR';
|
|
7794
|
+
delKrBtn.addEventListener('click', async () => {
|
|
7795
|
+
if (!confirm(`Delete KR "${kr.title}"?`)) return;
|
|
7796
|
+
try {
|
|
7797
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/kr/${encodeURIComponent(kr.id)}`, { method: 'DELETE' });
|
|
7798
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
7799
|
+
this._showToast('KR deleted', 'success');
|
|
7800
|
+
this._openProductDetail(slug);
|
|
7801
|
+
} catch (err) { this._showToast(`Failed: ${err.message}`, 'error'); }
|
|
7802
|
+
});
|
|
7803
|
+
|
|
7786
7804
|
krRow.appendChild(titleEl);
|
|
7787
7805
|
krRow.appendChild(document.createTextNode(': '));
|
|
7788
7806
|
krRow.appendChild(currentEl);
|
|
@@ -7791,6 +7809,7 @@ class AppController {
|
|
|
7791
7809
|
krRow.appendChild(unitEl);
|
|
7792
7810
|
krRow.appendChild(document.createTextNode(` (${pct.toFixed(0)}%)`));
|
|
7793
7811
|
krRow.appendChild(progTrack);
|
|
7812
|
+
krRow.appendChild(delKrBtn);
|
|
7794
7813
|
krList.appendChild(krRow);
|
|
7795
7814
|
}
|
|
7796
7815
|
|
|
@@ -7803,11 +7822,12 @@ class AppController {
|
|
|
7803
7822
|
container.appendChild(krList);
|
|
7804
7823
|
|
|
7805
7824
|
// Version History
|
|
7825
|
+
const verLabel = document.createElement('div');
|
|
7826
|
+
verLabel.className = 'product-section-label';
|
|
7827
|
+
verLabel.textContent = 'Version History';
|
|
7828
|
+
container.appendChild(verLabel);
|
|
7829
|
+
|
|
7806
7830
|
if (versions.length > 0) {
|
|
7807
|
-
const verLabel = document.createElement('div');
|
|
7808
|
-
verLabel.className = 'product-section-label';
|
|
7809
|
-
verLabel.textContent = 'Version History';
|
|
7810
|
-
container.appendChild(verLabel);
|
|
7811
7831
|
const verList = document.createElement('div');
|
|
7812
7832
|
verList.className = 'product-version-list';
|
|
7813
7833
|
for (const v of versions) {
|
|
@@ -7826,6 +7846,13 @@ class AppController {
|
|
|
7826
7846
|
}
|
|
7827
7847
|
container.appendChild(verList);
|
|
7828
7848
|
}
|
|
7849
|
+
|
|
7850
|
+
// Release Version button
|
|
7851
|
+
const releaseBtn = document.createElement('button');
|
|
7852
|
+
releaseBtn.className = 'btn-small';
|
|
7853
|
+
releaseBtn.textContent = '+ Release Version';
|
|
7854
|
+
releaseBtn.addEventListener('click', () => this._showReleaseVersionForm(container, slug));
|
|
7855
|
+
container.appendChild(releaseBtn);
|
|
7829
7856
|
}
|
|
7830
7857
|
|
|
7831
7858
|
_makeEditable(el, fieldName, slug) {
|
|
@@ -8152,10 +8179,40 @@ class AppController {
|
|
|
8152
8179
|
${labels ? `<div>Labels: ${labels}</div>` : ''}
|
|
8153
8180
|
${issue.created_by ? `<div>Created by: ${this._escHtml(issue.created_by)}</div>` : ''}
|
|
8154
8181
|
${issue.resolution ? `<div>Resolution: ${this._escHtml(issue.resolution)}</div>` : ''}
|
|
8155
|
-
${issue.sprint ? `<div>Sprint: ${this._escHtml(issue.sprint)}</div>` : ''}
|
|
8156
8182
|
`;
|
|
8157
8183
|
body.appendChild(metaEl);
|
|
8158
8184
|
|
|
8185
|
+
// Sprint picker (dropdown)
|
|
8186
|
+
const sprintRow = document.createElement('div');
|
|
8187
|
+
sprintRow.textContent = 'Sprint: ';
|
|
8188
|
+
const sprintSel = document.createElement('select');
|
|
8189
|
+
sprintSel.className = 'form-input';
|
|
8190
|
+
sprintSel.style.width = 'auto';
|
|
8191
|
+
sprintSel.style.display = 'inline';
|
|
8192
|
+
sprintSel.innerHTML = '<option value="">No Sprint</option>';
|
|
8193
|
+
fetch(`/api/product/${encodeURIComponent(slug)}/sprints`)
|
|
8194
|
+
.then(r => r.json())
|
|
8195
|
+
.then(sprints => {
|
|
8196
|
+
for (const s of sprints.filter(s => s.status !== 'closed')) {
|
|
8197
|
+
const opt = document.createElement('option');
|
|
8198
|
+
opt.value = s.id;
|
|
8199
|
+
opt.textContent = `${s.name}${s.status === 'active' ? ' (active)' : ''}`;
|
|
8200
|
+
sprintSel.appendChild(opt);
|
|
8201
|
+
}
|
|
8202
|
+
sprintSel.value = issue.sprint || '';
|
|
8203
|
+
})
|
|
8204
|
+
.catch(err => console.warn('Failed to load sprints:', err));
|
|
8205
|
+
sprintSel.addEventListener('change', () => {
|
|
8206
|
+
fetch(`/api/product/${encodeURIComponent(slug)}/issue/${encodeURIComponent(issue.id)}`, {
|
|
8207
|
+
method: 'PUT',
|
|
8208
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8209
|
+
body: JSON.stringify({ sprint: sprintSel.value || '' }),
|
|
8210
|
+
}).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
|
|
8211
|
+
.catch(err => this._showToast(`Failed: ${err.message}`, 'error'));
|
|
8212
|
+
});
|
|
8213
|
+
sprintRow.appendChild(sprintSel);
|
|
8214
|
+
body.appendChild(sprintRow);
|
|
8215
|
+
|
|
8159
8216
|
const assignRow = document.createElement('div');
|
|
8160
8217
|
assignRow.textContent = 'Assignee: ';
|
|
8161
8218
|
const assignSel = document.createElement('select');
|
|
@@ -8402,11 +8459,26 @@ class AppController {
|
|
|
8402
8459
|
<option value="P2" selected>P2</option><option value="P3">P3</option>
|
|
8403
8460
|
</select>
|
|
8404
8461
|
<input type="number" class="form-input issue-new-sp" placeholder="Story pts" style="width:60px" />
|
|
8405
|
-
<
|
|
8462
|
+
<select class="form-input issue-new-sprint" style="width:auto">
|
|
8463
|
+
<option value="">No Sprint</option>
|
|
8464
|
+
</select>
|
|
8406
8465
|
<button class="btn-small issue-new-save">Create</button>
|
|
8407
8466
|
<button class="kr-remove-btn issue-new-cancel">×</button>
|
|
8408
8467
|
</div>
|
|
8409
8468
|
`;
|
|
8469
|
+
// Populate sprint picker from available sprints
|
|
8470
|
+
const sprintSel = row.querySelector('.issue-new-sprint');
|
|
8471
|
+
fetch(`/api/product/${encodeURIComponent(slug)}/sprints`)
|
|
8472
|
+
.then(r => r.json())
|
|
8473
|
+
.then(sprints => {
|
|
8474
|
+
for (const s of sprints.filter(s => s.status !== 'closed')) {
|
|
8475
|
+
const opt = document.createElement('option');
|
|
8476
|
+
opt.value = s.id;
|
|
8477
|
+
opt.textContent = `${s.name}${s.status === 'active' ? ' (active)' : ''}`;
|
|
8478
|
+
sprintSel.appendChild(opt);
|
|
8479
|
+
}
|
|
8480
|
+
})
|
|
8481
|
+
.catch(err => console.warn('Failed to load sprints:', err));
|
|
8410
8482
|
row.querySelector('.issue-new-cancel').addEventListener('click', () => row.remove());
|
|
8411
8483
|
row.querySelector('.issue-new-save').addEventListener('click', async () => {
|
|
8412
8484
|
const title = row.querySelector('.issue-new-title').value.trim();
|
|
@@ -8607,6 +8679,11 @@ class AppController {
|
|
|
8607
8679
|
const actions = document.createElement('div');
|
|
8608
8680
|
actions.className = 'sprint-actions';
|
|
8609
8681
|
if (s.status === 'planning') {
|
|
8682
|
+
const editBtn = document.createElement('button');
|
|
8683
|
+
editBtn.className = 'sprint-action-btn';
|
|
8684
|
+
editBtn.textContent = 'Edit';
|
|
8685
|
+
editBtn.addEventListener('click', () => this._showEditSprintForm(bar, slug, s));
|
|
8686
|
+
actions.appendChild(editBtn);
|
|
8610
8687
|
const startBtn = document.createElement('button');
|
|
8611
8688
|
startBtn.className = 'sprint-action-btn';
|
|
8612
8689
|
startBtn.textContent = 'Start';
|
|
@@ -8758,10 +8835,26 @@ class AppController {
|
|
|
8758
8835
|
</div>
|
|
8759
8836
|
<div class="sprint-form-row">
|
|
8760
8837
|
<input type="number" class="form-input sprint-new-capacity" placeholder="Capacity (pts)" style="width:80px" />
|
|
8838
|
+
<span class="sprint-suggested-capacity" style="color:var(--text-dim);font-size:calc(5px + var(--font-boost));margin-left:4px"></span>
|
|
8761
8839
|
<button class="btn-small sprint-save-btn">Create</button>
|
|
8762
8840
|
<button class="kr-remove-btn sprint-cancel-btn">×</button>
|
|
8763
8841
|
</div>
|
|
8764
8842
|
`;
|
|
8843
|
+
// Show suggested capacity if available
|
|
8844
|
+
fetch(`/api/product/${encodeURIComponent(slug)}/sprint/suggest-capacity`)
|
|
8845
|
+
.then(r => r.json())
|
|
8846
|
+
.then(d => {
|
|
8847
|
+
if (d.suggested_capacity != null) {
|
|
8848
|
+
const hint = form.querySelector('.sprint-suggested-capacity');
|
|
8849
|
+
hint.textContent = `(suggested: ${d.suggested_capacity} pts)`;
|
|
8850
|
+
hint.style.cursor = 'pointer';
|
|
8851
|
+
hint.title = 'Click to use suggested capacity';
|
|
8852
|
+
hint.addEventListener('click', () => {
|
|
8853
|
+
form.querySelector('.sprint-new-capacity').value = d.suggested_capacity;
|
|
8854
|
+
});
|
|
8855
|
+
}
|
|
8856
|
+
})
|
|
8857
|
+
.catch(err => console.warn('Failed to load suggested capacity:', err));
|
|
8765
8858
|
// Default dates: today → +14 days
|
|
8766
8859
|
const today = new Date();
|
|
8767
8860
|
const end = new Date(today);
|
|
@@ -8796,6 +8889,51 @@ class AppController {
|
|
|
8796
8889
|
form.querySelector('.sprint-new-name').focus();
|
|
8797
8890
|
}
|
|
8798
8891
|
|
|
8892
|
+
_showEditSprintForm(barEl, slug, sprint) {
|
|
8893
|
+
if (barEl.querySelector('.sprint-inline-add')) return;
|
|
8894
|
+
const form = document.createElement('div');
|
|
8895
|
+
form.className = 'sprint-inline-add';
|
|
8896
|
+
form.innerHTML = `
|
|
8897
|
+
<input type="text" class="form-input sprint-edit-name" value="${this._escHtml(sprint.name)}" />
|
|
8898
|
+
<input type="text" class="form-input sprint-edit-goal" value="${this._escHtml(sprint.goal || '')}" placeholder="Goal" />
|
|
8899
|
+
<div class="sprint-form-row">
|
|
8900
|
+
<label style="color:var(--text-dim);font-size:calc(5px + var(--font-boost))">Start:</label>
|
|
8901
|
+
<input type="date" class="form-input sprint-edit-start" value="${sprint.start_date || ''}" style="width:auto" />
|
|
8902
|
+
<label style="color:var(--text-dim);font-size:calc(5px + var(--font-boost))">End:</label>
|
|
8903
|
+
<input type="date" class="form-input sprint-edit-end" value="${sprint.end_date || ''}" style="width:auto" />
|
|
8904
|
+
</div>
|
|
8905
|
+
<div class="sprint-form-row">
|
|
8906
|
+
<input type="number" class="form-input sprint-edit-capacity" value="${sprint.capacity || ''}" placeholder="Capacity" style="width:80px" />
|
|
8907
|
+
<button class="btn-small sprint-edit-save">Save</button>
|
|
8908
|
+
<button class="kr-remove-btn sprint-edit-cancel">×</button>
|
|
8909
|
+
</div>
|
|
8910
|
+
`;
|
|
8911
|
+
form.querySelector('.sprint-edit-cancel').addEventListener('click', () => form.remove());
|
|
8912
|
+
form.querySelector('.sprint-edit-save').addEventListener('click', async () => {
|
|
8913
|
+
const updates = {
|
|
8914
|
+
name: form.querySelector('.sprint-edit-name').value.trim(),
|
|
8915
|
+
goal: form.querySelector('.sprint-edit-goal').value.trim(),
|
|
8916
|
+
start_date: form.querySelector('.sprint-edit-start').value,
|
|
8917
|
+
end_date: form.querySelector('.sprint-edit-end').value,
|
|
8918
|
+
};
|
|
8919
|
+
const cap = parseInt(form.querySelector('.sprint-edit-capacity').value);
|
|
8920
|
+
if (!isNaN(cap)) updates.capacity = cap;
|
|
8921
|
+
if (!updates.name) { this._showToast('Sprint name is required', 'warning'); return; }
|
|
8922
|
+
try {
|
|
8923
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/sprint/${encodeURIComponent(sprint.id)}`, {
|
|
8924
|
+
method: 'PUT',
|
|
8925
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8926
|
+
body: JSON.stringify(updates),
|
|
8927
|
+
});
|
|
8928
|
+
if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
|
|
8929
|
+
this._showToast('Sprint updated', 'success');
|
|
8930
|
+
this._openProductDetail(slug);
|
|
8931
|
+
} catch (err) { this._showToast(`Update failed: ${err.message}`, 'error'); }
|
|
8932
|
+
});
|
|
8933
|
+
barEl.appendChild(form);
|
|
8934
|
+
form.querySelector('.sprint-edit-name').focus();
|
|
8935
|
+
}
|
|
8936
|
+
|
|
8799
8937
|
// ---------------------------------------------------------------------------
|
|
8800
8938
|
// Activity Feed Tab
|
|
8801
8939
|
// ---------------------------------------------------------------------------
|
|
@@ -8874,6 +9012,207 @@ class AppController {
|
|
|
8874
9012
|
.catch(err => { container.innerHTML = `<div class="error-text">Failed to load activity: ${err.message}</div>`; });
|
|
8875
9013
|
}
|
|
8876
9014
|
|
|
9015
|
+
// ---------------------------------------------------------------------------
|
|
9016
|
+
// Reviews Tab
|
|
9017
|
+
// ---------------------------------------------------------------------------
|
|
9018
|
+
|
|
9019
|
+
_renderProductReviews(reviews, slug, container) {
|
|
9020
|
+
container.innerHTML = '';
|
|
9021
|
+
|
|
9022
|
+
// Create Review button
|
|
9023
|
+
const toolbar = document.createElement('div');
|
|
9024
|
+
toolbar.className = 'issue-toolbar';
|
|
9025
|
+
const createBtn = document.createElement('button');
|
|
9026
|
+
createBtn.className = 'btn-small';
|
|
9027
|
+
createBtn.textContent = '+ Create Review';
|
|
9028
|
+
createBtn.addEventListener('click', async () => {
|
|
9029
|
+
try {
|
|
9030
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/review`, {
|
|
9031
|
+
method: 'POST',
|
|
9032
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9033
|
+
body: JSON.stringify({ trigger: 'manual', owner: '' }),
|
|
9034
|
+
});
|
|
9035
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
9036
|
+
this._showToast('Review created', 'success');
|
|
9037
|
+
this._openProductDetail(slug);
|
|
9038
|
+
} catch (err) { this._showToast(`Failed: ${err.message}`, 'error'); }
|
|
9039
|
+
});
|
|
9040
|
+
toolbar.appendChild(createBtn);
|
|
9041
|
+
container.appendChild(toolbar);
|
|
9042
|
+
|
|
9043
|
+
if (!reviews.length) {
|
|
9044
|
+
const emptyMsg = document.createElement('div');
|
|
9045
|
+
emptyMsg.className = 'task-empty';
|
|
9046
|
+
emptyMsg.textContent = 'No reviews yet.';
|
|
9047
|
+
container.appendChild(emptyMsg);
|
|
9048
|
+
return;
|
|
9049
|
+
}
|
|
9050
|
+
|
|
9051
|
+
// Group: open first, then completed
|
|
9052
|
+
const open = reviews.filter(r => r.status === 'open');
|
|
9053
|
+
const completed = reviews.filter(r => r.status === 'completed');
|
|
9054
|
+
|
|
9055
|
+
for (const group of [{ label: 'Open', items: open }, { label: 'Completed', items: completed }]) {
|
|
9056
|
+
if (!group.items.length) continue;
|
|
9057
|
+
const heading = document.createElement('div');
|
|
9058
|
+
heading.className = 'product-section-label';
|
|
9059
|
+
heading.textContent = `${group.label} (${group.items.length})`;
|
|
9060
|
+
container.appendChild(heading);
|
|
9061
|
+
|
|
9062
|
+
for (const rev of group.items) {
|
|
9063
|
+
const card = document.createElement('div');
|
|
9064
|
+
card.className = `review-card ${rev.status === 'completed' ? 'review-completed' : ''}`;
|
|
9065
|
+
|
|
9066
|
+
const header = document.createElement('div');
|
|
9067
|
+
header.className = 'review-card-header';
|
|
9068
|
+
const trigger = rev.trigger || 'manual';
|
|
9069
|
+
const dateStr = rev.created_at ? new Date(rev.created_at).toLocaleDateString() : '';
|
|
9070
|
+
header.innerHTML = `<span class="review-trigger">${this._escHtml(trigger)}</span> <span class="review-date">${dateStr}</span>`;
|
|
9071
|
+
if (rev.owner) header.innerHTML += ` <span class="review-owner">Owner: ${this._escHtml(rev.owner)}</span>`;
|
|
9072
|
+
card.appendChild(header);
|
|
9073
|
+
|
|
9074
|
+
// Checklist items
|
|
9075
|
+
const itemsList = document.createElement('div');
|
|
9076
|
+
itemsList.className = 'review-items';
|
|
9077
|
+
for (const item of (rev.items || [])) {
|
|
9078
|
+
const row = document.createElement('div');
|
|
9079
|
+
row.className = 'review-item-row';
|
|
9080
|
+
const checkbox = document.createElement('input');
|
|
9081
|
+
checkbox.type = 'checkbox';
|
|
9082
|
+
checkbox.checked = !!item.checked;
|
|
9083
|
+
checkbox.disabled = rev.status === 'completed';
|
|
9084
|
+
checkbox.addEventListener('change', async () => {
|
|
9085
|
+
try {
|
|
9086
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/review/${encodeURIComponent(rev.id)}/item/${encodeURIComponent(item.key)}`, {
|
|
9087
|
+
method: 'PUT',
|
|
9088
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9089
|
+
body: JSON.stringify({ checked: checkbox.checked }),
|
|
9090
|
+
});
|
|
9091
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
9092
|
+
} catch (err) {
|
|
9093
|
+
checkbox.checked = !checkbox.checked;
|
|
9094
|
+
this._showToast(`Failed: ${err.message}`, 'error');
|
|
9095
|
+
}
|
|
9096
|
+
});
|
|
9097
|
+
const label = document.createElement('span');
|
|
9098
|
+
label.className = item.checked ? 'review-item-checked' : '';
|
|
9099
|
+
label.textContent = item.label || item.key;
|
|
9100
|
+
row.appendChild(checkbox);
|
|
9101
|
+
row.appendChild(label);
|
|
9102
|
+
itemsList.appendChild(row);
|
|
9103
|
+
}
|
|
9104
|
+
card.appendChild(itemsList);
|
|
9105
|
+
|
|
9106
|
+
// Complete button for open reviews
|
|
9107
|
+
if (rev.status === 'open') {
|
|
9108
|
+
const completeBtn = document.createElement('button');
|
|
9109
|
+
completeBtn.className = 'btn-small';
|
|
9110
|
+
completeBtn.textContent = 'Complete Review';
|
|
9111
|
+
completeBtn.addEventListener('click', async () => {
|
|
9112
|
+
try {
|
|
9113
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/review/${encodeURIComponent(rev.id)}/complete`, { method: 'POST' });
|
|
9114
|
+
if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
|
|
9115
|
+
this._showToast('Review completed', 'success');
|
|
9116
|
+
this._openProductDetail(slug);
|
|
9117
|
+
} catch (err) { this._showToast(`Failed: ${err.message}`, 'error'); }
|
|
9118
|
+
});
|
|
9119
|
+
card.appendChild(completeBtn);
|
|
9120
|
+
}
|
|
9121
|
+
|
|
9122
|
+
container.appendChild(card);
|
|
9123
|
+
}
|
|
9124
|
+
}
|
|
9125
|
+
}
|
|
9126
|
+
|
|
9127
|
+
// ---------------------------------------------------------------------------
|
|
9128
|
+
// Release Version Form
|
|
9129
|
+
// ---------------------------------------------------------------------------
|
|
9130
|
+
|
|
9131
|
+
_showReleaseVersionForm(container, slug) {
|
|
9132
|
+
if (container.querySelector('.release-form')) return;
|
|
9133
|
+
const form = document.createElement('div');
|
|
9134
|
+
form.className = 'release-form sprint-inline-add';
|
|
9135
|
+
|
|
9136
|
+
// Show done issues that can be released
|
|
9137
|
+
fetch(`/api/product/${encodeURIComponent(slug)}/detail`)
|
|
9138
|
+
.then(r => r.json())
|
|
9139
|
+
.then(data => {
|
|
9140
|
+
const doneIssues = (data.issues || []).filter(i => i.status === 'done');
|
|
9141
|
+
if (!doneIssues.length) {
|
|
9142
|
+
form.innerHTML = '<div class="task-empty">No issues in DONE status to release.</div>';
|
|
9143
|
+
const closeBtn = document.createElement('button');
|
|
9144
|
+
closeBtn.className = 'kr-remove-btn';
|
|
9145
|
+
closeBtn.innerHTML = '×';
|
|
9146
|
+
closeBtn.addEventListener('click', () => form.remove());
|
|
9147
|
+
form.appendChild(closeBtn);
|
|
9148
|
+
return;
|
|
9149
|
+
}
|
|
9150
|
+
|
|
9151
|
+
const label = document.createElement('div');
|
|
9152
|
+
label.style.cssText = 'color:var(--text-dim);font-size:calc(5px + var(--font-boost));margin-bottom:4px';
|
|
9153
|
+
label.textContent = `Select issues to include in release (${doneIssues.length} done):`;
|
|
9154
|
+
form.appendChild(label);
|
|
9155
|
+
|
|
9156
|
+
const checkboxes = [];
|
|
9157
|
+
for (const issue of doneIssues) {
|
|
9158
|
+
const row = document.createElement('div');
|
|
9159
|
+
row.className = 'review-item-row';
|
|
9160
|
+
const cb = document.createElement('input');
|
|
9161
|
+
cb.type = 'checkbox';
|
|
9162
|
+
cb.checked = true;
|
|
9163
|
+
cb.dataset.issueId = issue.id;
|
|
9164
|
+
checkboxes.push(cb);
|
|
9165
|
+
const text = document.createElement('span');
|
|
9166
|
+
text.textContent = `[${issue.priority || 'P2'}] ${issue.title}`;
|
|
9167
|
+
row.appendChild(cb);
|
|
9168
|
+
row.appendChild(text);
|
|
9169
|
+
form.appendChild(row);
|
|
9170
|
+
}
|
|
9171
|
+
|
|
9172
|
+
const bumpRow = document.createElement('div');
|
|
9173
|
+
bumpRow.className = 'sprint-form-row';
|
|
9174
|
+
bumpRow.style.marginTop = '6px';
|
|
9175
|
+
const bumpLabel = document.createElement('label');
|
|
9176
|
+
bumpLabel.style.cssText = 'color:var(--text-dim);font-size:calc(5px + var(--font-boost))';
|
|
9177
|
+
bumpLabel.textContent = 'Bump:';
|
|
9178
|
+
const bumpSel = document.createElement('select');
|
|
9179
|
+
bumpSel.className = 'form-input';
|
|
9180
|
+
bumpSel.style.width = 'auto';
|
|
9181
|
+
bumpSel.innerHTML = '<option value="patch">Patch</option><option value="minor">Minor</option><option value="major">Major</option>';
|
|
9182
|
+
bumpRow.appendChild(bumpLabel);
|
|
9183
|
+
bumpRow.appendChild(bumpSel);
|
|
9184
|
+
|
|
9185
|
+
const releaseBtn = document.createElement('button');
|
|
9186
|
+
releaseBtn.className = 'btn-small';
|
|
9187
|
+
releaseBtn.textContent = 'Release';
|
|
9188
|
+
releaseBtn.addEventListener('click', async () => {
|
|
9189
|
+
const selectedIds = checkboxes.filter(cb => cb.checked).map(cb => cb.dataset.issueId);
|
|
9190
|
+
if (!selectedIds.length) { this._showToast('Select at least one issue', 'warning'); return; }
|
|
9191
|
+
try {
|
|
9192
|
+
const r = await fetch(`/api/product/${encodeURIComponent(slug)}/release`, {
|
|
9193
|
+
method: 'POST',
|
|
9194
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9195
|
+
body: JSON.stringify({ resolved_issue_ids: selectedIds, bump: bumpSel.value }),
|
|
9196
|
+
});
|
|
9197
|
+
if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
|
|
9198
|
+
const result = await r.json();
|
|
9199
|
+
this._showToast(`Released v${result.version}`, 'success');
|
|
9200
|
+
this._openProductDetail(slug);
|
|
9201
|
+
} catch (err) { this._showToast(`Release failed: ${err.message}`, 'error'); }
|
|
9202
|
+
});
|
|
9203
|
+
bumpRow.appendChild(releaseBtn);
|
|
9204
|
+
|
|
9205
|
+
const cancelBtn = document.createElement('button');
|
|
9206
|
+
cancelBtn.className = 'kr-remove-btn';
|
|
9207
|
+
cancelBtn.innerHTML = '×';
|
|
9208
|
+
cancelBtn.addEventListener('click', () => form.remove());
|
|
9209
|
+
bumpRow.appendChild(cancelBtn);
|
|
9210
|
+
form.appendChild(bumpRow);
|
|
9211
|
+
});
|
|
9212
|
+
|
|
9213
|
+
container.appendChild(form);
|
|
9214
|
+
}
|
|
9215
|
+
|
|
8877
9216
|
_doUpdateProjectsPanel() {
|
|
8878
9217
|
const panel = document.getElementById('projects-panel-list');
|
|
8879
9218
|
if (!panel) return;
|
package/frontend/style.css
CHANGED
|
@@ -6786,3 +6786,53 @@ body.resize-dragging {
|
|
|
6786
6786
|
border-color: #ff4444;
|
|
6787
6787
|
color: #ff4444;
|
|
6788
6788
|
}
|
|
6789
|
+
|
|
6790
|
+
/* Review Cards */
|
|
6791
|
+
.review-card {
|
|
6792
|
+
background: rgba(0,255,255,0.03);
|
|
6793
|
+
border: 1px solid rgba(0,255,255,0.1);
|
|
6794
|
+
border-radius: 4px;
|
|
6795
|
+
padding: 8px 10px;
|
|
6796
|
+
margin-bottom: 6px;
|
|
6797
|
+
}
|
|
6798
|
+
.review-card.review-completed {
|
|
6799
|
+
opacity: 0.7;
|
|
6800
|
+
border-color: rgba(0,255,0,0.15);
|
|
6801
|
+
}
|
|
6802
|
+
.review-card-header {
|
|
6803
|
+
display: flex;
|
|
6804
|
+
gap: 8px;
|
|
6805
|
+
align-items: center;
|
|
6806
|
+
margin-bottom: 6px;
|
|
6807
|
+
font-size: calc(5px + var(--font-boost));
|
|
6808
|
+
}
|
|
6809
|
+
.review-trigger {
|
|
6810
|
+
color: var(--accent);
|
|
6811
|
+
font-weight: bold;
|
|
6812
|
+
text-transform: uppercase;
|
|
6813
|
+
font-size: calc(5px + var(--font-boost));
|
|
6814
|
+
}
|
|
6815
|
+
.review-date { color: var(--text-dim); }
|
|
6816
|
+
.review-owner { color: var(--text-dim); }
|
|
6817
|
+
.review-items { margin: 4px 0; }
|
|
6818
|
+
.review-item-row {
|
|
6819
|
+
display: flex;
|
|
6820
|
+
align-items: center;
|
|
6821
|
+
gap: 6px;
|
|
6822
|
+
padding: 2px 0;
|
|
6823
|
+
font-size: calc(6px + var(--font-boost));
|
|
6824
|
+
}
|
|
6825
|
+
.review-item-row input[type="checkbox"] {
|
|
6826
|
+
width: 14px;
|
|
6827
|
+
height: 14px;
|
|
6828
|
+
accent-color: var(--accent);
|
|
6829
|
+
}
|
|
6830
|
+
.review-item-checked {
|
|
6831
|
+
text-decoration: line-through;
|
|
6832
|
+
color: var(--text-dim);
|
|
6833
|
+
}
|
|
6834
|
+
|
|
6835
|
+
/* Release Form */
|
|
6836
|
+
.release-form {
|
|
6837
|
+
margin-top: 8px;
|
|
6838
|
+
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -691,6 +691,58 @@ def version_management_tool(
|
|
|
691
691
|
return f"Error: {e}"
|
|
692
692
|
|
|
693
693
|
|
|
694
|
+
@tool
|
|
695
|
+
async def update_product_tool(
|
|
696
|
+
product_slug: str,
|
|
697
|
+
name: str = "",
|
|
698
|
+
description: str = "",
|
|
699
|
+
objective: str = "",
|
|
700
|
+
) -> str:
|
|
701
|
+
"""Update a product's name, description, or objective.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
product_slug: Product slug identifier
|
|
705
|
+
name: New product name (leave empty to keep current)
|
|
706
|
+
description: New description (leave empty to keep current)
|
|
707
|
+
objective: New objective (leave empty to keep current)
|
|
708
|
+
"""
|
|
709
|
+
fields: dict = {}
|
|
710
|
+
if name:
|
|
711
|
+
fields["name"] = name
|
|
712
|
+
if description:
|
|
713
|
+
fields["description"] = description
|
|
714
|
+
if objective:
|
|
715
|
+
fields["objective"] = objective
|
|
716
|
+
if not fields:
|
|
717
|
+
return "Error: no fields to update. Provide name, description, or objective."
|
|
718
|
+
try:
|
|
719
|
+
result = prod.update_product(product_slug, **fields)
|
|
720
|
+
if result is None:
|
|
721
|
+
return f"Error: product '{product_slug}' not found"
|
|
722
|
+
return f"Updated product '{product_slug}': {', '.join(fields.keys())}"
|
|
723
|
+
except (ValueError, FileNotFoundError) as e:
|
|
724
|
+
return f"Error: {e}"
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
@tool
|
|
728
|
+
async def delete_product_tool(product_slug: str) -> str:
|
|
729
|
+
"""Delete a product and all its issues, versions, and linked projects.
|
|
730
|
+
|
|
731
|
+
Args:
|
|
732
|
+
product_slug: Product slug identifier
|
|
733
|
+
"""
|
|
734
|
+
try:
|
|
735
|
+
summary = prod.delete_product(product_slug)
|
|
736
|
+
return (
|
|
737
|
+
f"Deleted product '{product_slug}'. "
|
|
738
|
+
f"Removed {summary.get('issues', 0)} issues, "
|
|
739
|
+
f"{summary.get('versions', 0)} versions, "
|
|
740
|
+
f"{summary.get('projects', 0)} linked projects."
|
|
741
|
+
)
|
|
742
|
+
except (ValueError, FileNotFoundError) as e:
|
|
743
|
+
return f"Error: {e}"
|
|
744
|
+
|
|
745
|
+
|
|
694
746
|
# ---------------------------------------------------------------------------
|
|
695
747
|
# Export
|
|
696
748
|
# ---------------------------------------------------------------------------
|
|
@@ -716,4 +768,6 @@ PRODUCT_TOOLS = [
|
|
|
716
768
|
delete_sprint_tool,
|
|
717
769
|
sprint_analytics_tool,
|
|
718
770
|
version_management_tool,
|
|
771
|
+
update_product_tool,
|
|
772
|
+
delete_product_tool,
|
|
719
773
|
]
|
|
@@ -7141,6 +7141,18 @@ async def api_update_kr(slug: str, kr_id: str, request: Request) -> dict:
|
|
|
7141
7141
|
return result
|
|
7142
7142
|
|
|
7143
7143
|
|
|
7144
|
+
@router.delete("/api/product/{slug}/kr/{kr_id}")
|
|
7145
|
+
async def api_delete_kr(slug: str, kr_id: str) -> dict:
|
|
7146
|
+
"""Delete a key result from a product."""
|
|
7147
|
+
from onemancompany.core import product as prod
|
|
7148
|
+
|
|
7149
|
+
try:
|
|
7150
|
+
prod.delete_key_result(slug, kr_id)
|
|
7151
|
+
except ValueError as exc:
|
|
7152
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
7153
|
+
return {"ok": True}
|
|
7154
|
+
|
|
7155
|
+
|
|
7144
7156
|
# ── Issues ──────────────────────────────────────────────────────────────────
|
|
7145
7157
|
|
|
7146
7158
|
|
|
@@ -7483,6 +7495,13 @@ async def api_create_sprint(slug: str, request: Request) -> dict:
|
|
|
7483
7495
|
)
|
|
7484
7496
|
except ValueError as exc:
|
|
7485
7497
|
raise HTTPException(status_code=404, detail=str(exc))
|
|
7498
|
+
await event_bus.publish(
|
|
7499
|
+
CompanyEvent(
|
|
7500
|
+
type=EventType.SPRINT_CREATED,
|
|
7501
|
+
payload={"product_slug": slug, "sprint_id": result["id"]},
|
|
7502
|
+
agent=SYSTEM_AGENT,
|
|
7503
|
+
)
|
|
7504
|
+
)
|
|
7486
7505
|
return result
|
|
7487
7506
|
|
|
7488
7507
|
|
|
@@ -7531,6 +7550,13 @@ async def api_close_sprint(slug: str, sprint_id: str) -> dict:
|
|
|
7531
7550
|
result = prod.close_sprint(slug, sprint_id)
|
|
7532
7551
|
except ValueError as exc:
|
|
7533
7552
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
7553
|
+
await event_bus.publish(
|
|
7554
|
+
CompanyEvent(
|
|
7555
|
+
type=EventType.SPRINT_CLOSED,
|
|
7556
|
+
payload={"product_slug": slug, "sprint_id": sprint_id},
|
|
7557
|
+
agent=SYSTEM_AGENT,
|
|
7558
|
+
)
|
|
7559
|
+
)
|
|
7534
7560
|
return result
|
|
7535
7561
|
|
|
7536
7562
|
|
|
@@ -7543,6 +7569,13 @@ async def api_start_sprint(slug: str, sprint_id: str) -> dict:
|
|
|
7543
7569
|
result = prod.start_sprint(slug, sprint_id)
|
|
7544
7570
|
except ValueError as exc:
|
|
7545
7571
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
7572
|
+
await event_bus.publish(
|
|
7573
|
+
CompanyEvent(
|
|
7574
|
+
type=EventType.SPRINT_STARTED,
|
|
7575
|
+
payload={"product_slug": slug, "sprint_id": sprint_id},
|
|
7576
|
+
agent=SYSTEM_AGENT,
|
|
7577
|
+
)
|
|
7578
|
+
)
|
|
7546
7579
|
return result
|
|
7547
7580
|
|
|
7548
7581
|
|
|
@@ -7649,6 +7682,13 @@ async def api_create_review(slug: str, request: Request) -> dict:
|
|
|
7649
7682
|
trigger_ref=trigger_ref,
|
|
7650
7683
|
owner=owner,
|
|
7651
7684
|
)
|
|
7685
|
+
await event_bus.publish(
|
|
7686
|
+
CompanyEvent(
|
|
7687
|
+
type=EventType.REVIEW_CREATED,
|
|
7688
|
+
payload={"product_slug": slug, "review_id": review["id"]},
|
|
7689
|
+
agent=SYSTEM_AGENT,
|
|
7690
|
+
)
|
|
7691
|
+
)
|
|
7652
7692
|
return review
|
|
7653
7693
|
|
|
7654
7694
|
|
|
@@ -7691,9 +7731,17 @@ async def api_complete_review(slug: str, review_id: str) -> dict:
|
|
|
7691
7731
|
from onemancompany.core import product as prod
|
|
7692
7732
|
|
|
7693
7733
|
try:
|
|
7694
|
-
|
|
7734
|
+
result = prod.complete_review(slug, review_id)
|
|
7695
7735
|
except ValueError as exc:
|
|
7696
7736
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
7737
|
+
await event_bus.publish(
|
|
7738
|
+
CompanyEvent(
|
|
7739
|
+
type=EventType.REVIEW_COMPLETED,
|
|
7740
|
+
payload={"product_slug": slug, "review_id": review_id},
|
|
7741
|
+
agent=SYSTEM_AGENT,
|
|
7742
|
+
)
|
|
7743
|
+
)
|
|
7744
|
+
return result
|
|
7697
7745
|
|
|
7698
7746
|
|
|
7699
7747
|
# ---------------------------------------------------------------------------
|
|
@@ -163,6 +163,7 @@ class EventType(str, Enum):
|
|
|
163
163
|
KR_UPDATED = "kr_updated"
|
|
164
164
|
VERSION_RELEASED = "version_released"
|
|
165
165
|
SPRINT_CREATED = "sprint_created"
|
|
166
|
+
SPRINT_STARTED = "sprint_started"
|
|
166
167
|
SPRINT_CLOSED = "sprint_closed"
|
|
167
168
|
REVIEW_CREATED = "review_created"
|
|
168
169
|
REVIEW_COMPLETED = "review_completed"
|
|
@@ -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
|
# ---------------------------------------------------------------------------
|
|
@@ -230,6 +236,24 @@ def update_kr_progress(slug: str, kr_id: str, *, current: float) -> dict:
|
|
|
230
236
|
raise ValueError(f"KR '{kr_id}' not found in product '{slug}'")
|
|
231
237
|
|
|
232
238
|
|
|
239
|
+
def delete_key_result(slug: str, kr_id: str) -> None:
|
|
240
|
+
"""Delete a key result from a product. Raises ValueError if not found."""
|
|
241
|
+
with _get_slug_lock(slug):
|
|
242
|
+
path = _product_yaml_path(slug)
|
|
243
|
+
data = _read_yaml(path)
|
|
244
|
+
if not data:
|
|
245
|
+
raise ValueError(f"Product '{slug}' not found")
|
|
246
|
+
krs = data.get("key_results", [])
|
|
247
|
+
original_len = len(krs)
|
|
248
|
+
data["key_results"] = [kr for kr in krs if kr["id"] != kr_id]
|
|
249
|
+
if len(data["key_results"]) == original_len:
|
|
250
|
+
raise ValueError(f"KR '{kr_id}' not found in product '{slug}'")
|
|
251
|
+
data["updated_at"] = datetime.now().isoformat()
|
|
252
|
+
_write_yaml(path, data)
|
|
253
|
+
mark_dirty(DirtyCategory.PRODUCTS)
|
|
254
|
+
logger.debug("Deleted KR {} from product {}", kr_id, slug)
|
|
255
|
+
|
|
256
|
+
|
|
233
257
|
def update_kr_fields(slug: str, kr_id: str, **fields) -> dict:
|
|
234
258
|
"""Update arbitrary fields on a key result. Returns updated KR dict.
|
|
235
259
|
|
|
@@ -269,8 +293,8 @@ def _append_history(data: dict, field: str, old_value, new_value, changed_by: st
|
|
|
269
293
|
"new_value": str(new_value) if new_value is not None else None,
|
|
270
294
|
"changed_by": changed_by,
|
|
271
295
|
})
|
|
272
|
-
if len(data["history"]) >
|
|
273
|
-
data["history"] = data["history"][-
|
|
296
|
+
if len(data["history"]) > HISTORY_MAX_ENTRIES:
|
|
297
|
+
data["history"] = data["history"][-HISTORY_MAX_ENTRIES:]
|
|
274
298
|
|
|
275
299
|
|
|
276
300
|
def create_issue(
|
|
@@ -355,8 +379,12 @@ def list_issues(
|
|
|
355
379
|
priority: IssuePriority | None = None,
|
|
356
380
|
labels: list[str] | None = None,
|
|
357
381
|
sprint: str | None = None,
|
|
382
|
+
assignee_id: str | None = None,
|
|
358
383
|
) -> list[dict]:
|
|
359
|
-
"""List issues for a product, optionally filtered.
|
|
384
|
+
"""List issues for a product, optionally filtered.
|
|
385
|
+
|
|
386
|
+
assignee_id: filter by assignee. Empty string "" means unassigned.
|
|
387
|
+
"""
|
|
360
388
|
issues_path = _issues_dir(slug)
|
|
361
389
|
if not issues_path.exists():
|
|
362
390
|
return []
|
|
@@ -378,12 +406,24 @@ def list_issues(
|
|
|
378
406
|
continue
|
|
379
407
|
if sprint is not None and data.get("sprint") != sprint:
|
|
380
408
|
continue
|
|
409
|
+
if assignee_id is not None:
|
|
410
|
+
issue_assignee = data.get("assignee_id") or ""
|
|
411
|
+
if assignee_id == "":
|
|
412
|
+
# Filter for unassigned
|
|
413
|
+
if issue_assignee:
|
|
414
|
+
continue
|
|
415
|
+
elif issue_assignee != assignee_id:
|
|
416
|
+
continue
|
|
381
417
|
results.append(data)
|
|
382
418
|
return results
|
|
383
419
|
|
|
384
420
|
|
|
385
|
-
def update_issue(slug: str, issue_id: str, **fields) -> dict:
|
|
386
|
-
"""Update issue fields. Returns updated dict. Raises ValueError if not found.
|
|
421
|
+
def update_issue(slug: str, issue_id: str, *, _skip_transition_check: bool = False, **fields) -> dict:
|
|
422
|
+
"""Update issue fields. Returns updated dict. Raises ValueError if not found.
|
|
423
|
+
|
|
424
|
+
_skip_transition_check: internal flag for system-derived status updates
|
|
425
|
+
that may jump non-adjacent states (e.g. sync_issue_statuses).
|
|
426
|
+
"""
|
|
387
427
|
with _get_slug_lock(slug):
|
|
388
428
|
path = _issues_dir(slug) / f"{issue_id}.yaml"
|
|
389
429
|
data = _read_yaml(path)
|
|
@@ -391,7 +431,7 @@ def update_issue(slug: str, issue_id: str, **fields) -> dict:
|
|
|
391
431
|
raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
|
|
392
432
|
# Validate status transition if status is being changed
|
|
393
433
|
new_status = fields.get("status")
|
|
394
|
-
if new_status is not None:
|
|
434
|
+
if new_status is not None and not _skip_transition_check:
|
|
395
435
|
current_status = data.get("status", IssueStatus.BACKLOG.value)
|
|
396
436
|
if new_status != current_status:
|
|
397
437
|
_validate_status_transition(current_status, new_status)
|
|
@@ -959,11 +999,11 @@ def release_version(
|
|
|
959
999
|
product["current_version"] = new_version
|
|
960
1000
|
_write_yaml(_product_yaml_path(product_slug), product)
|
|
961
1001
|
|
|
962
|
-
# Mark resolved issues as released
|
|
1002
|
+
# Mark resolved issues as released (bypass validation — release is a system operation)
|
|
963
1003
|
for issue_id in resolved_issue_ids:
|
|
964
1004
|
issue = load_issue(product_slug, issue_id)
|
|
965
1005
|
if issue and issue.get("status") != IssueStatus.RELEASED.value:
|
|
966
|
-
update_issue(product_slug, issue_id, status=IssueStatus.RELEASED.value)
|
|
1006
|
+
update_issue(product_slug, issue_id, _skip_transition_check=True, status=IssueStatus.RELEASED.value)
|
|
967
1007
|
|
|
968
1008
|
mark_dirty(DirtyCategory.PRODUCTS)
|
|
969
1009
|
logger.info("[VERSION] Released {} for product '{}'", new_version, product_slug)
|
|
@@ -994,12 +1034,14 @@ def build_product_context(product_slug: str) -> str:
|
|
|
994
1034
|
unit = kr.get("unit", "")
|
|
995
1035
|
suffix = f" {unit}" if unit else ""
|
|
996
1036
|
parts.append(f" - {kr['title']}: {current}/{target}{suffix} ({pct:.0f}%)")
|
|
997
|
-
|
|
1037
|
+
_terminal = {IssueStatus.DONE.value, IssueStatus.RELEASED.value}
|
|
1038
|
+
issues = [i for i in list_issues(product_slug) if i.get("status") not in _terminal]
|
|
998
1039
|
issues.sort(key=lambda i: i.get("priority", "P3"))
|
|
999
1040
|
if issues:
|
|
1000
1041
|
parts.append(f"\nActive Issues ({len(issues)}):")
|
|
1001
1042
|
for issue in issues[:10]:
|
|
1002
|
-
|
|
1043
|
+
status_tag = issue.get("status", "backlog")
|
|
1044
|
+
parts.append(f" - [{issue['priority']}][{status_tag}] {issue['title']} ({issue['id']})")
|
|
1003
1045
|
if len(issues) > 10:
|
|
1004
1046
|
parts.append(f" ... and {len(issues) - 10} more")
|
|
1005
1047
|
parts.append("=== End Product Context ===")
|
|
@@ -1270,7 +1312,7 @@ def sync_issue_statuses(slug: str) -> list[dict]:
|
|
|
1270
1312
|
current = issue.get("status", IssueStatus.BACKLOG.value)
|
|
1271
1313
|
|
|
1272
1314
|
if derived.value != current:
|
|
1273
|
-
update_issue(slug, issue["id"], status=derived.value)
|
|
1315
|
+
update_issue(slug, issue["id"], _skip_transition_check=True, status=derived.value)
|
|
1274
1316
|
changed.append({"issue_id": issue["id"], "old": current, "new": derived.value})
|
|
1275
1317
|
logger.debug("[PRODUCT] Issue {} status derived: {} → {}", issue["id"], current, derived.value)
|
|
1276
1318
|
|
|
@@ -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
|
|
|
@@ -755,6 +762,7 @@ def register_product_triggers() -> "asyncio.Task":
|
|
|
755
762
|
EventType.ISSUE_CLOSED,
|
|
756
763
|
EventType.ISSUE_ASSIGNED,
|
|
757
764
|
EventType.SPRINT_CREATED,
|
|
765
|
+
EventType.SPRINT_STARTED,
|
|
758
766
|
EventType.SPRINT_CLOSED,
|
|
759
767
|
EventType.VERSION_RELEASED,
|
|
760
768
|
EventType.REVIEW_CREATED,
|