Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51ac802d77 | ||
|
|
e0bc7271fa | ||
|
|
ddd87e9051 | ||
|
|
8b71de70bd | ||
|
|
2bd8426db8 | ||
|
|
9289434ad2 | ||
|
|
a73b3bc773 | ||
|
|
d155f0081f | ||
|
|
bad7d524f0 | ||
|
|
ea7f3cf098 | ||
|
|
d66c1b48ef | ||
|
|
8df41e2fe7 | ||
|
|
51bd9d41f2 | ||
|
|
7110b11c15 | ||
|
|
3ed3a2be58 | ||
|
|
bb00d3cf35 | ||
|
|
1b2a3c83af | ||
|
|
a579b95f47 | ||
|
|
6bf5512b47 | ||
|
|
1c2dbd2168 | ||
|
|
a25b547d20 | ||
|
|
4499a7f947 | ||
|
|
f655a5115a | ||
|
|
bca27f0330 | ||
|
|
19dfc93b85 | ||
|
|
ce26f54618 | ||
|
|
ea909ef222 | ||
|
|
3824ff180a | ||
|
|
174dac05b9 | ||
|
|
ea1f4ad837 | ||
|
|
d09ceb8650 | ||
|
|
c0cc6ec46e | ||
|
|
aaff109ede | ||
|
|
2fe9b6cc3d | ||
|
|
a08ce31858 | ||
|
|
092234af38 | ||
|
|
c63b23865c | ||
|
|
e04d6753ac | ||
|
|
b5545e380e |
No files matched your search
@@ -16,12 +16,15 @@ python3 -m pip install -r requirements.txt
|
||||
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
||||
token that can read dashboard data, update the authenticated user's notification
|
||||
threads, create and self-assign issues, discover, claim, and release issue assignments,
|
||||
set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
||||
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
||||
inspect/comment on assigned pull
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
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
|
||||
creation and assignment permission. The New issue sheet can optionally select an open
|
||||
repository milestone and due date; the dashboard validates both and sends them with
|
||||
self-assignment in the single create request, so planned work appears in its release
|
||||
lane immediately. 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, reload, process restart, or handoff to
|
||||
another worker replays a confirmed result instead of posting duplicate content. Results
|
||||
@@ -65,6 +68,44 @@ API process is running and does not contact Gitea. GET `/readyz` is the
|
||||
readiness check: it validates the configured Gitea credentials and returns
|
||||
HTTP 503 with an error when Gitea is unavailable or authentication fails.
|
||||
|
||||
## Offline mobile shell
|
||||
|
||||
At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**,
|
||||
**Search**, and **Drafts** thumb-reachable. It reuses the existing sheets and My Work
|
||||
filter, preserves the selected release lane, shows the current draft count, respects
|
||||
the device safe area, and moves out of the way while a full-screen task is open.
|
||||
Desktop layout is unchanged.
|
||||
|
||||
After one successful online load, the installed dashboard precaches a versioned,
|
||||
subpath-scoped application shell. During a network outage or a dashboard HTTP
|
||||
`500`, `502`, `503`, or `504` response, navigation falls back to that shell when
|
||||
it is available so local issue and review drafts remain reachable. Authentication
|
||||
and other client-error responses are never hidden. A visible, accessible offline
|
||||
or server-outage notice distinguishes cached mode from live Gitea data, and the
|
||||
dashboard keeps polling until it can restore its live snapshot automatically.
|
||||
|
||||
Users can explicitly enable **Keep My Work available offline**. Each healthy live
|
||||
refresh then stores a seven-day, versioned snapshot containing only the signed-in
|
||||
user identity and queue-card metadata for issues, pull requests, unread updates,
|
||||
and pagination totals. Bodies, comments, diffs, credentials, repository catalogs,
|
||||
events, and complete API responses are excluded. A cold offline launch labels the
|
||||
saved time and renders this snapshot read-only; opening live details, pagination,
|
||||
and server mutations remain disabled until reconnection. **Clear offline work
|
||||
data** deletes the snapshot, and opting out deletes it automatically.
|
||||
|
||||
API responses and mutations are never cached by the service worker. New issue captures,
|
||||
issue comments, pull-request comments, and unread-update replies use bounded local
|
||||
outboxes when connectivity or a retryable server failure prevents delivery. Drafts
|
||||
shows queued and needs-attention messages with explicit send/discard controls; reconnect
|
||||
flushes messages sequentially with their original idempotency keys. Tabs coordinate each
|
||||
queue through the browser lock manager (with an expiring local lease fallback), so an
|
||||
installed PWA and a browser tab cannot submit the same operation concurrently. Queue
|
||||
changes are broadcast to other tabs and refresh Drafts without polling; an abandoned
|
||||
lease can be reclaimed after expiry. State-sensitive actions such as reviews, merges,
|
||||
closures, labels, milestones, and assignments are never queued. Service-worker upgrades
|
||||
are atomic and remove only older
|
||||
`stackchain-dashboard-*` caches, preserving unrelated caches on the same origin.
|
||||
|
||||
Run the test suite with:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
|
||||
const storageKey = 'stackchain.authored-outbox.v1';
|
||||
const makeId = createOperationId || (() =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply']);
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && supportedKinds.has(item.kind));
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
coordinator?.notify('authored');
|
||||
}
|
||||
|
||||
function enqueue(message) {
|
||||
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
|
||||
const items = read();
|
||||
const requestedOperationId = String(message.operationId || '').slice(0, 128);
|
||||
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
|
||||
if (existing) return { ...existing };
|
||||
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
|
||||
const id = String(requestedOperationId || makeId()).slice(0, 128);
|
||||
const item = {
|
||||
id,
|
||||
operationId: requestedOperationId || id,
|
||||
kind: message.kind,
|
||||
repository: String(message.repository || ''),
|
||||
number: Number(message.number || 0),
|
||||
notificationId: Number(message.notificationId || 0),
|
||||
body: String(message.body || ''),
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
items.push(item);
|
||||
write(items);
|
||||
return item;
|
||||
}
|
||||
|
||||
function update(id, changes) {
|
||||
let updated = null;
|
||||
write(read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
const body = String(changes?.body ?? item.body);
|
||||
updated = {
|
||||
...item,
|
||||
body,
|
||||
operationId: body === item.body ? item.operationId : String(makeId()).slice(0, 128),
|
||||
status: 'queued',
|
||||
};
|
||||
delete updated.error;
|
||||
return updated;
|
||||
}));
|
||||
return updated;
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
if (!items.some(item => item.id === id)) return false;
|
||||
write(items.filter(item => item.id !== id));
|
||||
return true;
|
||||
}
|
||||
|
||||
function endpoint(item) {
|
||||
if (item.kind === 'update-reply') {
|
||||
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
|
||||
}
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
|
||||
}
|
||||
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const request = (async () => {
|
||||
try {
|
||||
const result = await fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({ body: item.body }),
|
||||
});
|
||||
discard(item.id);
|
||||
return { result };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
const permanent = status >= 400 && status < 500;
|
||||
if (permanent) {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate,
|
||||
status: 'attention',
|
||||
error: String(error.message || 'Message needs attention').slice(0, 240),
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !permanent };
|
||||
}
|
||||
})();
|
||||
pending.set(item.id, request);
|
||||
try { return await request; }
|
||||
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
|
||||
}
|
||||
|
||||
async function flushQueue(currentLogin) {
|
||||
const confirmed = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const outcome = await sendItem(item, currentLogin);
|
||||
if (outcome.result) confirmed.push(outcome.result);
|
||||
if (outcome.transient) break;
|
||||
}
|
||||
return { confirmed, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
if (coordinator) return coordinator.runExclusive('authored', () => flushQueue(currentLogin));
|
||||
return flushQueue(currentLogin);
|
||||
}
|
||||
|
||||
async function retryItem(id, currentLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return { confirmed: [], remaining: read() };
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
return { confirmed: [], remaining: read(), blocked: 1 };
|
||||
}
|
||||
const queued = item.status === 'attention' ? update(id, item) : item;
|
||||
const outcome = await sendItem(queued, currentLogin);
|
||||
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 };
|
||||
}
|
||||
|
||||
async function retry(id, currentLogin) {
|
||||
if (coordinator) return coordinator.runExclusive('authored', () => retryItem(id, currentLogin));
|
||||
return retryItem(id, currentLogin);
|
||||
}
|
||||
|
||||
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
|
||||
@@ -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;
|
||||
@@ -8,25 +8,44 @@ function newIssueOperationId() {
|
||||
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
|
||||
}
|
||||
|
||||
function normalizeSharedContent(value = {}) {
|
||||
const clean = input => String(input || '').replace(/\s+/g, ' ').trim();
|
||||
const text = String(value.text || '').trim().slice(0, 9500);
|
||||
const textSummary = clean(text);
|
||||
const sentence = (textSummary.match(/^.{1,80}?[.!?](?:\s|$)/)?.[0] || textSummary.slice(0, 80)).trim();
|
||||
const title = (clean(value.title) || sentence).slice(0, 240);
|
||||
const url = clean(value.url).slice(0, 2000);
|
||||
const body = text && url && text.includes(url) ? text : [text, url].filter(Boolean).join('\n\n');
|
||||
return { title, body };
|
||||
}
|
||||
|
||||
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
let pending = null;
|
||||
const safeLabelIds = value => Array.from(new Set(
|
||||
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
||||
)).slice(0, 20);
|
||||
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
|
||||
const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
|
||||
const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : '';
|
||||
|
||||
function loadStored() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
||||
if (!parsed || typeof parsed !== 'object') return {...emptyDraft(), operationId: ''};
|
||||
return {
|
||||
const draft = {
|
||||
repository: String(parsed.repository || ''),
|
||||
title: String(parsed.title || ''),
|
||||
body: String(parsed.body || ''),
|
||||
labelIds: safeLabelIds(parsed.labelIds),
|
||||
operationId: String(parsed.operationId || '').slice(0, 128),
|
||||
};
|
||||
const milestoneId = safeMilestoneId(parsed.milestoneId);
|
||||
const dueDate = safeDueDate(parsed.dueDate);
|
||||
if (milestoneId !== null) draft.milestoneId = milestoneId;
|
||||
if (dueDate) draft.dueDate = dueDate;
|
||||
return draft;
|
||||
} catch (_error) {
|
||||
return {...emptyDraft(), operationId: ''};
|
||||
}
|
||||
@@ -45,7 +64,12 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: safeLabelIds(draft?.labelIds),
|
||||
};
|
||||
const unchanged = ['repository', 'title', 'body'].every(key => previous[key] === safe[key]) &&
|
||||
const milestoneId = safeMilestoneId(draft?.milestoneId);
|
||||
const dueDate = safeDueDate(draft?.dueDate);
|
||||
if (milestoneId !== null) safe.milestoneId = milestoneId;
|
||||
if (dueDate) safe.dueDate = dueDate;
|
||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
|
||||
.every(key => (previous[key] || '') === (safe[key] || '')) &&
|
||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
|
||||
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
|
||||
return safe;
|
||||
@@ -61,6 +85,42 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
catch (_error) { /* Confirmed creation remains authoritative. */ }
|
||||
}
|
||||
|
||||
function pendingSharedContent() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(sharedStorageKey) || 'null');
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const shared = normalizeSharedContent({title: parsed.title, text: parsed.body});
|
||||
return shared.title || shared.body ? shared : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function acceptSharedContent() {
|
||||
const shared = pendingSharedContent();
|
||||
if (!shared) return loadDraft();
|
||||
const accepted = saveDraft({...loadDraft(), ...shared});
|
||||
try { storage.removeItem(sharedStorageKey); }
|
||||
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function discardSharedContent() {
|
||||
try { storage.removeItem(sharedStorageKey); }
|
||||
catch (_error) { /* The existing issue draft remains authoritative. */ }
|
||||
}
|
||||
|
||||
function stageSharedContent(value) {
|
||||
const shared = normalizeSharedContent(value);
|
||||
if (!shared.title && !shared.body) return {status: 'empty'};
|
||||
try { storage.setItem(sharedStorageKey, JSON.stringify(shared)); }
|
||||
catch (_error) { /* The caller can still use the in-page share payload. */ }
|
||||
const existing = loadDraft();
|
||||
if (existing.title || existing.body) return {status: 'conflict'};
|
||||
acceptSharedContent();
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function loadLabels(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||
@@ -73,6 +133,13 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
);
|
||||
}
|
||||
|
||||
function loadMilestones(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return fetchJson('api/v1/repos/' + encoded + '/milestones').then(milestones =>
|
||||
Array.isArray(milestones) ? milestones : []
|
||||
);
|
||||
}
|
||||
|
||||
function submit(draft) {
|
||||
if (pending) return pending;
|
||||
const saved = saveDraft(draft);
|
||||
@@ -88,6 +155,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: saved.title, body: saved.body, label_ids: saved.labelIds,
|
||||
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
|
||||
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
|
||||
}),
|
||||
}).then(issue => {
|
||||
clearDraft();
|
||||
@@ -96,7 +165,12 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
return pending;
|
||||
}
|
||||
|
||||
return { saveDraft, loadDraft, loadLabels, submit };
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
};
|
||||
}
|
||||
|
||||
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||
@@ -0,0 +1,216 @@
|
||||
function createDraftInbox({ storage, getCurrentLogin = () => '', 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 parseOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return record.items.filter(item =>
|
||||
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
|
||||
).map(item => {
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
return {
|
||||
id: 'stackchain.issue-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
kind: 'issue-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
label: item.status === 'attention' ? 'Needs attention' : 'Queued issue',
|
||||
repository: item.repository,
|
||||
title: textPreview(item.title) || 'Untitled queued issue',
|
||||
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: [item.title, item.body].filter(Boolean).join('\n\n'),
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
};
|
||||
});
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function parseAuthoredOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
|
||||
.map(item => {
|
||||
const isUpdate = item.kind === 'update-reply';
|
||||
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
return {
|
||||
id: 'stackchain.authored-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
label: item.status === 'attention' ? 'Needs attention' : 'Queued message',
|
||||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: item.body,
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
|
||||
{ kind:routeKind, repository:item.repository, number:item.number },
|
||||
};
|
||||
});
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
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; }
|
||||
if (key === 'stackchain.issue-outbox.v1') {
|
||||
parseOutbox(raw).forEach(item => drafts.push(item));
|
||||
seen.add(key);
|
||||
return;
|
||||
}
|
||||
if (key === 'stackchain.authored-outbox.v1') {
|
||||
parseAuthoredOutbox(raw).forEach(item => drafts.push(item));
|
||||
seen.add(key);
|
||||
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;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 966 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
+818
-144
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,145 @@
|
||||
function createIssueOutbox({ storage, fetchJson, coordinator, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) {
|
||||
const storageKey = 'stackchain.issue-outbox.v1';
|
||||
const operationId = createOperationId || (() =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && typeof item === 'object');
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
coordinator?.notify('issue');
|
||||
}
|
||||
|
||||
function enqueue(draft) {
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
|
||||
const items = read();
|
||||
if (items.length >= maxItems) throw new Error('Issue outbox is full. Send or discard a queued issue first.');
|
||||
const item = {
|
||||
id: String(operationId()).slice(0, 128),
|
||||
operationId: '',
|
||||
repository: String(draft?.repository || ''),
|
||||
title: String(draft?.title || ''),
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
item.operationId = item.id;
|
||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||
item.milestoneId = Number(draft.milestoneId);
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
|
||||
items.push(item);
|
||||
write(items);
|
||||
return item;
|
||||
}
|
||||
|
||||
function update(id, draft) {
|
||||
let updated = null;
|
||||
write(read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
updated = {
|
||||
...item,
|
||||
repository: String(draft?.repository || ''), title: String(draft?.title || ''),
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
|
||||
status: 'queued',
|
||||
};
|
||||
delete updated.error;
|
||||
return updated;
|
||||
}));
|
||||
return updated;
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
if (!items.some(item => item.id === id)) return false;
|
||||
write(items.filter(item => item.id !== id));
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
const request = (async () => {
|
||||
try {
|
||||
const issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
});
|
||||
discard(item.id);
|
||||
return { issue };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status >= 400 && status < 500) {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !(status >= 400 && status < 500) };
|
||||
}
|
||||
})();
|
||||
pending.set(item.id, request);
|
||||
try { return await request; }
|
||||
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
|
||||
}
|
||||
|
||||
async function flushQueue(currentLogin) {
|
||||
const confirmed = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const result = await sendItem(item, currentLogin);
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.transient) break;
|
||||
}
|
||||
return { confirmed, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
if (coordinator) return coordinator.runExclusive('issue', () => flushQueue(currentLogin));
|
||||
return flushQueue(currentLogin);
|
||||
}
|
||||
|
||||
async function retryItem(id, currentLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return { confirmed: [], remaining: read() };
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
return { confirmed: [], remaining: read(), blocked: 1 };
|
||||
}
|
||||
update(id, item);
|
||||
const result = await sendItem({ ...item, status: 'queued' }, currentLogin);
|
||||
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
|
||||
}
|
||||
|
||||
async function retry(id, currentLogin) {
|
||||
if (coordinator) return coordinator.runExclusive('issue', () => retryItem(id, currentLogin));
|
||||
return retryItem(id, currentLogin);
|
||||
}
|
||||
|
||||
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
|
||||
+32
-1
@@ -1,4 +1,24 @@
|
||||
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;
|
||||
@@ -21,6 +41,15 @@ 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' },
|
||||
@@ -204,4 +233,6 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
|
||||
};
|
||||
}
|
||||
|
||||
createIssueSheet.createPlanningLoader = createPlanningLoader;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Stackchain Dashboard",
|
||||
"short_name": "Stackchain",
|
||||
"description": "Capture, plan, and complete Gitea work from mobile.",
|
||||
"id": "./",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#07111f",
|
||||
"theme_color": "#0b1220",
|
||||
"icons": [
|
||||
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "./",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {"title": "title", "text": "text", "url": "url"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileTaskDock = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileTaskDock(options) {
|
||||
const nav = options.nav;
|
||||
const buttons = options.buttons;
|
||||
const overlays = options.overlays || [];
|
||||
let launcher = null;
|
||||
let wasHidden = false;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (key === name) button.setAttribute('aria-current', 'page');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function refreshVisibility() {
|
||||
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
|
||||
nav.hidden = hidden;
|
||||
if (wasHidden && !hidden && launcher) launcher.focus();
|
||||
wasHidden = hidden;
|
||||
}
|
||||
|
||||
function start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
button.addEventListener('click', event => {
|
||||
launcher = event.currentTarget;
|
||||
select(name);
|
||||
options.actions[name]();
|
||||
});
|
||||
});
|
||||
options.observe(refreshVisibility, overlays);
|
||||
refreshVisibility();
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
const total = Math.max(0, Number(count) || 0);
|
||||
const badge = options.attentionBadge;
|
||||
if (badge) {
|
||||
badge.textContent = String(total);
|
||||
badge.hidden = total === 0;
|
||||
}
|
||||
if (buttons.work) {
|
||||
buttons.work.setAttribute(
|
||||
'aria-label',
|
||||
total ? 'Work, ' + total + ' items need attention' : 'Work'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {start, refreshVisibility, updateAttention};
|
||||
});
|
||||
+51
-4
@@ -249,10 +249,16 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose }) {
|
||||
function createNotificationReader({
|
||||
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
|
||||
loadConversation = null,
|
||||
onConversation = () => {},
|
||||
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
|
||||
}) {
|
||||
let selected = null;
|
||||
let loadVersion = 0;
|
||||
let marking = false;
|
||||
let conversationPager = null;
|
||||
|
||||
async function open(item) {
|
||||
selected = item;
|
||||
@@ -263,6 +269,14 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
|
||||
const detail = await load(item.notification_id);
|
||||
if (selected !== item || version !== loadVersion) return false;
|
||||
onDetail(detail);
|
||||
if (createPager && loadConversation && detail.conversation) {
|
||||
conversationPager = createPager({
|
||||
loadPage: page => loadConversation(item.notification_id, page),
|
||||
});
|
||||
onConversation(conversationPager.reset(detail.conversation));
|
||||
} else {
|
||||
conversationPager = null;
|
||||
}
|
||||
onStatus('Update ready.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
@@ -275,6 +289,29 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
|
||||
|
||||
return {
|
||||
open,
|
||||
appendReply(comment) {
|
||||
if (!conversationPager) return false;
|
||||
onConversation(conversationPager.append(comment));
|
||||
return true;
|
||||
},
|
||||
async loadOlder() {
|
||||
if (!conversationPager || !selected) return false;
|
||||
const pager = conversationPager;
|
||||
const version = loadVersion;
|
||||
onStatus('Loading older messages…');
|
||||
try {
|
||||
const state = await pager.loadOlder();
|
||||
if (pager !== conversationPager || version !== loadVersion) return false;
|
||||
onConversation(state);
|
||||
onStatus(state.comments.length + ' of ' + state.total + ' messages loaded.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (pager === conversationPager && version === loadVersion) {
|
||||
onStatus('Could not load older messages. Retry.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async markReadAndNext(items) {
|
||||
if (!selected || marking) return false;
|
||||
const current = selected;
|
||||
@@ -307,7 +344,7 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
|
||||
}
|
||||
|
||||
function createNotificationReplier({
|
||||
post, storage, onStatus,
|
||||
post, storage, onStatus, authoredOutbox,
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
}) {
|
||||
let pending = false;
|
||||
@@ -343,7 +380,15 @@ function createNotificationReplier({
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
onStatus('Reply posted. You can mark this update read when ready.');
|
||||
return result;
|
||||
} catch (_error) {
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (authoredOutbox && (!status || status >= 500)) {
|
||||
authoredOutbox.enqueue({
|
||||
kind: 'update-reply', notificationId: item.notification_id, body, operationId,
|
||||
});
|
||||
onStatus('Queued for sync when the connection returns.');
|
||||
return { queued: true };
|
||||
}
|
||||
onStatus('Could not send reply. Your draft is safe; retry.');
|
||||
return false;
|
||||
} finally {
|
||||
@@ -355,7 +400,8 @@ function createNotificationReplier({
|
||||
|
||||
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
let filtered = items;
|
||||
if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||
if (selectedFilter === 'attention') filtered = items.filter((item) => item.has_update || item.is_review);
|
||||
else 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;
|
||||
@@ -514,6 +560,7 @@ function summarizeMyWork(items) {
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
attention: items.filter((item) => item.has_update || item.is_review).length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.createOfflineWorkStore = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
const ENABLED_KEY = 'stackchain.offline-work.enabled.v1';
|
||||
const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1';
|
||||
const VERSION = 1;
|
||||
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const ITEM_FIELDS = [
|
||||
'id', 'number', 'title', 'state', 'repository', 'labels', 'assignees',
|
||||
'updated_at', 'due_date', 'milestone', 'url', 'work_reasons',
|
||||
];
|
||||
const NOTIFICATION_FIELDS = [
|
||||
'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url',
|
||||
];
|
||||
|
||||
function pick(source, fields) {
|
||||
const output = {};
|
||||
fields.forEach(field => {
|
||||
if (source && source[field] !== undefined) output[field] = source[field];
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function createOfflineWorkStore({ storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS }) {
|
||||
function enabled() {
|
||||
try { return storage.getItem(ENABLED_KEY) === 'true'; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
function clear() {
|
||||
try { storage.removeItem(SNAPSHOT_KEY); }
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function setEnabled(value) {
|
||||
try {
|
||||
storage.setItem(ENABLED_KEY, value ? 'true' : 'false');
|
||||
if (!value) clear();
|
||||
return true;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
function save(snapshot) {
|
||||
if (!enabled() || !snapshot?.user?.login) return false;
|
||||
const record = {
|
||||
version: VERSION,
|
||||
user_login: String(snapshot.user.login),
|
||||
saved_at: now().toISOString(),
|
||||
data: {
|
||||
user: pick(snapshot.user, ['login', 'full_name']),
|
||||
issues: (snapshot.issues || []).map(item => pick(item, ITEM_FIELDS)),
|
||||
pull_requests: (snapshot.pull_requests || []).map(item => pick(item, ITEM_FIELDS)),
|
||||
notifications: (snapshot.notifications || []).map(item => pick(item, NOTIFICATION_FIELDS)),
|
||||
work_pagination: snapshot.work_pagination || {},
|
||||
notification_pagination: snapshot.notification_pagination || {},
|
||||
},
|
||||
};
|
||||
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function load(expectedLogin) {
|
||||
let record;
|
||||
try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
|
||||
catch (_) { clear(); return null; }
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
|
||||
!Number.isFinite(savedAt);
|
||||
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
|
||||
if (invalid || expired) {
|
||||
clear();
|
||||
return null;
|
||||
}
|
||||
if (expectedLogin && record.user_login !== expectedLogin) return null;
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
|
||||
return { enabled, setEnabled, save, load, clear };
|
||||
}
|
||||
|
||||
return createOfflineWorkStore;
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
function createOutboxCoordinator({
|
||||
storage,
|
||||
locks = globalThis.navigator?.locks,
|
||||
channelFactory = typeof globalThis.BroadcastChannel === 'function' ?
|
||||
() => new globalThis.BroadcastChannel('stackchain-outbox-v1') : null,
|
||||
addStorageListener = globalThis.addEventListener?.bind(globalThis),
|
||||
removeStorageListener = globalThis.removeEventListener?.bind(globalThis),
|
||||
tabId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
now = () => Date.now(),
|
||||
leaseMs = 15000,
|
||||
} = {}) {
|
||||
const changeKey = 'stackchain.outbox-change.v1';
|
||||
const subscribers = new Set();
|
||||
const channel = channelFactory ? channelFactory() : null;
|
||||
let sequence = 0;
|
||||
|
||||
function validChange(value) {
|
||||
return value && (value.queue === 'issue' || value.queue === 'authored') ? value : null;
|
||||
}
|
||||
|
||||
function publish(change) {
|
||||
subscribers.forEach(listener => listener(change));
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
channel.onmessage = event => {
|
||||
const change = validChange(event?.data);
|
||||
if (change && change.tabId !== tabId) publish(change);
|
||||
};
|
||||
}
|
||||
|
||||
const onStorage = event => {
|
||||
if (event?.key !== changeKey || !event.newValue) return;
|
||||
try {
|
||||
const change = validChange(JSON.parse(event.newValue));
|
||||
if (change && change.tabId !== tabId) publish(change);
|
||||
} catch (_error) { /* Ignore malformed cross-tab signals. */ }
|
||||
};
|
||||
if (addStorageListener) addStorageListener('storage', onStorage);
|
||||
|
||||
function notify(queue) {
|
||||
const change = { queue, tabId, changedAt: Number(now()), sequence: ++sequence };
|
||||
if (!validChange(change)) return;
|
||||
try { storage?.setItem(changeKey, JSON.stringify(change)); } catch (_error) { /* Broadcast may still work. */ }
|
||||
try { channel?.postMessage(change); } catch (_error) { /* Storage events remain available. */ }
|
||||
}
|
||||
|
||||
function skipped() {
|
||||
return { confirmed: [], remaining: [], blocked: 0, lease_skipped: true };
|
||||
}
|
||||
|
||||
async function withFallbackLease(queue, work) {
|
||||
const key = 'stackchain.outbox-lease.' + queue + '.v1';
|
||||
const token = tabId + ':' + (++sequence);
|
||||
const timestamp = Number(now());
|
||||
try {
|
||||
const current = JSON.parse(storage?.getItem(key) || 'null');
|
||||
if (current?.token && Number(current.expiresAt) > timestamp) return skipped();
|
||||
storage?.setItem(key, JSON.stringify({ token, expiresAt: timestamp + leaseMs }));
|
||||
const claimed = JSON.parse(storage?.getItem(key) || 'null');
|
||||
if (claimed?.token !== token) return skipped();
|
||||
} catch (_error) {
|
||||
return work();
|
||||
}
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
try {
|
||||
const current = JSON.parse(storage?.getItem(key) || 'null');
|
||||
if (current?.token === token) storage?.removeItem(key);
|
||||
} catch (_error) { /* An expired lease will be reclaimed. */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function runExclusive(queue, work) {
|
||||
if (locks?.request) {
|
||||
return locks.request('stackchain-outbox-' + queue + '-v1', { mode: 'exclusive', ifAvailable: true },
|
||||
lock => lock ? work() : skipped());
|
||||
}
|
||||
return withFallbackLease(queue, work);
|
||||
}
|
||||
|
||||
function subscribe(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
subscribers.add(listener);
|
||||
return () => subscribers.delete(listener);
|
||||
}
|
||||
|
||||
function close() {
|
||||
subscribers.clear();
|
||||
try { channel?.close(); } catch (_error) { /* No-op. */ }
|
||||
if (removeStorageListener) removeStorageListener('storage', onStorage);
|
||||
}
|
||||
|
||||
return { runExclusive, notify, subscribe, close };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createOutboxCoordinator;
|
||||
@@ -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;
|
||||
|
||||
+26
-1
@@ -37,9 +37,11 @@ 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 reviewRequests = new Map();
|
||||
const reviewCache = new Map();
|
||||
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
|
||||
.map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number;
|
||||
@@ -62,6 +64,29 @@ function createPullSheet({ fetchJson, storage, createOperationId = () => globalT
|
||||
load(item) {
|
||||
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
|
||||
},
|
||||
loadReview(item, headSha) {
|
||||
const key = item.repository + '#' + item.number + ':' + headSha;
|
||||
if (reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
|
||||
if (reviewRequests.has(key)) return reviewRequests.get(key);
|
||||
const request = fetchJson(pathFor(item) + '/review-data', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(detail => {
|
||||
const confirmedKey = item.repository + '#' + item.number + ':' + detail.head_sha;
|
||||
reviewCache.set(confirmedKey, detail);
|
||||
return detail;
|
||||
}).finally(() => reviewRequests.delete(key));
|
||||
reviewRequests.set(key, request);
|
||||
return request;
|
||||
},
|
||||
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);
|
||||
|
||||
@@ -309,6 +309,18 @@ async function copyAndContinue({ text, url, copy, open, fallback }) {
|
||||
return { copied: true, opened: true };
|
||||
}
|
||||
|
||||
function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }) {
|
||||
const reasons = Array.isArray(item?.work_reasons) ? item.work_reasons : [];
|
||||
if (decision !== 'approve' || !reasons.includes('assigned_to_me') || !headSha) return false;
|
||||
const filenames = Array.isArray(reviewed)
|
||||
? reviewed.filter(filename => typeof filename === 'string' && filename)
|
||||
: [];
|
||||
const key = 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + headSha;
|
||||
try { storage?.setItem(key, JSON.stringify(filenames)); }
|
||||
catch (_error) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
createReviewController.renderDiffFile = renderDiffFile;
|
||||
createReviewController.parseDiffLines = parseDiffLines;
|
||||
createReviewController.toggleDiff = toggleDiff;
|
||||
@@ -316,6 +328,7 @@ createReviewController.createProgress = createProgress;
|
||||
createReviewController.createDraft = createDraft;
|
||||
createReviewController.formatFeedback = formatFeedback;
|
||||
createReviewController.copyAndContinue = copyAndContinue;
|
||||
createReviewController.prepareMergeContinuation = prepareMergeContinuation;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createReviewController;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
const CACHE = 'stackchain-dashboard-shell-v11';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/pull-sheet.js',
|
||||
BASE + 'static/review-sheet.js',
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(caches.keys().then(keys => Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||
.map(key => caches.delete(key))
|
||||
)).then(() => self.clients.claim()));
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const request = event.request;
|
||||
if (request.method !== 'GET' || request.url.includes('/api/')) return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request).then(async response => {
|
||||
const cache = await caches.open(CACHE);
|
||||
if (response.ok) {
|
||||
await cache.put(BASE, response.clone());
|
||||
} else if (OUTAGE_STATUSES.has(response.status)) {
|
||||
const cached = await cache.match(BASE);
|
||||
if (cached) return cached;
|
||||
}
|
||||
return response;
|
||||
}).catch(async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
return cache.match(BASE);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
}
|
||||
});
|
||||
+39
-2
@@ -46,31 +46,68 @@
|
||||
Number(item.number) === route.number;
|
||||
}
|
||||
|
||||
function createController({ location, history, eventTarget, onOpen, onClose, onInvalid }) {
|
||||
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) onInvalid();
|
||||
if (ready) resolveMissing(fragment, route);
|
||||
return;
|
||||
}
|
||||
if (active === fragment) return;
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
}
|
||||
|
||||
+241
-41
@@ -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."""
|
||||
|
||||
@@ -324,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(),
|
||||
@@ -338,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))
|
||||
@@ -508,8 +533,30 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
subject = subject_value if isinstance(subject_value, dict) else {}
|
||||
subject_path = _gitea_api_path(subject.get("url"))
|
||||
comment_path = _gitea_api_path(subject.get("latest_comment_url"))
|
||||
subject_detail = await fetch(subject_path) if subject_path else {}
|
||||
comment = await fetch(comment_path) if comment_path else {}
|
||||
conversation_match = re.fullmatch(
|
||||
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
|
||||
)
|
||||
repository_name = repository.get("full_name")
|
||||
supported_conversation = (
|
||||
conversation_match
|
||||
and conversation_match.group(1) == repository_name
|
||||
and subject.get("type") in {"Issue", "Pull"}
|
||||
)
|
||||
if supported_conversation:
|
||||
assert conversation_match is not None
|
||||
subject_detail, comment, conversation = await asyncio.gather(
|
||||
fetch(subject_path),
|
||||
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
||||
issue_conversation_page(
|
||||
conversation_match.group(1), int(conversation_match.group(3))
|
||||
),
|
||||
)
|
||||
else:
|
||||
subject_detail, comment, conversation = await asyncio.gather(
|
||||
fetch(subject_path) if subject_path else asyncio.sleep(0, result={}),
|
||||
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
||||
asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}),
|
||||
)
|
||||
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
|
||||
comment = comment if isinstance(comment, dict) else {}
|
||||
user_value = comment.get("user")
|
||||
@@ -548,9 +595,33 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
else "",
|
||||
"url": latest_url,
|
||||
},
|
||||
"conversation": conversation,
|
||||
}
|
||||
|
||||
|
||||
async def notification_conversation_page(
|
||||
thread_id: int, page: int, limit: int = 20
|
||||
) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
raise ValueError("Gitea notification thread response was not an object")
|
||||
repository = thread.get("repository")
|
||||
subject = thread.get("subject")
|
||||
if not isinstance(repository, dict) or not isinstance(subject, dict):
|
||||
raise ValueError("Notification does not identify a conversation")
|
||||
subject_path = _gitea_api_path(subject.get("url"))
|
||||
match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path)
|
||||
if (
|
||||
not match
|
||||
or match.group(1) != repository.get("full_name")
|
||||
or subject.get("type") not in {"Issue", "Pull"}
|
||||
):
|
||||
raise ValueError("Notification subject is not a supported conversation")
|
||||
return await issue_conversation_page(
|
||||
match.group(1), int(match.group(3)), page=page, limit=limit
|
||||
)
|
||||
|
||||
|
||||
async def reply_to_notification(thread_id: int, body: str) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -579,10 +650,7 @@ async def reply_to_notification(thread_id: int, body: str) -> dict:
|
||||
comment = response.json()
|
||||
if not isinstance(comment, dict):
|
||||
raise ValueError("Gitea comment response was not an object")
|
||||
return {
|
||||
"id": comment.get("id"),
|
||||
"url": _safe_web_url(comment.get("html_url")),
|
||||
}
|
||||
return _normalize_issue_comment(comment)
|
||||
|
||||
|
||||
async def close_issue(repository: str, number: int) -> dict:
|
||||
@@ -683,10 +751,16 @@ async def create_issue(
|
||||
body: str,
|
||||
assignee: str,
|
||||
label_ids: list[int] | None = None,
|
||||
milestone_id: int | None = None,
|
||||
due_date: str | None = None,
|
||||
) -> dict:
|
||||
payload: dict = {"title": title, "body": body, "assignee": assignee}
|
||||
if label_ids:
|
||||
payload["labels"] = label_ids
|
||||
if milestone_id is not None:
|
||||
payload["milestone"] = milestone_id
|
||||
if due_date is not None:
|
||||
payload["due_date"] = due_date
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/issues",
|
||||
headers=_auth(),
|
||||
@@ -707,6 +781,22 @@ async def create_issue(
|
||||
raise ValueError("Gitea did not confirm issue self-assignment")
|
||||
labels_value = issue.get("labels")
|
||||
labels = labels_value if isinstance(labels_value, list) else []
|
||||
milestone_value = issue.get("milestone")
|
||||
milestone = (
|
||||
{"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
|
||||
)
|
||||
confirmed_due_date = (
|
||||
issue.get("due_date") if isinstance(issue.get("due_date"), str) else None
|
||||
)
|
||||
if (
|
||||
(milestone_id is not None and (milestone or {}).get("id") != milestone_id)
|
||||
or (due_date is not None and confirmed_due_date != due_date)
|
||||
):
|
||||
raise ValueError("Gitea did not confirm issue release plan")
|
||||
return {
|
||||
"id": issue.get("id"),
|
||||
"number": issue["number"],
|
||||
@@ -723,6 +813,8 @@ async def create_issue(
|
||||
if isinstance(item, dict) and isinstance(item.get("name"), str)
|
||||
],
|
||||
"assignees": confirmed_assignees,
|
||||
"milestone": milestone,
|
||||
"due_date": confirmed_due_date,
|
||||
"updated_at": issue.get("updated_at", "")
|
||||
if isinstance(issue.get("updated_at"), str)
|
||||
else "",
|
||||
@@ -938,10 +1030,58 @@ async def update_assigned_issue_milestone(
|
||||
}
|
||||
|
||||
|
||||
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")
|
||||
@@ -949,12 +1089,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,
|
||||
@@ -980,6 +1115,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -1040,6 +1176,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}"
|
||||
@@ -1102,18 +1289,9 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
|
||||
pull = await fetch(base)
|
||||
if not isinstance(pull, dict):
|
||||
raise ValueError("Gitea pull request response was not an object")
|
||||
conversation = await issue_conversation_page(repository, number)
|
||||
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(
|
||||
fetch(f"{base}/files"),
|
||||
fetch(f"repos/{repository}/commits/{sha}/status"),
|
||||
fetch(f"repos/{repository}/issues/{number}/comments?limit=20&page=1"),
|
||||
fetch_text(
|
||||
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
|
||||
),
|
||||
)
|
||||
diff, diff_truncated = diff_result
|
||||
previews = _diff_previews(diff, diff_truncated)
|
||||
user = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
return {
|
||||
"repository": repository,
|
||||
@@ -1124,6 +1302,33 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
|
||||
"author": user.get("login") if isinstance(user.get("login"), str) else "",
|
||||
"head_sha": sha,
|
||||
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
|
||||
"conversation": conversation,
|
||||
}
|
||||
|
||||
|
||||
async def pull_completion_review(repository: str, number: int) -> dict:
|
||||
base = f"repos/{repository}/pulls/{number}"
|
||||
pull = await fetch(base)
|
||||
if not isinstance(pull, dict):
|
||||
raise ValueError("Gitea pull request response was not an object")
|
||||
head_value = pull.get("head")
|
||||
head: dict = head_value if isinstance(head_value, dict) else {}
|
||||
sha_value = head.get("sha")
|
||||
sha = sha_value if isinstance(sha_value, str) else ""
|
||||
files, status, diff_result = await asyncio.gather(
|
||||
fetch(f"{base}/files"),
|
||||
fetch(f"repos/{repository}/commits/{sha}/status"),
|
||||
fetch_text(
|
||||
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
|
||||
),
|
||||
)
|
||||
diff, diff_truncated = diff_result
|
||||
previews = _diff_previews(diff, diff_truncated)
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"head_sha": sha,
|
||||
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
|
||||
"draft": pull.get("draft") is True,
|
||||
"mergeable": pull.get("mergeable") is True,
|
||||
"merged": pull.get("merged") is True,
|
||||
@@ -1147,11 +1352,6 @@ 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)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
+211
-6
@@ -4,6 +4,7 @@ import os
|
||||
import time
|
||||
from collections.abc import Awaitable, Coroutine
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -76,6 +77,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
|
||||
@@ -107,6 +109,7 @@ _idempotency_ledger = IdempotencyLedger(
|
||||
_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):
|
||||
@@ -149,6 +152,12 @@ class IssueCreation(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
label_ids: list[int] = Field(default_factory=list, max_length=20)
|
||||
milestone_id: PositiveInt | None = None
|
||||
due_date: str | None = Field(
|
||||
default=None,
|
||||
pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$",
|
||||
max_length=20,
|
||||
)
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
@@ -163,6 +172,13 @@ class IssueCreation(BaseModel):
|
||||
def strip_issue_body(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
@field_validator("due_date")
|
||||
@classmethod
|
||||
def validate_due_date(cls, value: str | None) -> str | None:
|
||||
if value is not None:
|
||||
datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
|
||||
return value
|
||||
|
||||
|
||||
class IssueContentUpdate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
@@ -397,7 +413,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", "/api/v1/search"} 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 (
|
||||
@@ -547,6 +563,33 @@ async def global_search_preview(
|
||||
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"],
|
||||
@@ -573,13 +616,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 (
|
||||
@@ -588,16 +642,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
|
||||
|
||||
|
||||
@@ -615,7 +678,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:
|
||||
@@ -627,6 +690,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)
|
||||
|
||||
|
||||
@@ -1042,6 +1109,32 @@ async def notification_thread_detail(
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/notifications/{thread_id}/conversation")
|
||||
async def notification_thread_conversation(
|
||||
thread_id: int = PathParam(gt=0),
|
||||
page: int = Query(ge=1),
|
||||
limit: int = Query(default=20, ge=1, le=50),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.notification_conversation_page(thread_id, page, limit),
|
||||
timeout=NOTIFICATION_DETAIL_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Loading older messages timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Older messages are temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/notifications/{thread_id}/read")
|
||||
async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
try:
|
||||
@@ -1360,6 +1453,27 @@ async def create_assigned_issue(
|
||||
}
|
||||
if any(label_id not in valid_label_ids for label_id in creation.label_ids):
|
||||
raise HTTPException(status_code=422, detail="Unknown repository label")
|
||||
if creation.milestone_id is not None:
|
||||
available_milestones = await gitea_proxy.repo_milestones(repository)
|
||||
valid_milestone_ids = {
|
||||
item.get("id")
|
||||
for item in available_milestones
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), int)
|
||||
}
|
||||
if creation.milestone_id not in valid_milestone_ids:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Unknown open repository milestone"
|
||||
)
|
||||
if creation.milestone_id is not None or creation.due_date is not None:
|
||||
return await gitea_proxy.create_issue(
|
||||
repository,
|
||||
creation.title,
|
||||
creation.body,
|
||||
login,
|
||||
creation.label_ids,
|
||||
creation.milestone_id,
|
||||
creation.due_date,
|
||||
)
|
||||
return await gitea_proxy.create_issue(
|
||||
repository, creation.title, creation.body, login, creation.label_ids
|
||||
)
|
||||
@@ -1374,6 +1488,8 @@ async def create_assigned_issue(
|
||||
creation.title,
|
||||
creation.body,
|
||||
tuple(creation.label_ids),
|
||||
creation.milestone_id,
|
||||
creation.due_date,
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
@@ -1388,6 +1504,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,
|
||||
@@ -1560,6 +1705,66 @@ async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data")
|
||||
async def assigned_pull_review_data(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_assigned_pull_review():
|
||||
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.pull_completion_review(repository, number)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
load_assigned_pull_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Loading review and merge data timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Review and merge data is temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
|
||||
+25
-2
@@ -1,12 +1,35 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
|
||||
router = APIRouter()
|
||||
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
|
||||
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
|
||||
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
class RevalidatingHTMLResponse(HTMLResponse):
|
||||
def __init__(self, content, status_code=200, headers=None, media_type=None, background=None):
|
||||
response_headers = dict(headers or {})
|
||||
response_headers["Cache-Control"] = "no-cache"
|
||||
super().__init__(content, status_code, response_headers, media_type, background)
|
||||
|
||||
|
||||
@router.get("/", response_class=RevalidatingHTMLResponse)
|
||||
async def dashboard() -> str:
|
||||
return DASHBOARD_FILE.read_text()
|
||||
|
||||
|
||||
@router.get("/manifest.webmanifest", response_class=FileResponse)
|
||||
async def web_app_manifest() -> FileResponse:
|
||||
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
|
||||
|
||||
|
||||
@router.get("/service-worker.js", response_class=FileResponse)
|
||||
async def service_worker() -> FileResponse:
|
||||
return FileResponse(
|
||||
SERVICE_WORKER_FILE,
|
||||
media_type="application/javascript",
|
||||
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
OUTBOX = Path(__file__).parents[1] / "frontend" / "authored-outbox.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_authored_outbox_quarantines_legacy_and_cross_account_messages():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify({{version:1,items:[
|
||||
{{id:'legacy',operationId:'legacy',kind:'issue-comment',repository:'o/r',number:1,body:'Old',status:'queued'}}
|
||||
]}})]]);
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const calls = [];
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy',
|
||||
fetchJson:async url => {{calls.push(url); return {{id:1}};}},
|
||||
}});
|
||||
const bound = outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:2,body:'Mine',operationId:'mine'}});
|
||||
(async()=>{{
|
||||
const wrong = await outbox.flush('alexander');
|
||||
const right = await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{bound,wrong,right,calls,remaining:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["bound"]["ownerLogin"] == "timmy"
|
||||
assert output["wrong"]["blocked"] == 2
|
||||
assert len(output["right"]["confirmed"]) == 1
|
||||
assert [item["id"] for item in output["remaining"]] == ["legacy"]
|
||||
assert len(output["calls"]) == 1
|
||||
|
||||
|
||||
def test_authored_outbox_persists_each_message_kind_and_flushes_sequentially():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const calls = [];
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', now:() => 1234,
|
||||
fetchJson:async (url, options) => {{
|
||||
calls.push({{url,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
return {{id:calls.length}};
|
||||
}},
|
||||
}});
|
||||
outbox.enqueue({{kind:'issue-comment',repository:'stackchain/api',number:7,body:'Issue note',operationId:'issue-op'}});
|
||||
outbox.enqueue({{kind:'pull-comment',repository:'stackchain/web',number:8,body:'PR note',operationId:'pull-op'}});
|
||||
outbox.enqueue({{kind:'update-reply',notificationId:9,body:'Update note',operationId:'update-op'}});
|
||||
const persisted = createAuthoredOutbox({{storage,fetchJson:outbox.fetchJson}}).list();
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persisted,calls,result,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [item["kind"] for item in output["persisted"]] == [
|
||||
"issue-comment", "pull-comment", "update-reply"
|
||||
]
|
||||
assert [item["operationId"] for item in output["persisted"]] == ["issue-op", "pull-op", "update-op"]
|
||||
assert output["calls"] == [
|
||||
{"url": "api/v1/repos/stackchain/api/issues/7/comments", "key": "issue-op", "body": {"body": "Issue note"}},
|
||||
{"url": "api/v1/repos/stackchain/web/pulls/8/comments", "key": "pull-op", "body": {"body": "PR note"}},
|
||||
{"url": "api/v1/notifications/9/reply", "key": "update-op", "body": {"body": "Update note"}},
|
||||
]
|
||||
assert len(output["result"]["confirmed"]) == 3
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
let phase = 'permanent';
|
||||
const calls = [];
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage,
|
||||
getOwnerLogin:()=>'timmy',
|
||||
fetchJson:async (_url, options) => {{
|
||||
const body = JSON.parse(options.body).body; calls.push(body);
|
||||
if (body === 'Bad' && phase === 'permanent') {{ const e = new Error('Reply rejected'); e.status=422; throw e; }}
|
||||
if (body === 'Later' && phase === 'transient') {{ const e = new Error('Offline'); e.status=503; throw e; }}
|
||||
return {{id:calls.length}};
|
||||
}},
|
||||
}});
|
||||
const bad=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Bad',operationId:'a'}});
|
||||
outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Good',operationId:'b'}});
|
||||
outbox.flush('timmy').then(async first => {{
|
||||
phase='transient';
|
||||
outbox.enqueue({{kind:'update-reply',notificationId:3,body:'Later',operationId:'c'}});
|
||||
const second=await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{first,second,calls,remaining:outbox.list(),badId:bad.id}}));
|
||||
}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == ["Bad", "Good", "Later"]
|
||||
assert len(output["first"]["confirmed"]) == 1
|
||||
assert output["remaining"][0]["id"] == output["badId"]
|
||||
assert output["remaining"][0]["status"] == "attention"
|
||||
assert output["remaining"][0]["error"] == "Reply rejected"
|
||||
assert output["remaining"][1]["status"] == "queued"
|
||||
|
||||
|
||||
def test_authored_outbox_retry_is_single_flight_and_edit_rotates_identity():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
let release; const gate=new Promise(resolve=>release=resolve); let calls=0; let sequence=0;
|
||||
const outbox=createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'new-' + (++sequence),
|
||||
fetchJson:async()=>{{calls++; await gate; return {{id:1}};}},
|
||||
}});
|
||||
const item=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Old',operationId:'old'}});
|
||||
const edited=outbox.update(item.id,{{body:'New'}});
|
||||
const flush=outbox.flush('timmy'); const retry=outbox.retry(item.id, 'timmy'); release();
|
||||
Promise.all([flush,retry]).then(results=>process.stdout.write(JSON.stringify({{edited,calls,results,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["edited"]["body"] == "New"
|
||||
assert output["edited"]["operationId"] == "new-1"
|
||||
assert output["calls"] == 1
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_deduplicates_repeated_queue_attempts_by_operation_id():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy'}});
|
||||
const first=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}});
|
||||
const second=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}});
|
||||
process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == output["second"]
|
||||
assert len(output["items"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/authored-outbox.js"></script>' in html
|
||||
assert "const authoredOutbox = createAuthoredOutbox({" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ?" in html
|
||||
assert "flushAuthoredOutbox();" in html
|
||||
assert "authoredOutbox.enqueue" in html
|
||||
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
|
||||
assert "authoredOutbox.discard(item.outbox_id)" in html
|
||||
assert "if (result?.queued)" in html
|
||||
@@ -0,0 +1,181 @@
|
||||
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}
|
||||
|
||||
|
||||
def test_draft_inbox_expands_issue_outbox_items_with_actionable_states():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:1,items:[
|
||||
{{id:'queued-1',repository:'stackchain/api',title:'Offline capture',body:'Context',status:'queued',queuedAt:100}},
|
||||
{{id:'attention-2',repository:'stackchain/web',title:'Fix labels',body:'Details',status:'attention',error:'Unknown label',queuedAt:200}},
|
||||
]}})]]);
|
||||
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:()=>300}}).list();
|
||||
process.stdout.write(JSON.stringify(drafts));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [item["outbox_id"] for item in output] == ["attention-2", "queued-1"]
|
||||
assert [item["status"] for item in output] == ["attention", "queued"]
|
||||
assert output[0]["label"] == "Needs attention"
|
||||
assert output[1]["label"] == "Queued issue"
|
||||
|
||||
|
||||
def test_draft_inbox_marks_mismatched_and_legacy_outbox_content_copy_only():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'other',repository:'o/r',title:'Other issue',body:'Private context',ownerLogin:'alexander',status:'queued'}},
|
||||
{{id:'legacy',repository:'o/r',title:'Legacy issue',body:'Old context',status:'queued'}}
|
||||
]}})],
|
||||
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'mine',kind:'issue-comment',repository:'o/r',number:2,body:'My comment',ownerLogin:'timmy',status:'queued'}}
|
||||
]}})],
|
||||
]);
|
||||
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)}};
|
||||
const drafts = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list();
|
||||
process.stdout.write(JSON.stringify(drafts));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
by_id = {item["outbox_id"]: item for item in output}
|
||||
assert by_id["other"]["quarantined"] is True
|
||||
assert by_id["other"]["ownership"] == "Queued by alexander — current account is timmy"
|
||||
assert by_id["other"]["copy_text"] == "Other issue\n\nPrivate context"
|
||||
assert by_id["legacy"]["ownership"] == "Queued by an unknown account — current account is timmy"
|
||||
assert by_id["mine"]["quarantined"] is False
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot():
|
||||
html = await dashboard()
|
||||
|
||||
assert "let confirmedOwnerLogin = '';" in html
|
||||
assert "let activeFlushLogin = '';" in html
|
||||
assert "getOwnerLogin: () => confirmedOwnerLogin" in html
|
||||
assert "getCurrentLogin: () => activeFlushLogin" in html
|
||||
assert "const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&" in html
|
||||
assert "!contextFreshness?.degraded && !contextFreshness?.revalidating;" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';" in html
|
||||
assert "issueOutbox.flush(activeFlushLogin)" in html
|
||||
assert "authoredOutbox.flush(activeFlushLogin)" in html
|
||||
assert "activeFlushLogin = '';" in html
|
||||
assert 'class="draft-copy"' in html
|
||||
assert "navigator.clipboard.writeText(item.copy_text)" in html
|
||||
assert "item.quarantined" in html
|
||||
@@ -136,11 +136,20 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
return httpx.Response(200, json={"body": "Deploy fails after **three** retries."})
|
||||
if request.url.path.endswith("/issues/comments/9"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 9,
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"user": {"login": "alexander"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
})
|
||||
if request.url.path.endswith("/issues/7/comments"):
|
||||
return httpx.Response(200, json=[{
|
||||
"id": 9,
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"user": {"login": "alexander"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
}], headers={"X-Total-Count": "1"})
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
@@ -153,6 +162,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
|
||||
]
|
||||
assert result == {
|
||||
"id": 42,
|
||||
@@ -168,6 +178,18 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
"conversation": {
|
||||
"comments": [{
|
||||
"id": 9,
|
||||
"author": "alexander",
|
||||
"body": "Logs point to the worker timeout.",
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
}],
|
||||
"page": 1,
|
||||
"older_page": None,
|
||||
"total": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -223,6 +245,9 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
|
||||
})
|
||||
return httpx.Response(201, json={
|
||||
"id": 91,
|
||||
"user": {"login": "timmy"},
|
||||
"body": "Please retry the worker.",
|
||||
"created_at": "2026-08-07T19:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
})
|
||||
|
||||
@@ -246,5 +271,8 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
|
||||
]
|
||||
assert result == {
|
||||
"id": 91,
|
||||
"author": "timmy",
|
||||
"body": "Please retry the worker.",
|
||||
"created_at": "2026-08-07T19:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -373,6 +373,55 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def available_repos():
|
||||
return [{"full_name": "stackchain/api"}]
|
||||
|
||||
async def milestones(repository):
|
||||
assert repository == "stackchain/api"
|
||||
return [{"id": 9, "title": "August RC"}]
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids, milestone_id, due_date):
|
||||
calls.append((repository, title, body, assignee, label_ids, milestone_id, due_date))
|
||||
return {
|
||||
"id": 221, "number": 221, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
"due_date": "2026-08-31T23:59:59Z",
|
||||
"updated_at": "2026-08-07T20:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/221",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
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",
|
||||
json={
|
||||
"title": "Ship mobile plan",
|
||||
"milestone_id": 9,
|
||||
"due_date": "2026-08-31T23:59:59Z",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["milestone"] == {"id": 9, "title": "August RC"}
|
||||
assert response.json()["due_date"] == "2026-08-31T23:59:59Z"
|
||||
assert calls == [(
|
||||
"stackchain/api", "Ship mobile plan", "", "timmy", [], 9,
|
||||
"2026-08-31T23:59:59Z",
|
||||
)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_replays_one_upstream_result_for_concurrent_idempotent_requests(monkeypatch):
|
||||
calls = 0
|
||||
@@ -630,6 +679,26 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
|
||||
assert result["labels"] == ["P0"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_issue_requires_confirmed_release_plan():
|
||||
async def handler(_request):
|
||||
return httpx.Response(201, json={
|
||||
"id": 221, "number": 221, "title": "Planned work", "state": "open",
|
||||
"assignees": [{"login": "timmy"}], "labels": [],
|
||||
"milestone": None, "due_date": "2026-09-01T23:59:59Z",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(ValueError, match="release plan"):
|
||||
await gitea_proxy.create_issue(
|
||||
"stackchain/api", "Planned work", "", "timmy", [], 9,
|
||||
"2026-08-31T23:59:59Z",
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_repo_labels_returns_safe_touch_picker_options():
|
||||
requests = []
|
||||
@@ -702,6 +771,27 @@ async def test_create_issue_rejects_blank_title_before_upstream(monkeypatch):
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_rejects_nonexistent_due_date_before_upstream(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def user():
|
||||
nonlocal called
|
||||
called = True
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
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",
|
||||
json={"title": "Impossible plan", "due_date": "2026-02-31T23:59:59Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
|
||||
created = False
|
||||
@@ -912,6 +1002,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()
|
||||
@@ -1157,9 +1285,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 = []
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.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_issue_outbox_binds_items_and_only_flushes_for_the_matching_account():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const calls = [];
|
||||
let confirmedLogin = 'timmy';
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:() => confirmedLogin, createOperationId:() => 'bound-op',
|
||||
fetchJson:async url => {{ calls.push(url); return {{number:1}}; }},
|
||||
}});
|
||||
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bound',body:'Context'}});
|
||||
confirmedLogin = 'alexander';
|
||||
(async () => {{
|
||||
const mismatch = await outbox.flush('alexander');
|
||||
const unknown = await outbox.retry(queued.id, '');
|
||||
const matched = await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{queued,mismatch,unknown,matched,calls}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["ownerLogin"] == "timmy"
|
||||
assert output["mismatch"]["blocked"] == 1
|
||||
assert output["unknown"]["blocked"] == 1
|
||||
assert len(output["matched"]["confirmed"]) == 1
|
||||
assert len(output["calls"]) == 1
|
||||
|
||||
|
||||
def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
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 sequence = 0;
|
||||
const outbox = createIssueOutbox({{storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}});
|
||||
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One',labelIds:[3]}});
|
||||
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two',milestoneId:4,dueDate:'2026-08-09'}});
|
||||
const reloaded = createIssueOutbox({{storage}}).list();
|
||||
process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.get('stackchain.issue-outbox.v1'))}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [(item["repository"], item["title"]) for item in output["items"]] == [
|
||||
("stackchain/api", "First"),
|
||||
("stackchain/web", "Second"),
|
||||
]
|
||||
assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"]
|
||||
assert all(item["status"] == "queued" for item in output["items"])
|
||||
assert output["stored"]["version"] == 2
|
||||
|
||||
|
||||
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
let sequence = 0;
|
||||
const calls = [];
|
||||
const outbox = createIssueOutbox({{
|
||||
storage,
|
||||
getOwnerLogin:()=>'timmy',
|
||||
createOperationId:() => 'stable-' + (++sequence),
|
||||
fetchJson:async (url, options) => {{
|
||||
calls.push({{url, key:options.headers['Idempotency-Key']}});
|
||||
if (calls.length === 2) {{ const error = new Error('upstream unavailable'); error.status = 503; throw error; }}
|
||||
return {{repository:'stackchain/api',number:41,title:'First'}};
|
||||
}},
|
||||
}});
|
||||
outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One'}});
|
||||
outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two'}});
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["result"]["confirmed"][0]["number"] == 41
|
||||
assert output["calls"] == [
|
||||
{"url": "api/v1/repos/stackchain/api/issues", "key": "stable-1"},
|
||||
{"url": "api/v1/repos/stackchain/web/issues", "key": "stable-2"},
|
||||
]
|
||||
assert len(output["remaining"]) == 1
|
||||
assert output["remaining"][0]["title"] == "Second"
|
||||
assert output["remaining"][0]["status"] == "queued"
|
||||
|
||||
|
||||
def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
let invalid = true;
|
||||
const calls = [];
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'stable-edit',
|
||||
fetchJson:async (_url, options) => {{
|
||||
calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }}
|
||||
return {{repository:'stackchain/api',number:42,title:'Fixed title'}};
|
||||
}},
|
||||
}});
|
||||
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bad',body:'Context'}});
|
||||
outbox.flush('timmy').then(async () => {{
|
||||
const attention = outbox.list()[0];
|
||||
outbox.update(queued.id, {{...attention,title:'Fixed title'}});
|
||||
invalid = false;
|
||||
const result = await outbox.retry(queued.id, 'timmy');
|
||||
process.stdout.write(JSON.stringify({{attention,result,calls,remaining:outbox.list()}}));
|
||||
}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["attention"]["status"] == "attention"
|
||||
assert output["attention"]["error"] == "Title is invalid"
|
||||
assert [call["key"] for call in output["calls"]] == ["stable-edit", "stable-edit"]
|
||||
assert output["calls"][1]["body"]["title"] == "Fixed title"
|
||||
assert output["result"]["confirmed"][0]["number"] == 42
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
let calls = 0;
|
||||
let release;
|
||||
const gate = new Promise(resolve => {{ release = resolve; }});
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'single-flight',
|
||||
fetchJson:async () => {{ calls += 1; await gate; return {{repository:'stackchain/api',number:43,title:'Once'}}; }},
|
||||
}});
|
||||
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Once',body:'Context'}});
|
||||
const reconnect = outbox.flush('timmy');
|
||||
const sendNow = outbox.retry(queued.id, 'timmy');
|
||||
release();
|
||||
Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.stringify({{calls,results,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == 1
|
||||
assert output["remaining"] == []
|
||||
assert sum(len(result["confirmed"]) for result in output["results"]) >= 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/issue-outbox.js"></script>' in html
|
||||
assert "const issueOutbox = createIssueOutbox({" in html
|
||||
assert "issueOutbox.enqueue(captureDraft)" in html
|
||||
assert "issueOutbox.retry(queued.id, activeFlushLogin)" in html
|
||||
assert "navigator.onLine" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ?" in html
|
||||
assert "flushIssueOutbox();" in html
|
||||
assert 'class="draft-send"' in html
|
||||
assert 'class="draft-edit"' in html
|
||||
assert 'Queued for sync' in html
|
||||
assert 'Needs attention' in html
|
||||
assert "issueOutbox.discard(item.outbox_id)" in html
|
||||
assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html
|
||||
@@ -0,0 +1,123 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
|
||||
|
||||
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
class FakeElement {{
|
||||
constructor() {{
|
||||
this.listeners = {{}};
|
||||
this.attributes = {{}};
|
||||
this.classList = {{ values:new Set(), contains:value => this.classList.values.has(value) }};
|
||||
this.hidden = false;
|
||||
this.focuses = 0;
|
||||
}}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
click() {{ this.listeners.click({{currentTarget:this}}); }}
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
focus() {{ this.focuses += 1; }}
|
||||
}}
|
||||
const nav = new FakeElement();
|
||||
const buttons = Object.fromEntries(['work','find','new','search','drafts'].map(name => [name, new FakeElement()]));
|
||||
const overlay = new FakeElement();
|
||||
const calls = [];
|
||||
let observerCallback;
|
||||
const dock = createDock({{
|
||||
nav, buttons, overlays:[overlay],
|
||||
actions: Object.fromEntries(Object.keys(buttons).map(name => [name, () => calls.push(name)])),
|
||||
observe(callback) {{ observerCallback = callback; return {{disconnect() {{}}}}; }},
|
||||
}});
|
||||
dock.start();
|
||||
buttons.work.click();
|
||||
buttons.find.click();
|
||||
overlay.classList.values.add('open'); observerCallback();
|
||||
const hiddenWhileOpen = nav.hidden;
|
||||
overlay.classList.values.delete('open'); observerCallback();
|
||||
buttons.drafts.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden,
|
||||
findFocuses:buttons.find.focuses,
|
||||
current:Object.fromEntries(Object.entries(buttons).map(([name, button]) => [name, button.attributes['aria-current'] || null])),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": ["work", "find", "drafts"],
|
||||
"hiddenWhileOpen": True,
|
||||
"hiddenAfterClose": False,
|
||||
"findFocuses": 1,
|
||||
"current": {
|
||||
"work": None,
|
||||
"find": None,
|
||||
"new": None,
|
||||
"search": None,
|
||||
"drafts": "page",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
|
||||
const badge = {{ textContent:'', hidden:true }};
|
||||
const dock = createDock({{ nav:{{}}, buttons:{{work}}, attentionBadge:badge }});
|
||||
dock.updateAttention(3);
|
||||
const pending = {{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
dock.updateAttention(0);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
pending,
|
||||
empty:{{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }},
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"pending": {"count": "3", "hidden": False, "label": "Work, 3 items need attention"},
|
||||
"empty": {"count": "0", "hidden": True, "label": "Work"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">' in html
|
||||
assert html.count('class="mobile-task-action" data-mobile-task=') == 5
|
||||
for task in ("work", "find", "new", "search", "drafts"):
|
||||
assert f'data-mobile-task="{task}"' in html
|
||||
assert 'id="mobile-draft-count"' in html
|
||||
assert 'data-work-filter="attention"' in html
|
||||
assert 'id="mobile-attention-count"' in html
|
||||
assert '.mobile-task-dock { display:none;' in html
|
||||
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
|
||||
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
|
||||
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
|
||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: openMobileWork" in html
|
||||
assert "counts.attention ? 'attention' : 'all'" in html
|
||||
assert "qs('[data-work-filter=\"' + filter + '\"]').click()" in html
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
assert "search: () => qs('#open-palette').click()" in html
|
||||
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
|
||||
assert "draftCount.textContent = sourceDraftCount.textContent" in html
|
||||
assert "mobileTaskDock.updateAttention(counts.attention)" in html
|
||||
+683
-4
@@ -12,6 +12,7 @@ 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"
|
||||
|
||||
@@ -100,6 +101,74 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
|
||||
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))});
|
||||
@@ -135,6 +204,9 @@ async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share
|
||||
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
|
||||
@@ -449,6 +521,33 @@ 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_new_issue_sheet_exposes_touch_safe_release_planning_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="create-issue-milestone"' in html
|
||||
assert 'id="create-issue-due-date" type="date"' in html
|
||||
assert 'id="create-issue-milestone-status" aria-live="polite"' in html
|
||||
assert '.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px;' in html
|
||||
assert 'issueCapture.loadMilestones(repository)' in html
|
||||
assert "milestoneId: Number(qs('#create-issue-milestone').value) || null" in html
|
||||
assert "dueDate: qs('#create-issue-due-date').value" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_launches_share_capture_with_draft_conflict_choices():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<link rel="manifest" href="manifest.webmanifest"' in html
|
||||
assert "issueCapture.stageSharedContent(sharedLaunch)" in html
|
||||
assert 'id="use-shared-content"' in html
|
||||
assert 'id="resume-issue-draft"' in html
|
||||
assert 'id="shared-content-conflict"' in html
|
||||
assert '.shared-content-actions button { min-height:44px;' in html
|
||||
assert "history.replaceState({}, '', cleanUrl)" in html
|
||||
assert "navigator.serviceWorker.register('service-worker.js')" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor():
|
||||
html = await dashboard()
|
||||
@@ -463,6 +562,71 @@ async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor
|
||||
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 & 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},
|
||||
@@ -487,6 +651,32 @@ process.stdout.write(JSON.stringify({{
|
||||
assert output["summary"] == "1 review · 2 assigned"
|
||||
|
||||
|
||||
def test_attention_filter_unions_updates_and_reviews_without_double_counting():
|
||||
items = [
|
||||
{"title": "Unread issue", "kind": "issue", "has_update": True, "is_review": False},
|
||||
{"title": "Requested review", "kind": "pull", "has_update": False, "is_review": True},
|
||||
{"title": "Updated review", "kind": "pull", "has_update": True, "is_review": True},
|
||||
{"title": "Ordinary assignment", "kind": "issue", "has_update": False, "is_review": False},
|
||||
]
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = {json.dumps(items)};
|
||||
process.stdout.write(JSON.stringify({{
|
||||
attention: buildMyWork.filterMyWork(items, 'attention').map(item => item.title),
|
||||
count: buildMyWork.countMyWork(items).attention,
|
||||
}}));
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"attention": ["Unread issue", "Requested review", "Updated review"],
|
||||
"count": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
|
||||
payload = {
|
||||
"issues": [
|
||||
@@ -678,7 +868,9 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1, "update": 0}
|
||||
assert json.loads(result.stdout) == {
|
||||
"all": 3, "attention": 1, "issue": 1, "pull": 1, "review": 1, "update": 0
|
||||
}
|
||||
|
||||
|
||||
def test_work_pager_is_single_flight_and_unions_pull_responsibilities():
|
||||
@@ -848,6 +1040,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))});
|
||||
@@ -928,6 +1154,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
|
||||
@@ -1045,6 +1274,105 @@ first.submit(draft).catch(() => {{
|
||||
assert output["number"] == 17
|
||||
|
||||
|
||||
def test_issue_capture_normalizes_shared_mobile_content_without_repeating_source_url():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const normalized = createIssueCapture.normalizeSharedContent({{
|
||||
title: ' Production alert ',
|
||||
text: 'Latency crossed the threshold https://status.example/incidents/42',
|
||||
url: 'https://status.example/incidents/42',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(normalized));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"title": "Production alert",
|
||||
"body": "Latency crossed the threshold https://status.example/incidents/42",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_suggests_a_title_when_mobile_share_only_contains_text():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
process.stdout.write(JSON.stringify(createIssueCapture.normalizeSharedContent({{
|
||||
text: 'Investigate checkout latency before the release window opens. More diagnostic context follows.'
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"title": "Investigate checkout latency before the release window opens.",
|
||||
"body": "Investigate checkout latency before the release window opens. More diagnostic context follows.",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_keeps_existing_draft_until_shared_content_is_accepted():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
|
||||
capture.saveDraft({{
|
||||
repository:'stackchain/api', title:'Existing draft', body:'Keep me', labelIds:[3], milestoneId:9,
|
||||
}});
|
||||
const staged = capture.stageSharedContent({{title:'Shared alert', text:'Investigate', url:'https://status.example/42'}});
|
||||
const before = capture.loadDraft();
|
||||
capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
|
||||
const pendingAfterReload = capture.pendingSharedContent();
|
||||
const accepted = capture.acceptSharedContent();
|
||||
process.stdout.write(JSON.stringify({{staged, before, pendingAfterReload, accepted, pending:capture.pendingSharedContent()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"staged": {"status": "conflict"},
|
||||
"before": {
|
||||
"repository": "stackchain/api", "title": "Existing draft", "body": "Keep me",
|
||||
"labelIds": [3], "milestoneId": 9,
|
||||
},
|
||||
"pendingAfterReload": {
|
||||
"title": "Shared alert", "body": "Investigate\n\nhttps://status.example/42",
|
||||
},
|
||||
"accepted": {
|
||||
"repository": "stackchain/api", "title": "Shared alert",
|
||||
"body": "Investigate\n\nhttps://status.example/42", "labelIds": [3], "milestoneId": 9,
|
||||
},
|
||||
"pending": None,
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_can_resume_existing_draft_and_discard_staged_share():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key=>values.get(key)||null, setItem:(key,value)=>values.set(key,value), removeItem:key=>values.delete(key)}};
|
||||
const capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
|
||||
capture.saveDraft({{repository:'stackchain/api', title:'Existing', body:'Keep', labelIds:[]}});
|
||||
capture.stageSharedContent({{title:'Incoming', text:'Replace'}});
|
||||
capture.discardSharedContent();
|
||||
process.stdout.write(JSON.stringify({{draft:capture.loadDraft(), pending:capture.pendingSharedContent()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"draft": {"repository": "stackchain/api", "title": "Existing", "body": "Keep", "labelIds": []},
|
||||
"pending": None,
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_loads_repository_labels_with_priorities_first():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
@@ -1071,6 +1399,50 @@ capture.loadLabels('stackchain/api').then(labels => process.stdout.write(JSON.st
|
||||
assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"]
|
||||
|
||||
|
||||
def test_issue_capture_persists_and_submits_milestone_and_due_date():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const calls = [];
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const capture = createIssueCapture({{
|
||||
storage,
|
||||
createOperationId: () => 'planned-operation',
|
||||
fetchJson: (url, options) => {{
|
||||
calls.push({{url, body:options?.body ? JSON.parse(options.body) : null}});
|
||||
if (url.endsWith('/milestones')) return Promise.resolve([{{id:9,title:'August RC'}}]);
|
||||
return Promise.resolve({{number:221, milestone:{{id:9,title:'August RC'}}, due_date:'2026-08-31T23:59:59Z'}});
|
||||
}},
|
||||
}});
|
||||
const draft = {{repository:'stackchain/api', title:'Ship plan', body:'', labelIds:[], milestoneId:9, dueDate:'2026-08-31'}};
|
||||
capture.saveDraft(draft);
|
||||
Promise.all([capture.loadMilestones('stackchain/api'), capture.submit(capture.loadDraft())]).then(results =>
|
||||
process.stdout.write(JSON.stringify({{calls, stored:results[1], milestones:results[0]}}))
|
||||
);
|
||||
"""
|
||||
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/milestones", "body": None},
|
||||
{
|
||||
"url": "api/v1/repos/stackchain/api/issues",
|
||||
"body": {
|
||||
"title": "Ship plan", "body": "", "label_ids": [],
|
||||
"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z",
|
||||
},
|
||||
},
|
||||
]
|
||||
assert output["milestones"] == [{"id": 9, "title": "August RC"}]
|
||||
assert output["stored"]["number"] == 221
|
||||
|
||||
|
||||
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
@@ -1114,7 +1486,9 @@ process.stdout.write(JSON.stringify({{
|
||||
assert output["updates"][0]["kind"] == "update"
|
||||
assert output["updates"][1]["kind"] == "issue"
|
||||
assert output["updates"][1]["url"].endswith("#issuecomment-9")
|
||||
assert output["counts"] == {"all": 2, "issue": 1, "pull": 0, "review": 0, "update": 2}
|
||||
assert output["counts"] == {
|
||||
"all": 2, "attention": 2, "issue": 1, "pull": 0, "review": 0, "update": 2
|
||||
}
|
||||
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
|
||||
|
||||
|
||||
@@ -1445,6 +1819,72 @@ reader.open(item, [item]).then(() =>
|
||||
}
|
||||
|
||||
|
||||
def test_notification_reader_pages_conversation_single_flight_and_ignores_stale_update():
|
||||
script = f"""
|
||||
const build = require({json.dumps(str(MY_WORK))});
|
||||
const createPager = require({json.dumps(str(CONVERSATION))});
|
||||
const details = {{
|
||||
42: {{id:42, conversation:{{comments:[{{id:41,created_at:'2026-08-07T12:41:00Z'}}],page:3,older_page:2,total:47}}}},
|
||||
43: {{id:43, conversation:{{comments:[{{id:90,created_at:'2026-08-07T13:00:00Z'}}],page:1,older_page:null,total:1}}}},
|
||||
}};
|
||||
let release;
|
||||
const requested = [];
|
||||
const states = [];
|
||||
const reader = build.createNotificationReader({{
|
||||
load: async id => details[id], createPager,
|
||||
loadConversation: (id, page) => {{
|
||||
requested.push([id, page]);
|
||||
return new Promise(resolve => {{ release = () => resolve({{comments:[{{id:21,created_at:'2026-08-07T12:21:00Z'}}],page:2,older_page:1,total:47}}); }});
|
||||
}},
|
||||
markRead: async () => {{}}, onOpen: () => {{}}, onDetail: () => {{}},
|
||||
onConversation: state => states.push(state.comments.map(item => item.id)),
|
||||
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
|
||||
}});
|
||||
const firstItem = {{notification_id:42}};
|
||||
const secondItem = {{notification_id:43}};
|
||||
reader.open(firstItem).then(async () => {{
|
||||
const first = reader.loadOlder();
|
||||
const duplicate = reader.loadOlder();
|
||||
await reader.open(secondItem);
|
||||
release();
|
||||
await Promise.all([first, duplicate]);
|
||||
process.stdout.write(JSON.stringify({{requested, states}}));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["requested"] == [[42, 2]]
|
||||
assert output["states"] == [[41], [90]]
|
||||
|
||||
|
||||
def test_notification_reader_appends_a_confirmed_reply_exactly_once():
|
||||
script = f"""
|
||||
const build = require({json.dumps(str(MY_WORK))});
|
||||
const createPager = require({json.dumps(str(CONVERSATION))});
|
||||
const states = [];
|
||||
const reader = build.createNotificationReader({{
|
||||
load: async () => ({{conversation:{{comments:[{{id:41}}],page:1,older_page:null,total:1}}}}),
|
||||
loadConversation: async () => ({{}}), createPager,
|
||||
markRead: async () => {{}}, onOpen: () => {{}}, onDetail: () => {{}},
|
||||
onConversation: state => states.push(state.comments.map(item => item.id)),
|
||||
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
|
||||
}});
|
||||
reader.open({{notification_id:42}}).then(() => {{
|
||||
reader.appendReply({{id:91, author:'timmy', body:'Ship it'}});
|
||||
reader.appendReply({{id:91, author:'timmy', body:'Ship it'}});
|
||||
process.stdout.write(JSON.stringify(states));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == [[41], [41, 91], [41, 91]]
|
||||
|
||||
|
||||
def test_notification_reader_wraps_to_an_earlier_visible_unread_update():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
@@ -1498,6 +1938,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))});
|
||||
@@ -1755,6 +2289,63 @@ process.stdout.write(JSON.stringify({{
|
||||
assert output["next"] == "frontend/app.js"
|
||||
|
||||
|
||||
def test_pull_sheet_lazy_review_is_single_flight_cached_by_head_and_retryable():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const calls = [];
|
||||
let rejectFirst;
|
||||
const fetchJson = url => {{
|
||||
calls.push(url);
|
||||
if (calls.length === 1) return new Promise((_resolve, reject) => {{ rejectFirst = reject; }});
|
||||
return Promise.resolve({{head_sha:'abc123', files:[]}});
|
||||
}};
|
||||
const sheet = createPullSheet({{fetchJson, storage:null}});
|
||||
const item = {{repository:'stackchain/api', number:7}};
|
||||
const first = sheet.loadReview(item, 'abc123');
|
||||
const concurrent = sheet.loadReview(item, 'abc123');
|
||||
rejectFirst(new Error('diff unavailable'));
|
||||
Promise.allSettled([first, concurrent]).then(async failed => {{
|
||||
const retried = await sheet.loadReview(item, 'abc123');
|
||||
const cached = await sheet.loadReview(item, 'abc123');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
same:first === concurrent,
|
||||
failed:failed.map(result => result.status),
|
||||
retried,
|
||||
cached,
|
||||
calls,
|
||||
}}));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["same"] is True
|
||||
assert output["failed"] == ["rejected", "rejected"]
|
||||
assert output["retried"]["head_sha"] == "abc123"
|
||||
assert output["cached"] == output["retried"]
|
||||
assert output["calls"] == [
|
||||
"api/v1/repos/stackchain/api/pulls/7/review-data",
|
||||
"api/v1/repos/stackchain/api/pulls/7/review-data",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_pull_sheet_puts_reading_before_collapsed_review_controls():
|
||||
html = await dashboard()
|
||||
|
||||
body = html.index('id="pull-sheet-body"')
|
||||
conversation = html.index('<h2>Full conversation</h2>', body)
|
||||
composer = html.index('id="pull-comment-title"', conversation)
|
||||
review = html.index('id="pull-review"', composer)
|
||||
files = html.index('id="pull-files"', review)
|
||||
|
||||
assert '<summary><h2>Review & merge</h2></summary>' in html
|
||||
assert '<details class="pull-review" id="pull-review">' in html
|
||||
assert 'id="pull-review-retry"' in html
|
||||
assert 'id="pull-review-status"' in html
|
||||
assert body < conversation < composer < review < files
|
||||
|
||||
|
||||
def test_pull_sheet_renders_mobile_diff_fallbacks_and_review_controls():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
@@ -1789,6 +2380,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
|
||||
@@ -1814,6 +2411,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
|
||||
@@ -1946,7 +2549,7 @@ async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
|
||||
assert 'id="keep-update-unread"' in html
|
||||
assert 'id="mark-update-read-next"' in html
|
||||
assert 'id="retry-update-load"' in html
|
||||
assert 'id="update-comment-body"' in html
|
||||
assert 'id="update-comments"' in html
|
||||
assert 'id="update-subject-body"' in html
|
||||
assert 'id="open-update-gitea"' in html
|
||||
assert '.update-sheet-panel { width:min(560px,100%);' in html
|
||||
@@ -2068,11 +2671,13 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||
|
||||
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
|
||||
assert 'data-work-count="all"' in html
|
||||
assert 'data-work-count="attention"' in html
|
||||
assert 'data-work-count="issue"' in html
|
||||
assert 'data-work-count="pull"' in html
|
||||
assert 'data-work-count="review"' in html
|
||||
assert 'data-work-count="update"' in html
|
||||
assert "['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)" in html
|
||||
assert "['all', 'attention', '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
|
||||
|
||||
@@ -2532,6 +3137,63 @@ process.stdout.write(JSON.stringify({{removed, draft:draft.snapshot(), progress:
|
||||
assert payload["progress"]["reviewedCount"] == 0
|
||||
|
||||
|
||||
def test_approved_assigned_review_carries_exact_head_progress_into_merge():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.get(key) || null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}};
|
||||
const assigned = {{
|
||||
repository:'stackchain/api', number:7,
|
||||
work_reasons:['assigned_to_me', 'review_requested']
|
||||
}};
|
||||
const carried = reviewSheet.prepareMergeContinuation({{
|
||||
storage, item:assigned, headSha:'abc123',
|
||||
reviewed:['a.py', 'b.py'], decision:'approve'
|
||||
}});
|
||||
const unassigned = reviewSheet.prepareMergeContinuation({{
|
||||
storage, item:{{...assigned, number:8, work_reasons:['review_requested']}},
|
||||
headSha:'def456', reviewed:['c.py'], decision:'approve'
|
||||
}});
|
||||
const changesRequested = reviewSheet.prepareMergeContinuation({{
|
||||
storage, item:{{...assigned, number:9}}, headSha:'ghi789',
|
||||
reviewed:['d.py'], decision:'request_changes'
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{carried, unassigned, changesRequested, entries:[...values.entries()]}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"carried": True,
|
||||
"unassigned": False,
|
||||
"changesRequested": False,
|
||||
"entries": [[
|
||||
"stackchain.pull-review.v1:stackchain/api#7:abc123",
|
||||
'["a.py","b.py"]',
|
||||
]],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_assigned_approval_continues_to_merge_in_place():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="continue-review-to-merge"' in html
|
||||
assert '#continue-review-to-merge[hidden]' in html and 'display:none' in html
|
||||
assert '.review-merge-continuation' in html and 'position:sticky' in html
|
||||
assert '#continue-review-to-merge { min-height:44px;' in html
|
||||
assert "createReviewController.prepareMergeContinuation" in html
|
||||
assert "reviewed: progressSnapshot.reviewed" in html
|
||||
assert "decision: snapshot.decision" in html
|
||||
assert "workRoute.open({ ...item, kind:'pull', is_review:false }, { replace:true })" in html
|
||||
assert "qs('#continue-review-to-merge').hidden = false" in html
|
||||
assert "qs('#continue-review-to-merge').focus()" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item():
|
||||
html = await dashboard()
|
||||
@@ -2544,6 +3206,23 @@ async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item(
|
||||
assert "qs('#retry-review-load').focus()" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_sheet_renders_and_pages_the_complete_conversation():
|
||||
html = await dashboard()
|
||||
|
||||
update_sheet = html[html.index('id="update-sheet"'):html.index('id="pull-sheet"')]
|
||||
assert '<h2>Full conversation</h2>' in update_sheet
|
||||
assert 'id="update-comments"' in update_sheet
|
||||
assert 'id="load-older-update-comments"' in update_sheet
|
||||
assert 'id="update-conversation-status"' in update_sheet
|
||||
assert '.conversation-more' in html and 'min-height:44px' in html
|
||||
assert "'/conversation?page='" in html
|
||||
assert "loadConversation: fetchNotificationConversation" in html
|
||||
assert "onConversation: renderUpdateConversation" in html
|
||||
assert "notificationReader.loadOlder()" in html
|
||||
assert "notificationReader.appendReply(result)" in html
|
||||
|
||||
|
||||
def test_issue_comment_reuses_operation_key_after_reload_until_success():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
|
||||
@@ -3,7 +3,59 @@ import asyncio
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_opens_the_newest_conversation_page_in_chronological_order(monkeypatch):
|
||||
requested_pages = []
|
||||
|
||||
def comment(comment_id):
|
||||
return {
|
||||
"id": comment_id,
|
||||
"user": {"login": f"user-{comment_id}"},
|
||||
"body": f"message {comment_id}",
|
||||
"created_at": f"2026-08-06T12:{comment_id:02d}:00Z",
|
||||
"html_url": f"https://forge.example/stackchain/api/issues/7#issuecomment-{comment_id}",
|
||||
}
|
||||
|
||||
def handler(request):
|
||||
if request.url.path.endswith("/notifications/threads/42"):
|
||||
return httpx.Response(200, json={
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"type": "Issue", "title": "Retry failed deploy", "state": "open",
|
||||
"url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
|
||||
"latest_comment_url": "https://forge.example/api/v1/repos/stackchain/api/issues/comments/47",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
},
|
||||
})
|
||||
if request.url.path.endswith("/repos/stackchain/api/issues/7/comments"):
|
||||
page = int(request.url.params["page"])
|
||||
requested_pages.append(page)
|
||||
comments = [comment(i) for i in (range(1, 21) if page == 1 else range(41, 48))]
|
||||
return httpx.Response(200, json=comments, headers={"X-Total-Count": "47"})
|
||||
if request.url.path.endswith("/repos/stackchain/api/issues/7"):
|
||||
return httpx.Response(200, json={"body": "Deploy fails after retries."})
|
||||
if request.url.path.endswith("/repos/stackchain/api/issues/comments/47"):
|
||||
return httpx.Response(200, json=comment(47))
|
||||
raise AssertionError(f"unexpected request: {request.url}")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.notification_detail(42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requested_pages == [1, 3]
|
||||
assert [item["id"] for item in result["conversation"]["comments"]] == list(range(41, 48))
|
||||
assert result["conversation"] == {
|
||||
"comments": result["conversation"]["comments"],
|
||||
"page": 3,
|
||||
"older_page": 2,
|
||||
"total": 47,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -41,6 +93,32 @@ async def test_notification_detail_api_is_bounded_and_never_cacheable(monkeypatc
|
||||
assert calls == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_conversation_api_loads_one_bounded_older_page(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def conversation(thread_id, page, limit):
|
||||
calls.append((thread_id, page, limit))
|
||||
return {
|
||||
"comments": [{"id": 21, "author": "timmy", "body": "Earlier context"}],
|
||||
"page": page,
|
||||
"older_page": 1,
|
||||
"total": 47,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_page", conversation, 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/notifications/42/conversation?page=2&limit=20")
|
||||
invalid = await client.get("/api/v1/notifications/42/conversation?page=0&limit=51")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["comments"][0]["id"] == 21
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert invalid.status_code == 422
|
||||
assert calls == [(42, 2, 20)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_timeout_is_sanitized_and_retryable(monkeypatch):
|
||||
async def detail(_thread_id):
|
||||
|
||||
@@ -3,7 +3,42 @@ import asyncio
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_reply_returns_the_confirmed_comment_for_exact_once_append(monkeypatch):
|
||||
def handler(request):
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"type": "Issue",
|
||||
"url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
|
||||
},
|
||||
})
|
||||
return httpx.Response(201, json={
|
||||
"id": 91,
|
||||
"user": {"login": "timmy"},
|
||||
"body": "Please retry the worker.",
|
||||
"created_at": "2026-08-07T19:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
})
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.reply_to_notification(42, "Please retry the worker.")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"id": 91,
|
||||
"author": "timmy",
|
||||
"body": "Please retry the worker.",
|
||||
"created_at": "2026-08-07T19:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
OFFLINE_WORK = Path(__file__).parents[1] / "frontend" / "offline-work.js"
|
||||
|
||||
|
||||
def run_scenario(scenario: str) -> dict:
|
||||
script = f"""
|
||||
const createOfflineWorkStore = require({json.dumps(str(OFFLINE_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
{scenario}
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_opted_in_snapshot_survives_restart_with_only_queue_card_fields():
|
||||
result = run_scenario("""
|
||||
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
|
||||
store.setEnabled(true);
|
||||
store.save({
|
||||
user:{login:'timmy', full_name:'Timmy', token:'secret'},
|
||||
issues:[{id:1, number:8, title:'Ship offline work', body:'private body', state:'open',
|
||||
repository:'stackchain/dashboard', labels:['P0'], assignees:['timmy'],
|
||||
updated_at:'2026-08-07T11:00:00Z', url:'https://forge.example/issues/8'}],
|
||||
pull_requests:[],
|
||||
notifications:[{id:9, number:8, title:'Updated', unread:true, subject_body:'private comment',
|
||||
repository:'stackchain/dashboard', subject_type:'Issue', updated_at:'2026-08-07T11:30:00Z',
|
||||
url:'https://forge.example/issues/8'}],
|
||||
work_pagination:{issue:{page:1,total:1,has_more:false}},
|
||||
repos:[{private:true}], events:[{body:'private event'}],
|
||||
});
|
||||
const restarted = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:01:00Z')});
|
||||
process.stdout.write(JSON.stringify({loaded:restarted.load('timmy'), raw:[...values.values()].join(' ')}));
|
||||
""")
|
||||
|
||||
assert result["loaded"]["user"] == {"login": "timmy", "full_name": "Timmy"}
|
||||
assert result["loaded"]["issues"][0]["title"] == "Ship offline work"
|
||||
assert result["loaded"]["notifications"][0]["unread"] is True
|
||||
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
|
||||
assert "private body" not in result["raw"]
|
||||
assert "private comment" not in result["raw"]
|
||||
assert "private event" not in result["raw"]
|
||||
assert "secret" not in result["raw"]
|
||||
|
||||
|
||||
def test_expired_snapshot_is_deleted_and_wrong_user_cannot_load_it():
|
||||
result = run_scenario("""
|
||||
const writer = createOfflineWorkStore({storage, now:() => new Date('2026-08-01T12:00:00Z'), maxAgeMs:1000});
|
||||
writer.setEnabled(true);
|
||||
writer.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
|
||||
const wrongUser = writer.load('alexander');
|
||||
const expired = createOfflineWorkStore({storage, now:() => new Date('2026-08-01T12:00:02Z'), maxAgeMs:1000});
|
||||
const expiredValue = expired.load('timmy');
|
||||
process.stdout.write(JSON.stringify({wrongUser, expiredValue, snapshotStillStored:values.has('stackchain.offline-work.snapshot.v1')}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"wrongUser": None,
|
||||
"expiredValue": None,
|
||||
"snapshotStillStored": False,
|
||||
}
|
||||
|
||||
|
||||
def test_opting_out_deletes_the_persisted_snapshot():
|
||||
result = run_scenario("""
|
||||
const store = createOfflineWorkStore({storage});
|
||||
store.setEnabled(true);
|
||||
store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
|
||||
store.setEnabled(false);
|
||||
process.stdout.write(JSON.stringify({enabled:store.enabled(), loaded:store.load()}));
|
||||
""")
|
||||
|
||||
assert result == {"enabled": False, "loaded": None}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/offline-work.js"></script>' in html
|
||||
assert 'id="keep-work-offline"' in html
|
||||
assert 'Keep My Work available offline' in html
|
||||
assert 'id="clear-offline-work"' in html
|
||||
assert 'id="offline-work-status"' in html
|
||||
assert 'offlineWorkStore.save({' in html
|
||||
assert 'offlineWorkStore.load()' in html
|
||||
assert 'Offline · saved ' in html
|
||||
assert "setOfflineWorkMode(true)" in html
|
||||
assert "if (offlineWorkMode)" in html
|
||||
assert '.offline-work-controls button { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snapshot():
|
||||
html = await dashboard()
|
||||
|
||||
assert "if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;" in html
|
||||
assert "Outage · saved " in html
|
||||
assert "Server unavailable · showing private My Work saved " in html
|
||||
assert "Live details and actions will return automatically." in html
|
||||
assert "function renderLiveSnapshot(snapshot) {\n setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
COORDINATOR = ROOT / "frontend" / "outbox-coordinator.js"
|
||||
ISSUE_OUTBOX = ROOT / "frontend" / "issue-outbox.js"
|
||||
AUTHORED_OUTBOX = ROOT / "frontend" / "authored-outbox.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_two_tabs_share_one_flush_lease_for_each_outbox():
|
||||
script = f"""
|
||||
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
const createIssueOutbox = require({json.dumps(str(ISSUE_OUTBOX))});
|
||||
const createAuthoredOutbox = require({json.dumps(str(AUTHORED_OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const held = new Set();
|
||||
const locks = {{request: async (name, options, work) => {{
|
||||
if (held.has(name)) return work(null);
|
||||
held.add(name);
|
||||
try {{ return await work({{name}}); }} finally {{ held.delete(name); }}
|
||||
}}}};
|
||||
let releaseIssue, releaseMessage;
|
||||
const issueGate = new Promise(resolve => releaseIssue = resolve);
|
||||
const messageGate = new Promise(resolve => releaseMessage = resolve);
|
||||
let issueCalls = 0, messageCalls = 0;
|
||||
const issueA = createIssueOutbox({{storage,getOwnerLogin:()=> 'timmy',createOperationId:()=> 'issue-1',
|
||||
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'a'}}),fetchJson:async()=>{{issueCalls++;await issueGate;return {{number:1}};}}}});
|
||||
const issueB = createIssueOutbox({{storage,getOwnerLogin:()=> 'timmy',
|
||||
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'b'}}),fetchJson:async()=>{{issueCalls++;return {{number:1}};}}}});
|
||||
issueA.enqueue({{repository:'o/r',title:'One'}});
|
||||
const messageA = createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy',
|
||||
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'a'}}),fetchJson:async()=>{{messageCalls++;await messageGate;return {{id:1}};}}}});
|
||||
const messageB = createAuthoredOutbox({{storage,getOwnerLogin:()=> 'timmy',
|
||||
coordinator:createCoordinator({{storage,locks,channelFactory:null,tabId:'b'}}),fetchJson:async()=>{{messageCalls++;return {{id:1}};}}}});
|
||||
messageA.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Reply',operationId:'message-1'}});
|
||||
(async()=>{{
|
||||
const issueFirst=issueA.flush('timmy'); const issueSecond=issueB.retry('issue-1', 'timmy');
|
||||
const messageFirst=messageA.flush('timmy'); const messageSecond=messageB.retry('message-1', 'timmy');
|
||||
await Promise.resolve();
|
||||
releaseIssue(); releaseMessage();
|
||||
const results=await Promise.all([issueFirst,issueSecond,messageFirst,messageSecond]);
|
||||
process.stdout.write(JSON.stringify({{issueCalls,messageCalls,results,issueRemaining:issueA.list(),messageRemaining:messageA.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["issueCalls"] == 1
|
||||
assert output["messageCalls"] == 1
|
||||
assert output["issueRemaining"] == []
|
||||
assert output["messageRemaining"] == []
|
||||
assert sum(result.get("lease_skipped", False) for result in output["results"]) == 2
|
||||
|
||||
|
||||
def test_fallback_lease_expires_and_stale_owner_cannot_release_successor():
|
||||
script = f"""
|
||||
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
let clock=100;
|
||||
let releaseFirst;
|
||||
const firstGate=new Promise(resolve=>releaseFirst=resolve);
|
||||
const first=createCoordinator({{storage,locks:null,channelFactory:null,tabId:'first',now:()=>clock,leaseMs:50}});
|
||||
const second=createCoordinator({{storage,locks:null,channelFactory:null,tabId:'second',now:()=>clock,leaseMs:50}});
|
||||
(async()=>{{
|
||||
const running=first.runExclusive('issue', async()=>{{await firstGate;return 'first-done';}});
|
||||
await Promise.resolve();
|
||||
const blocked=await second.runExclusive('issue', async()=> 'too-early');
|
||||
clock=151;
|
||||
const takeover=await second.runExclusive('issue', async()=> 'taken-over');
|
||||
releaseFirst();
|
||||
const original=await running;
|
||||
const after=await second.runExclusive('issue', async()=> 'after-release');
|
||||
process.stdout.write(JSON.stringify({{blocked,takeover,original,after,lease:storage.getItem('stackchain.outbox-lease.issue.v1')}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["blocked"]["lease_skipped"] is True
|
||||
assert output["takeover"] == "taken-over"
|
||||
assert output["original"] == "first-done"
|
||||
assert output["after"] == "after-release"
|
||||
assert output["lease"] is None
|
||||
|
||||
|
||||
def test_queue_change_notifications_cross_tabs_and_can_unsubscribe():
|
||||
script = f"""
|
||||
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const channels=[];
|
||||
function channelFactory() {{
|
||||
const channel={{onmessage:null,postMessage(message){{channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data:message}}));}},close(){{}}}};
|
||||
channels.push(channel); return channel;
|
||||
}}
|
||||
const first=createCoordinator({{storage,channelFactory,tabId:'first'}});
|
||||
const second=createCoordinator({{storage,channelFactory,tabId:'second'}});
|
||||
const seen=[];
|
||||
const unsubscribe=second.subscribe(change=>seen.push(change));
|
||||
first.notify('issue');
|
||||
first.notify('authored');
|
||||
unsubscribe();
|
||||
first.notify('issue');
|
||||
process.stdout.write(JSON.stringify({{seen,signal:JSON.parse(storage.getItem('stackchain.outbox-change.v1'))}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [change["queue"] for change in output["seen"]] == ["issue", "authored"]
|
||||
assert output["signal"]["queue"] == "issue"
|
||||
assert output["signal"]["tabId"] == "first"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_cross_tab_draft_refresh_and_precaches_coordinator():
|
||||
html = await dashboard()
|
||||
worker = (ROOT / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert '<script src="static/outbox-coordinator.js"></script>' in html
|
||||
assert "const outboxCoordinator = createOutboxCoordinator" in html
|
||||
assert "coordinator: outboxCoordinator" in html
|
||||
assert "outboxCoordinator.subscribe" in html
|
||||
assert "refreshMyWorkView()" in html
|
||||
assert "BASE + 'static/outbox-coordinator.js'" in worker
|
||||
+104
-2
@@ -54,7 +54,77 @@ async def test_assigned_pull_detail_rejects_unassigned_pull(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_assigned_pull_detail_includes_bounded_diff_previews():
|
||||
async def test_gitea_assigned_pull_detail_loads_reading_without_review_resources():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.url.path.endswith("/pulls/7"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 7,
|
||||
"title": "Read this first",
|
||||
"body": "The discussion should not wait for the diff.",
|
||||
"state": "open",
|
||||
"mergeable": True,
|
||||
"head": {"sha": "abc123"},
|
||||
"user": {"login": "alex"},
|
||||
})
|
||||
if request.url.path.endswith("/issues/7/comments"):
|
||||
return httpx.Response(200, json=[{
|
||||
"id": 9, "body": "Question", "user": {"login": "sam"},
|
||||
"created_at": "2026-08-07T18:00:00Z",
|
||||
}], headers={"X-Total-Count": "1"})
|
||||
raise AssertionError(f"review resource requested during read load: {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
detail = await gitea_proxy.pull_completion_detail("stackchain/api", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/7/comments"),
|
||||
]
|
||||
assert detail["head_sha"] == "abc123"
|
||||
assert detail["conversation"]["comments"][0]["body"] == "Question"
|
||||
assert "files" not in detail
|
||||
assert "ci_state" not in detail
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
calls.append(("assigned", repository, number))
|
||||
return True
|
||||
|
||||
async def review(repository, number):
|
||||
calls.append(("review", repository, number))
|
||||
return {
|
||||
"repository": repository, "number": number, "head_sha": "abc123",
|
||||
"state": "open", "draft": False, "mergeable": True, "merged": False,
|
||||
"ci_state": "success", "files": [{"filename": "src/api.py"}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", review, 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/pulls/7/review-data")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["files"] == [{"filename": "src/api.py"}]
|
||||
assert calls == [
|
||||
("assigned", "stackchain/api", 7),
|
||||
("review", "stackchain/api", 7),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
|
||||
async def handler(request):
|
||||
path = request.url.path
|
||||
if path.endswith("/pulls/7"):
|
||||
@@ -86,7 +156,7 @@ async def test_gitea_assigned_pull_detail_includes_bounded_diff_previews():
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
detail = await gitea_proxy.pull_completion_detail("stackchain/api", 7)
|
||||
detail = await gitea_proxy.pull_completion_review("stackchain/api", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
@@ -94,6 +164,38 @@ 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 "conversation" not in detail
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
WORKER = Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_worker_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
|
||||
const cache = {{
|
||||
addAll: async urls => {{ state.added = urls; }},
|
||||
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
|
||||
put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
|
||||
}};
|
||||
const context = {{
|
||||
URL, Request, Response,
|
||||
console,
|
||||
self: {{
|
||||
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
clients: {{ claim: async () => {{ state.claimed = true; }} }},
|
||||
}},
|
||||
caches: {{
|
||||
open: async () => cache,
|
||||
keys: async () => ['stackchain-dashboard-old', 'another-app-cache'],
|
||||
delete: async key => {{ state.deleted.push(key); return true; }},
|
||||
match: async request => cache.match(request),
|
||||
}},
|
||||
fetch: async request => {{
|
||||
state.fetches.push(String(request.url || request));
|
||||
if (state.failFetch) throw new Error('offline');
|
||||
return new Response('network', {{ status: state.fetchStatus }});
|
||||
}},
|
||||
}};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8'), context);
|
||||
async function dispatch(name, request) {{
|
||||
let pending;
|
||||
let response;
|
||||
listeners[name]({{
|
||||
request,
|
||||
waitUntil: promise => {{ pending = promise; }},
|
||||
respondWith: promise => {{ response = promise; }},
|
||||
}});
|
||||
if (pending) await pending;
|
||||
return response ? await response : null;
|
||||
}}
|
||||
(async () => {{
|
||||
{scenario}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], capture_output=True, check=True, text=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_mobile_attention_queue_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v11" in source
|
||||
|
||||
|
||||
def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatch('install');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["skipped"] is True
|
||||
assert result["added"][0] == "/dashboard/"
|
||||
assert set(result["added"]) == {
|
||||
"/dashboard/",
|
||||
"/dashboard/manifest.webmanifest",
|
||||
"/dashboard/static/icons/stackchain-192.png",
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/markdown.js",
|
||||
"/dashboard/static/commands.js",
|
||||
"/dashboard/static/search-preview.js",
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
"/dashboard/static/outbox-coordinator.js",
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
"/dashboard/static/pull-sheet.js",
|
||||
"/dashboard/static/review-sheet.js",
|
||||
"/dashboard/static/work-route.js",
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
}
|
||||
|
||||
|
||||
def test_activate_deletes_only_stale_stackchain_caches():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatch('activate');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["claimed"] is True
|
||||
assert result["deleted"] == ["stackchain-dashboard-old"]
|
||||
|
||||
|
||||
def test_offline_navigation_returns_cached_shell_for_share_target_url():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.failFetch = true;
|
||||
state.cachedBody = 'cached dashboard';
|
||||
const response = await dispatch('fetch', {
|
||||
method: 'GET', mode: 'navigate',
|
||||
url: 'https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["body"] == "cached dashboard"
|
||||
assert result["state"]["fetches"] == [
|
||||
"https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com"
|
||||
]
|
||||
|
||||
|
||||
def test_cross_origin_navigation_is_not_intercepted_or_cached():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const response = await dispatch('fetch', {
|
||||
method: 'GET', mode: 'navigate', url: 'https://outside.example/page',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({ intercepted: response !== null, state }));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["intercepted"] is False
|
||||
assert result["state"]["fetches"] == []
|
||||
assert result["state"]["puts"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [500, 502, 503, 504])
|
||||
def test_server_outage_navigation_returns_working_cached_shell_without_replacing_it(status):
|
||||
result = run_worker_scenario(
|
||||
f"""
|
||||
state.fetchStatus = {status};
|
||||
state.cachedBody = 'cached dashboard';
|
||||
const response = await dispatch('fetch', {{
|
||||
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{ status: response.status, body: await response.text(), state }}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["status"] == 200
|
||||
assert result["body"] == "cached dashboard"
|
||||
assert result["state"]["puts"] == []
|
||||
|
||||
|
||||
def test_server_outage_without_cached_shell_preserves_network_error():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.fetchStatus = 503;
|
||||
const response = await dispatch('fetch', {
|
||||
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({ status: response.status, body: await response.text(), state }));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["status"] == 503
|
||||
assert result["body"] == "network"
|
||||
assert result["state"]["puts"] == []
|
||||
|
||||
|
||||
def test_client_error_navigation_is_not_hidden_by_cached_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.fetchStatus = 401;
|
||||
state.cachedBody = 'cached dashboard';
|
||||
const response = await dispatch('fetch', {
|
||||
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({ status: response.status, body: await response.text(), state }));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["status"] == 401
|
||||
assert result["body"] == "network"
|
||||
assert result["state"]["puts"] == []
|
||||
@@ -61,4 +61,53 @@ async def test_work_page_endpoint_rejects_unknown_stream_before_upstream_io(monk
|
||||
response = await client.get("/api/v1/work/unknown?page=2")
|
||||
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_data():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
manifest = await client.get("/manifest.webmanifest")
|
||||
worker = await client.get("/service-worker.js")
|
||||
|
||||
assert manifest.status_code == 200
|
||||
assert manifest.headers["content-type"].startswith("application/manifest+json")
|
||||
assert manifest.json()["id"] == "./"
|
||||
assert manifest.json()["start_url"] == "./"
|
||||
assert manifest.json()["scope"] == "./"
|
||||
assert manifest.json()["share_target"] == {
|
||||
"action": "./", "method": "GET", "enctype": "application/x-www-form-urlencoded",
|
||||
"params": {"title": "title", "text": "text", "url": "url"},
|
||||
}
|
||||
assert worker.status_code == 200
|
||||
assert worker.headers["content-type"].startswith("application/javascript")
|
||||
assert worker.headers["service-worker-allowed"] == "/"
|
||||
assert "request.url.includes('/api/')" in worker.text
|
||||
assert "request.method !== 'GET'" in worker.text
|
||||
assert "new URL('./', self.location.href).pathname" in worker.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_announces_offline_mode_and_refreshes_after_reconnect():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'id="offline-status"' in response.text
|
||||
assert 'role="status"' in response.text
|
||||
assert 'aria-live="polite"' in response.text
|
||||
assert "window.addEventListener('offline'" in response.text
|
||||
assert "window.addEventListener('online'" in response.text
|
||||
assert "contextPoller.refresh()" in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_shell_revalidates_instead_of_being_stored_as_fresh():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-cache"
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user