feat: plan mobile work by milestone (#203)
This commit is contained in:
parent
09b2898d1c
commit
2be29b684a
|
|
@ -62,6 +62,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
|
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
|
||||||
.work-filter { min-height: 44px; }
|
.work-filter { min-height: 44px; }
|
||||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||||
|
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
|
||||||
|
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||||
|
|
@ -153,6 +155,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.issue-due-editor input { box-sizing:border-box; width:100%; max-width:100%; }
|
.issue-due-editor input { box-sizing:border-box; width:100%; max-width:100%; }
|
||||||
.issue-due-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
.issue-due-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
.issue-due-editor input, .issue-due-editor button { min-height:44px; }
|
.issue-due-editor input, .issue-due-editor button { min-height:44px; }
|
||||||
|
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||||
|
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||||
|
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
|
||||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||||
|
|
@ -269,6 +274,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
|
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
|
||||||
|
<select class="work-milestone-filter" id="work-milestone-filter">
|
||||||
|
<option value="all">All milestones</option>
|
||||||
|
<option value="unplanned">Unplanned</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-work-list" id="my-work-list"></div>
|
<div class="my-work-list" id="my-work-list"></div>
|
||||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||||
|
|
@ -412,6 +423,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
</div>
|
</div>
|
||||||
<div id="issue-due-status" class="small" aria-live="assertive">Load an issue to schedule it.</div>
|
<div id="issue-due-status" class="small" aria-live="assertive">Load an issue to schedule it.</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset class="issue-milestone-editor" aria-describedby="issue-milestone-status">
|
||||||
|
<legend>Milestone</legend>
|
||||||
|
<label for="issue-milestone" class="small">Plan this issue into a release</label>
|
||||||
|
<select id="issue-milestone" disabled><option value="">No milestone</option></select>
|
||||||
|
<button id="save-issue-milestone" type="button" disabled>Save milestone</button>
|
||||||
|
<div id="issue-milestone-status" class="small" aria-live="assertive">Load an issue to plan it.</div>
|
||||||
|
</fieldset>
|
||||||
<p class="issue-sheet-content" id="issue-sheet-body"></p>
|
<p class="issue-sheet-content" id="issue-sheet-body"></p>
|
||||||
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
|
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
|
||||||
<form class="issue-edit-form" id="issue-edit-form" hidden>
|
<form class="issue-edit-form" id="issue-edit-form" hidden>
|
||||||
|
|
@ -664,10 +682,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
});
|
});
|
||||||
let liveMode = true;
|
let liveMode = true;
|
||||||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||||||
|
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
|
||||||
let selectedWorkFilter = 'all';
|
let selectedWorkFilter = 'all';
|
||||||
|
let selectedWorkMilestone = 'all';
|
||||||
try {
|
try {
|
||||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||||
if (['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
if (['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||||
|
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
|
||||||
|
if (savedMilestone) selectedWorkMilestone = savedMilestone;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Could not restore My Work filter', e);
|
console.warn('Could not restore My Work filter', e);
|
||||||
}
|
}
|
||||||
|
|
@ -927,6 +949,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
const workSession = createWorkSession({
|
const workSession = createWorkSession({
|
||||||
getItems: () => lastMyWork,
|
getItems: () => lastMyWork,
|
||||||
getFilter: () => selectedWorkFilter,
|
getFilter: () => selectedWorkFilter,
|
||||||
|
getMilestone: () => selectedWorkMilestone,
|
||||||
onOpen: openWorkSessionItem,
|
onOpen: openWorkSessionItem,
|
||||||
onProgress: state => {
|
onProgress: state => {
|
||||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
|
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
|
||||||
|
|
@ -1020,6 +1043,20 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
const element = qs('[data-work-count="' + filter + '"]');
|
const element = qs('[data-work-count="' + filter + '"]');
|
||||||
if (element) element.textContent = count;
|
if (element) element.textContent = count;
|
||||||
});
|
});
|
||||||
|
const milestoneSelect = qs('#work-milestone-filter');
|
||||||
|
const lanes = milestoneLanes(lastMyWork);
|
||||||
|
milestoneSelect.innerHTML = '<option value="all">All milestones</option>' +
|
||||||
|
'<option value="unplanned">Unplanned</option>' + lanes.map(lane =>
|
||||||
|
'<option value="' + Number(lane.id) + '">' + escapeHtml(lane.title) + '</option>'
|
||||||
|
).join('');
|
||||||
|
milestoneSelect.value = selectedWorkMilestone;
|
||||||
|
if (milestoneSelect.value !== selectedWorkMilestone) {
|
||||||
|
const retained = document.createElement('option');
|
||||||
|
retained.value = selectedWorkMilestone;
|
||||||
|
retained.textContent = 'Selected milestone';
|
||||||
|
milestoneSelect.append(retained);
|
||||||
|
milestoneSelect.value = selectedWorkMilestone;
|
||||||
|
}
|
||||||
qs('#my-work').removeAttribute('data-stale');
|
qs('#my-work').removeAttribute('data-stale');
|
||||||
qs('#my-work-status').textContent = lastMyWork.length ?
|
qs('#my-work-status').textContent = lastMyWork.length ?
|
||||||
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
|
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
|
||||||
|
|
@ -1052,7 +1089,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMyWork() {
|
function renderMyWork() {
|
||||||
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
|
const visible = filterMyWork(lastMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||||||
|
|
@ -1060,6 +1097,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
|
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
|
||||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||||
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
|
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
|
||||||
|
(item.milestone?.title ? ' <span class="pill milestone-badge">' + escapeHtml(item.milestone.title) + '</span>' : '') +
|
||||||
(item.due_label ? ' <span class="pill due-badge">' + escapeHtml(item.due_label) + '</span>' : '') +
|
(item.due_label ? ' <span class="pill due-badge">' + escapeHtml(item.due_label) + '</span>' : '') +
|
||||||
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
|
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
|
||||||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||||||
|
|
@ -1197,6 +1235,30 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.map(input => Number(input.value)).filter(Number.isInteger);
|
.map(input => Number(input.value)).filter(Number.isInteger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadIssueMilestoneEditor(item, confirmedMilestone) {
|
||||||
|
const select = qs('#issue-milestone');
|
||||||
|
const status = qs('#issue-milestone-status');
|
||||||
|
select.innerHTML = '<option value="">No milestone</option>';
|
||||||
|
select.disabled = true;
|
||||||
|
qs('#save-issue-milestone').disabled = true;
|
||||||
|
status.textContent = 'Loading milestones…';
|
||||||
|
try {
|
||||||
|
const milestones = await issueController.loadMilestones(item);
|
||||||
|
if (selectedIssue !== item) return;
|
||||||
|
select.innerHTML += milestones.map(milestone =>
|
||||||
|
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
|
||||||
|
).join('');
|
||||||
|
const draft = issueController.loadMilestoneDraft(item);
|
||||||
|
select.value = String(draft ?? confirmedMilestone?.id ?? '');
|
||||||
|
select.disabled = false;
|
||||||
|
qs('#save-issue-milestone').disabled = false;
|
||||||
|
status.textContent = confirmedMilestone ?
|
||||||
|
'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.';
|
||||||
|
} catch (_error) {
|
||||||
|
status.textContent = 'Milestones could not be loaded. Retry by reopening this issue.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openIssueSheet(item, trigger) {
|
async function openIssueSheet(item, trigger) {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
selectedIssue = item;
|
selectedIssue = item;
|
||||||
|
|
@ -1220,6 +1282,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#save-issue-due-date').disabled = true;
|
qs('#save-issue-due-date').disabled = true;
|
||||||
qs('#clear-issue-due-date').disabled = true;
|
qs('#clear-issue-due-date').disabled = true;
|
||||||
qs('#issue-due-status').textContent = 'Loading due date…';
|
qs('#issue-due-status').textContent = 'Loading due date…';
|
||||||
|
qs('#issue-milestone').innerHTML = '<option value="">No milestone</option>';
|
||||||
|
qs('#issue-milestone').disabled = true;
|
||||||
|
qs('#save-issue-milestone').disabled = true;
|
||||||
|
qs('#issue-milestone-status').textContent = 'Loading milestones…';
|
||||||
qs('#retry-issue-load').hidden = true;
|
qs('#retry-issue-load').hidden = true;
|
||||||
qs('#open-issue-gitea').href = item.url || '#';
|
qs('#open-issue-gitea').href = item.url || '#';
|
||||||
qs('#send-issue-comment').disabled = false;
|
qs('#send-issue-comment').disabled = false;
|
||||||
|
|
@ -1238,6 +1304,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
'<span class="pill">' + escapeHtml(label) + '</span>'
|
'<span class="pill">' + escapeHtml(label) + '</span>'
|
||||||
).join(' ');
|
).join(' ');
|
||||||
loadIssueLabelEditor(item, detail.labels || []);
|
loadIssueLabelEditor(item, detail.labels || []);
|
||||||
|
loadIssueMilestoneEditor(item, detail.milestone);
|
||||||
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
|
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
|
||||||
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
|
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
|
||||||
qs('#issue-comments').innerHTML = (detail.comments || []).length ?
|
qs('#issue-comments').innerHTML = (detail.comments || []).length ?
|
||||||
|
|
@ -2211,6 +2278,33 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
saveSelectedIssueDueDate(value + 'T23:59:59Z');
|
saveSelectedIssueDueDate(value + 'T23:59:59Z');
|
||||||
});
|
});
|
||||||
qs('#clear-issue-due-date').addEventListener('click', () => saveSelectedIssueDueDate(null));
|
qs('#clear-issue-due-date').addEventListener('click', () => saveSelectedIssueDueDate(null));
|
||||||
|
qs('#save-issue-milestone').addEventListener('click', async () => {
|
||||||
|
if (!selectedIssue || !lastContextSnapshot) return;
|
||||||
|
const editing = selectedIssue;
|
||||||
|
const button = qs('#save-issue-milestone');
|
||||||
|
const raw = qs('#issue-milestone').value;
|
||||||
|
const milestoneId = raw ? Number(raw) : null;
|
||||||
|
button.disabled = true;
|
||||||
|
qs('#issue-milestone').disabled = true;
|
||||||
|
qs('#issue-milestone-status').textContent = milestoneId ? 'Saving milestone…' : 'Clearing milestone…';
|
||||||
|
try {
|
||||||
|
const confirmed = await issueController.updateMilestone(editing, milestoneId);
|
||||||
|
lastContextSnapshot = buildMyWork.replaceIssueMilestone(
|
||||||
|
lastContextSnapshot, editing.repository, editing.number, confirmed.milestone
|
||||||
|
);
|
||||||
|
selectedIssue = { ...editing, milestone: confirmed.milestone };
|
||||||
|
selectedIssueDetail = { ...selectedIssueDetail, milestone: confirmed.milestone };
|
||||||
|
paintMyWork(lastContextSnapshot);
|
||||||
|
qs('#issue-milestone-status').textContent = confirmed.milestone ?
|
||||||
|
'Planned for ' + confirmed.milestone.title + '. Release lane updated.' : 'Milestone cleared.';
|
||||||
|
} catch (error) {
|
||||||
|
qs('#issue-milestone-status').textContent = error.message + ' Your selection is safe; retry.';
|
||||||
|
qs('#issue-milestone').focus();
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
qs('#issue-milestone').disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
qs('#send-issue-comment').addEventListener('click', async () => {
|
qs('#send-issue-comment').addEventListener('click', async () => {
|
||||||
if (!selectedIssue) return;
|
if (!selectedIssue) return;
|
||||||
const body = qs('#issue-comment').value.trim();
|
const body = qs('#issue-comment').value.trim();
|
||||||
|
|
@ -2552,6 +2646,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
updateWorkPaginationControls();
|
updateWorkPaginationControls();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
qs('#work-milestone-filter').addEventListener('change', event => {
|
||||||
|
selectedWorkMilestone = event.target.value;
|
||||||
|
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
|
||||||
|
catch (e) { console.warn('Could not persist My Work milestone lane', e); }
|
||||||
|
renderMyWork();
|
||||||
|
if (workSession.active()) workSession.reconcile();
|
||||||
|
});
|
||||||
contextPoller.start();
|
contextPoller.start();
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
contextPoller.setVisible(!document.hidden);
|
contextPoller.setVisible(!document.hidden);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
||||||
let labelRequest = null;
|
let labelRequest = null;
|
||||||
let editRequest = null;
|
let editRequest = null;
|
||||||
let dueDateRequest = null;
|
let dueDateRequest = null;
|
||||||
|
let milestoneRequest = null;
|
||||||
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||||
'/issues/' + encodeURIComponent(item.number);
|
'/issues/' + encodeURIComponent(item.number);
|
||||||
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
|
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
|
||||||
|
|
@ -12,6 +13,7 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
||||||
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
|
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
|
||||||
const editDraftKey = item => 'stackchain.issue-content.v1:' + item.repository + '#' + item.number;
|
const editDraftKey = item => 'stackchain.issue-content.v1:' + item.repository + '#' + item.number;
|
||||||
const dueDateDraftKey = item => 'stackchain.issue-due-date.v1:' + item.repository + '#' + item.number;
|
const dueDateDraftKey = item => 'stackchain.issue-due-date.v1:' + item.repository + '#' + item.number;
|
||||||
|
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
load(item) {
|
load(item) {
|
||||||
|
|
@ -24,6 +26,12 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
loadMilestones(item) {
|
||||||
|
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||||
|
return fetchJson('api/v1/repos/' + repository + '/milestones', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
},
|
||||||
loadDraft(item) {
|
loadDraft(item) {
|
||||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||||
catch (_error) { return ''; }
|
catch (_error) { return ''; }
|
||||||
|
|
@ -93,6 +101,33 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
||||||
}).finally(() => { dueDateRequest = null; });
|
}).finally(() => { dueDateRequest = null; });
|
||||||
return dueDateRequest;
|
return dueDateRequest;
|
||||||
},
|
},
|
||||||
|
loadMilestoneDraft(item) {
|
||||||
|
try {
|
||||||
|
const raw = storage?.getItem(milestoneDraftKey(item));
|
||||||
|
if (raw === null || raw === undefined) return null;
|
||||||
|
const value = JSON.parse(raw);
|
||||||
|
return value === null || Number.isInteger(value) ? value : null;
|
||||||
|
} catch (_error) { return null; }
|
||||||
|
},
|
||||||
|
updateMilestone(item, milestoneId) {
|
||||||
|
if (milestoneRequest) return milestoneRequest;
|
||||||
|
try { storage?.setItem(milestoneDraftKey(item), JSON.stringify(milestoneId)); }
|
||||||
|
catch (_error) { /* The select remains the fallback. */ }
|
||||||
|
milestoneRequest = fetchJson(issuePath(item) + '/milestone', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ milestone_id: milestoneId }),
|
||||||
|
}).then(result => {
|
||||||
|
if (result?.number !== item.number ||
|
||||||
|
(milestoneId === null ? result?.milestone !== null : result?.milestone?.id !== milestoneId)) {
|
||||||
|
throw new Error('Issue milestone was not confirmed.');
|
||||||
|
}
|
||||||
|
try { storage?.removeItem(milestoneDraftKey(item)); }
|
||||||
|
catch (_error) { /* Confirmed upstream milestone is authoritative. */ }
|
||||||
|
return result;
|
||||||
|
}).finally(() => { milestoneRequest = null; });
|
||||||
|
return milestoneRequest;
|
||||||
|
},
|
||||||
loadLabelDraft(item) {
|
loadLabelDraft(item) {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
|
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
|
||||||
|
|
|
||||||
|
|
@ -353,11 +353,29 @@ function createNotificationReplier({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterMyWork(items, selectedFilter) {
|
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||||
if (selectedFilter === 'all') return items;
|
let filtered = items;
|
||||||
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
|
if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||||
if (selectedFilter === 'update') return items.filter((item) => item.has_update);
|
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||||
return items.filter((item) => item.kind === selectedFilter);
|
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||||
|
if (selectedMilestone === 'all') return filtered;
|
||||||
|
if (selectedMilestone === 'unplanned') {
|
||||||
|
return filtered.filter(item => item.kind === 'issue' && !item.milestone);
|
||||||
|
}
|
||||||
|
return filtered.filter(item =>
|
||||||
|
item.kind === 'issue' && String(item.milestone?.id || '') === String(selectedMilestone)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function milestoneLanes(items) {
|
||||||
|
const lanes = new Map();
|
||||||
|
(items || []).forEach(item => {
|
||||||
|
if (item?.kind === 'issue' && Number.isInteger(item.milestone?.id) &&
|
||||||
|
typeof item.milestone?.title === 'string') {
|
||||||
|
lanes.set(item.milestone.id, { id: item.milestone.id, title: item.milestone.title });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(lanes.values()).sort((left, right) => left.title.localeCompare(right.title));
|
||||||
}
|
}
|
||||||
|
|
||||||
function workIdentity(item) {
|
function workIdentity(item) {
|
||||||
|
|
@ -369,12 +387,12 @@ function workIdentity(item) {
|
||||||
return [kind, repository, number, notification].join(':');
|
return [kind, repository, number, notification].join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWorkSession({ getItems, getFilter, onOpen, onProgress, onFinish }) {
|
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) {
|
||||||
let currentIdentity = '';
|
let currentIdentity = '';
|
||||||
let currentIndex = -1;
|
let currentIndex = -1;
|
||||||
let running = false;
|
let running = false;
|
||||||
|
|
||||||
const queue = () => filterMyWork(getItems() || [], getFilter());
|
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||||
const report = (items, index) => onProgress({
|
const report = (items, index) => onProgress({
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
total: items.length,
|
total: items.length,
|
||||||
|
|
@ -465,6 +483,15 @@ function replaceIssueDueDate(data, repository, number, dueDate) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceIssueMilestone(data, repository, number, milestone) {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
issues: (data.issues || []).map(item =>
|
||||||
|
item.repository === repository && item.number === number ? { ...item, milestone } : item
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function removeIssue(data, repository, number) {
|
function removeIssue(data, repository, number) {
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -496,11 +523,13 @@ function countMyWork(items) {
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
buildMyWork.filterMyWork = filterMyWork;
|
buildMyWork.filterMyWork = filterMyWork;
|
||||||
|
buildMyWork.milestoneLanes = milestoneLanes;
|
||||||
buildMyWork.createWorkSession = createWorkSession;
|
buildMyWork.createWorkSession = createWorkSession;
|
||||||
buildMyWork.workIdentity = workIdentity;
|
buildMyWork.workIdentity = workIdentity;
|
||||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||||
buildMyWork.replaceIssueContent = replaceIssueContent;
|
buildMyWork.replaceIssueContent = replaceIssueContent;
|
||||||
buildMyWork.replaceIssueDueDate = replaceIssueDueDate;
|
buildMyWork.replaceIssueDueDate = replaceIssueDueDate;
|
||||||
|
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
|
||||||
buildMyWork.removeIssue = removeIssue;
|
buildMyWork.removeIssue = removeIssue;
|
||||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||||
buildMyWork.countMyWork = countMyWork;
|
buildMyWork.countMyWork = countMyWork;
|
||||||
|
|
|
||||||
|
|
@ -657,6 +657,26 @@ async def repo_labels(repository: str) -> list[dict]:
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def repo_milestones(repository: str) -> list[dict]:
|
||||||
|
response = await _get_client().get(
|
||||||
|
f"/api/v1/repos/{repository}/milestones",
|
||||||
|
headers=_auth(),
|
||||||
|
params={"state": "open", "limit": 50, "page": 1},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise ValueError("Gitea milestones response was not a list")
|
||||||
|
return [
|
||||||
|
{"id": item["id"], "title": item["title"]}
|
||||||
|
for item in payload
|
||||||
|
if isinstance(item, dict)
|
||||||
|
and isinstance(item.get("id"), int)
|
||||||
|
and isinstance(item.get("title"), str)
|
||||||
|
and item.get("state") == "open"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def create_issue(
|
async def create_issue(
|
||||||
repository: str,
|
repository: str,
|
||||||
title: str,
|
title: str,
|
||||||
|
|
@ -871,6 +891,53 @@ async def update_assigned_issue_due_date(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_assigned_issue_milestone(
|
||||||
|
repository: str, number: int, milestone_id: int | None
|
||||||
|
) -> dict:
|
||||||
|
path = f"repos/{repository}/issues/{number}"
|
||||||
|
login, issue = await _current_login_and_target(path)
|
||||||
|
if (
|
||||||
|
issue.get("state") != "open"
|
||||||
|
or isinstance(issue.get("pull_request"), dict)
|
||||||
|
or not _login_in_users(login, issue.get("assignees"))
|
||||||
|
):
|
||||||
|
raise IssueNotAvailableError("assigned issue not found")
|
||||||
|
|
||||||
|
selected = None
|
||||||
|
if milestone_id is not None:
|
||||||
|
selected = next(
|
||||||
|
(item for item in await repo_milestones(repository) if item["id"] == milestone_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if selected is None:
|
||||||
|
raise ValueError("Unknown open repository milestone")
|
||||||
|
response = await _get_client().patch(
|
||||||
|
f"/api/v1/{path}", headers=_auth(), json={"milestone": milestone_id or 0}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
confirmed = response.json()
|
||||||
|
milestone_value = confirmed.get("milestone") if isinstance(confirmed, dict) else None
|
||||||
|
normalized = (
|
||||||
|
{"id": milestone_value["id"], "title": milestone_value["title"]}
|
||||||
|
if isinstance(milestone_value, dict)
|
||||||
|
and isinstance(milestone_value.get("id"), int)
|
||||||
|
and isinstance(milestone_value.get("title"), str)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not isinstance(confirmed, dict)
|
||||||
|
or confirmed.get("number") != number
|
||||||
|
or normalized != selected
|
||||||
|
):
|
||||||
|
raise ValueError("Gitea did not confirm the issue milestone update")
|
||||||
|
return {
|
||||||
|
"repository": repository,
|
||||||
|
"number": number,
|
||||||
|
"state": confirmed.get("state", "open"),
|
||||||
|
"milestone": normalized,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def issue_detail(repository: str, number: int) -> dict:
|
async def issue_detail(repository: str, number: int) -> dict:
|
||||||
base = f"repos/{repository}/issues/{number}"
|
base = f"repos/{repository}/issues/{number}"
|
||||||
issue, comments = await asyncio.gather(
|
issue, comments = await asyncio.gather(
|
||||||
|
|
@ -896,6 +963,13 @@ async def issue_detail(repository: str, number: int) -> dict:
|
||||||
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
|
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
|
||||||
"updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "",
|
"updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "",
|
||||||
"due_date": issue.get("due_date") if isinstance(issue.get("due_date"), str) else None,
|
"due_date": issue.get("due_date") if isinstance(issue.get("due_date"), str) else None,
|
||||||
|
"milestone": (
|
||||||
|
{"id": issue["milestone"]["id"], "title": issue["milestone"]["title"]}
|
||||||
|
if isinstance(issue.get("milestone"), dict)
|
||||||
|
and isinstance(issue["milestone"].get("id"), int)
|
||||||
|
and isinstance(issue["milestone"].get("title"), str)
|
||||||
|
else None
|
||||||
|
),
|
||||||
"url": _safe_web_url(issue.get("html_url")),
|
"url": _safe_web_url(issue.get("html_url")),
|
||||||
"labels": [
|
"labels": [
|
||||||
label["name"] for label in labels
|
label["name"] for label in labels
|
||||||
|
|
|
||||||
78
src/main.py
78
src/main.py
|
|
@ -26,7 +26,7 @@ from src.gitea_proxy import (
|
||||||
repos,
|
repos,
|
||||||
)
|
)
|
||||||
from src.idempotency import IdempotencyLedger
|
from src.idempotency import IdempotencyLedger
|
||||||
from src.models import Issue, PullRequest, Repo, User
|
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||||
from src.suggestion_engine import compute
|
from src.suggestion_engine import compute
|
||||||
from src.views import router as frontend_router
|
from src.views import router as frontend_router
|
||||||
|
|
||||||
|
|
@ -195,6 +195,10 @@ class IssueDueDateUpdate(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IssueMilestoneUpdate(BaseModel):
|
||||||
|
milestone_id: PositiveInt | None = None
|
||||||
|
|
||||||
|
|
||||||
class PullReviewComment(BaseModel):
|
class PullReviewComment(BaseModel):
|
||||||
path: str = Field(min_length=1, max_length=1_000)
|
path: str = Field(min_length=1, max_length=1_000)
|
||||||
body: str = Field(min_length=1, max_length=10_000)
|
body: str = Field(min_length=1, max_length=10_000)
|
||||||
|
|
@ -318,6 +322,9 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||||
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
|
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
|
||||||
updated_at=i.get("updated_at") or "",
|
updated_at=i.get("updated_at") or "",
|
||||||
due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None,
|
due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None,
|
||||||
|
milestone=Milestone(**i["milestone"]) if isinstance(i.get("milestone"), dict)
|
||||||
|
and isinstance(i["milestone"].get("id"), int)
|
||||||
|
and isinstance(i["milestone"].get("title"), str) else None,
|
||||||
url=i["html_url"],
|
url=i["html_url"],
|
||||||
)
|
)
|
||||||
for i in (issues_data or [])[:50]
|
for i in (issues_data or [])[:50]
|
||||||
|
|
@ -358,6 +365,10 @@ def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
|
||||||
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
|
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
|
||||||
updated_at=item.get("updated_at") or "",
|
updated_at=item.get("updated_at") or "",
|
||||||
due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None,
|
due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None,
|
||||||
|
milestone=Milestone(**item["milestone"])
|
||||||
|
if isinstance(item.get("milestone"), dict)
|
||||||
|
and isinstance(item["milestone"].get("id"), int)
|
||||||
|
and isinstance(item["milestone"].get("title"), str) else None,
|
||||||
url=item["html_url"],
|
url=item["html_url"],
|
||||||
).model_dump()
|
).model_dump()
|
||||||
for item in items
|
for item in items
|
||||||
|
|
@ -397,7 +408,7 @@ async def prevent_live_api_caching(request, call_next):
|
||||||
and request.url.path.endswith("/issues")
|
and request.url.path.endswith("/issues")
|
||||||
) or (
|
) or (
|
||||||
request.url.path.startswith("/api/v1/repos/")
|
request.url.path.startswith("/api/v1/repos/")
|
||||||
and request.url.path.endswith("/labels")
|
and request.url.path.endswith(("/labels", "/milestones"))
|
||||||
):
|
):
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
return response
|
return response
|
||||||
|
|
@ -1229,6 +1240,69 @@ async def update_assigned_issue_due_date(
|
||||||
return JSONResponse(result)
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/milestone")
|
||||||
|
async def update_assigned_issue_milestone(
|
||||||
|
update: IssueMilestoneUpdate,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
number: int = PathParam(gt=0),
|
||||||
|
):
|
||||||
|
repository = f"{owner}/{repo}"
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
gitea_proxy.update_assigned_issue_milestone(
|
||||||
|
repository, number, update.milestone_id
|
||||||
|
),
|
||||||
|
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except gitea_proxy.IssueNotAvailableError:
|
||||||
|
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||||
|
except ValueError as exc:
|
||||||
|
if str(exc) == "Unknown open repository milestone":
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "The milestone could not be updated. Your selection is safe; please retry."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "The milestone could not be updated. Your selection is safe; please retry."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/repos/{owner}/{repo}/milestones")
|
||||||
|
async def repository_milestones(owner: str, repo: str):
|
||||||
|
repository = f"{owner}/{repo}"
|
||||||
|
|
||||||
|
async def load_milestones():
|
||||||
|
available = await gitea_proxy.repos()
|
||||||
|
accessible = {
|
||||||
|
item.get("full_name")
|
||||||
|
for item in (available if isinstance(available, list) else [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
|
if repository not in accessible:
|
||||||
|
raise HTTPException(status_code=404, detail="Repository not found")
|
||||||
|
return await gitea_proxy.repo_milestones(repository)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
load_milestones(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Milestones could not be loaded. Please retry."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/repos/{owner}/{repo}/labels")
|
@app.get("/api/v1/repos/{owner}/{repo}/labels")
|
||||||
async def repository_labels(owner: str, repo: str):
|
async def repository_labels(owner: str, repo: str):
|
||||||
repository = f"{owner}/{repo}"
|
repository = f"{owner}/{repo}"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ class Repo(BaseModel):
|
||||||
updated_at: str = ""
|
updated_at: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class Milestone(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
|
||||||
|
|
||||||
class Issue(BaseModel):
|
class Issue(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
number: int
|
number: int
|
||||||
|
|
@ -27,6 +32,7 @@ class Issue(BaseModel):
|
||||||
repository: str = ""
|
repository: str = ""
|
||||||
updated_at: str = ""
|
updated_at: str = ""
|
||||||
due_date: str | None = None
|
due_date: str | None = None
|
||||||
|
milestone: Milestone | None = None
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,91 @@ from src import gitea_proxy, main
|
||||||
from src.idempotency import IdempotencyLedger
|
from src.idempotency import IdempotencyLedger
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_gitea_milestone_update_revalidates_assignment_and_open_repository_milestone():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
requests.append(request)
|
||||||
|
if request.url.path == "/api/v1/user":
|
||||||
|
return httpx.Response(200, json={"login": "timmy"})
|
||||||
|
if request.method == "GET" and request.url.path.endswith("/issues/17"):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"number": 17, "state": "open", "pull_request": None,
|
||||||
|
"assignees": [{"login": "timmy"}],
|
||||||
|
})
|
||||||
|
if request.method == "GET" and request.url.path.endswith("/milestones"):
|
||||||
|
return httpx.Response(200, json=[
|
||||||
|
{"id": 9, "title": "August RC", "state": "open"},
|
||||||
|
{"id": 10, "title": "Old release", "state": "closed"},
|
||||||
|
{"id": "bad", "title": "Invalid", "state": "open"},
|
||||||
|
])
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"number": 17, "state": "open",
|
||||||
|
"milestone": {"id": 9, "title": "August RC"},
|
||||||
|
})
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
options = await gitea_proxy.repo_milestones("stackchain/api")
|
||||||
|
result = await gitea_proxy.update_assigned_issue_milestone(
|
||||||
|
"stackchain/api", 17, 9
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert options == [{"id": 9, "title": "August RC"}]
|
||||||
|
patch_request = next(request for request in requests if request.method == "PATCH")
|
||||||
|
assert patch_request.url.path == "/api/v1/repos/stackchain/api/issues/17"
|
||||||
|
assert json.loads(patch_request.content) == {"milestone": 9}
|
||||||
|
assert result == {
|
||||||
|
"repository": "stackchain/api", "number": 17, "state": "open",
|
||||||
|
"milestone": {"id": 9, "title": "August RC"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def available_repos():
|
||||||
|
return [{"full_name": "stackchain/api"}]
|
||||||
|
|
||||||
|
async def milestones(repository):
|
||||||
|
calls.append(("list", repository))
|
||||||
|
return [{"id": 9, "title": "August RC"}]
|
||||||
|
|
||||||
|
async def update(repository, number, milestone_id):
|
||||||
|
calls.append(("update", repository, number, milestone_id))
|
||||||
|
return {
|
||||||
|
"repository": repository, "number": number, "state": "open",
|
||||||
|
"milestone": {"id": milestone_id, "title": "August RC"} if milestone_id else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones, raising=False)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue_milestone", update, raising=False)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
listed = await client.get("/api/v1/repos/stackchain/api/milestones")
|
||||||
|
updated = await client.patch(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/17/milestone", json={"milestone_id": 9}
|
||||||
|
)
|
||||||
|
missing = await client.get("/api/v1/repos/other/private/milestones")
|
||||||
|
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert listed.headers["cache-control"] == "no-store"
|
||||||
|
assert listed.json() == [{"id": 9, "title": "August RC"}]
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.headers["cache-control"] == "no-store"
|
||||||
|
assert updated.json()["milestone"] == {"id": 9, "title": "August RC"}
|
||||||
|
assert missing.status_code == 404
|
||||||
|
assert calls == [
|
||||||
|
("list", "stackchain/api"),
|
||||||
|
("update", "stackchain/api", 17, 9),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("due_date", "expected_payload", "confirmed_due_date"),
|
("due_date", "expected_payload", "confirmed_due_date"),
|
||||||
|
|
@ -1034,6 +1119,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||||
"state": "open",
|
"state": "open",
|
||||||
"body": "Full issue context",
|
"body": "Full issue context",
|
||||||
"updated_at": "2026-08-07T09:59:00Z",
|
"updated_at": "2026-08-07T09:59:00Z",
|
||||||
|
"milestone": {"id": 9, "title": "August RC", "description": "not exposed"},
|
||||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||||
"labels": [{"name": "P1"}],
|
"labels": [{"name": "P1"}],
|
||||||
"assignees": [{"login": "timmy"}],
|
"assignees": [{"login": "timmy"}],
|
||||||
|
|
@ -1058,6 +1144,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||||
"body": "Full issue context",
|
"body": "Full issue context",
|
||||||
"updated_at": "2026-08-07T09:59:00Z",
|
"updated_at": "2026-08-07T09:59:00Z",
|
||||||
"due_date": None,
|
"due_date": None,
|
||||||
|
"milestone": {"id": 9, "title": "August RC"},
|
||||||
"url": "https://forge.example/stackchain/api/issues/7",
|
"url": "https://forge.example/stackchain/api/issues/7",
|
||||||
"labels": ["P1"],
|
"labels": ["P1"],
|
||||||
"assignees": ["timmy"],
|
"assignees": ["timmy"],
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,20 @@ async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_b
|
||||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
|
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="work-milestone-filter"' in html
|
||||||
|
assert '<option value="unplanned">Unplanned</option>' in html
|
||||||
|
assert 'id="issue-milestone"' in html
|
||||||
|
assert 'id="save-issue-milestone"' in html
|
||||||
|
assert 'class="pill milestone-badge"' in html
|
||||||
|
assert '.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px;' in html
|
||||||
|
assert 'getMilestone: () => selectedWorkMilestone' in html
|
||||||
|
assert 'buildMyWork.replaceIssueMilestone(' in html
|
||||||
|
|
||||||
|
|
||||||
def test_my_work_reviews_filter_and_summary_are_actionable():
|
def test_my_work_reviews_filter_and_summary_are_actionable():
|
||||||
items = [
|
items = [
|
||||||
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
|
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
|
||||||
|
|
@ -345,6 +359,88 @@ process.stdout.write(JSON.stringify({{
|
||||||
assert output["summary"] == "1 review · 2 assigned"
|
assert output["summary"] == "1 review · 2 assigned"
|
||||||
|
|
||||||
|
|
||||||
|
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
|
||||||
|
payload = {
|
||||||
|
"issues": [
|
||||||
|
{"number": 1, "repository": "stackchain/api", "title": "RC issue",
|
||||||
|
"milestone": {"id": 9, "title": "August RC"}},
|
||||||
|
{"number": 2, "repository": "stackchain/api", "title": "Unplanned",
|
||||||
|
"milestone": None},
|
||||||
|
],
|
||||||
|
"pull_requests": [
|
||||||
|
{"number": 3, "repository": "stackchain/api", "title": "PR"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
script = f"""
|
||||||
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
const original = {json.dumps(payload)};
|
||||||
|
const queue = buildMyWork(original);
|
||||||
|
const updated = buildMyWork.replaceIssueMilestone(
|
||||||
|
original, 'stackchain/api', 2, {{id:9,title:'August RC'}}
|
||||||
|
);
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
rc: buildMyWork.filterMyWork(queue, 'issue', '9').map(item => item.title),
|
||||||
|
unplanned: buildMyWork.filterMyWork(queue, 'all', 'unplanned').map(item => item.title),
|
||||||
|
options: buildMyWork.milestoneLanes(queue),
|
||||||
|
updated: updated.issues[1].milestone,
|
||||||
|
original: original.issues[1].milestone,
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"rc": ["RC issue"],
|
||||||
|
"unplanned": ["Unplanned"],
|
||||||
|
"options": [{"id": 9, "title": "August RC"}],
|
||||||
|
"updated": {"id": 9, "title": "August RC"},
|
||||||
|
"original": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_milestone_editor_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
||||||
|
script = f"""
|
||||||
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{
|
||||||
|
getItem:key => values.has(key) ? values.get(key) : null,
|
||||||
|
setItem:(key,value) => values.set(key,value),
|
||||||
|
removeItem:key => values.delete(key),
|
||||||
|
}};
|
||||||
|
let calls = [];
|
||||||
|
let finish;
|
||||||
|
const controller = createIssueSheet({{
|
||||||
|
storage,
|
||||||
|
fetchJson:(url, options) => {{
|
||||||
|
calls.push({{url, options}});
|
||||||
|
return new Promise(resolve => {{ finish = resolve; }});
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
const item = {{repository:'stackchain/api', number:17}};
|
||||||
|
const first = controller.updateMilestone(item, 9);
|
||||||
|
const duplicate = controller.updateMilestone(item, 9);
|
||||||
|
const during = controller.loadMilestoneDraft(item);
|
||||||
|
finish({{repository:'stackchain/api',number:17,state:'open',milestone:{{id:9,title:'August RC'}}}});
|
||||||
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||||
|
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
|
||||||
|
same:first === duplicate, during, after:controller.loadMilestoneDraft(item), results
|
||||||
|
}})));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
output = json.loads(result.stdout)
|
||||||
|
assert output["calls"] == [{
|
||||||
|
"url": "api/v1/repos/stackchain/api/issues/17/milestone",
|
||||||
|
"method": "PATCH", "body": {"milestone_id": 9},
|
||||||
|
}]
|
||||||
|
assert output["same"] is True
|
||||||
|
assert output["during"] == 9
|
||||||
|
assert output["after"] is None
|
||||||
|
assert output["results"][0]["milestone"] == {"id": 9, "title": "August RC"}
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_work_session_follows_filter_and_reconciles_by_identity():
|
def test_mobile_work_session_follows_filter_and_reconciles_by_identity():
|
||||||
items = [
|
items = [
|
||||||
{"kind": "issue", "repository": "stackchain/api", "number": 1, "title": "First"},
|
{"kind": "issue", "repository": "stackchain/api", "number": 1, "title": "First"},
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
|
||||||
"repository": {"full_name": "stackchain/api"},
|
"repository": {"full_name": "stackchain/api"},
|
||||||
"updated_at": "2026-08-07T10:00:00Z",
|
"updated_at": "2026-08-07T10:00:00Z",
|
||||||
"due_date": "2026-08-09T23:59:59Z",
|
"due_date": "2026-08-09T23:59:59Z",
|
||||||
|
"milestone": {"id": 9, "title": "August RC", "state": "open"},
|
||||||
"html_url": "https://forge.example/stackchain/api/issues/51",
|
"html_url": "https://forge.example/stackchain/api/issues/51",
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +41,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
|
||||||
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
|
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
|
||||||
"updated_at": "2026-08-07T10:00:00Z",
|
"updated_at": "2026-08-07T10:00:00Z",
|
||||||
"due_date": "2026-08-09T23:59:59Z",
|
"due_date": "2026-08-09T23:59:59Z",
|
||||||
|
"milestone": {"id": 9, "title": "August RC"},
|
||||||
"url": "https://forge.example/stackchain/api/issues/51",
|
"url": "https://forge.example/stackchain/api/issues/51",
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user