Compare commits

...
Author SHA1 Message Date
timmy 04bb6839ae feat: hand off assigned issues from mobile (#281)
CI / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 08:37:48 +00:00
rockachopa 1fdf6f417d Merge pull request 'Plan and run an ordered mobile Today queue' (#280) from timmy/279-mobile-today-queue into main
CI / lint (push) Successful in 27s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-08 08:01:26 +00:00
timmy cc38b3d0f6 feat: add ordered mobile Today queue (#279)
CI / lint (pull_request) Successful in 30s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 07:58:49 +00:00
10 changed files with 718 additions and 15 deletions

No files matched your search

+129 -11
View File
@@ -82,8 +82,9 @@ textarea { resize: vertical; min-height: 120px; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
.my-work-card:hover { border-color:var(--accent); }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
.later-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.later-actions button { min-height:44px; width:100%; }
.today-actions button { min-height:44px; width:100%; }
.detail-defer { grid-column:1/-1; max-width:100%; }
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@@ -188,6 +189,10 @@ textarea { resize: vertical; min-height: 120px; }
.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; }
.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div { display:grid; gap:8px; margin-top:10px; }
.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
.issue-handoff select, .issue-handoff button { min-height:44px; }
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
.new-issue { min-height:44px; }
.find-work-action { min-height:44px; }
@@ -315,6 +320,7 @@ textarea { resize: vertical; min-height: 120px; }
</div>
<div class="work-filters" aria-label="Filter My Work">
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
@@ -517,6 +523,16 @@ textarea { resize: vertical; min-height: 120px; }
<div id="issue-milestone-status" class="small" aria-live="assertive">Expand planning controls to load milestones.</div>
</fieldset>
</details>
<details class="issue-handoff" id="issue-handoff">
<summary>Hand off to teammate</summary>
<div>
<button id="load-issue-handoff" type="button">Choose teammate</button>
<label for="issue-handoff-recipient" class="small">Eligible repository assignee</label>
<select id="issue-handoff-recipient" disabled><option value="">Select a teammate</option></select>
<button id="confirm-issue-handoff" type="button" disabled>Confirm handoff</button>
<div id="issue-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
</div>
</details>
<div class="issue-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<button id="release-issue" type="button">Release assignment</button>
@@ -762,6 +778,7 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/authored-outbox.js"></script>
<script src="static/offline-work.js"></script>
<script src="static/my-work.js"></script>
<script src="static/today-work.js"></script>
<script src="static/later-work.js"></script>
<script src="static/detail-defer.js"></script>
<script src="static/pick-work.js"></script>
@@ -804,7 +821,7 @@ textarea { resize: vertical; min-height: 120px; }
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
function openMobileWork() {
const counts = countMyWork(activeMyWork);
const filter = counts.attention ? 'attention' : 'all';
const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');
qs('[data-work-filter="' + filter + '"]').click();
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
@@ -844,7 +861,7 @@ textarea { resize: vertical; min-height: 120px; }
let selectedWorkMilestone = 'all';
try {
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
if (['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
if (['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
if (savedMilestone) selectedWorkMilestone = savedMilestone;
} catch (e) {
@@ -886,6 +903,11 @@ textarea { resize: vertical; min-height: 120px; }
let activeFlushLogin = '';
let activeMyWork = [];
let laterMyWork = [];
let todayMyWork = [];
const todayWork = createTodayWork({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
});
const laterWork = createLaterWork({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
@@ -1246,8 +1268,8 @@ textarea { resize: vertical; min-height: 120px; }
}
const workSession = createWorkSession({
getItems: () => activeMyWork,
getFilter: () => selectedWorkFilter,
getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork,
getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter,
getMilestone: () => selectedWorkMilestone,
onOpen: openWorkSessionItem,
onProgress: state => {
@@ -1370,7 +1392,11 @@ textarea { resize: vertical; min-height: 120px; }
});
activeMyWork = partitioned.active;
laterMyWork = partitioned.later;
todayMyWork = todayWork.reconcile(lastMyWork, {
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
});
const counts = countMyWork(activeMyWork);
counts.today = todayMyWork.length;
counts.later = laterMyWork.length;
counts.draft = lastDrafts.length;
Object.entries(counts).forEach(([filter, count]) => {
@@ -1402,6 +1428,7 @@ textarea { resize: vertical; min-height: 120px; }
}
function activeWorkStreams() {
if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review'];
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
if (selectedWorkFilter === 'issue') return ['issue'];
if (selectedWorkFilter === 'pull') return ['pull'];
@@ -1515,7 +1542,8 @@ textarea { resize: vertical; min-height: 120px; }
qs('#load-more-notifications').hidden = true;
return;
}
const visible = selectedWorkFilter === 'later' ?
const visible = selectedWorkFilter === 'today' ?
filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ?
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
@@ -1539,16 +1567,22 @@ textarea { resize: vertical; min-height: 120px; }
const laterActions = selectedWorkFilter === 'later' ?
'<div class="later-actions"><button type="button" data-later-restore data-work-index="' + index + '">Bring back now</button></div>' :
'<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '">Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '">Tomorrow</button></div>';
const alreadyToday = todayWork.contains(item);
const todayPosition = todayWork.position(item);
const todayActions = selectedWorkFilter === 'today' ?
'<div class="today-actions" aria-label="Reorder Today"><button type="button" data-today-move="up" data-work-index="' + index + '"' + (todayPosition.can_up ? '' : ' disabled') + '>Move up</button><button type="button" data-today-move="down" data-work-index="' + index + '"' + (todayPosition.can_down ? '' : ' disabled') + '>Move down</button><button type="button" data-today-remove data-work-index="' + index + '">Remove from Today</button></div>' :
'<div class="today-actions"><button type="button" data-today-add data-work-index="' + index + '"' + (alreadyToday ? ' disabled' : '') + '>' + (alreadyToday ? 'Added to Today' : 'Add to Today') + '</button></div>';
const planningActions = todayActions + laterActions;
if (item.is_review) {
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
}
if (item.kind === 'issue') {
return '<article class="my-work-card"><a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
return '<article class="my-work-card"><a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
}
if (item.kind === 'pull') {
return '<article class="my-work-card"><a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + laterActions + '</article>';
return '<article class="my-work-card"><a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
}
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + laterActions + '</article>';
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + planningActions + '</article>';
}).join('') : '<div class="muted">' + (incomplete ?
'More work is available. Load the next page.' :
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
@@ -1599,6 +1633,29 @@ textarea { resize: vertical; min-height: 120px; }
refreshMyWorkView();
});
});
document.querySelectorAll('[data-today-add]').forEach(button => {
button.addEventListener('click', () => {
const result = todayWork.add(lastMyWork[Number(button.dataset.workIndex)]);
qs('#my-work-action-status').textContent = result === 'full' ?
'Today is limited to 5 items. Remove one before adding more.' :
(result === 'added' ? 'Added to Today without changing Gitea.' : 'This item is already in Today.');
refreshMyWorkView();
});
});
document.querySelectorAll('[data-today-remove]').forEach(button => {
button.addEventListener('click', () => {
todayWork.remove(lastMyWork[Number(button.dataset.workIndex)]);
qs('#my-work-action-status').textContent = 'Removed from Today without changing Gitea.';
refreshMyWorkView();
});
});
document.querySelectorAll('[data-today-move]').forEach(button => {
button.addEventListener('click', () => {
todayWork.move(lastMyWork[Number(button.dataset.workIndex)], button.dataset.todayMove);
refreshMyWorkView();
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
});
});
const allIds = notificationIds(visible);
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
const bulkBar = qs('#bulk-mark-read-bar');
@@ -1760,6 +1817,12 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-conversation-status').textContent = 'Loading newest messages…';
qs('#issue-comment').value = issueController.loadDraft(item);
qs('#issue-comment-status').textContent = '';
qs('#issue-handoff').open = false;
qs('#issue-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
qs('#issue-handoff-recipient').disabled = true;
qs('#confirm-issue-handoff').disabled = true;
qs('#load-issue-handoff').disabled = false;
qs('#issue-handoff-status').textContent = 'Load teammates to transfer ownership.';
qs('#issue-planning').open = false;
qs('#retry-issue-planning').hidden = true;
qs('#issue-label-list').textContent = '';
@@ -3087,6 +3150,60 @@ textarea { resize: vertical; min-height: 120px; }
button.focus();
}
});
qs('#load-issue-handoff').addEventListener('click', async () => {
if (!selectedIssue) return;
const button = qs('#load-issue-handoff');
const select = qs('#issue-handoff-recipient');
button.disabled = true;
qs('#issue-handoff-status').textContent = 'Loading eligible teammates…';
try {
const candidates = await issueController.loadHandoffCandidates(selectedIssue);
select.textContent = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates';
select.appendChild(placeholder);
candidates.forEach(candidate => {
const option = document.createElement('option');
option.value = candidate.login;
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
select.appendChild(option);
});
select.disabled = !candidates.length;
qs('#confirm-issue-handoff').disabled = true;
qs('#issue-handoff-status').textContent = candidates.length ?
'Choose who should own this issue next.' : 'No other eligible assignees were found.';
if (candidates.length) select.focus();
} catch (error) {
qs('#issue-handoff-status').textContent = error.message + ' Retry loading teammates.';
button.disabled = false;
button.focus();
}
});
qs('#issue-handoff-recipient').addEventListener('change', event => {
qs('#confirm-issue-handoff').disabled = !event.target.value;
});
qs('#confirm-issue-handoff').addEventListener('click', async () => {
const recipient = qs('#issue-handoff-recipient').value;
if (!selectedIssue || !recipient || !window.confirm('Hand off ' + selectedIssue.key + ' to @' + recipient + '?')) return;
const handingOff = selectedIssue;
const button = qs('#confirm-issue-handoff');
button.disabled = true;
qs('#issue-handoff-status').textContent = 'Confirming handoff…';
try {
await issueController.handoff(selectedIssue, recipient, lastContextSnapshot?.user?.login);
lastContextSnapshot = buildMyWork.removeIssue(
lastContextSnapshot, handingOff.repository, handingOff.number
);
closeIssueSheet();
paintMyWork(lastContextSnapshot);
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '.';
} catch (error) {
qs('#issue-handoff-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#close-issue').addEventListener('click', async () => {
if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return;
const closing = selectedIssue;
@@ -3485,7 +3602,8 @@ textarea { resize: vertical; min-height: 120px; }
qs('#refresh').addEventListener('click', load);
qs('#start-work-session').addEventListener('click', () => {
if (!filterMyWork(lastMyWork, selectedWorkFilter).length) {
const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter);
if (!sessionItems.length) {
qs('#my-work-action-status').textContent = 'No visible work to start.';
return;
}
+26
View File
@@ -22,6 +22,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
let handoffRequest = null;
let labelRequest = null;
let editRequest = null;
let dueDateRequest = null;
@@ -61,6 +62,11 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
headers: { Accept: 'application/json' },
});
},
loadHandoffCandidates(item) {
return fetchJson(issuePath(item) + '/handoff-candidates', {
headers: { Accept: 'application/json' },
});
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
@@ -209,6 +215,26 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
}).finally(() => { releaseRequest = null; });
return releaseRequest;
},
handoff(item, recipient, currentLogin) {
if (handoffRequest) return handoffRequest;
handoffRequest = fetchJson(issuePath(item) + '/handoff', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ recipient }),
}).then(result => {
if (
result?.number !== item.number ||
result?.recipient !== recipient ||
!Array.isArray(result.assignees) ||
!result.assignees.includes(recipient) ||
result.assignees.includes(currentLogin)
) {
throw new Error('Issue handoff was not confirmed.');
}
return result;
}).finally(() => { handoffRequest = null; });
return handoffRequest;
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
+2 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v19';
const CACHE = 'stackchain-dashboard-shell-v21';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
@@ -18,6 +18,7 @@ const SHELL = [
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/my-work.js',
BASE + 'static/today-work.js',
BASE + 'static/later-work.js',
BASE + 'static/detail-defer.js',
BASE + 'static/pick-work.js',
+91
View File
@@ -0,0 +1,91 @@
function createTodayWork({ storage, getLogin, limit = 5 }) {
const prefix = 'stackchain.today-work.v1.';
function identity(item) {
if (!item) return '';
const kind = item.is_review ? 'review' : (item.kind || 'work');
const number = Number.isInteger(item.number) ? item.number : '';
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
return [kind, item.repository || '', number, notification].join(':');
}
function storageKey() {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function read() {
const key = storageKey();
if (!key || !storage) return [];
try {
const value = JSON.parse(storage.getItem(key) || '[]');
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
} catch (_error) {
return [];
}
}
function write(ids) {
const key = storageKey();
if (!key || !storage) return false;
try {
if (ids.length) storage.setItem(key, JSON.stringify(ids));
else storage.removeItem(key);
return true;
} catch (_error) {
return false;
}
}
function add(item) {
if (!storageKey()) return 'unavailable';
const id = identity(item);
if (!id) return 'unavailable';
const ids = read();
if (ids.includes(id)) return 'exists';
if (ids.length >= limit) return 'full';
ids.push(id);
return write(ids) ? 'added' : 'unavailable';
}
function remove(item) {
const id = identity(item);
const ids = read();
const next = ids.filter(candidate => candidate !== id);
return next.length !== ids.length && write(next);
}
function move(item, direction) {
const ids = read();
const index = ids.indexOf(identity(item));
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
if (index < 0 || target < 0 || target >= ids.length) return false;
[ids[index], ids[target]] = [ids[target], ids[index]];
return write(ids);
}
function reconcile(items, { pruneMissing = false } = {}) {
const available = new Map((items || []).map(item => [identity(item), item]));
const ids = read();
const retained = pruneMissing ? ids.filter(id => available.has(id)) : ids;
if (retained.length !== ids.length) write(retained);
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
}
function contains(item) {
return read().includes(identity(item));
}
function position(item) {
const ids = read();
const index = ids.indexOf(identity(item));
return {
can_up: index > 0,
can_down: index >= 0 && index < ids.length - 1,
};
}
return { identity, add, remove, move, reconcile, contains, position, limit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
+84
View File
@@ -1038,6 +1038,90 @@ async def release_assigned_issue(repository: str, number: int) -> dict:
}
async def issue_handoff_candidates(repository: str) -> list[dict]:
user, response = await asyncio.gather(
current_user(),
_get_client().get(
f"/api/v1/repos/{repository}/assignees", headers=_auth()
),
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea assignees response was not a list")
current_login = user.get("login") if isinstance(user, dict) else None
candidates = []
for item in payload:
if not isinstance(item, dict):
continue
login = item.get("login")
if not isinstance(login, str) or not login or login == current_login:
continue
full_name = item.get("full_name")
candidates.append({
"login": login,
"name": full_name if isinstance(full_name, str) and full_name else login,
})
return candidates
async def handoff_assigned_issue(
repository: str, number: int, recipient: str
) -> dict:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
)
if (
issue.get("state") != "open"
or issue.get("pull_request") is not None
or not _login_in_users(login, issue.get("assignees"))
):
raise IssueNotAvailableError("Issue is not assigned to the current user")
eligible = {
item["login"] for item in await issue_handoff_candidates(repository)
}
if recipient not in eligible:
raise IssueNotAvailableError("Handoff recipient is not eligible")
assignees_value = issue.get("assignees")
assignees = assignees_value if isinstance(assignees_value, list) else []
desired = [
item["login"] for item in assignees
if isinstance(item, dict)
and isinstance(item.get("login"), str)
and item["login"] != login
]
if recipient not in desired:
desired.append(recipient)
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"assignees": desired},
)
response.raise_for_status()
confirmed = response.json()
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
confirmed_assignees = [
item["login"] for item in confirmed_value
if isinstance(item, dict) and isinstance(item.get("login"), str)
] if isinstance(confirmed_value, list) else []
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or login in confirmed_assignees
or recipient not in confirmed_assignees
or set(confirmed_assignees) != set(desired)
):
raise ValueError("Gitea did not confirm issue handoff")
return {
"repository": repository,
"number": number,
"state": confirmed.get("state", "open"),
"assignees": confirmed_assignees,
"recipient": recipient,
}
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
+61
View File
@@ -239,6 +239,12 @@ class IssueMilestoneUpdate(BaseModel):
milestone_id: PositiveInt | None = None
class IssueHandoff(BaseModel):
recipient: str = Field(
min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.-]+$"
)
class PullReviewComment(BaseModel):
path: str = Field(min_length=1, max_length=1_000)
body: str = Field(min_length=1, max_length=10_000)
@@ -1478,6 +1484,61 @@ async def release_assigned_issue(
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff-candidates")
async def issue_handoff_candidates(
owner: str, repo: str, number: int = PathParam(gt=0)
) -> JSONResponse:
repository = f"{owner}/{repo}"
async def load_candidates():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.issue_handoff_candidates(repository)
try:
result = await asyncio.wait_for(
load_candidates(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Teammates could not be loaded. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff")
async def handoff_assigned_issue(
handoff: IssueHandoff,
owner: str,
repo: str,
number: int = PathParam(gt=0),
) -> JSONResponse:
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.handoff_assigned_issue(
repository, number, handoff.recipient
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "The issue or recipient changed. Reload before handing off."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The handoff could not be confirmed. It remains in My Work; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
repository = f"{owner}/{repo}"
+158
View File
@@ -955,6 +955,76 @@ async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_
assert result["available"] is False
@pytest.mark.anyio
async def test_gitea_handoff_candidates_exclude_current_and_malformed_users():
async def handler(request):
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
assert request.url.path == "/api/v1/repos/stackchain/api/assignees"
return httpx.Response(200, json=[
{"login": "timmy", "full_name": "Timmy"},
{"login": "alex", "full_name": "Alexander"},
{"login": "casey", "full_name": ""},
{"login": ""},
{"full_name": "Missing login"},
"malformed",
])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_handoff_candidates("stackchain/api")
finally:
await gitea_proxy.stop_client()
assert result == [
{"login": "alex", "name": "Alexander"},
{"login": "casey", "name": "casey"},
]
@pytest.mark.anyio
async def test_gitea_handoff_replaces_operator_and_preserves_coassignees():
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/assignees"):
return httpx.Response(200, json=[
{"login": "alex", "full_name": "Alexander"},
{"login": "casey", "full_name": "Casey"},
])
if request.method == "GET":
return httpx.Response(200, json={
"number": 17, "state": "open", "pull_request": None,
"assignees": [{"login": "timmy"}, {"login": "casey"}],
})
assert request.content == b'{"assignees":["casey","alex"]}'
return httpx.Response(200, json={
"number": 17, "state": "open",
"assignees": [{"login": "casey"}, {"login": "alex"}],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.handoff_assigned_issue("stackchain/api", 17, "alex")
finally:
await gitea_proxy.stop_client()
assert result == {
"repository": "stackchain/api", "number": 17, "state": "open",
"assignees": ["casey", "alex"], "recipient": "alex",
}
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/assignees"),
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
]
@pytest.mark.anyio
async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch):
calls = []
@@ -981,6 +1051,94 @@ async def test_release_issue_endpoint_invalidates_available_work_and_returns_con
assert main._available_issue_snapshot_created_at is None
@pytest.mark.anyio
async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def candidates(repository):
calls.append(("candidates", repository))
return [{"login": "alex", "name": "Alexander"}]
async def handoff(repository, number, recipient):
calls.append(("handoff", repository, number, recipient))
return {
"repository": repository, "number": number, "state": "open",
"assignees": [recipient], "recipient": recipient,
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
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/issues/17/handoff-candidates"
)
transferred = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/handoff",
json={"recipient": "alex"},
)
assert listed.status_code == 200
assert listed.json() == [{"login": "alex", "name": "Alexander"}]
assert transferred.status_code == 200
assert transferred.json()["recipient"] == "alex"
assert listed.headers["cache-control"] == "no-store"
assert transferred.headers["cache-control"] == "no-store"
assert calls == [
("assigned", "stackchain/api", 17),
("candidates", "stackchain/api"),
("handoff", "stackchain/api", 17, "alex"),
]
@pytest.mark.anyio
async def test_issue_handoff_returns_conflict_when_assignment_or_recipient_changed(monkeypatch):
async def handoff(_repository, _number, _recipient):
raise gitea_proxy.IssueNotAvailableError("stale")
monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/handoff",
json={"recipient": "alex"},
)
assert response.status_code == 409
assert response.json() == {
"error": "The issue or recipient changed. Reload before handing off."
}
@pytest.mark.anyio
async def test_issue_handoff_candidates_reject_an_issue_not_assigned_to_operator(monkeypatch):
called = False
async def assigned(_repository, _number):
return False
async def candidates(_repository):
nonlocal called
called = True
return []
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/repos/stackchain/api/issues/17/handoff-candidates"
)
assert response.status_code == 404
assert called is False
@pytest.mark.anyio
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
async def assigned(repository, number):
+67 -1
View File
@@ -470,6 +470,58 @@ controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
assert result.stdout == "Issue release was not confirmed."
def test_issue_handoff_is_single_flight_and_requires_confirmed_transfer():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let calls = [];
let finish;
const controller = createIssueSheet({{
fetchJson: (url, options) => {{
calls.push({{url, options}});
return new Promise(resolve => {{ finish = resolve; }});
}},
storage: null,
}});
const item = {{repository:'stackchain/api', number:17}};
const first = controller.handoff(item, 'alex', 'timmy');
const duplicate = controller.handoff(item, 'alex', 'timmy');
finish({{number:17, repository:'stackchain/api', recipient:'alex', assignees:['alex']}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, same: first === duplicate, results
}})));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["same"] is True
assert len(output["calls"]) == 1
assert output["calls"][0]["url"].endswith("/issues/17/handoff")
assert output["calls"][0]["options"]["method"] == "PATCH"
assert json.loads(output["calls"][0]["options"]["body"]) == {"recipient": "alex"}
assert output["results"][0]["assignees"] == ["alex"]
def test_issue_handoff_candidates_use_the_assigned_issue_route():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let call;
const controller = createIssueSheet({{
fetchJson: async (url, options) => {{ call = {{url, options}}; return [{{login:'alex', name:'Alexander'}}]; }},
storage: null,
}});
controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}})
.then(result => process.stdout.write(JSON.stringify({{call, result}})));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["call"]["url"].endswith("/issues/17/handoff-candidates")
assert output["call"]["options"]["headers"]["Accept"] == "application/json"
assert output["result"] == [{"login": "alex", "name": "Alexander"}]
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -576,6 +628,19 @@ 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_issue_sheet_exposes_touch_safe_teammate_handoff():
html = await dashboard()
assert 'id="issue-handoff-recipient"' in html
assert 'id="load-issue-handoff"' in html
assert 'id="confirm-issue-handoff"' in html
assert 'id="issue-handoff-status" class="small" aria-live="assertive"' in html
assert '.issue-handoff select, .issue-handoff button { min-height:44px;' in html
assert "issueController.loadHandoffCandidates(selectedIssue)" in html
assert "issueController.handoff(selectedIssue, recipient" in html
@pytest.mark.anyio
async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
html = await dashboard()
@@ -2989,13 +3054,14 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
assert 'data-work-count="all"' in html
assert 'data-work-count="today"' in html
assert 'data-work-count="attention"' in html
assert 'data-work-count="issue"' in html
assert 'data-work-count="pull"' in html
assert 'data-work-count="review"' in html
assert 'data-work-count="update"' in html
assert 'data-work-count="later"' in html
assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
assert "['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
assert 'data-work-count="draft"' in html
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
+4 -2
View File
@@ -91,10 +91,11 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout)
def test_deadline_attention_flow_ships_in_a_new_shell_cache():
def test_issue_handoff_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v19" in source
assert "stackchain-dashboard-shell-v21" in source
assert "BASE + 'static/today-work.js'" in source
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
@@ -194,6 +195,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/authored-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/my-work.js",
"/dashboard/static/today-work.js",
"/dashboard/static/later-work.js",
"/dashboard/static/detail-defer.js",
"/dashboard/static/pick-work.js",
+96
View File
@@ -0,0 +1,96 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
def run_node(script):
return subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
def test_today_queue_is_account_scoped_ordered_unique_and_bounded():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
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 login = 'timmy';
const queue = createTodayWork({{storage, getLogin: () => login, limit: 3}});
const issue = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const first = queue.add(issue(1));
const duplicate = queue.add(issue(1));
queue.add(issue(2));
queue.add(issue(3));
const full = queue.add(issue(4));
queue.move(issue(3), 'up');
queue.move(issue(3), 'up');
const timmy = queue.reconcile([issue(1), issue(2), issue(3), issue(4)]).map(item => item.number);
const persisted = createTodayWork({{storage, getLogin: () => login, limit: 3}})
.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number);
login = 'alexander';
const isolated = queue.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number);
process.stdout.write(JSON.stringify({{first, duplicate, full, timmy, persisted, isolated}}));
"""
assert json.loads(run_node(script)) == {
"first": "added",
"duplicate": "exists",
"full": "full",
"timmy": [3, 1, 2],
"persisted": [3, 1, 2],
"isolated": [],
}
def test_today_queue_exposes_reorder_boundaries_for_touch_controls():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{
getItem: key => values.get(key) || null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
const queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number}});
queue.add(item(1)); queue.add(item(2)); queue.add(item(3));
process.stdout.write(JSON.stringify([
queue.position(item(1)), queue.position(item(2)), queue.position(item(3)), queue.position(item(9))
]));
"""
assert json.loads(run_node(script)) == [
{"can_up": False, "can_down": True},
{"can_up": True, "can_down": True},
{"can_up": True, "can_down": False},
{"can_up": False, "can_down": False},
]
@pytest.mark.anyio
async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
html = await dashboard()
assert '<script src="static/today-work.js"></script>' in html
assert 'data-work-filter="today"' in html
assert 'data-work-count="today"' in html
assert "const todayWork = createTodayWork({" in html
assert "const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');" in html
assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html
assert 'data-today-add' in html
assert 'data-today-remove' in html
assert 'data-today-move="up"' in html
assert 'data-today-move="down"' in html
assert 'Today is limited to 5 items' in html
assert '.today-actions button' in html and 'min-height:44px' in html