Merge pull request 'Plan and focus mobile My Work by milestone' (#204) from timmy/203-mobile-milestone-planning into main
This commit is contained in:
commit
5e826bcc7e
|
|
@ -62,6 +62,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.work-filter { min-height: 44px; }
|
||||
.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-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; }
|
||||
|
|
@ -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-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.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 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; }
|
||||
|
|
@ -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="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
|
||||
</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 class="my-work-list" id="my-work-list"></div>
|
||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||
|
|
@ -412,6 +423,13 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
</div>
|
||||
<div id="issue-due-status" class="small" aria-live="assertive">Load an issue to schedule it.</div>
|
||||
</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>
|
||||
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
|
||||
<form class="issue-edit-form" id="issue-edit-form" hidden>
|
||||
|
|
@ -664,10 +682,14 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
});
|
||||
let liveMode = true;
|
||||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||||
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
|
||||
let selectedWorkFilter = 'all';
|
||||
let selectedWorkMilestone = 'all';
|
||||
try {
|
||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||
if (['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
|
||||
if (savedMilestone) selectedWorkMilestone = savedMilestone;
|
||||
} catch (e) {
|
||||
console.warn('Could not restore My Work filter', e);
|
||||
}
|
||||
|
|
@ -927,6 +949,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
const workSession = createWorkSession({
|
||||
getItems: () => lastMyWork,
|
||||
getFilter: () => selectedWorkFilter,
|
||||
getMilestone: () => selectedWorkMilestone,
|
||||
onOpen: openWorkSessionItem,
|
||||
onProgress: state => {
|
||||
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 + '"]');
|
||||
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-status').textContent = lastMyWork.length ?
|
||||
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
|
||||
|
|
@ -1052,7 +1089,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
|
||||
function renderMyWork() {
|
||||
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
|
||||
const visible = filterMyWork(lastMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||
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="my-work-card-title">' + escapeHtml(item.title) + '</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.has_update ? ' <span class="pill">Unread update</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);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!item) return;
|
||||
selectedIssue = item;
|
||||
|
|
@ -1220,6 +1282,10 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
qs('#save-issue-due-date').disabled = true;
|
||||
qs('#clear-issue-due-date').disabled = true;
|
||||
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('#open-issue-gitea').href = item.url || '#';
|
||||
qs('#send-issue-comment').disabled = false;
|
||||
|
|
@ -1238,6 +1304,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
'<span class="pill">' + escapeHtml(label) + '</span>'
|
||||
).join(' ');
|
||||
loadIssueLabelEditor(item, detail.labels || []);
|
||||
loadIssueMilestoneEditor(item, detail.milestone);
|
||||
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
|
||||
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
|
||||
qs('#issue-comments').innerHTML = (detail.comments || []).length ?
|
||||
|
|
@ -2211,6 +2278,33 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
saveSelectedIssueDueDate(value + 'T23:59:59Z');
|
||||
});
|
||||
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 () => {
|
||||
if (!selectedIssue) return;
|
||||
const body = qs('#issue-comment').value.trim();
|
||||
|
|
@ -2552,6 +2646,13 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
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();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
contextPoller.setVisible(!document.hidden);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
|||
let labelRequest = null;
|
||||
let editRequest = null;
|
||||
let dueDateRequest = null;
|
||||
let milestoneRequest = null;
|
||||
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(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 editDraftKey = item => 'stackchain.issue-content.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 {
|
||||
load(item) {
|
||||
|
|
@ -24,6 +26,12 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
|||
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) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
|
|
@ -93,6 +101,33 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
|||
}).finally(() => { dueDateRequest = null; });
|
||||
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) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
|
||||
|
|
|
|||
|
|
@ -353,11 +353,29 @@ function createNotificationReplier({
|
|||
};
|
||||
}
|
||||
|
||||
function filterMyWork(items, selectedFilter) {
|
||||
if (selectedFilter === 'all') return items;
|
||||
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
|
||||
if (selectedFilter === 'update') return items.filter((item) => item.has_update);
|
||||
return items.filter((item) => item.kind === selectedFilter);
|
||||
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
let filtered = items;
|
||||
if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||
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) {
|
||||
|
|
@ -369,12 +387,12 @@ function workIdentity(item) {
|
|||
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 currentIndex = -1;
|
||||
let running = false;
|
||||
|
||||
const queue = () => filterMyWork(getItems() || [], getFilter());
|
||||
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||
const report = (items, index) => onProgress({
|
||||
index: index + 1,
|
||||
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) {
|
||||
return {
|
||||
...data,
|
||||
|
|
@ -496,11 +523,13 @@ function countMyWork(items) {
|
|||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.milestoneLanes = milestoneLanes;
|
||||
buildMyWork.createWorkSession = createWorkSession;
|
||||
buildMyWork.workIdentity = workIdentity;
|
||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||
buildMyWork.replaceIssueContent = replaceIssueContent;
|
||||
buildMyWork.replaceIssueDueDate = replaceIssueDueDate;
|
||||
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
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(
|
||||
repository: 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:
|
||||
base = f"repos/{repository}/issues/{number}"
|
||||
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 "",
|
||||
"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,
|
||||
"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")),
|
||||
"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,
|
||||
)
|
||||
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.views import router as frontend_router
|
||||
|
||||
|
|
@ -195,6 +195,10 @@ class IssueDueDateUpdate(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class IssueMilestoneUpdate(BaseModel):
|
||||
milestone_id: PositiveInt | None = None
|
||||
|
||||
|
||||
class PullReviewComment(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_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 "",
|
||||
updated_at=i.get("updated_at") or "",
|
||||
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"],
|
||||
)
|
||||
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 "",
|
||||
updated_at=item.get("updated_at") or "",
|
||||
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"],
|
||||
).model_dump()
|
||||
for item in items
|
||||
|
|
@ -397,7 +408,7 @@ async def prevent_live_api_caching(request, call_next):
|
|||
and request.url.path.endswith("/issues")
|
||||
) or (
|
||||
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"
|
||||
return response
|
||||
|
|
@ -1229,6 +1240,69 @@ async def update_assigned_issue_due_date(
|
|||
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")
|
||||
async def repository_labels(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ class Repo(BaseModel):
|
|||
updated_at: str = ""
|
||||
|
||||
|
||||
class Milestone(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
|
||||
|
||||
class Issue(BaseModel):
|
||||
id: int
|
||||
number: int
|
||||
|
|
@ -27,6 +32,7 @@ class Issue(BaseModel):
|
|||
repository: str = ""
|
||||
updated_at: str = ""
|
||||
due_date: str | None = None
|
||||
milestone: Milestone | None = None
|
||||
url: str
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,91 @@ from src import gitea_proxy, main
|
|||
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.parametrize(
|
||||
("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",
|
||||
"body": "Full issue context",
|
||||
"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",
|
||||
"labels": [{"name": "P1"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
|
|
@ -1058,6 +1144,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
|||
"body": "Full issue context",
|
||||
"updated_at": "2026-08-07T09:59:00Z",
|
||||
"due_date": None,
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
"url": "https://forge.example/stackchain/api/issues/7",
|
||||
"labels": ["P1"],
|
||||
"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
|
||||
|
||||
|
||||
@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():
|
||||
items = [
|
||||
{"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"
|
||||
|
||||
|
||||
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():
|
||||
items = [
|
||||
{"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"},
|
||||
"updated_at": "2026-08-07T10:00:00Z",
|
||||
"due_date": "2026-08-09T23:59:59Z",
|
||||
"milestone": {"id": 9, "title": "August RC", "state": "open"},
|
||||
"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",
|
||||
"updated_at": "2026-08-07T10:00:00Z",
|
||||
"due_date": "2026-08-09T23:59:59Z",
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
"url": "https://forge.example/stackchain/api/issues/51",
|
||||
}],
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user