@1mancompany/onemancompany 0.7.33 → 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 +32 -8
- package/src/onemancompany/core/product_triggers.py +1 -0
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"
|
|
@@ -236,6 +236,24 @@ def update_kr_progress(slug: str, kr_id: str, *, current: float) -> dict:
|
|
|
236
236
|
raise ValueError(f"KR '{kr_id}' not found in product '{slug}'")
|
|
237
237
|
|
|
238
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
|
+
|
|
239
257
|
def update_kr_fields(slug: str, kr_id: str, **fields) -> dict:
|
|
240
258
|
"""Update arbitrary fields on a key result. Returns updated KR dict.
|
|
241
259
|
|
|
@@ -400,8 +418,12 @@ def list_issues(
|
|
|
400
418
|
return results
|
|
401
419
|
|
|
402
420
|
|
|
403
|
-
def update_issue(slug: str, issue_id: str, **fields) -> dict:
|
|
404
|
-
"""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
|
+
"""
|
|
405
427
|
with _get_slug_lock(slug):
|
|
406
428
|
path = _issues_dir(slug) / f"{issue_id}.yaml"
|
|
407
429
|
data = _read_yaml(path)
|
|
@@ -409,7 +431,7 @@ def update_issue(slug: str, issue_id: str, **fields) -> dict:
|
|
|
409
431
|
raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
|
|
410
432
|
# Validate status transition if status is being changed
|
|
411
433
|
new_status = fields.get("status")
|
|
412
|
-
if new_status is not None:
|
|
434
|
+
if new_status is not None and not _skip_transition_check:
|
|
413
435
|
current_status = data.get("status", IssueStatus.BACKLOG.value)
|
|
414
436
|
if new_status != current_status:
|
|
415
437
|
_validate_status_transition(current_status, new_status)
|
|
@@ -977,11 +999,11 @@ def release_version(
|
|
|
977
999
|
product["current_version"] = new_version
|
|
978
1000
|
_write_yaml(_product_yaml_path(product_slug), product)
|
|
979
1001
|
|
|
980
|
-
# Mark resolved issues as released
|
|
1002
|
+
# Mark resolved issues as released (bypass validation — release is a system operation)
|
|
981
1003
|
for issue_id in resolved_issue_ids:
|
|
982
1004
|
issue = load_issue(product_slug, issue_id)
|
|
983
1005
|
if issue and issue.get("status") != IssueStatus.RELEASED.value:
|
|
984
|
-
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)
|
|
985
1007
|
|
|
986
1008
|
mark_dirty(DirtyCategory.PRODUCTS)
|
|
987
1009
|
logger.info("[VERSION] Released {} for product '{}'", new_version, product_slug)
|
|
@@ -1012,12 +1034,14 @@ def build_product_context(product_slug: str) -> str:
|
|
|
1012
1034
|
unit = kr.get("unit", "")
|
|
1013
1035
|
suffix = f" {unit}" if unit else ""
|
|
1014
1036
|
parts.append(f" - {kr['title']}: {current}/{target}{suffix} ({pct:.0f}%)")
|
|
1015
|
-
|
|
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]
|
|
1016
1039
|
issues.sort(key=lambda i: i.get("priority", "P3"))
|
|
1017
1040
|
if issues:
|
|
1018
1041
|
parts.append(f"\nActive Issues ({len(issues)}):")
|
|
1019
1042
|
for issue in issues[:10]:
|
|
1020
|
-
|
|
1043
|
+
status_tag = issue.get("status", "backlog")
|
|
1044
|
+
parts.append(f" - [{issue['priority']}][{status_tag}] {issue['title']} ({issue['id']})")
|
|
1021
1045
|
if len(issues) > 10:
|
|
1022
1046
|
parts.append(f" ... and {len(issues) - 10} more")
|
|
1023
1047
|
parts.append("=== End Product Context ===")
|
|
@@ -1288,7 +1312,7 @@ def sync_issue_statuses(slug: str) -> list[dict]:
|
|
|
1288
1312
|
current = issue.get("status", IssueStatus.BACKLOG.value)
|
|
1289
1313
|
|
|
1290
1314
|
if derived.value != current:
|
|
1291
|
-
update_issue(slug, issue["id"], status=derived.value)
|
|
1315
|
+
update_issue(slug, issue["id"], _skip_transition_check=True, status=derived.value)
|
|
1292
1316
|
changed.append({"issue_id": issue["id"], "old": current, "new": derived.value})
|
|
1293
1317
|
logger.debug("[PRODUCT] Issue {} status derived: {} → {}", issue["id"], current, derived.value)
|
|
1294
1318
|
|
|
@@ -762,6 +762,7 @@ def register_product_triggers() -> "asyncio.Task":
|
|
|
762
762
|
EventType.ISSUE_CLOSED,
|
|
763
763
|
EventType.ISSUE_ASSIGNED,
|
|
764
764
|
EventType.SPRINT_CREATED,
|
|
765
|
+
EventType.SPRINT_STARTED,
|
|
765
766
|
EventType.SPRINT_CLOSED,
|
|
766
767
|
EventType.VERSION_RELEASED,
|
|
767
768
|
EventType.REVIEW_CREATED,
|