Schedule assigned issues from mobile My Work #192
|
|
@ -16,7 +16,8 @@ python3 -m pip install -r requirements.txt
|
||||||
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
||||||
token that can read dashboard data, update the authenticated user's notification
|
token that can read dashboard data, update the authenticated user's notification
|
||||||
threads, create and self-assign issues, discover, claim, and release issue assignments,
|
threads, create and self-assign issues, discover, claim, and release issue assignments,
|
||||||
create issue comments, close assigned issues, inspect/comment on assigned pull
|
set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
||||||
|
inspect/comment on assigned pull
|
||||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||||
issue-comment API; mobile issue capture requires issue
|
issue-comment API; mobile issue capture requires issue
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
|
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
|
||||||
.issue-label-option input { width:20px; height:20px; flex:0 0 auto; }
|
.issue-label-option input { width:20px; height:20px; flex:0 0 auto; }
|
||||||
.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; }
|
.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; }
|
||||||
|
.issue-due-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||||
|
.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-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; }
|
||||||
|
|
@ -361,6 +365,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<div class="small" id="issue-label-status" aria-live="polite">Load an issue to edit labels.</div>
|
<div class="small" id="issue-label-status" aria-live="polite">Load an issue to edit labels.</div>
|
||||||
<button id="save-issue-labels" type="button" disabled>Save labels</button>
|
<button id="save-issue-labels" type="button" disabled>Save labels</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset class="issue-due-editor" aria-describedby="issue-due-status">
|
||||||
|
<legend>Due date</legend>
|
||||||
|
<label for="issue-due-date" class="small">Schedule this assigned issue</label>
|
||||||
|
<input id="issue-due-date" type="date" disabled />
|
||||||
|
<div class="issue-due-actions">
|
||||||
|
<button id="save-issue-due-date" type="button" disabled>Save due date</button>
|
||||||
|
<button id="clear-issue-due-date" type="button" disabled>Clear due date</button>
|
||||||
|
</div>
|
||||||
|
<div id="issue-due-status" class="small" aria-live="assertive">Load an issue to schedule 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>
|
||||||
|
|
@ -934,6 +948,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.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>' : '');
|
||||||
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
|
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
|
||||||
|
|
@ -1088,6 +1103,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#issue-label-list').textContent = '';
|
qs('#issue-label-list').textContent = '';
|
||||||
qs('#issue-label-status').textContent = 'Loading labels…';
|
qs('#issue-label-status').textContent = 'Loading labels…';
|
||||||
qs('#save-issue-labels').disabled = true;
|
qs('#save-issue-labels').disabled = true;
|
||||||
|
qs('#issue-due-date').value = '';
|
||||||
|
qs('#issue-due-date').disabled = true;
|
||||||
|
qs('#save-issue-due-date').disabled = true;
|
||||||
|
qs('#clear-issue-due-date').disabled = true;
|
||||||
|
qs('#issue-due-status').textContent = 'Loading due date…';
|
||||||
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;
|
||||||
|
|
@ -1113,6 +1133,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
||||||
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
|
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
|
||||||
qs('#edit-issue-content').disabled = false;
|
qs('#edit-issue-content').disabled = false;
|
||||||
|
const dueDraft = issueController.loadDueDateDraft(item);
|
||||||
|
qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10);
|
||||||
|
qs('#issue-due-date').disabled = false;
|
||||||
|
qs('#save-issue-due-date').disabled = false;
|
||||||
|
qs('#clear-issue-due-date').disabled = !detail.due_date;
|
||||||
|
qs('#issue-due-status').textContent = detail.due_date ?
|
||||||
|
'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (selectedIssue !== item) return;
|
if (selectedIssue !== item) return;
|
||||||
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
|
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
|
||||||
|
|
@ -1853,6 +1880,45 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function saveSelectedIssueDueDate(dueDate) {
|
||||||
|
if (!selectedIssue || !lastContextSnapshot) return;
|
||||||
|
const editing = selectedIssue;
|
||||||
|
const saveButton = qs('#save-issue-due-date');
|
||||||
|
const clearButton = qs('#clear-issue-due-date');
|
||||||
|
saveButton.disabled = true;
|
||||||
|
clearButton.disabled = true;
|
||||||
|
qs('#issue-due-status').textContent = dueDate ? 'Saving due date…' : 'Clearing due date…';
|
||||||
|
try {
|
||||||
|
const confirmed = await issueController.updateDueDate(editing, dueDate);
|
||||||
|
lastContextSnapshot = buildMyWork.replaceIssueDueDate(
|
||||||
|
lastContextSnapshot, editing.repository, editing.number, confirmed.due_date
|
||||||
|
);
|
||||||
|
selectedIssue = { ...editing, due_date: confirmed.due_date };
|
||||||
|
selectedIssueDetail = { ...selectedIssueDetail, due_date: confirmed.due_date };
|
||||||
|
qs('#issue-due-date').value = String(confirmed.due_date || '').slice(0, 10);
|
||||||
|
paintMyWork(lastContextSnapshot);
|
||||||
|
qs('#issue-due-status').textContent = confirmed.due_date ?
|
||||||
|
'Due date saved. My Work reprioritized.' : 'Due date cleared.';
|
||||||
|
} catch (error) {
|
||||||
|
qs('#issue-due-status').textContent = error.message + ' Your selection is safe; retry.';
|
||||||
|
qs('#issue-due-date').focus();
|
||||||
|
} finally {
|
||||||
|
saveButton.disabled = false;
|
||||||
|
clearButton.disabled = !selectedIssueDetail?.due_date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qs('#save-issue-due-date').addEventListener('click', () => {
|
||||||
|
const value = qs('#issue-due-date').value;
|
||||||
|
if (!value) {
|
||||||
|
qs('#issue-due-status').textContent = 'Choose a date or use Clear due date.';
|
||||||
|
qs('#issue-due-date').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveSelectedIssueDueDate(value + 'T23:59:59Z');
|
||||||
|
});
|
||||||
|
qs('#clear-issue-due-date').addEventListener('click', () => saveSelectedIssueDueDate(null));
|
||||||
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();
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
||||||
let releaseRequest = null;
|
let releaseRequest = null;
|
||||||
let labelRequest = null;
|
let labelRequest = null;
|
||||||
let editRequest = null;
|
let editRequest = null;
|
||||||
|
let dueDateRequest = 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;
|
||||||
const operationKey = item => draftKey(item) + ':operation';
|
const operationKey = item => draftKey(item) + ':operation';
|
||||||
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;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
load(item) {
|
load(item) {
|
||||||
|
|
@ -65,6 +67,32 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
||||||
}).finally(() => { editRequest = null; });
|
}).finally(() => { editRequest = null; });
|
||||||
return editRequest;
|
return editRequest;
|
||||||
},
|
},
|
||||||
|
loadDueDateDraft(item) {
|
||||||
|
try {
|
||||||
|
const raw = storage?.getItem(dueDateDraftKey(item));
|
||||||
|
if (raw === null || raw === undefined) return null;
|
||||||
|
const value = JSON.parse(raw);
|
||||||
|
return typeof value === 'string' ? value : null;
|
||||||
|
} catch (_error) { return null; }
|
||||||
|
},
|
||||||
|
updateDueDate(item, dueDate) {
|
||||||
|
if (dueDateRequest) return dueDateRequest;
|
||||||
|
try { storage?.setItem(dueDateDraftKey(item), JSON.stringify(dueDate)); }
|
||||||
|
catch (_error) { /* The date input remains the fallback. */ }
|
||||||
|
dueDateRequest = fetchJson(issuePath(item) + '/due-date', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ due_date: dueDate }),
|
||||||
|
}).then(result => {
|
||||||
|
if (result?.number !== item.number || result?.due_date !== dueDate) {
|
||||||
|
throw new Error('Issue due date was not confirmed.');
|
||||||
|
}
|
||||||
|
try { storage?.removeItem(dueDateDraftKey(item)); }
|
||||||
|
catch (_error) { /* Confirmed upstream deadline is authoritative. */ }
|
||||||
|
return result;
|
||||||
|
}).finally(() => { dueDateRequest = null; });
|
||||||
|
return dueDateRequest;
|
||||||
|
},
|
||||||
loadLabelDraft(item) {
|
loadLabelDraft(item) {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
|
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,20 @@
|
||||||
function buildMyWork(data) {
|
function issueDueState(dueDate, now) {
|
||||||
|
if (!dueDate) return null;
|
||||||
|
const due = new Date(dueDate);
|
||||||
|
if (Number.isNaN(due.getTime())) return null;
|
||||||
|
const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
|
||||||
|
String(value.getDate()).padStart(2, '0');
|
||||||
|
const dueDay = day(due);
|
||||||
|
const today = day(now);
|
||||||
|
if (dueDay < today) return { label: 'Overdue', priority: 2 };
|
||||||
|
if (dueDay === today) return { label: 'Due today', priority: 2.5 };
|
||||||
|
return {
|
||||||
|
label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||||
|
priority: 4,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMyWork(data, now = new Date()) {
|
||||||
const login = data.user?.login || '';
|
const login = data.user?.login || '';
|
||||||
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
|
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
|
||||||
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
|
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
|
||||||
|
|
@ -11,15 +27,19 @@ function buildMyWork(data) {
|
||||||
);
|
);
|
||||||
const assigned = (item.assignees || []).includes(login);
|
const assigned = (item.assignees || []).includes(login);
|
||||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||||
|
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
key: (item.repository || 'unknown') + '#' + item.number,
|
key: (item.repository || 'unknown') + '#' + item.number,
|
||||||
is_review: isReview,
|
is_review: isReview,
|
||||||
is_assigned: assigned,
|
is_assigned: assigned,
|
||||||
has_update: false,
|
has_update: false,
|
||||||
|
...(due ? { due_label: due.label } : {}),
|
||||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')),
|
(due && due.priority < 4 ? due.label :
|
||||||
_priority: priorityLabel ? 0 : (isReview ? 2 : (assigned ? 3 : 4)),
|
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work'))),
|
||||||
|
_priority: priorityLabel ? 0 :
|
||||||
|
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -358,6 +378,15 @@ function replaceIssueContent(data, repository, number, content) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceIssueDueDate(data, repository, number, dueDate) {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
issues: (data.issues || []).map(item =>
|
||||||
|
item.repository === repository && item.number === number ? { ...item, due_date: dueDate } : item
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function removeIssue(data, repository, number) {
|
function removeIssue(data, repository, number) {
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -391,6 +420,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||||
buildMyWork.filterMyWork = filterMyWork;
|
buildMyWork.filterMyWork = filterMyWork;
|
||||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||||
buildMyWork.replaceIssueContent = replaceIssueContent;
|
buildMyWork.replaceIssueContent = replaceIssueContent;
|
||||||
|
buildMyWork.replaceIssueDueDate = replaceIssueDueDate;
|
||||||
buildMyWork.removeIssue = removeIssue;
|
buildMyWork.removeIssue = removeIssue;
|
||||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||||
buildMyWork.countMyWork = countMyWork;
|
buildMyWork.countMyWork = countMyWork;
|
||||||
|
|
|
||||||
|
|
@ -732,6 +732,39 @@ async def update_issue_labels(repository: str, number: int, label_ids: list[int]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_assigned_issue_due_date(
|
||||||
|
repository: str, number: int, due_date: str | 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")
|
||||||
|
|
||||||
|
payload = {"due_date": due_date} if due_date else {"unset_due_date": True}
|
||||||
|
response = await _get_client().patch(
|
||||||
|
f"/api/v1/{path}", headers=_auth(), json=payload
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
confirmed = response.json()
|
||||||
|
confirmed_due_date = confirmed.get("due_date") if isinstance(confirmed, dict) else None
|
||||||
|
if (
|
||||||
|
not isinstance(confirmed, dict)
|
||||||
|
or confirmed.get("number") != number
|
||||||
|
or confirmed_due_date != due_date
|
||||||
|
):
|
||||||
|
raise ValueError("Gitea did not confirm the issue due date update")
|
||||||
|
return {
|
||||||
|
"repository": repository,
|
||||||
|
"number": number,
|
||||||
|
"state": confirmed.get("state", "open"),
|
||||||
|
"due_date": confirmed_due_date,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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(
|
||||||
|
|
@ -756,6 +789,7 @@ async def issue_detail(repository: str, number: int) -> dict:
|
||||||
"state": issue.get("state", "") if isinstance(issue.get("state"), str) else "",
|
"state": issue.get("state", "") if isinstance(issue.get("state"), str) else "",
|
||||||
"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,
|
||||||
"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
|
||||||
|
|
|
||||||
42
src/main.py
42
src/main.py
|
|
@ -178,6 +178,14 @@ class IssueLabelUpdate(BaseModel):
|
||||||
label_ids: list[PositiveInt] = Field(max_length=20)
|
label_ids: list[PositiveInt] = Field(max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class IssueDueDateUpdate(BaseModel):
|
||||||
|
due_date: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$",
|
||||||
|
max_length=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -286,7 +294,9 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||||
labels=[label.get("name", "") for label in (i.get("labels") or []) if isinstance(label, dict)],
|
labels=[label.get("name", "") for label in (i.get("labels") or []) if isinstance(label, dict)],
|
||||||
assignees=[assignee.get("login", "") for assignee in (i.get("assignees") or []) if isinstance(assignee, dict)],
|
assignees=[assignee.get("login", "") for assignee in (i.get("assignees") or []) if isinstance(assignee, 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 "", url=i["html_url"],
|
updated_at=i.get("updated_at") or "",
|
||||||
|
due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None,
|
||||||
|
url=i["html_url"],
|
||||||
)
|
)
|
||||||
for i in (issues_data or [])[:50]
|
for i in (issues_data or [])[:50]
|
||||||
if isinstance(i, dict)
|
if isinstance(i, dict)
|
||||||
|
|
@ -324,7 +334,9 @@ def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
|
||||||
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
|
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
|
||||||
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)],
|
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, 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 "", url=item["html_url"],
|
updated_at=item.get("updated_at") or "",
|
||||||
|
due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None,
|
||||||
|
url=item["html_url"],
|
||||||
).model_dump()
|
).model_dump()
|
||||||
for item in items
|
for item in items
|
||||||
if all(field in item for field in ("id", "number", "title", "state", "html_url"))
|
if all(field in item for field in ("id", "number", "title", "state", "html_url"))
|
||||||
|
|
@ -1126,6 +1138,32 @@ async def update_assigned_issue_content(
|
||||||
return JSONResponse(result)
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/due-date")
|
||||||
|
async def update_assigned_issue_due_date(
|
||||||
|
update: IssueDueDateUpdate,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
number: int = PathParam(gt=0),
|
||||||
|
):
|
||||||
|
repository = f"{owner}/{repo}"
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
gitea_proxy.update_assigned_issue_due_date(
|
||||||
|
repository, number, update.due_date
|
||||||
|
),
|
||||||
|
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except gitea_proxy.IssueNotAvailableError:
|
||||||
|
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "The due date 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}/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}"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ class Issue(BaseModel):
|
||||||
assignees: list[str] = []
|
assignees: list[str] = []
|
||||||
repository: str = ""
|
repository: str = ""
|
||||||
updated_at: str = ""
|
updated_at: str = ""
|
||||||
|
due_date: str | None = None
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -421,6 +421,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
|
||||||
"assignees": [{"login": "timmy"}],
|
"assignees": [{"login": "timmy"}],
|
||||||
"repository": {"full_name": "stackchain/mobile"},
|
"repository": {"full_name": "stackchain/mobile"},
|
||||||
"updated_at": "2026-08-06T12:00:00Z",
|
"updated_at": "2026-08-06T12:00:00Z",
|
||||||
|
"due_date": "2026-08-09T23:59:59Z",
|
||||||
"html_url": "https://forge.example/stackchain/mobile/issues/7",
|
"html_url": "https://forge.example/stackchain/mobile/issues/7",
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
@ -448,6 +449,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
|
||||||
|
|
||||||
assert payload["issues"][0]["repository"] == "stackchain/mobile"
|
assert payload["issues"][0]["repository"] == "stackchain/mobile"
|
||||||
assert payload["issues"][0]["updated_at"] == "2026-08-06T12:00:00Z"
|
assert payload["issues"][0]["updated_at"] == "2026-08-06T12:00:00Z"
|
||||||
|
assert payload["issues"][0]["due_date"] == "2026-08-09T23:59:59Z"
|
||||||
assert payload["pull_requests"][0]["repository"] == "stackchain/api"
|
assert payload["pull_requests"][0]["repository"] == "stackchain/api"
|
||||||
assert payload["pull_requests"][0]["updated_at"] == "2026-08-06T11:00:00Z"
|
assert payload["pull_requests"][0]["updated_at"] == "2026-08-06T11:00:00Z"
|
||||||
assert payload["pull_requests"][0]["labels"] == ["priority-high"]
|
assert payload["pull_requests"][0]["labels"] == ["priority-high"]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -6,6 +7,87 @@ import pytest
|
||||||
from src import gitea_proxy, main
|
from src import gitea_proxy, main
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("due_date", "expected_payload", "confirmed_due_date"),
|
||||||
|
[
|
||||||
|
("2026-08-09T23:59:59Z", {"due_date": "2026-08-09T23:59:59Z"}, "2026-08-09T23:59:59Z"),
|
||||||
|
(None, {"unset_due_date": True}, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_gitea_due_date_update_revalidates_assignment_and_confirms_result(
|
||||||
|
due_date, expected_payload, confirmed_due_date
|
||||||
|
):
|
||||||
|
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":
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"number": 17, "state": "open", "pull_request": None,
|
||||||
|
"assignees": [{"login": "timmy"}],
|
||||||
|
})
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"number": 17, "state": "open", "due_date": confirmed_due_date,
|
||||||
|
})
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
result = await gitea_proxy.update_assigned_issue_due_date(
|
||||||
|
"stackchain/api", 17, due_date
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert [(request.method, request.url.path) for request in requests] == [
|
||||||
|
("GET", "/api/v1/user"),
|
||||||
|
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||||
|
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
|
||||||
|
]
|
||||||
|
assert json.loads(requests[2].content) == expected_payload
|
||||||
|
assert result == {
|
||||||
|
"repository": "stackchain/api", "number": 17,
|
||||||
|
"state": "open", "due_date": confirmed_due_date,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_due_date_endpoint_sets_or_clears_assigned_issue(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def update(repository, number, due_date):
|
||||||
|
calls.append((repository, number, due_date))
|
||||||
|
return {
|
||||||
|
"repository": repository, "number": number, "state": "open",
|
||||||
|
"due_date": due_date,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main.gitea_proxy, "update_assigned_issue_due_date", update, raising=False
|
||||||
|
)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
set_response = await client.patch(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/17/due-date",
|
||||||
|
json={"due_date": "2026-08-09T23:59:59Z"},
|
||||||
|
)
|
||||||
|
clear_response = await client.patch(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/17/due-date",
|
||||||
|
json={"due_date": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [set_response.status_code, clear_response.status_code] == [200, 200]
|
||||||
|
assert set_response.headers["cache-control"] == "no-store"
|
||||||
|
assert set_response.json()["due_date"] == "2026-08-09T23:59:59Z"
|
||||||
|
assert clear_response.json()["due_date"] is None
|
||||||
|
assert calls == [
|
||||||
|
("stackchain/api", 17, "2026-08-09T23:59:59Z"),
|
||||||
|
("stackchain/api", 17, None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_edit_assigned_issue_updates_title_and_body_at_expected_revision(monkeypatch):
|
async def test_edit_assigned_issue_updates_title_and_body_at_expected_revision(monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
@ -909,6 +991,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",
|
||||||
|
"due_date": None,
|
||||||
"url": "https://forge.example/stackchain/api/issues/7",
|
"url": "https://forge.example/stackchain/api/issues/7",
|
||||||
"labels": ["P1"],
|
"labels": ["P1"],
|
||||||
"assignees": ["timmy"],
|
"assignees": ["timmy"],
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,38 @@ process.stdout.write(JSON.stringify(queue));
|
||||||
assert queue[2]["reason"] == "Assigned to you"
|
assert queue[2]["reason"] == "Assigned to you"
|
||||||
|
|
||||||
|
|
||||||
|
def test_my_work_surfaces_and_ranks_due_issues_after_p0_before_ordinary_work():
|
||||||
|
payload = {
|
||||||
|
"user": {"login": "timmy"},
|
||||||
|
"issues": [
|
||||||
|
{"number": 1, "title": "Ordinary", "repository": "stackchain/api",
|
||||||
|
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-07T12:00:00Z"},
|
||||||
|
{"number": 2, "title": "Due today", "repository": "stackchain/api",
|
||||||
|
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-07T23:59:59Z"},
|
||||||
|
{"number": 3, "title": "Overdue", "repository": "stackchain/api",
|
||||||
|
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z"},
|
||||||
|
{"number": 4, "title": "P0 future", "repository": "stackchain/api",
|
||||||
|
"labels": ["P0"], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
|
||||||
|
],
|
||||||
|
"pull_requests": [],
|
||||||
|
}
|
||||||
|
script = f"""
|
||||||
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
const queue = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'));
|
||||||
|
process.stdout.write(JSON.stringify(queue.map(item => ({{title:item.title,reason:item.reason,due_label:item.due_label}}))));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == [
|
||||||
|
{"title": "P0 future", "reason": "P0 priority", "due_label": "Due Aug 10"},
|
||||||
|
{"title": "Overdue", "reason": "Overdue", "due_label": "Overdue"},
|
||||||
|
{"title": "Due today", "reason": "Due today", "due_label": "Due today"},
|
||||||
|
{"title": "Ordinary", "reason": "Assigned to you"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_confirmed_issue_labels_replace_snapshot_and_reprioritize_queue():
|
def test_confirmed_issue_labels_replace_snapshot_and_reprioritize_queue():
|
||||||
payload = {
|
payload = {
|
||||||
"user": {"login": "timmy"},
|
"user": {"login": "timmy"},
|
||||||
|
|
@ -234,6 +266,61 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
||||||
assert output["results"][0]["updated_at"] == "2026-08-07T10:01:00Z"
|
assert output["results"][0]["updated_at"] == "2026-08-07T10:01:00Z"
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_due_date_update_is_single_flight_and_keeps_draft_until_confirmed():
|
||||||
|
script = f"""
|
||||||
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{
|
||||||
|
getItem: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.updateDueDate(item, '2026-08-09T23:59:59Z');
|
||||||
|
const duplicate = controller.updateDueDate(item, '2026-08-09T23:59:59Z');
|
||||||
|
const during = controller.loadDueDateDraft(item);
|
||||||
|
finish({{repository:'stackchain/api',number:17,state:'open',due_date:'2026-08-09T23:59:59Z'}});
|
||||||
|
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.loadDueDateDraft(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/due-date",
|
||||||
|
"method": "PATCH",
|
||||||
|
"body": {"due_date": "2026-08-09T23:59:59Z"},
|
||||||
|
}]
|
||||||
|
assert output["same"] is True
|
||||||
|
assert output["during"] == "2026-08-09T23:59:59Z"
|
||||||
|
assert output["after"] is None
|
||||||
|
assert output["results"][0]["due_date"] == "2026-08-09T23:59:59Z"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_badge():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="issue-due-date" type="date"' in html
|
||||||
|
assert 'id="save-issue-due-date"' in html
|
||||||
|
assert 'id="clear-issue-due-date"' in html
|
||||||
|
assert 'class="pill due-badge"' in html
|
||||||
|
assert '.issue-due-editor input, .issue-due-editor button { min-height:44px;' in html
|
||||||
|
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' 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},
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
|
||||||
"labels": [], "assignees": [{"login": "timmy"}],
|
"labels": [], "assignees": [{"login": "timmy"}],
|
||||||
"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",
|
||||||
"html_url": "https://forge.example/stackchain/api/issues/51",
|
"html_url": "https://forge.example/stackchain/api/issues/51",
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
|
|
@ -38,6 +39,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
|
||||||
"id": 51, "number": 51, "title": "Older issue", "state": "open",
|
"id": 51, "number": 51, "title": "Older issue", "state": "open",
|
||||||
"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",
|
||||||
"url": "https://forge.example/stackchain/api/issues/51",
|
"url": "https://forge.example/stackchain/api/issues/51",
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user