Compare commits

...
Author SHA1 Message Date
timmy d09ceb8650 feat: make mobile issue sheets read-first (#215)
CI / lint (pull_request) Successful in 18s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 17:38:01 +00:00
timmy c0cc6ec46e Merge pull request 'Keep Find Work instant while its catalog refreshes' (#214) from timmy/213-instant-find-work into main
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 17:16:30 +00:00
timmy aaff109ede feat: keep Find Work instant during refresh (#213)
CI / lint (pull_request) Successful in 18s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 17:15:31 +00:00
timmy 2fe9b6cc3d Merge pull request 'Recover unfinished mobile drafts from My Work' (#212) from timmy/211-mobile-draft-recovery into main
CI / lint (push) Successful in 18s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 16:56:08 +00:00
timmy a08ce31858 feat: recover mobile drafts from My Work (#211)
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 16:55:00 +00:00
timmy 092234af38 Merge pull request 'Load complete mobile conversations without leaving My Work' (#210) from timmy/209-mobile-conversation-history into main
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 16:23:32 +00:00
timmy c63b23865c feat: load complete mobile conversations (#209)
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 16:22:30 +00:00
rockachopa e04d6753ac Merge pull request 'Resolve shared mobile My Work routes beyond page one' (#208) from timmy/207-resolve-mobile-work-routes into main
CI / lint (push) Successful in 21s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-07 16:00:02 +00:00
timmy b5545e380e feat: resolve shared My Work routes
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 15:58:56 +00:00
timmy 2fecccfbde Make mobile My Work sheets URL-addressable and back-button safe (#206)
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Closes #205
2026-08-07 15:18:23 +00:00
timmy 9e697dc63f feat: route mobile My Work sheets (#205)
CI / lint (pull_request) Successful in 16s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 15:17:21 +00:00
timmy 5e826bcc7e Merge pull request 'Plan and focus mobile My Work by milestone' (#204) from timmy/203-mobile-milestone-planning into main
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 15:01:56 +00:00
timmy 2be29b684a feat: plan mobile work by milestone (#203)
CI / lint (pull_request) Successful in 16s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 15:01:02 +00:00
timmy 09b2898d1c Merge pull request 'Persist authored action idempotency across restarts and workers' (#202) from timmy/201-durable-idempotency into main
CI / lint (push) Successful in 16s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-07 14:38:07 +00:00
timmy a5f63a6fa6 feat: persist authored action idempotency (#201)
CI / lint (pull_request) Successful in 16s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 14:37:10 +00:00
timmy 4cc00d2e48 Merge pull request 'Keep global search useful during partial failures and rapid typing' (#200) from timmy/199-partial-cancellable-search into main
CI / lint (push) Successful in 18s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
Keep global search useful during partial failures and rapid typing (#200)

Closes #199
2026-08-07 14:19:53 +00:00
timmy f1448e6b58 feat: keep global search useful during partial failures (#199)
CI / lint (pull_request) Successful in 15s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 14:18:38 +00:00
timmy 2243b9453c Merge pull request 'Preview and claim global search results without leaving the dashboard' (#198)
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 13:45:47 +00:00
timmy fe834a37a4 feat: preview global search results in dashboard (#197)
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 13:34:26 +00:00
rockachopa 46070d03f6 Search global work from the command palette (#196)
CI / lint (push) Successful in 14s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
Closes #195
2026-08-07 12:29:10 +00:00
timmy 7651e5b090 feat: add global work search (#195)
CI / lint (pull_request) Successful in 15s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 12:27:53 +00:00
rockachopa de20406a45 Merge pull request 'Work through My Work sequentially on mobile' (#194) from timmy/193-mobile-work-session into main
CI / lint (push) Successful in 14s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 11:55:25 +00:00
29 changed files with 3799 additions and 253 deletions

No files matched your search

+1
View File
@@ -2,3 +2,4 @@ __pycache__/
*.py[cod]
.pytest_cache/
.release-engine/
.stackchain-state/
+16 -4
View File
@@ -23,8 +23,11 @@ Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue
creation and assignment permission. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout or reload replays a confirmed result instead
of posting duplicate content. Direct API callers should preserve the `Idempotency-Key`
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. Results
are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Direct API callers should preserve the `Idempotency-Key`
header with the unchanged route and payload until a `201` response is confirmed. Closing an assigned issue, native Comment,
Approve, and Request changes reviews, and assigned-PR merge require repository
write permission. Native Comment, Approve, and Request changes reviews support
@@ -45,8 +48,17 @@ uvicorn src.main:app --host 127.0.0.1 --port 8000
Open `http://127.0.0.1:8000/` for the dashboard. To verify the backend and its
Gitea connection directly, request
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
`user`, `repos`, `issues`, and `pull_requests`. Never commit the token or place
it in a tracked configuration file.
`user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard
to search commands plus issues and pull requests across every repository visible to
the configured Gitea token. Remote search starts after two characters, is debounced,
and keeps local commands usable if Gitea search is unavailable. Selecting a remote
result opens a mobile-safe, read-only preview without discarding the search query;
open unassigned issues can be claimed in place and handed into My Work after Gitea
confirms the assignment. Closed work and pull requests remain read-only with a safe
canonical Gitea link. The bounded APIs are available at
`GET /api/v1/search?q=<query>&limit=<1-25>` and
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`. Never commit the token
or place it in a tracked configuration file.
For service monitoring, GET `/healthz` is a liveness check that confirms the
API process is running and does not contact Gitea. GET `/readyz` is the
+50 -1
View File
@@ -3,8 +3,57 @@
if (typeof module === 'object' && module.exports) module.exports = filterCommands;
if (root) root.filterCommands = filterCommands;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function filterCommands(commands, filter) {
function filterCommands(commands, filter) {
const term = String(filter || '').toLowerCase();
return commands.filter(command => command.name.toLowerCase().includes(term));
}
filterCommands.nextSelection = function nextSelection(current, key, count) {
if (count < 1) return -1;
if (key === 'ArrowDown') return (current + 1 + count) % count;
if (key === 'ArrowUp') return (current - 1 + count) % count;
return current;
};
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
const search = options.search;
const onState = options.onState;
const delay = options.delay === undefined ? 250 : options.delay;
let timer = null;
let generation = 0;
let activeController = null;
return {
setQuery(value) {
const query = String(value || '').trim();
generation += 1;
const current = generation;
if (timer !== null) clearTimeout(timer);
if (activeController !== null) activeController.abort();
activeController = null;
if (query.length < 2) {
onState({ status: 'idle', query, items: [] });
return;
}
onState({ status: 'loading', query, items: [] });
timer = setTimeout(async () => {
const requestController = new AbortController();
activeController = requestController;
try {
const result = await search(query, requestController.signal);
const items = Array.isArray(result) ? result : result.items;
const partial = !Array.isArray(result) && result.partial === true;
if (current === generation) onState({ status: 'ready', query, items, partial });
} catch (error) {
if (error && error.name === 'AbortError') return;
if (current === generation) onState({ status: 'error', query, items: [], error });
} finally {
if (activeController === requestController) activeController = null;
}
}, delay);
},
};
};
return filterCommands;
});
+69
View File
@@ -0,0 +1,69 @@
function createConversationPager({ loadPage }) {
let state = { comments: [], page: 1, older_page: null, total: 0 };
let olderRequest = null;
const validComments = comments => Array.isArray(comments)
? comments.filter(comment => comment && Number.isInteger(comment.id))
: [];
const unique = comments => {
const seen = new Set();
return comments.filter(comment => {
if (seen.has(comment.id)) return false;
seen.add(comment.id);
return true;
}).sort((left, right) => {
const leftTime = Date.parse(left.created_at || '');
const rightTime = Date.parse(right.created_at || '');
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return left.id - right.id;
});
};
const snapshot = () => ({ ...state, comments: state.comments.map(comment => ({ ...comment })) });
return {
reset(page) {
const comments = unique(validComments(page?.comments));
state = {
comments,
page: Number.isInteger(page?.page) ? page.page : 1,
older_page: Number.isInteger(page?.older_page) ? page.older_page : null,
total: Number.isInteger(page?.total) ? Math.max(page.total, comments.length) : comments.length,
};
olderRequest = null;
return snapshot();
},
snapshot,
loadOlder() {
if (olderRequest) return olderRequest;
if (!Number.isInteger(state.older_page)) return Promise.resolve(snapshot());
const requestedPage = state.older_page;
olderRequest = Promise.resolve(loadPage(requestedPage)).then(page => {
state = {
comments: unique(validComments(page?.comments).concat(state.comments)),
page: Number.isInteger(page?.page) ? page.page : requestedPage,
older_page: Number.isInteger(page?.older_page) ? page.older_page : null,
total: Number.isInteger(page?.total) ? Math.max(page.total, state.comments.length) : state.total,
};
return snapshot();
}).finally(() => { olderRequest = null; });
return olderRequest;
},
append(comment) {
if (!comment || !Number.isInteger(comment.id)) return snapshot();
if (!state.comments.some(existing => existing.id === comment.id)) {
state = {
...state,
comments: state.comments.concat(comment),
total: Math.max(state.total + 1, state.comments.length + 1),
};
}
return snapshot();
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationPager;
+144
View File
@@ -0,0 +1,144 @@
function createDraftInbox({ storage, now = () => Date.now() }) {
const indexKey = 'stackchain.draft-index.v1';
function readIndex() {
try {
const value = JSON.parse(storage?.getItem(indexKey) || '{}');
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch (_error) { return {}; }
}
function writeIndex(index) {
try { storage?.setItem(indexKey, JSON.stringify(index)); }
catch (_error) { /* Draft discovery remains available without metadata. */ }
}
function keys() {
try {
return Array.from({ length: Number(storage?.length || 0) }, (_, index) => storage.key(index))
.filter(Boolean);
} catch (_error) { return []; }
}
function textPreview(value) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 180);
}
function parseTarget(value) {
const match = String(value || '').match(/^(.+)#(\d+)$/);
return match ? { repository: match[1], number: Number(match[2]) } : null;
}
function parse(key, raw) {
let match;
if (key === 'stackchain.issue-capture.v1') {
try {
const value = JSON.parse(raw);
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
if (!preview && !value?.repository) return null;
return {
kind: 'new-issue', label: 'New issue', repository: String(value?.repository || ''),
title: textPreview(value?.title) || 'Untitled new issue', preview,
};
} catch (_error) { return null; }
}
match = key.match(/^stackchain\.(issue|pull)-comment\.v1:(.+#\d+)$/);
if (match) {
const target = parseTarget(match[2]);
const preview = textPreview(raw);
if (!target || !preview) return null;
const routeKind = match[1] === 'issue' ? 'issue' : 'pull';
return {
kind: routeKind + '-comment', label: routeKind === 'issue' ? 'Issue comment' : 'PR comment',
...target, title: target.repository + '#' + target.number, preview,
route: { kind: routeKind, ...target },
};
}
match = key.match(/^stackchain\.issue-content\.v1:(.+#\d+)$/);
if (match) {
const target = parseTarget(match[1]);
try {
const value = JSON.parse(raw);
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
if (!target || !preview || typeof value?.expectedUpdatedAt !== 'string') return null;
return {
kind: 'issue-edit', label: 'Issue edit', ...target,
title: target.repository + '#' + target.number, preview,
route: { kind: 'issue', ...target },
};
} catch (_error) { return null; }
}
match = key.match(/^stackchain\.update-reply\.v1\.(\d+)$/);
if (match) {
const preview = textPreview(raw);
if (!preview) return null;
const notificationId = Number(match[1]);
return {
kind: 'update-reply', label: 'Update reply', notification_id: notificationId,
title: 'Update #' + notificationId, preview,
route: { kind: 'update', notification_id: notificationId },
};
}
match = key.match(/^stackchain\.review-draft\.v1:(.+#\d+)@([^:]+)$/);
if (match) {
const target = parseTarget(match[1]);
try {
const value = JSON.parse(raw);
const notes = Object.values(value?.notes || {});
const comments = Array.isArray(value?.comments) ? value.comments.map(item => item?.body) : [];
const preview = textPreview([value?.summary, ...notes, ...comments].filter(Boolean).join(' — '));
if (!target || !preview) return null;
return {
kind: 'review', label: 'PR review', ...target, head_sha: match[2],
title: target.repository + '#' + target.number, preview,
route: { kind: 'review', ...target },
};
} catch (_error) { return null; }
}
return null;
}
function list() {
const index = readIndex();
const seen = new Set();
const drafts = [];
keys().forEach(key => {
if (key === indexKey || key.endsWith(':operation')) return;
let raw;
try { raw = storage.getItem(key); }
catch (_error) { return; }
const parsed = parse(key, raw);
if (!parsed) return;
seen.add(key);
const fingerprint = String(raw);
const previous = index[key];
if (!previous || previous.fingerprint !== fingerprint) {
index[key] = { fingerprint, updated_at: Number(now()) };
}
drafts.push({ id: key, ...parsed, updated_at: index[key].updated_at });
});
Object.keys(index).forEach(key => { if (!seen.has(key)) delete index[key]; });
writeIndex(index);
return drafts.sort((left, right) =>
Number(right.updated_at || 0) - Number(left.updated_at || 0) || left.id.localeCompare(right.id)
);
}
function discard(id) {
if (typeof id !== 'string' || !parse(id, (() => {
try { return storage?.getItem(id); } catch (_error) { return null; }
})())) return false;
try {
storage?.removeItem(id);
storage?.removeItem(id + ':operation');
const index = readIndex();
delete index[id];
writeIndex(index);
return true;
} catch (_error) { return false; }
}
return { list, discard };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
+685 -123
View File
File diff suppressed because it is too large. Load diff
+67 -1
View File
@@ -1,10 +1,31 @@
function createIssueSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function createPlanningLoader({ loadLabels, loadMilestones }) {
const requests = new Map();
const keyFor = item => item.repository + '#' + item.number;
return {
open(item) {
const key = keyFor(item);
if (requests.has(key)) return requests.get(key);
const request = Promise.all([loadLabels(item), loadMilestones(item)])
.then(([labels, milestones]) => ({ labels, milestones }))
.catch(error => {
if (requests.get(key) === request) requests.delete(key);
throw error;
});
requests.set(key, request);
return request;
},
};
}
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
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 +33,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) {
@@ -19,11 +41,26 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
headers: { Accept: 'application/json' },
});
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
}),
});
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
return pager;
},
loadLabels(item) {
return fetchJson(issuePath(item) + '/labels', {
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 +130,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)) || '[]');
@@ -169,4 +233,6 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
};
}
createIssueSheet.createPlanningLoader = createPlanningLoader;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
+36 -7
View File
@@ -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;
+5
View File
@@ -39,6 +39,11 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
headers: { Accept: 'application/json' },
}).then(result => {
apply(result, append);
if (result?.refresh_failed === true) {
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
} else if (result?.revalidating === true) {
onStatus('Showing saved available work while the catalog refreshes…');
}
return result;
}).finally(() => { loadRequest = null; });
return loadRequest;
+10 -1
View File
@@ -37,7 +37,7 @@ function renderFile(file, index, reviewed, escapeHtml) {
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
}
function createPullSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
@@ -62,6 +62,15 @@ function createPullSheet({ fetchJson, storage, createOperationId = () => globalT
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
}),
});
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
return pager;
},
reviewState,
toggleReviewed(item, detail, filename) {
const state = reviewState(item, detail);
+48
View File
@@ -0,0 +1,48 @@
(function (root, factory) {
const createSearchPreview = factory();
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
if (root) root.createSearchPreview = createSearchPreview;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, claim, onState }) {
let generation = 0;
let current = null;
let claimRequest = null;
return {
open(item) {
generation += 1;
const requestGeneration = generation;
current = { ...item };
onState({ status: 'loading', item: current });
return fetchJson(current).then(detail => {
if (requestGeneration === generation) {
onState({ status: 'ready', item: current, detail });
}
return detail;
}).catch(error => {
if (requestGeneration === generation) {
onState({ status: 'error', item: current, error });
}
throw error;
});
},
close() {
generation += 1;
current = null;
onState({ status: 'closed' });
},
claim(detail) {
if (claimRequest) return claimRequest;
onState({ status: 'claiming', item: current, detail });
claimRequest = claim(detail).then(result => {
onState({ status: 'claimed', item: current, detail, result });
return result;
}).catch(error => {
onState({ status: 'ready', item: current, detail, error });
throw error;
}).finally(() => { claimRequest = null; });
return claimRequest;
},
};
};
});
+161
View File
@@ -0,0 +1,161 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createWorkRoute = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
function positiveInteger(value) {
const number = Number(value);
return Number.isSafeInteger(number) && number > 0 ? number : null;
}
function parse(fragment) {
const parts = String(fragment || '').split('/');
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
if (parts[2] === 'update' && parts.length === 4) {
const notificationId = positiveInteger(parts[3]);
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
}
if (!['issue', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
const number = positiveInteger(parts[5]);
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
}
function serialize(item) {
if (item?.kind === 'update') {
const notificationId = positiveInteger(item.notification_id);
return notificationId ? '#/my-work/update/' + notificationId : '';
}
if (!['issue', 'pull', 'review'].includes(item?.kind)) return '';
const repository = String(item.repository || '').split('/');
const number = positiveInteger(item.number);
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
return '#/my-work/' + item.kind + '/' + repository.join('/') + '/' + number;
}
function sameRoute(item, route) {
if (route.kind === 'update') {
return Number(item.notification_id) === route.notification_id;
}
const itemKind = item.is_review ? 'review' : item.kind;
return itemKind === route.kind && item.repository === route.repository &&
Number(item.number) === route.number;
}
function createController({
location, history, eventTarget, onOpen, onClose, onInvalid,
resolve, onResolving = function () {}, onError = function () {},
}) {
let items = [];
let started = false;
let active = '';
let ready = false;
let resolving = '';
let resolution = 0;
function resolveMissing(fragment, route) {
if (typeof resolve !== 'function') {
onInvalid();
return;
}
if (resolving === fragment) return;
resolving = fragment;
const request = ++resolution;
onResolving(route);
Promise.resolve(resolve(route)).then(item => {
if (request !== resolution || String(location.hash || '') !== fragment) return;
resolving = '';
if (!item || !sameRoute(item, route)) {
onInvalid();
return;
}
active = fragment;
onOpen({ ...item, kind: route.kind });
}).catch(error => {
if (request !== resolution || String(location.hash || '') !== fragment) return;
resolving = '';
if (error?.unavailable) onInvalid();
else onError(error, route);
});
}
function sync() {
const fragment = String(location.hash || '');
if (!fragment) {
resolution += 1;
resolving = '';
if (active) onClose();
active = '';
return;
}
const route = parse(fragment);
if (!route) {
resolution += 1;
resolving = '';
if (fragment.startsWith('#/my-work/')) onInvalid();
active = '';
return;
}
const item = items.find(candidate => sameRoute(candidate, route));
if (!item) {
if (ready) resolveMissing(fragment, route);
return;
}
if (active === fragment) return;
resolution += 1;
resolving = '';
active = fragment;
onOpen({ ...item, kind: route.kind });
}
return {
start() {
if (started) return;
started = true;
eventTarget.addEventListener('popstate', sync);
eventTarget.addEventListener('hashchange', sync);
sync();
},
setItems(nextItems) {
items = Array.isArray(nextItems) ? nextItems.slice() : [];
ready = true;
sync();
},
open(item, options = {}) {
const fragment = serialize(item);
if (!fragment) return false;
const method = options.replace ? 'replaceState' : 'pushState';
history[method]({ workRoute: fragment }, '', fragment);
active = fragment;
onOpen(item);
return true;
},
close() {
if (parse(location.hash)) history.back();
else {
active = '';
onClose();
}
},
sync,
};
}
async function share(url, navigatorObject, clipboard) {
if (typeof navigatorObject?.share === 'function') {
await navigatorObject.share({ url });
return 'shared';
}
if (typeof clipboard?.writeText === 'function') {
await clipboard.writeText(url);
return 'copied';
}
throw new Error('Sharing is unavailable.');
}
return { parse, serialize, createController, share };
});
+324 -27
View File
@@ -11,6 +11,7 @@ GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000").rstrip("/")
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
REVIEW_DIFF_MAX_BYTES = 64 * 1024
REVIEW_DIFF_MAX_LINES = 400
AVAILABLE_ISSUE_PAGE_CONCURRENCY = 3
_client: httpx.AsyncClient | None = None
@@ -22,6 +23,10 @@ class WorkItems(list[dict]):
self.pagination = pagination
class WorkRouteUnavailableError(ValueError):
"""Raised when a shared route no longer belongs in the current user's queue."""
class StaleReviewError(ValueError):
"""Raised before mutation when a pull request head changed during review."""
@@ -183,6 +188,112 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
}
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
if not isinstance(item, dict):
return None
repository = item.get("repository")
repository = repository if isinstance(repository, dict) else {}
url = _safe_web_url(item.get("html_url"))
if not (
isinstance(item.get("number"), int)
and isinstance(item.get("title"), str)
and isinstance(item.get("state"), str)
and isinstance(repository.get("full_name"), str)
and repository.get("full_name")
and url
):
return None
return {
"kind": kind,
"repository": repository["full_name"],
"number": item["number"],
"title": item["title"],
"state": item["state"],
"url": url,
}
async def global_search(query: str, limit: int = 10) -> dict:
"""Search accessible issues and pulls concurrently with a bounded result set."""
async def load(item_type: str) -> Any:
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
params={"q": query, "type": item_type, "limit": limit, "page": 1},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea global search response was not a list")
return payload
outcomes = await asyncio.gather(
load("issues"), load("pulls"), return_exceptions=True
)
for outcome in outcomes:
if isinstance(outcome, asyncio.CancelledError):
raise outcome
if all(isinstance(outcome, BaseException) for outcome in outcomes):
raise outcomes[0]
results = []
seen: set[tuple[str, str, int]] = set()
for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True):
if isinstance(outcome, BaseException):
continue
for item in outcome:
normalized = _normalize_global_search_item(item, kind)
if normalized is not None:
identity = (kind, normalized["repository"], normalized["number"])
if identity in seen:
continue
seen.add(identity)
results.append(normalized)
return {
"items": results[:limit],
"partial": any(isinstance(outcome, BaseException) for outcome in outcomes),
}
async def work_preview(repository: str, kind: str, number: int) -> dict:
"""Load bounded, read-only context for a global search result."""
issue, user = await asyncio.gather(
fetch(f"repos/{repository}/issues/{number}"), current_user()
)
if not isinstance(issue, dict):
raise ValueError("Gitea work preview response was not an object")
labels_value = issue.get("labels")
assignees_value = issue.get("assignees")
labels = labels_value if isinstance(labels_value, list) else []
assignees = assignees_value if isinstance(assignees_value, list) else []
assignee_names = [
assignee["login"] for assignee in assignees
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
]
author = issue.get("user")
author = author if isinstance(author, dict) else {}
login = user.get("login") if isinstance(user, dict) else ""
state = issue.get("state") if isinstance(issue.get("state"), str) else ""
actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue"
return {
"kind": actual_kind,
"repository": repository,
"number": number,
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
"state": state,
"author": author.get("login", "") if isinstance(author.get("login"), str) else "",
"labels": [
label["name"] for label in labels
if isinstance(label, dict) and isinstance(label.get("name"), str)
],
"assignees": assignee_names,
"url": _safe_web_url(issue.get("html_url")),
"claimable": actual_kind == "issue" and state == "open" and not assignee_names,
"assigned_to_me": bool(login and login in assignee_names),
}
def _normalize_available_issue(item: Any) -> dict | None:
if (
not isinstance(item, dict)
@@ -218,8 +329,8 @@ async def available_issue_snapshot(max_pages: int = 10, upstream_limit: int = 50
"""Load, filter, and globally rank a bounded snapshot of available issues."""
items: list[dict] = []
seen_ids: set[Any] = set()
upstream_total: int | None = None
for upstream_page in range(1, max_pages + 1):
async def load_page(upstream_page: int) -> tuple[list[Any], int | None]:
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
@@ -232,21 +343,41 @@ async def available_issue_snapshot(max_pages: int = 10, upstream_limit: int = 50
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea available issue search response was not a list")
if upstream_total is None:
try:
upstream_total = max(0, int(response.headers["X-Total-Count"]))
except (KeyError, TypeError, ValueError):
upstream_total = None
try:
total = max(0, int(response.headers["X-Total-Count"]))
except (KeyError, TypeError, ValueError):
total = None
return payload, total
first_payload, upstream_total = await load_page(1)
pages: list[list[Any]] = [first_payload]
if upstream_total is not None:
page_count = min(max_pages, max(1, (upstream_total + upstream_limit - 1) // upstream_limit))
semaphore = asyncio.Semaphore(AVAILABLE_ISSUE_PAGE_CONCURRENCY)
async def load_bounded(page: int) -> list[Any]:
async with semaphore:
payload, _ = await load_page(page)
return payload
if page_count > 1:
pages.extend(await asyncio.gather(*(
load_bounded(page) for page in range(2, page_count + 1)
)))
else:
previous = first_payload
for page in range(2, max_pages + 1):
if not previous or len(previous) < upstream_limit:
break
previous, _ = await load_page(page)
pages.append(previous)
for payload in pages:
for raw_item in payload:
item = _normalize_available_issue(raw_item)
if item is not None and item["id"] not in seen_ids:
seen_ids.add(item["id"])
items.append(item)
loaded = upstream_page * upstream_limit
if not payload or len(payload) < upstream_limit or (
upstream_total is not None and loaded >= upstream_total
):
break
priority = {"p0", "priority-high", "critical"}
items.sort(key=lambda item: (item["repository"], item["number"] or 0))
@@ -551,6 +682,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,
@@ -765,10 +916,105 @@ 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_conversation_page(
repository: str,
number: int,
page: int | None = None,
limit: int = 20,
) -> dict:
"""Return one bounded comment page, opening on the newest page by default."""
bounded_limit = min(50, max(1, limit))
requested_page = max(1, page or 1)
path = f"/api/v1/repos/{repository}/issues/{number}/comments"
async def load(selected_page: int) -> tuple[list, int]:
response = await _get_client().get(
path,
headers=_auth(),
params={"limit": bounded_limit, "page": selected_page},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea issue comments response was not a list")
comments = [item for item in payload if isinstance(item, dict)]
try:
total = max(len(comments), int(response.headers.get("X-Total-Count", len(comments))))
except (TypeError, ValueError):
total = len(comments)
return comments, total
comments, total = await load(requested_page)
newest_page = max(1, (total + bounded_limit - 1) // bounded_limit)
if len(comments) >= total:
selected_page = newest_page if page is None else min(requested_page, newest_page)
start = (selected_page - 1) * bounded_limit
comments = comments[start:start + bounded_limit]
else:
selected_page = requested_page
if page is None and newest_page != requested_page:
selected_page = newest_page
comments, confirmed_total = await load(selected_page)
total = max(total, confirmed_total)
return {
"comments": [_normalize_issue_comment(item) for item in comments],
"page": selected_page,
"older_page": selected_page - 1 if selected_page > 1 else None,
"total": total,
}
async def issue_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/issues/{number}"
issue, comments = await asyncio.gather(
fetch(base), fetch(f"{base}/comments?limit=20&page=1")
issue, conversation = await asyncio.gather(
fetch(base), issue_conversation_page(repository, number)
)
if not isinstance(issue, dict):
raise ValueError("Gitea issue response was not an object")
@@ -776,12 +1022,7 @@ async def issue_detail(repository: str, number: int) -> dict:
labels: list = labels_value if isinstance(labels_value, list) else []
assignees_value = issue.get("assignees")
assignees: list = assignees_value if isinstance(assignees_value, list) else []
comments_value: list = comments if isinstance(comments, list) else []
normalized_comments = [
_normalize_issue_comment(comment)
for comment in comments_value
if isinstance(comment, dict)
]
normalized_comments = conversation["comments"]
return {
"repository": repository,
"number": number,
@@ -790,6 +1031,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
@@ -800,6 +1048,7 @@ async def issue_detail(repository: str, number: int) -> dict:
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
],
"comments": normalized_comments,
"conversation": conversation,
}
@@ -860,6 +1109,57 @@ def _login_in_users(login: str, value: object) -> bool:
)
async def resolve_work_route(
kind: str,
repository: str | None,
number: int | None,
notification_id: int | None,
) -> dict:
if kind == "update":
if notification_id is None:
raise WorkRouteUnavailableError("Notification identity is missing")
thread = await fetch(f"notifications/threads/{notification_id}")
if not isinstance(thread, dict) or thread.get("unread") is not True:
raise WorkRouteUnavailableError("Notification is no longer unread")
detail = await notification_detail(notification_id)
return {
"kind": "update",
"notification_id": notification_id,
"has_update": True,
"repository": detail.get("repository", ""),
"title": detail.get("title", ""),
"url": detail.get("url", ""),
}
if repository is None or number is None or kind not in {"issue", "pull", "review"}:
raise WorkRouteUnavailableError("Work identity is missing")
target_path = "issues" if kind == "issue" else "pulls"
login, target = await _current_login_and_target(
f"repos/{repository}/{target_path}/{number}"
)
assigned = _login_in_users(login, target.get("assignees"))
requested = _login_in_users(login, target.get("requested_reviewers"))
eligible = (
target.get("state") == "open"
and (
(kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned)
or (kind == "pull" and assigned)
or (kind == "review" and requested)
)
)
if not eligible:
raise WorkRouteUnavailableError("Work item is no longer in My Work")
return {
"kind": kind,
"repository": repository,
"number": number,
"title": target.get("title", "") if isinstance(target.get("title"), str) else "",
"state": "open",
"url": _safe_web_url(target.get("html_url")),
**({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}),
}
async def is_assigned_issue(repository: str, number: int) -> bool:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
@@ -924,10 +1224,10 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
raise ValueError("Gitea pull request response was not an object")
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
sha = head.get("sha") if isinstance(head.get("sha"), str) else ""
files, status, comments, diff_result = await asyncio.gather(
files, status, conversation, diff_result = await asyncio.gather(
fetch(f"{base}/files"),
fetch(f"repos/{repository}/commits/{sha}/status"),
fetch(f"repos/{repository}/issues/{number}/comments?limit=20&page=1"),
issue_conversation_page(repository, number),
fetch_text(
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
),
@@ -967,11 +1267,8 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
for item in (files if isinstance(files, list) else [])[:100]
if isinstance(item, dict) and isinstance(item.get("filename"), str)
],
"comments": [
_normalize_issue_comment(item)
for item in (comments if isinstance(comments, list) else [])[:20]
if isinstance(item, dict)
],
"comments": conversation["comments"],
"conversation": conversation,
}
+114
View File
@@ -0,0 +1,114 @@
import json
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
@dataclass(frozen=True)
class Reservation:
state: str
response: Any = None
class IdempotencyLedger:
"""Small durable ledger for replaying successful Gitea mutations."""
def __init__(
self,
path: str | Path,
*,
ttl_seconds: float,
max_entries: int,
clock: Callable[[], float] = time.time,
) -> None:
self.path = Path(path)
self.ttl_seconds = ttl_seconds
self.max_entries = max_entries
self.clock = clock
self.path.parent.mkdir(parents=True, exist_ok=True)
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=5)
connection.execute("PRAGMA busy_timeout = 5000")
return connection
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS idempotency_operations (
key TEXT PRIMARY KEY,
fingerprint TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending', 'completed')),
response_json TEXT,
created_at REAL NOT NULL,
completed_at REAL
)
"""
)
connection.execute(
"CREATE INDEX IF NOT EXISTS idempotency_completed_at_idx "
"ON idempotency_operations(completed_at)"
)
@staticmethod
def _fingerprint(value: tuple[Any, ...]) -> str:
return json.dumps(value, separators=(",", ":"), sort_keys=True)
def reserve(self, key: str, fingerprint: tuple[Any, ...]) -> Reservation:
encoded = self._fingerprint(fingerprint)
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"DELETE FROM idempotency_operations "
"WHERE status = 'completed' AND completed_at <= ?",
(now - self.ttl_seconds,),
)
row = connection.execute(
"SELECT fingerprint, status, response_json FROM idempotency_operations "
"WHERE key = ?",
(key,),
).fetchone()
if row is not None:
if row[0] != encoded:
return Reservation("conflict")
if row[1] == "completed":
return Reservation("completed", json.loads(row[2]))
return Reservation("pending")
count = connection.execute(
"SELECT COUNT(*) FROM idempotency_operations"
).fetchone()[0]
if count >= self.max_entries:
completed = connection.execute(
"SELECT key FROM idempotency_operations "
"WHERE status = 'completed' ORDER BY completed_at, rowid LIMIT 1"
).fetchone()
if completed is None:
return Reservation("busy")
connection.execute(
"DELETE FROM idempotency_operations WHERE key = ?",
(completed[0],),
)
connection.execute(
"INSERT INTO idempotency_operations "
"(key, fingerprint, status, created_at) VALUES (?, ?, 'pending', ?)",
(key, encoded, now),
)
return Reservation("reserved")
def clear(self) -> None:
with self._connect() as connection:
connection.execute("DELETE FROM idempotency_operations")
def complete(self, key: str, response: Any) -> None:
encoded = json.dumps(response, separators=(",", ":"), sort_keys=True)
with self._connect() as connection:
connection.execute(
"UPDATE idempotency_operations SET status = 'completed', "
"response_json = ?, completed_at = ? WHERE key = ?",
(encoded, self.clock(), key),
)
+301 -80
View File
@@ -1,5 +1,6 @@
import asyncio
import math
import os
import time
from collections.abc import Awaitable, Coroutine
from contextlib import asynccontextmanager
@@ -24,7 +25,8 @@ from src.gitea_proxy import (
pull_review_detail,
repos,
)
from src.models import Issue, PullRequest, Repo, User
from src.idempotency import IdempotencyLedger
from src.models import Issue, Milestone, PullRequest, Repo, User
from src.suggestion_engine import compute
from src.views import router as frontend_router
@@ -66,6 +68,7 @@ AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES = 256
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
WORK_PAGE_TIMEOUT_SECONDS = 5.0
GLOBAL_SEARCH_TIMEOUT_SECONDS = 3.0
NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0
BULK_NOTIFICATION_CONCURRENCY = 5
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
@@ -73,6 +76,7 @@ LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
LIVE_SNAPSHOT_RETRY_BASE_SECONDS = 5.0
LIVE_SNAPSHOT_RETRY_MAX_SECONDS = 60.0
AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS = 15.0
AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS = 5.0
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
_live_snapshot_task: asyncio.Task | None = None
_live_snapshot_value: dict | None = None
@@ -89,15 +93,22 @@ _live_section_retry_at: dict[str, float | None] = {
}
_live_snapshot_refreshing_sections: set[str] = set()
_read_notification_ids: set[int] = set()
_issue_creation_operations: dict[
str, tuple[tuple[Any, ...], asyncio.Task, float]
] = {}
_authored_action_operations: dict[
str, tuple[tuple[Any, ...], asyncio.Task, float]
] = {}
_state_dir = Path(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"))
_idempotency_ledger = IdempotencyLedger(
os.getenv("STACKCHAIN_IDEMPOTENCY_DB", str(_state_dir / "idempotency.sqlite3")),
ttl_seconds=AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS,
max_entries=(
AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES
+ ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES
),
)
_available_issue_snapshot_task: asyncio.Task | None = None
_available_issue_snapshot_value: list[dict] | None = None
_available_issue_snapshot_created_at: float | None = None
_available_issue_snapshot_retry_at: float | None = None
class ContextPayloadError(ValueError):
@@ -186,6 +197,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)
@@ -235,39 +250,52 @@ async def _run_idempotent_authored_action(
if not idempotency_key:
return await asyncio.wait_for(operation, timeout=timeout)
now = time.monotonic()
expired = [
key for key, (_, task, created_at) in _authored_action_operations.items()
if task.done() and now - created_at >= AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS
]
for key in expired:
_authored_action_operations.pop(key, None)
reservation = _idempotency_ledger.reserve(idempotency_key, fingerprint)
if reservation.state == "conflict":
operation.close()
raise HTTPException(status_code=409, detail="Idempotency key already used")
if reservation.state == "completed":
operation.close()
return reservation.response
if reservation.state == "busy":
operation.close()
raise HTTPException(
status_code=503,
detail="Authored action queue is busy; please retry",
headers={"Retry-After": "1"},
)
existing = _authored_action_operations.get(idempotency_key)
if existing is not None:
if reservation.state == "pending":
operation.close()
if existing[0] != fingerprint:
raise HTTPException(status_code=409, detail="Idempotency key already used")
if existing is None or existing[0] != fingerprint:
raise HTTPException(
status_code=503,
detail=(
"This action may still be completing; verify its result before retrying"
),
headers={"Retry-After": "5"},
)
task = existing[1]
else:
while len(_authored_action_operations) >= AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES:
completed = [
key for key, (_, task, _) in _authored_action_operations.items()
if task.done()
]
if not completed:
operation.close()
raise HTTPException(
status_code=503,
detail="Authored action queue is busy; please retry",
headers={"Retry-After": "1"},
)
oldest = min(
completed, key=lambda key: _authored_action_operations[key][2]
)
_authored_action_operations.pop(oldest)
task = asyncio.create_task(operation)
_authored_action_operations[idempotency_key] = (fingerprint, task, now)
async def persist_result():
result = await operation
_idempotency_ledger.complete(idempotency_key, result)
return result
task = asyncio.create_task(persist_result())
_authored_action_operations[idempotency_key] = (
fingerprint,
task,
time.monotonic(),
)
def discard_completed(completed: asyncio.Task) -> None:
existing_operation = _authored_action_operations.get(idempotency_key)
if existing_operation is not None and existing_operation[1] is completed:
_authored_action_operations.pop(idempotency_key, None)
task.add_done_callback(discard_completed)
return await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
@@ -296,6 +324,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]
@@ -336,6 +367,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
@@ -364,7 +399,7 @@ app.include_router(frontend_router)
@app.middleware("http")
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues"} or request.url.path.startswith("/api/v1/work/") or (
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route"} or request.url.path.startswith("/api/v1/work/") or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/review")
) or request.url.path.startswith("/api/v1/notifications") or (
@@ -375,7 +410,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
@@ -471,6 +506,76 @@ async def context() -> JSONResponse:
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
@app.get("/api/v1/search")
async def global_search(
q: str = Query(min_length=2, max_length=100),
limit: int = Query(default=10, ge=1, le=25),
) -> JSONResponse:
query = q.strip()
if len(query) < 2:
raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters")
try:
result = await asyncio.wait_for(
gitea_proxy.global_search(query, limit),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{"error": "Search is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse({"query": query, **result})
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview")
async def global_search_preview(
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(),
) -> JSONResponse:
try:
preview = await asyncio.wait_for(
gitea_proxy.work_preview(f"{owner}/{repo}", kind, number),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{"error": "This work item is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(preview)
@app.get("/api/v1/work-route")
async def resolve_work_route(
kind: Literal["issue", "pull", "review", "update"] = Query(),
repository: str | None = Query(default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"),
number: int | None = Query(default=None, gt=0),
notification_id: int | None = Query(default=None, gt=0),
) -> JSONResponse:
repository_route = repository is not None and number is not None and notification_id is None
update_route = kind == "update" and notification_id is not None and repository is None and number is None
if (kind == "update" and not update_route) or (kind != "update" and not repository_route):
raise HTTPException(status_code=422, detail="Invalid work route identity")
try:
result = await asyncio.wait_for(
gitea_proxy.resolve_work_route(kind, repository, number, notification_id),
timeout=WORK_PAGE_TIMEOUT_SECONDS,
)
except gitea_proxy.WorkRouteUnavailableError:
raise HTTPException(status_code=404, detail="Work item is no longer in My Work")
except Exception:
return JSONResponse(
{"error": "The shared work item is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/work/{stream}")
async def paged_work(
stream: Literal["issue", "pull", "review"],
@@ -497,13 +602,24 @@ async def paged_work(
async def _refresh_available_issue_snapshot() -> list[dict]:
global _available_issue_snapshot_value, _available_issue_snapshot_created_at
global _available_issue_snapshot_retry_at
result = await gitea_proxy.available_issue_snapshot()
_available_issue_snapshot_value = result
_available_issue_snapshot_created_at = time.monotonic()
_available_issue_snapshot_retry_at = None
return result
async def _available_issue_snapshot() -> tuple[list[dict], bool]:
def _observe_available_issue_refresh(task: asyncio.Task) -> None:
global _available_issue_snapshot_retry_at
if not task.cancelled():
if task.exception() is not None:
_available_issue_snapshot_retry_at = (
time.monotonic() + AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS
)
async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
global _available_issue_snapshot_task
now = time.monotonic()
if (
@@ -512,16 +628,25 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool]:
and now - _available_issue_snapshot_created_at
< AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS
):
return _available_issue_snapshot_value, False
return _available_issue_snapshot_value, False, False, False
if (
_available_issue_snapshot_value is not None
and _available_issue_snapshot_retry_at is not None
and now < _available_issue_snapshot_retry_at
):
return _available_issue_snapshot_value, True, False, True
if _available_issue_snapshot_task is None or _available_issue_snapshot_task.done():
_available_issue_snapshot_task = asyncio.create_task(
_refresh_available_issue_snapshot()
)
_available_issue_snapshot_task.add_done_callback(_observe_available_issue_refresh)
if _available_issue_snapshot_value is not None:
return _available_issue_snapshot_value, True, True, False
try:
return await asyncio.shield(_available_issue_snapshot_task), False
return await asyncio.shield(_available_issue_snapshot_task), False, False, False
except Exception:
if _available_issue_snapshot_value is not None:
return _available_issue_snapshot_value, True
return _available_issue_snapshot_value, True, False, True
raise
@@ -539,7 +664,7 @@ def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict
@app.get("/api/v1/available-issues")
async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONResponse:
try:
items, stale = await asyncio.wait_for(
items, stale, revalidating, refresh_failed = await asyncio.wait_for(
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
)
except Exception:
@@ -551,6 +676,10 @@ async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONRe
result = _available_issue_page(items, page)
if stale:
result["stale"] = True
if revalidating:
result["revalidating"] = True
if refresh_failed:
result["refresh_failed"] = True
return JSONResponse(result)
@@ -1164,6 +1293,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}"
@@ -1225,48 +1417,19 @@ async def create_assigned_issue(
repository, creation.title, creation.body, login, creation.label_ids
)
operation = create_issue()
if idempotency_key:
now = time.monotonic()
expired = [
key for key, (_, task, created_at) in _issue_creation_operations.items()
if task.done()
and now - created_at >= ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS
]
for key in expired:
_issue_creation_operations.pop(key, None)
fingerprint = (
repository, creation.title, creation.body, tuple(creation.label_ids)
)
existing = _issue_creation_operations.get(idempotency_key)
if existing is not None:
operation.close()
if existing[0] != fingerprint:
raise HTTPException(status_code=409, detail="Idempotency key already used")
task = existing[1]
else:
while len(_issue_creation_operations) >= ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES:
completed = [
key for key, (_, task, _) in _issue_creation_operations.items()
if task.done()
]
if not completed:
operation.close()
raise HTTPException(
status_code=503,
detail="Issue creation is busy; please retry",
headers={"Retry-After": "1"},
)
oldest = min(
completed, key=lambda key: _issue_creation_operations[key][2]
)
_issue_creation_operations.pop(oldest)
task = asyncio.create_task(operation)
_issue_creation_operations[idempotency_key] = (fingerprint, task, now)
operation = asyncio.shield(task)
try:
result = await asyncio.wait_for(operation, timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
result = await _run_idempotent_authored_action(
create_issue(),
idempotency_key=idempotency_key,
fingerprint=(
"issue-create",
repository,
creation.title,
creation.body,
tuple(creation.label_ids),
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
@@ -1278,6 +1441,35 @@ async def create_assigned_issue(
return JSONResponse(result, status_code=201)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/comments")
async def assigned_issue_conversation(
owner: str,
repo: str,
number: int = PathParam(gt=0),
page: int | None = Query(default=None, ge=1, le=100),
limit: int = Query(default=20, ge=1, le=50),
):
repository = f"{owner}/{repo}"
async def load_conversation():
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_conversation_page(repository, number, page, limit)
try:
return await asyncio.wait_for(
load_conversation(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The conversation could not be loaded. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/comments", status_code=201)
async def comment_on_assigned_issue(
comment: IssueComment,
@@ -1450,6 +1642,35 @@ async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments")
async def assigned_pull_conversation(
owner: str,
repo: str,
number: int = PathParam(gt=0),
page: int | None = Query(default=None, ge=1, le=100),
limit: int = Query(default=20, ge=1, le=50),
):
repository = f"{owner}/{repo}"
async def load_conversation():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.issue_conversation_page(repository, number, page, limit)
try:
return await asyncio.wait_for(
load_conversation(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The conversation could not be loaded. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", status_code=201)
async def comment_on_assigned_pull(
comment: IssueComment,
+6
View File
@@ -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
+1
View File
@@ -19,5 +19,6 @@ def test_api_requests_resolve_inside_dashboard_subpath():
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
}
+88
View File
@@ -4,13 +4,16 @@ import httpx
import pytest
from src import main
from src.idempotency import IdempotencyLedger
@pytest.fixture(autouse=True)
def clear_authored_operations():
main._authored_action_operations.clear()
main._idempotency_ledger.clear()
yield
main._authored_action_operations.clear()
main._idempotency_ledger.clear()
@pytest.mark.anyio
@@ -140,3 +143,88 @@ async def test_review_replays_same_key_and_rejects_changed_head(monkeypatch):
assert [created.status_code, replayed.status_code, conflict.status_code] == [201, 201, 409]
assert calls == [("abc", "approve", "Good")]
@pytest.mark.anyio
async def test_completed_comment_replays_after_ledger_reconstruction(monkeypatch, tmp_path):
calls = 0
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
nonlocal calls
calls += 1
return {"id": 201, "body": body}
database = tmp_path / "actions.sqlite3"
monkeypatch.setattr(
main,
"_idempotency_ledger",
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
raising=False,
)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "restart-comment-201"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post(
"/api/v1/repos/stackchain/api/issues/7/comments",
json={"body": "Persist me"},
headers=headers,
)
main._authored_action_operations.clear()
monkeypatch.setattr(
main,
"_idempotency_ledger",
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
)
replayed = await client.post(
"/api/v1/repos/stackchain/api/issues/7/comments",
json={"body": "Persist me"},
headers=headers,
)
assert [created.status_code, replayed.status_code] == [201, 201]
assert replayed.json() == {"id": 201, "body": "Persist me"}
assert calls == 1
@pytest.mark.anyio
async def test_orphaned_pending_comment_fails_closed_without_upstream_retry(
monkeypatch, tmp_path
):
calls = 0
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
nonlocal calls
calls += 1
return {"id": 202, "body": body}
ledger = IdempotencyLedger(
tmp_path / "orphan.sqlite3", ttl_seconds=600, max_entries=256
)
fingerprint = ("issue-comment", "stackchain/api", 7, "Verify first")
assert ledger.reserve("orphan-comment-201", fingerprint).state == "reserved"
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/7/comments",
json={"body": "Verify first"},
headers={"Idempotency-Key": "orphan-comment-201"},
)
assert response.status_code == 503
assert response.json()["detail"] == (
"This action may still be completing; verify its result before retrying"
)
assert calls == 0
+192
View File
@@ -7,6 +7,7 @@ from urllib.parse import urljoin
FRONTEND = Path(__file__).parents[1] / "frontend"
COMMANDS = FRONTEND / "commands.js"
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
class ScriptSourceParser(HTMLParser):
@@ -46,3 +47,194 @@ def test_command_script_resolves_inside_dashboard_subpath():
assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/", command_source
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
def test_remote_command_search_ignores_stale_responses():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const pending = new Map();
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: query => new Promise(resolve => pending.set(query, resolve)),
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
controller.setQuery('release');
await new Promise(resolve => setTimeout(resolve, 0));
pending.get('release')([{{ title: 'Current result' }}]);
await new Promise(resolve => setTimeout(resolve, 0));
pending.get('mobile')([{{ title: 'Stale result' }}]);
await new Promise(resolve => setTimeout(resolve, 0));
const ready = states.filter(state => state.status === 'ready');
if (ready.length !== 1) throw new Error(`expected one ready state, got ${{ready.length}}`);
if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result') {{
throw new Error(`stale response replaced current state: ${{JSON.stringify(ready)}}`);
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(
["node", "-e", script],
check=True, capture_output=True, text=True,
)
def test_remote_command_search_aborts_superseded_and_cleared_queries():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const signals = [];
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: (query, signal) => {{
signals.push(signal);
return new Promise((resolve, reject) => signal.addEventListener('abort', () => {{
const error = new Error('aborted');
error.name = 'AbortError';
reject(error);
}}));
}},
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
controller.setQuery('release');
if (!signals[0].aborted) throw new Error('superseded request was not aborted');
await new Promise(resolve => setTimeout(resolve, 0));
controller.setQuery('');
if (!signals[1].aborted) throw new Error('cleared query request was not aborted');
await new Promise(resolve => setTimeout(resolve, 0));
if (states.some(state => state.status === 'error')) throw new Error('abort rendered an error');
if (states.at(-1).status !== 'idle') throw new Error('clear did not restore idle state');
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_command_search_preserves_partial_result_status():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: () => Promise.resolve({{ items:[{{ title:'Useful result' }}], partial:true }}),
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
await new Promise(resolve => setTimeout(resolve, 0));
const ready = states.find(state => state.status === 'ready');
if (!ready || ready.items.length !== 1 || ready.partial !== true) {{
throw new Error('partial result metadata was lost: ' + JSON.stringify(states));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_command_selection_wraps_for_arrow_keys():
script = f"""
const commands = require({json.dumps(str(COMMANDS))});
if (commands.nextSelection(-1, 'ArrowDown', 3) !== 0) throw new Error('should select first');
if (commands.nextSelection(2, 'ArrowDown', 3) !== 0) throw new Error('should wrap down');
if (commands.nextSelection(0, 'ArrowUp', 3) !== 2) throw new Error('should wrap up');
if (commands.nextSelection(1, 'Enter', 3) !== 1) throw new Error('other keys should not move');
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath():
html = (FRONTEND / "index.html").read_text()
assert 'role="combobox"' in html
assert 'aria-controls="cmd-results"' in html
assert 'role="listbox"' in html
assert "fetch('api/v1/search?q='" in html
assert "async function searchGlobalWork(query, signal)" in html
assert "signal," in html
assert "Some results are temporarily unavailable." in html
assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/",
"api/v1/search?q=mobile",
) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile"
def test_search_preview_ignores_stale_result_details():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const pending = new Map();
const states = [];
const preview = createSearchPreview({{
fetchJson: item => new Promise(resolve => pending.set(item.number, resolve)),
claim: () => Promise.resolve(),
onState: state => states.push(state),
}});
preview.open({{ repository:'stackchain/api', number:1, kind:'issue' }});
preview.open({{ repository:'stackchain/api', number:2, kind:'issue' }});
pending.get(2)({{ number:2, title:'Current' }});
await new Promise(resolve => setTimeout(resolve, 0));
pending.get(1)({{ number:1, title:'Stale' }});
await new Promise(resolve => setTimeout(resolve, 0));
const ready = states.filter(state => state.status === 'ready');
if (ready.length !== 1 || ready[0].detail.number !== 2) {{
throw new Error('stale detail replaced current preview: ' + JSON.stringify(ready));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_search_preview_claim_is_single_flight_and_reports_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let claims = 0;
let resolveClaim;
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve(item),
claim: () => {{ claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }},
onState: state => states.push(state),
}});
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
const detail = {{ repository:'stackchain/api', number:42, claimable:true }};
const first = preview.claim(detail);
const second = preview.claim(detail);
if (claims !== 1 || first !== second) throw new Error('claim was not single-flight');
resolveClaim({{ assignees:['timmy'] }});
await first;
if (!states.some(state => state.status === 'claimed')) throw new Error('claim confirmation missing');
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_search_selection_opens_native_preview_without_navigation():
html = (FRONTEND / "index.html").read_text()
run_item = html.split("function runCommandItem(item)", 1)[1].split(
"function renderCommands", 1
)[0]
assert "searchPreview.open(item.result)" in run_item
assert "window.location.assign" not in run_item
remote_branch = run_item.split("} else {", 1)[1]
assert "qs('#cmd-input').value = ''" not in remote_branch
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
html = (FRONTEND / "index.html").read_text()
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
assert "openPreviewIssueInMyWork" in html
+117
View File
@@ -0,0 +1,117 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_draft_inbox_discovers_existing_formats_orders_activity_and_ignores_corruption():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-capture.v1', JSON.stringify({{repository:'stackchain/api',title:'Ship recovery',body:'Detailed plan',labelIds:[]}})],
['stackchain.issue-comment.v1:stackchain/api#17', 'Please add a regression test'],
['stackchain.pull-comment.v1:stackchain/web#9', 'Looks good on mobile'],
['stackchain.update-reply.v1.44', 'I can take this'],
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{'app.js':'Handle offline state'}},comments:[],summary:'Needs one fix',decision:'request_changes'}})],
['stackchain.issue-content.v1:stackchain/api#18', '{{broken-json'],
['stackchain.issue-comment.v1:stackchain/api#20:operation', 'not-a-draft'],
]);
const storage = {{
get length() {{ return values.size; }},
key: index => Array.from(values.keys())[index] || null,
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
let clock = 1000;
const inbox = createDraftInbox({{storage, now:() => ++clock}});
const first = inbox.list();
values.set('stackchain.issue-comment.v1:stackchain/api#17', 'Newest comment text');
const second = inbox.list();
process.stdout.write(JSON.stringify({{
kinds:first.map(item => item.kind).sort(),
newest:second[0],
count:second.length,
}}));
"""
output = run_node(script)
assert output["kinds"] == ["issue-comment", "new-issue", "pull-comment", "review", "update-reply"]
assert output["count"] == 5
assert output["newest"]["kind"] == "issue-comment"
assert output["newest"]["repository"] == "stackchain/api"
assert output["newest"]["number"] == 17
assert output["newest"]["preview"] == "Newest comment text"
assert output["newest"]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 17}
def test_draft_discard_removes_only_selected_content_and_operation_identity():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-comment.v1:stackchain/api#17', 'One'],
['stackchain.issue-comment.v1:stackchain/api#17:operation', 'operation-one'],
['stackchain.pull-comment.v1:stackchain/web#9', 'Two'],
]);
const storage = {{
get length() {{ return values.size; }}, key:i => Array.from(values.keys())[i] || null,
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key),
}};
const inbox = createDraftInbox({{storage, now:() => 1000}});
const selected = inbox.list().find(item => item.kind === 'issue-comment');
inbox.discard(selected.id);
process.stdout.write(JSON.stringify({{remaining:inbox.list(), keys:Array.from(values.keys()).sort()}}));
"""
output = run_node(script)
assert [item["kind"] for item in output["remaining"]] == ["pull-comment"]
assert "stackchain.issue-comment.v1:stackchain/api#17" not in output["keys"]
assert "stackchain.issue-comment.v1:stackchain/api#17:operation" not in output["keys"]
assert "stackchain.pull-comment.v1:stackchain/web#9" in output["keys"]
def test_draft_inbox_recovers_issue_edit_and_review_content_without_exposing_empty_records():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-content.v1:stackchain/api#18', JSON.stringify({{title:'Clarify acceptance',body:'Describe mobile flow',expectedUpdatedAt:'2026-08-07T10:00:00Z'}})],
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{}},comments:[],summary:'',decision:'approve'}})],
['stackchain.pull-comment.v1:stackchain/web#10', ' '],
]);
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const drafts = createDraftInbox({{storage,now:()=>42}}).list();
process.stdout.write(JSON.stringify(drafts));
"""
output = run_node(script)
assert len(output) == 1
assert output[0]["kind"] == "issue-edit"
assert output[0]["preview"] == "Clarify acceptance — Describe mobile flow"
assert output[0]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 18}
@pytest.mark.anyio
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
html = await dashboard()
assert '<script src="static/drafts.js"></script>' in html
assert 'data-work-filter="draft"' in html
assert 'data-work-count="draft"' in html
assert 'class="draft-resume"' in html
assert 'class="draft-discard"' in html
assert 'Discard this unfinished draft?' in html
assert '.draft-actions button { min-height:44px;' in html
assert 'createDraftInbox({ storage: localStorage' in html
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
+110 -1
View File
@@ -13,6 +13,7 @@ def reset_available_issue_snapshot():
main._available_issue_snapshot_task = None
main._available_issue_snapshot_value = None
main._available_issue_snapshot_created_at = None
main._available_issue_snapshot_retry_at = None
yield
task = main._available_issue_snapshot_task
if task is not None and not task.done():
@@ -20,6 +21,7 @@ def reset_available_issue_snapshot():
main._available_issue_snapshot_task = None
main._available_issue_snapshot_value = None
main._available_issue_snapshot_created_at = None
main._available_issue_snapshot_retry_at = None
@pytest.mark.anyio
@@ -136,6 +138,53 @@ async def test_available_issue_page_ranks_all_upstream_pages_before_logical_pagi
}
@pytest.mark.anyio
async def test_available_issue_snapshot_loads_known_remaining_pages_concurrently():
active = 0
peak_active = 0
remaining_started = asyncio.Event()
release = asyncio.Event()
async def upstream(request):
nonlocal active, peak_active
page = int(request.url.params["page"])
if page == 1:
return httpx.Response(
200, headers={"X-Total-Count": "200"},
json=[{
"id": number, "number": number, "title": f"Issue {number}",
"state": "open", "assignees": [], "pull_request": None,
"labels": [], "repository": {"full_name": "stackchain/api"},
} for number in range(1, 51)],
)
active += 1
peak_active = max(peak_active, active)
if peak_active == 3:
remaining_started.set()
await release.wait()
active -= 1
start = (page - 1) * 50 + 1
return httpx.Response(200, headers={"X-Total-Count": "200"}, json=[{
"id": number, "number": number, "title": f"Issue {number}",
"state": "open", "assignees": [], "pull_request": None,
"labels": [], "repository": {"full_name": "stackchain/api"},
} for number in range(start, start + 50)])
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
task = asyncio.create_task(gitea_proxy.available_issue_snapshot())
try:
await asyncio.wait_for(remaining_started.wait(), timeout=1)
assert peak_active == 3
release.set()
result = await asyncio.wait_for(task, timeout=1)
finally:
release.set()
await gitea_proxy.stop_client()
assert len(result) == 200
assert peak_active == 3
@pytest.mark.anyio
async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkeypatch):
calls = []
@@ -204,7 +253,67 @@ async def test_available_issue_endpoint_retains_last_snapshot_on_refresh_failure
assert response.status_code == 200
assert response.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"has_more": False, "stale": True,
"has_more": False, "stale": True, "revalidating": True,
}
@pytest.mark.anyio
async def test_available_issue_endpoint_serves_expired_snapshot_while_one_refresh_runs(monkeypatch):
calls = 0
started = asyncio.Event()
release = asyncio.Event()
async def refresh():
nonlocal calls
calls += 1
started.set()
await release.wait()
return [{"number": 8}]
main._available_issue_snapshot_value = [{"number": 7}]
main._available_issue_snapshot_created_at = 0.0
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", refresh)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first, second = await asyncio.gather(
client.get("/api/v1/available-issues?page=1"),
client.get("/api/v1/available-issues?page=1"),
)
await asyncio.wait_for(started.wait(), timeout=1)
assert calls == 1
assert first.json() == second.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"has_more": False, "stale": True, "revalidating": True,
}
release.set()
await asyncio.wait_for(main._available_issue_snapshot_task, timeout=1)
assert main._available_issue_snapshot_value == [{"number": 8}]
@pytest.mark.anyio
async def test_available_issue_endpoint_backs_off_after_background_refresh_failure(monkeypatch):
calls = 0
async def unavailable():
nonlocal calls
calls += 1
raise httpx.ConnectError("offline")
main._available_issue_snapshot_value = [{"number": 7}]
main._available_issue_snapshot_created_at = 0.0
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", unavailable)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = await client.get("/api/v1/available-issues?page=1")
await asyncio.gather(main._available_issue_snapshot_task, return_exceptions=True)
second = await client.get("/api/v1/available-issues?page=1")
assert calls == 1
assert first.json()["revalidating"] is True
assert second.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"has_more": False, "stale": True, "refresh_failed": True,
}
+257
View File
@@ -0,0 +1,257 @@
import asyncio
import httpx
import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
requested = []
async def search(query, limit):
requested.append((query, limit))
return {
"items": [{
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
"title": "Repair mobile queue",
"state": "open",
"url": "https://forge.example/stackchain/api/issues/42",
}],
"partial": False,
}
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/search?q=mobile&limit=7")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("mobile", 7)]
assert response.json() == {"query": "mobile", "items": [{
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
"title": "Repair mobile queue",
"state": "open",
"url": "https://forge.example/stackchain/api/issues/42",
}], "partial": False}
@pytest.mark.anyio
async def test_global_search_preview_returns_normalized_action_context(monkeypatch):
requested = []
async def preview(repository, kind, number):
requested.append((repository, kind, number))
return {
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
"title": "Repair mobile queue",
"body": "Restore the queue before release.",
"state": "open",
"author": "alex",
"labels": ["P1"],
"assignees": [],
"url": "https://forge.example/stackchain/api/issues/42",
"claimable": True,
"assigned_to_me": False,
}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview, raising=False)
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/42/preview?kind=issue"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("stackchain/api", "issue", 42)]
assert response.json()["claimable"] is True
@pytest.mark.anyio
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
requests = []
async def handler(request):
requests.append(dict(request.url.params))
kind = request.url.params["type"]
if kind == "issues":
return httpx.Response(200, json=[{
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/42",
}, {
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/issues/42",
}, {
"id": 5, "number": 43, "title": "Unsafe", "state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "javascript:alert(1)",
}])
return httpx.Response(200, json=[{
"id": 8, "number": 9, "title": "Improve search", "state": "closed",
"repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
results = await gitea_proxy.global_search("mobile queue", 7)
finally:
await gitea_proxy.stop_client()
assert {request["type"] for request in requests} == {"issues", "pulls"}
assert all(request["q"] == "mobile queue" and request["limit"] == "7" for request in requests)
assert results == {
"items": [{
"kind": "issue", "repository": "stackchain/api", "number": 42,
"title": "Repair queue", "state": "open",
"url": "https://forge.example/stackchain/api/issues/42",
}, {
"kind": "pull", "repository": "stackchain/web", "number": 9,
"title": "Improve search", "state": "closed",
"url": "https://forge.example/stackchain/web/pulls/9",
}],
"partial": False,
}
@pytest.mark.anyio
async def test_global_search_returns_healthy_stream_when_other_stream_fails():
async def handler(request):
if request.url.params["type"] == "issues":
return httpx.Response(503, json={"message": "upstream details must stay private"})
return httpx.Response(200, json=[{
"number": 9,
"title": "Improve search",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.global_search("search", 10)
finally:
await gitea_proxy.stop_client()
assert result == {
"items": [{
"kind": "pull",
"repository": "stackchain/web",
"number": 9,
"title": "Improve search",
"state": "open",
"url": "https://forge.example/stackchain/web/pulls/9",
}],
"partial": True,
}
@pytest.mark.anyio
async def test_global_search_caps_combined_results_to_requested_limit():
async def handler(request):
kind = request.url.params["type"]
items = [{
"number": number,
"title": f"{kind} {number}",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": f"https://forge.example/stackchain/web/{kind}/{number}",
} for number in (1, 2)]
return httpx.Response(200, json=items)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.global_search("search", 2)
finally:
await gitea_proxy.stop_client()
assert len(result["items"]) == 2
assert result["partial"] is False
@pytest.mark.anyio
async def test_global_search_propagates_cancellation_to_stop_obsolete_work():
async def handler(request):
if request.url.params["type"] == "issues":
raise asyncio.CancelledError()
return httpx.Response(200, json=[])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(asyncio.CancelledError):
await gitea_proxy.global_search("obsolete", 10)
finally:
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_issues():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"number": 42,
"title": "Repair queue",
"body": "Keep mobile operators moving.",
"state": "open",
"html_url": "https://forge.example/stackchain/api/issues/42",
"user": {"login": "alex"},
"labels": [{"name": "P1"}, None],
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/api", "issue", 42)
finally:
await gitea_proxy.stop_client()
assert preview == {
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
"title": "Repair queue",
"body": "Keep mobile operators moving.",
"state": "open",
"author": "alex",
"labels": ["P1"],
"assignees": [],
"url": "https://forge.example/stackchain/api/issues/42",
"claimable": True,
"assigned_to_me": False,
}
@pytest.mark.anyio
async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"number": 9,
"title": "Improve search",
"state": "open",
"html_url": "https://forge.example/stackchain/web/pulls/9",
"pull_request": {"merged": False},
"assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
preview = await gitea_proxy.work_preview("stackchain/web", "issue", 9)
finally:
await gitea_proxy.stop_client()
assert preview["kind"] == "pull"
assert preview["claimable"] is False
+73
View File
@@ -0,0 +1,73 @@
import sqlite3
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from src.idempotency import IdempotencyLedger
def test_completed_result_replays_after_ledger_reconstruction(tmp_path):
database = tmp_path / "idempotency.sqlite3"
first = IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
reservation = first.reserve("create-201", ("issue-create", "stackchain/api", "Ship it"))
assert reservation.state == "reserved"
first.complete("create-201", {"number": 42, "title": "Ship it"})
reconstructed = IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
replay = reconstructed.reserve(
"create-201", ("issue-create", "stackchain/api", "Ship it")
)
assert replay.state == "completed"
assert replay.response == {"number": 42, "title": "Ship it"}
assert sqlite3.connect(database).execute(
"SELECT status FROM idempotency_operations WHERE key = ?", ("create-201",)
).fetchone() == ("completed",)
def test_reservation_expires_completed_rows_and_enforces_capacity(tmp_path):
now = [1_000.0]
ledger = IdempotencyLedger(
tmp_path / "bounded.sqlite3",
ttl_seconds=10,
max_entries=2,
clock=lambda: now[0],
)
assert ledger.reserve("old", ("comment", "old")).state == "reserved"
ledger.complete("old", {"id": 1})
now[0] += 11
assert ledger.reserve("new-1", ("comment", "new-1")).state == "reserved"
ledger.complete("new-1", {"id": 2})
assert ledger.reserve("new-2", ("comment", "new-2")).state == "reserved"
ledger.complete("new-2", {"id": 3})
replacement = ledger.reserve("new-3", ("comment", "new-3"))
assert replacement.state == "reserved"
with sqlite3.connect(ledger.path) as connection:
keys = connection.execute(
"SELECT key FROM idempotency_operations ORDER BY key"
).fetchall()
indexes = connection.execute(
"PRAGMA index_list(idempotency_operations)"
).fetchall()
assert keys == [("new-2",), ("new-3",)]
assert any(index[1] == "idempotency_completed_at_idx" for index in indexes)
def test_independent_connections_atomically_reserve_one_operation(tmp_path):
database = tmp_path / "workers.sqlite3"
ledgers = [
IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
for _ in range(2)
]
barrier = Barrier(2)
def reserve(ledger):
barrier.wait()
return ledger.reserve("worker-key-201", ("review", "stackchain/api", 7)).state
with ThreadPoolExecutor(max_workers=2) as executor:
states = list(executor.map(reserve, ledgers))
assert sorted(states) == ["pending", "reserved"]
+267 -6
View File
@@ -5,6 +5,92 @@ import httpx
import pytest
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
@@ -226,9 +312,11 @@ async def test_edit_assigned_issue_reports_revision_conflict_without_mutation(mo
@pytest.fixture(autouse=True)
def clear_issue_creation_operations():
main._issue_creation_operations.clear()
main._authored_action_operations.clear()
main._idempotency_ledger.clear()
yield
main._issue_creation_operations.clear()
main._authored_action_operations.clear()
main._idempotency_ledger.clear()
@pytest.mark.anyio
@@ -331,6 +419,61 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
assert calls == 1
@pytest.mark.anyio
async def test_completed_issue_creation_replays_after_ledger_reconstruction(monkeypatch, tmp_path):
calls = 0
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee, label_ids):
nonlocal calls
calls += 1
return {
"id": 201, "number": 201, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T15:00:00Z",
"url": "https://forge.example/stackchain/api/issues/201",
}
database = tmp_path / "issues.sqlite3"
monkeypatch.setattr(
main,
"_idempotency_ledger",
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "restart-create-201"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post(
"/api/v1/repos/stackchain/api/issues",
json={"title": "Durable capture"},
headers=headers,
)
main._authored_action_operations.clear()
monkeypatch.setattr(
main,
"_idempotency_ledger",
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
)
replayed = await client.post(
"/api/v1/repos/stackchain/api/issues",
json={"title": "Durable capture"},
headers=headers,
)
assert [created.status_code, replayed.status_code] == [201, 201]
assert replayed.json()["number"] == 201
assert calls == 1
@pytest.mark.anyio
async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_key(monkeypatch):
calls = []
@@ -412,7 +555,9 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
@pytest.mark.anyio
async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_limit(monkeypatch):
async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
monkeypatch, tmp_path
):
async def user():
return {"login": "timmy"}
@@ -427,7 +572,10 @@ async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_lim
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main, "ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES", 2)
ledger = IdempotencyLedger(
tmp_path / "bounded-issues.sqlite3", ttl_seconds=600, max_entries=2
)
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
@@ -441,8 +589,11 @@ async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_lim
)
assert response.status_code == 201
assert len(main._issue_creation_operations) == 2
assert "capture-177-bounded-0" not in main._issue_creation_operations
replay = ledger.reserve(
"capture-177-bounded-0",
("issue-create", "stackchain/api", "Capture 0", "", ()),
)
assert replay.state == "reserved"
@pytest.mark.anyio
@@ -761,6 +912,44 @@ async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkey
}
@pytest.mark.anyio
async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no_store(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return repository == "stackchain/api"
async def conversation(repository, number, page, limit):
calls.append(("conversation", repository, number, page, limit))
return {"comments": [{"id": 21}], "page": 2, "older_page": 1, "total": 21}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation)
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/7/comments?page=2&limit=20"
)
missing = await client.get(
"/api/v1/repos/private/secret/issues/7/comments?page=2&limit=20"
)
invalid = await client.get(
"/api/v1/repos/stackchain/api/issues/7/comments?page=999&limit=999"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["older_page"] == 1
assert missing.status_code == 404
assert invalid.status_code == 422
assert calls == [
("assigned", "stackchain/api", 7),
("conversation", "stackchain/api", 7, 2, 20),
("assigned", "private/secret", 7),
]
@pytest.mark.anyio
async def test_issue_detail_deadline_is_retryable_sanitized_and_cancels_work(monkeypatch):
cancelled = asyncio.Event()
@@ -968,6 +1157,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"}],
@@ -992,6 +1182,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"],
@@ -1004,9 +1195,79 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
}
],
"conversation": {
"comments": [
{
"id": 81,
"author": "sam",
"body": "Latest update",
"created_at": "2026-08-07T10:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
}
],
"page": 1,
"older_page": None,
"total": 1,
},
}
@pytest.mark.anyio
async def test_gitea_conversation_page_opens_newest_page_and_reports_older_history():
requests = []
async def handler(request):
requests.append((request.url.path, request.url.query.decode()))
page = request.url.params.get("page")
comments = {
"1": [{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
for value in range(1, 21)],
"3": [{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
for value in range(41, 48)],
}[page]
return httpx.Response(200, json=comments, headers={"X-Total-Count": "47"})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_conversation_page("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert requests == [
("/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
("/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=3"),
]
assert [comment["id"] for comment in result["comments"]] == list(range(41, 48))
assert result["page"] == 3
assert result["older_page"] == 2
assert result["total"] == 47
@pytest.mark.anyio
async def test_gitea_conversation_page_locally_bounds_unpaginated_gitea_comments():
requests = []
all_comments = [
{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
for value in range(1, 48)
]
async def handler(request):
requests.append(request.url.query.decode())
return httpx.Response(200, json=all_comments, headers={"X-Total-Count": "47"})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_conversation_page("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert requests == ["limit=20&page=1"]
assert [comment["id"] for comment in result["comments"]] == list(range(41, 48))
assert result["page"] == 3
assert result["older_page"] == 2
assert result["total"] == 47
@pytest.mark.anyio
async def test_update_assigned_issue_labels_validates_and_returns_confirmed_labels(monkeypatch):
calls = []
+507 -2
View File
@@ -12,7 +12,207 @@ REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const inputs = [
{{kind:'issue',repository:'stackchain/api',number:17}},
{{kind:'pull',repository:'stackchain/dashboard',number:42}},
{{kind:'review',repository:'stackchain/dashboard',number:42}},
{{kind:'update',notification_id:913}},
];
process.stdout.write(JSON.stringify({{
paths: inputs.map(routes.serialize),
parsed: inputs.map(item => routes.parse(routes.serialize(item))),
invalid: [
'#/my-work/issue/../../etc/1',
'#/my-work/issue/stackchain/api/not-a-number',
'#/my-work/update/-1',
'#/other/issue/stackchain/api/1',
].map(routes.parse),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"paths": [
"#/my-work/issue/stackchain/api/17",
"#/my-work/pull/stackchain/dashboard/42",
"#/my-work/review/stackchain/dashboard/42",
"#/my-work/update/913",
],
"parsed": [
{"kind": "issue", "repository": "stackchain/api", "number": 17},
{"kind": "pull", "repository": "stackchain/dashboard", "number": 42},
{"kind": "review", "repository": "stackchain/dashboard", "number": 42},
{"kind": "update", "notification_id": 913},
],
"invalid": [None, None, None, None],
}
def test_work_route_controller_restores_direct_links_and_uses_history_for_close():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const listeners = {{}};
const location = {{hash:'#/my-work/issue/stackchain/api/17', href:'https://forge.example/dashboard/#/my-work/issue/stackchain/api/17'}};
const calls = [];
const history = {{
state: null,
pushState(state, _, hash) {{ this.state = state; location.hash = hash; calls.push(['push', hash]); }},
replaceState(state, _, hash) {{ this.state = state; location.hash = hash; calls.push(['replace', hash]); }},
back() {{ calls.push(['back']); location.hash = ''; listeners.popstate(); }},
}};
const controller = routes.createController({{
location, history,
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
onOpen(item) {{ calls.push(['open', item.kind, item.repository, item.number || item.notification_id]); }},
onClose() {{ calls.push(['close']); }},
onInvalid() {{ calls.push(['invalid']); }},
}});
controller.start();
controller.setItems([{{kind:'issue',repository:'stackchain/api',number:17}}]);
controller.close();
controller.open({{kind:'pull',repository:'stackchain/dashboard',number:9}});
controller.open({{kind:'review',repository:'stackchain/dashboard',number:10}}, {{replace:true}});
process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == [
["open", "issue", "stackchain/api", 17],
["back"],
["close"],
["push", "#/my-work/pull/stackchain/dashboard/9"],
["open", "pull", "stackchain/dashboard", 9],
["replace", "#/my-work/review/stackchain/dashboard/10"],
["open", "review", "stackchain/dashboard", 10],
]
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
def test_work_route_controller_resolves_cold_routes_without_erasing_the_fragment():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const location = {{hash:'#/my-work/issue/stackchain/api/87'}};
const calls = [];
const controller = routes.createController({{
location,
history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
eventTarget: {{addEventListener() {{}}}},
resolve: async route => {{
calls.push(['resolve', route.kind, route.repository, route.number]);
return {{kind:'issue', repository:'stackchain/api', number:87, title:'Older assigned issue'}};
}},
onResolving: route => calls.push(['resolving', route.number]),
onOpen: item => calls.push(['open', item.number, item.title]),
onClose() {{}},
onInvalid: () => calls.push(['invalid']),
onError: () => calls.push(['error']),
}});
(async () => {{
controller.start();
controller.setItems([]);
await new Promise(resolve => setTimeout(resolve, 0));
process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"calls": [
["resolving", 87],
["resolve", "issue", "stackchain/api", 87],
["open", 87, "Older assigned issue"],
],
"hash": "#/my-work/issue/stackchain/api/87",
}
def test_work_route_controller_dismisses_only_confirmed_unavailable_routes():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const location = {{hash:'#/my-work/update/913'}};
const calls = [];
const unavailable = new Error('gone'); unavailable.unavailable = true;
const controller = routes.createController({{
location,
history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
eventTarget: {{addEventListener() {{}}}},
resolve: async () => {{ throw unavailable; }},
onResolving() {{}}, onOpen() {{}}, onClose() {{}},
onInvalid: () => calls.push('invalid'),
onError: () => calls.push('error'),
}});
(async () => {{
controller.start(); controller.setItems([]);
await new Promise(resolve => setTimeout(resolve, 0));
process.stdout.write(JSON.stringify(calls));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == ["invalid"]
def test_work_route_share_prefers_native_share_and_falls_back_to_clipboard():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const calls = [];
(async () => {{
const native = await routes.share('https://forge.example/dashboard/#/my-work/update/9', {{
share: async payload => calls.push(['native', payload.url]),
}}, null);
const fallback = await routes.share('https://forge.example/dashboard/#/my-work/update/10', {{}}, {{
writeText: async text => calls.push(['clipboard', text]),
}});
process.stdout.write(JSON.stringify({{native, fallback, calls}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"native": "shared",
"fallback": "copied",
"calls": [
["native", "https://forge.example/dashboard/#/my-work/update/9"],
["clipboard", "https://forge.example/dashboard/#/my-work/update/10"],
],
}
@pytest.mark.anyio
async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share():
html = await dashboard()
assert '<script src="static/work-route.js"></script>' in html
assert 'const workRoute = createWorkRoute.createController({' in html
assert 'workRoute.setItems(lastMyWork);' in html
assert "api('api/v1/work-route?' + params.toString())" in html
assert "Loading shared work item…" in html
assert 'id="retry-work-route"' in html
assert 'href="' + "' + escAttr(createWorkRoute.serialize(" in html
assert '.read-update { min-height:44px; width:100%; display:flex;' in html
assert 'workRoute.close();' in html
assert html.count('class="share-work-route"') == 4
assert 'createWorkRoute.share(window.location.href, navigator, navigator.clipboard)' in html
assert 'Route unavailable · this item is no longer in My Work.' in html
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
@@ -321,6 +521,85 @@ 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
@pytest.mark.anyio
async def test_mobile_issue_sheet_puts_reading_before_collapsed_planning_controls():
html = await dashboard()
body = html.index('id="issue-sheet-body"')
conversation = html.index('<h2>Full conversation</h2>', body)
planning = html.index('id="issue-planning"', conversation)
labels = html.index('id="issue-label-editor"', planning)
milestone = html.index('id="issue-milestone"', planning)
assert '<summary>Plan &amp; edit</summary>' in html[planning:labels]
assert '<details class="issue-planning" id="issue-planning">' in html
assert body < conversation < planning < labels < milestone
def test_issue_planning_metadata_is_lazy_cached_and_retryable_after_failure():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const calls = [];
let fail = true;
const planning = createIssueSheet.createPlanningLoader({{
loadLabels: async item => {{ calls.push('labels:' + item.number); return [{{id:1,name:'P0'}}]; }},
loadMilestones: async item => {{
calls.push('milestones:' + item.number);
if (fail) throw new Error('offline');
return [{{id:9,title:'RC'}}];
}},
}});
const item = {{repository:'stackchain/api', number:17}};
(async () => {{
const before = calls.slice();
let failed = false;
try {{ await planning.open(item); }} catch (_error) {{ failed = true; }}
fail = false;
const first = await planning.open(item);
const second = await planning.open(item);
process.stdout.write(JSON.stringify({{before, failed, calls, same:first === second, first}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"before": [],
"failed": True,
"calls": ["labels:17", "milestones:17", "labels:17", "milestones:17"],
"same": True,
"first": {"labels": [{"id": 1, "name": "P0"}], "milestones": [{"id": 9, "title": "RC"}]},
}
@pytest.mark.anyio
async def test_opening_issue_defers_planning_requests_until_disclosure_expands():
html = await dashboard()
open_handler = html[html.index('async function openIssueSheet'):html.index('function closeIssueSheet')]
assert 'loadIssueLabelEditor(' not in open_handler
assert 'loadIssueMilestoneEditor(' not in open_handler
assert "if (qs('#issue-planning').open) loadIssuePlanning();" in open_handler
assert "qs('#issue-planning').addEventListener('toggle'" in html
assert 'planningLoader.open(selectedIssue)' in html
assert 'id="retry-issue-planning"' 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 +624,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"},
@@ -624,6 +985,40 @@ controller.load().then(() => controller.loadMore()).then(() =>
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
def test_find_work_keeps_retained_cards_and_announces_refresh_freshness():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
const statuses = [];
const states = [];
const responses = [
{{items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:1,has_more:false,
stale:true,revalidating:true}},
{{items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:1,has_more:false,
stale:true,refresh_failed:true}},
];
const controller = createFindWork({{
fetchJson: () => Promise.resolve(responses.shift()),
onItems: items => states.push(items),
onPagination: () => {{}},
onStatus: status => statuses.push(status),
}});
controller.load().then(() => controller.load()).then(() =>
process.stdout.write(JSON.stringify({{statuses,states,items:controller.items()}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["statuses"] == [
"Showing saved available work while the catalog refreshes…",
"Showing saved available work. Catalog refresh failed; retrying shortly.",
]
assert [item["number"] for item in output["items"]] == [1]
assert len(output["states"]) == 2
def test_find_work_preview_stays_with_issue_across_pagination_and_clears_when_claimed():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
@@ -704,6 +1099,9 @@ async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe
assert '.find-work-action { min-height:44px;' in html
assert 'padding-bottom:calc(18px + env(safe-area-inset-bottom))' in html
assert '@media(max-width:320px)' in html
assert 'const retainedItems = findWorkController.items();' in html
assert 'if (retainedItems.length) renderAvailableIssues(retainedItems);' in html
assert 'Refreshing available issues…' in html
@pytest.mark.anyio
@@ -1274,6 +1672,100 @@ reader.open(item, [item]).then(() => {{
]
def test_conversation_pager_prepends_older_pages_deduplicates_and_appends_once():
script = f"""
const createConversationPager = require({json.dumps(str(CONVERSATION))});
let calls = 0;
const pager = createConversationPager({{
loadPage: async page => {{ calls += 1; return {{
comments:[{{id:20,body:'duplicate'}},{{id:1,body:'oldest'}}],
page, older_page:null, total:3
}}; }}
}});
pager.reset({{comments:[{{id:20,body:'middle'}},{{id:21,body:'newest'}}],page:2,older_page:1,total:3}});
const first = pager.loadOlder();
const duplicate = pager.loadOlder();
Promise.all([first, duplicate]).then(() => {{
pager.append({{id:22,body:'posted'}});
pager.append({{id:22,body:'posted'}});
process.stdout.write(JSON.stringify({{calls,same:first===duplicate,state:pager.snapshot()}}));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert output["same"] is True
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 20, 21, 22]
assert output["state"]["older_page"] is None
assert output["state"]["total"] == 4
def test_conversation_pager_keeps_loaded_messages_when_older_page_fails():
script = f"""
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const pager = createConversationPager({{loadPage: async () => {{ throw new Error('offline'); }}}});
pager.reset({{comments:[{{id:21,body:'newest'}}],page:2,older_page:1,total:21}});
pager.loadOlder().catch(error => process.stdout.write(JSON.stringify({{
error:error.message, state:pager.snapshot()
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["error"] == "offline"
assert output["state"]["comments"] == [{"id": 21, "body": "newest"}]
assert output["state"]["older_page"] == 1
def test_issue_sheet_conversation_loads_older_history_through_assigned_boundary():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const calls = [];
const controller = createIssueSheet({{
createConversationPager,
fetchJson: async url => {{ calls.push(url); return {{comments:[{{id:1}}],page:1,older_page:null,total:21}}; }}
}});
const pager = controller.conversation(
{{repository:'stackchain/api',number:7}},
{{comments:[{{id:21}}],page:2,older_page:1,total:21}}
);
pager.loadOlder().then(state => process.stdout.write(JSON.stringify({{calls,state}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == ["api/v1/repos/stackchain/api/issues/7/comments?page=1&limit=20"]
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 21]
def test_pull_sheet_conversation_loads_older_history_through_assigned_boundary():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const calls = [];
const controller = createPullSheet({{
createConversationPager,
fetchJson: async url => {{ calls.push(url); return {{comments:[{{id:1}}],page:1,older_page:null,total:21}}; }}
}});
const pager = controller.conversation(
{{repository:'stackchain/api',number:7}},
{{comments:[{{id:21}}],page:2,older_page:1,total:21}}
);
pager.loadOlder().then(state => process.stdout.write(JSON.stringify({{calls,state}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == ["api/v1/repos/stackchain/api/pulls/7/comments?page=1&limit=20"]
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 21]
def test_issue_sheet_loads_encoded_assigned_issue_detail_path():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -1565,6 +2057,12 @@ async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
assert 'id="pull-files"' in html and 'id="pull-comments"' in html
assert 'id="pull-review-progress"' in html and 'aria-live="polite"' in html
assert 'id="next-unreviewed-pull-file"' in html
assert 'id="load-older-pull-comments"' in html
assert 'id="pull-conversation-status"' in html and 'aria-live="assertive"' in html
assert '<script src="static/conversation.js"></script>' in html
assert "pullController.conversation(item, detail.conversation)" in html
assert "pullConversation.loadOlder()" in html
assert "pullConversation.append(comment)" in html
assert 'id="pull-comment"' in html and 'maxlength="10000"' in html
assert 'id="merge-pull"' in html and 'id="open-pull-gitea"' in html
assert '<script src="static/pull-sheet.js"></script>' in html
@@ -1590,6 +2088,12 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut
assert 'id="issue-sheet-body"' in html
assert 'id="issue-labels"' in html and 'id="issue-assignees"' in html
assert 'id="issue-comments"' in html
assert 'id="load-older-issue-comments"' in html
assert 'id="issue-conversation-status"' in html and 'aria-live="assertive"' in html
assert "issueController.conversation(item, detail.conversation)" in html
assert "issueConversation.loadOlder()" in html
assert "issueConversation.append(comment)" in html
assert '.conversation-more { min-height:44px;' in html
assert 'id="issue-comment"' in html and 'maxlength="10000"' in html
assert 'id="send-issue-comment"' in html
assert 'id="close-issue"' in html
@@ -1675,7 +2179,7 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
assert '<script src="static/create-issue-sheet.js"></script>' in html
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
assert 'openIssueSheet(created' in html
assert 'openRoutedWork(created' in html
assert 'issueCapture.loadLabels(repository)' in html
assert "input[name=\"create-issue-label\"]:checked" in html
assert '.create-issue-label-option' in html and 'min-height:44px' in html
@@ -1848,7 +2352,8 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
assert 'data-work-count="pull"' in html
assert 'data-work-count="review"' in html
assert 'data-work-count="update"' in html
assert "['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)" in html
assert "['all', 'issue', 'pull', 'review', 'update', '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
+34
View File
@@ -94,6 +94,40 @@ async def test_gitea_assigned_pull_detail_includes_bounded_diff_previews():
assert "+new" in detail["files"][0]["diff_lines"]
assert detail["files"][1]["diff_binary"] is True
assert detail["files"][1]["diff_available"] is False
assert detail["conversation"] == {
"comments": [], "page": 1, "older_page": None, "total": 0
}
@pytest.mark.anyio
async def test_assigned_pull_conversation_endpoint_reuses_issue_thread_with_pull_authorization(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def conversation(repository, number, page, limit):
calls.append(("conversation", repository, number, page, limit))
return {"comments": [{"id": 41}], "page": 3, "older_page": 2, "total": 47}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation)
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/pulls/7/comments?page=3&limit=20"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"comments": [{"id": 41}], "page": 3, "older_page": 2, "total": 47
}
assert calls == [
("assigned", "stackchain/api", 7),
("conversation", "stackchain/api", 7, 3, 20),
]
@pytest.mark.anyio
+14
View File
@@ -19,3 +19,17 @@ async def test_dashboard_viewport_allows_mobile_zoom():
assert 'name="viewport"' in html
assert "width=device-width" in html
assert "user-scalable=no" not in html
@pytest.mark.anyio
async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
html = await dashboard()
assert 'id="search-preview" role="dialog" aria-modal="true"' in html
assert 'aria-labelledby="search-preview-title"' in html
assert 'id="claim-search-result"' in html
assert 'id="open-search-result-gitea"' in html
assert 'src="static/search-preview.js"' in html
assert ".search-preview-panel" in html
assert "height:100dvh" in html
assert "env(safe-area-inset-bottom)" in html
+2
View File
@@ -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",
}],
}
+100
View File
@@ -0,0 +1,100 @@
import httpx
import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_work_route_endpoint_resolves_one_authorized_item(monkeypatch):
requested = []
async def resolve(kind, repository, number, notification_id):
requested.append((kind, repository, number, notification_id))
return {
"kind": "review",
"repository": "stackchain/dashboard",
"number": 87,
"title": "Review the mobile resolver",
"is_review": True,
"url": "https://forge.example/stackchain/dashboard/pulls/87",
}
monkeypatch.setattr(main.gitea_proxy, "resolve_work_route", resolve, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/work-route?kind=review&repository=stackchain/dashboard&number=87"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("review", "stackchain/dashboard", 87, None)]
assert response.json() == {
"kind": "review",
"repository": "stackchain/dashboard",
"number": 87,
"title": "Review the mobile resolver",
"is_review": True,
"url": "https://forge.example/stackchain/dashboard/pulls/87",
}
@pytest.mark.anyio
async def test_resolve_work_route_returns_only_a_requested_review():
requests = []
async def upstream(request):
requests.append(request.url.path)
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 700,
"number": 87,
"title": "Review the resolver",
"state": "open",
"html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/pulls/87",
"repository": {"full_name": "stackchain/dashboard"},
"requested_reviewers": [{"login": "timmy"}],
"assignees": [],
"user": {"login": "alex"},
})
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.resolve_work_route(
"review", "stackchain/dashboard", 87, None
)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 2
assert result == {
"kind": "review",
"repository": "stackchain/dashboard",
"number": 87,
"title": "Review the resolver",
"state": "open",
"url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/pulls/87",
"is_review": True,
"work_reasons": ["review_requested"],
}
@pytest.mark.anyio
async def test_resolve_work_route_rejects_a_notification_that_is_already_read():
async def upstream(request):
if request.url.path.endswith("/notifications/threads/913"):
return httpx.Response(200, json={
"id": 913,
"unread": False,
"repository": {"full_name": "stackchain/dashboard"},
"subject": {"title": "Already handled", "type": "Issue"},
})
return httpx.Response(404)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
with pytest.raises(gitea_proxy.WorkRouteUnavailableError):
await gitea_proxy.resolve_work_route("update", None, None, 913)
finally:
await gitea_proxy.stop_client()