Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb00d3cf35 | ||
|
|
1b2a3c83af | ||
|
|
a579b95f47 | ||
|
|
6bf5512b47 | ||
|
|
1c2dbd2168 | ||
|
|
a25b547d20 | ||
|
|
4499a7f947 | ||
|
|
f655a5115a | ||
|
|
bca27f0330 | ||
|
|
19dfc93b85 | ||
|
|
ce26f54618 | ||
|
|
ea909ef222 | ||
|
|
3824ff180a | ||
|
|
174dac05b9 | ||
|
|
ea1f4ad837 | ||
|
|
d09ceb8650 | ||
|
|
c0cc6ec46e | ||
|
|
aaff109ede | ||
|
|
2fe9b6cc3d | ||
|
|
a08ce31858 | ||
|
|
092234af38 | ||
|
|
c63b23865c | ||
|
|
e04d6753ac | ||
|
|
b5545e380e | ||
|
|
2fecccfbde | ||
|
|
9e697dc63f | ||
|
|
5e826bcc7e | ||
|
|
2be29b684a | ||
|
|
09b2898d1c | ||
|
|
a5f63a6fa6 | ||
|
|
4cc00d2e48 | ||
|
|
f1448e6b58 | ||
|
|
2243b9453c | ||
|
|
fe834a37a4 | ||
|
|
46070d03f6 | ||
|
|
7651e5b090 | ||
|
|
de20406a45 | ||
|
|
2072de337e | ||
|
|
ecd4b55a26 | ||
|
|
c639b09aaf | ||
|
|
5406ee2297 | ||
|
|
937df5d20b | ||
|
|
c0eabbf865 | ||
|
|
2d8a659ebe | ||
|
|
3ff173ca89 | ||
|
|
0bc6e01eb5 | ||
|
|
4da3b198d0 |
No files matched your search
@@ -2,3 +2,4 @@ __pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.release-engine/
|
||||
.stackchain-state/
|
||||
@@ -16,14 +16,22 @@ 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,
|
||||
create issue comments, close assigned issues, inspect/comment on assigned pull
|
||||
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 persists a per-draft idempotency key,
|
||||
so retrying after a timeout or reload replays a confirmed creation instead of posting a
|
||||
duplicate; callers integrating directly should preserve the `Idempotency-Key` header
|
||||
with the unchanged payload until a `201` response is confirmed. Closing an assigned issue, native Comment,
|
||||
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
|
||||
are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Direct API callers should preserve the `Idempotency-Key`
|
||||
header with the unchanged route and payload until a `201` response is confirmed. Closing an assigned issue, native Comment,
|
||||
Approve, and Request changes reviews, and assigned-PR merge require repository
|
||||
write permission. Native Comment, Approve, and Request changes reviews support
|
||||
head-scoped draft comments anchored to changed lines; the dashboard validates each
|
||||
@@ -43,14 +51,45 @@ uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
Open `http://127.0.0.1:8000/` for the dashboard. To verify the backend and its
|
||||
Gitea connection directly, request
|
||||
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
|
||||
`user`, `repos`, `issues`, and `pull_requests`. Never commit the token or place
|
||||
it in a tracked configuration file.
|
||||
`user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard
|
||||
to search commands plus issues and pull requests across every repository visible to
|
||||
the configured Gitea token. Remote search starts after two characters, is debounced,
|
||||
and keeps local commands usable if Gitea search is unavailable. Selecting a remote
|
||||
result opens a mobile-safe, read-only preview without discarding the search query;
|
||||
open unassigned issues can be claimed in place and handed into My Work after Gitea
|
||||
confirms the assignment. Closed work and pull requests remain read-only with a safe
|
||||
canonical Gitea link. The bounded APIs are available at
|
||||
`GET /api/v1/search?q=<query>&limit=<1-25>` and
|
||||
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`. Never commit the token
|
||||
or place it in a tracked configuration file.
|
||||
|
||||
For service monitoring, GET `/healthz` is a liveness check that confirms the
|
||||
API process is running and does not contact Gitea. GET `/readyz` is the
|
||||
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
|
||||
|
||||
After one successful online load, the installed dashboard precaches a versioned,
|
||||
subpath-scoped application shell. During a network outage, navigation falls back
|
||||
to that shell so local issue and review drafts remain reachable. A visible,
|
||||
accessible offline notice distinguishes this mode from live Gitea data, and the
|
||||
dashboard refreshes its live snapshot when connectivity returns.
|
||||
|
||||
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 or queued;
|
||||
submissions still require connectivity. 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
|
||||
|
||||
+50
-1
@@ -3,8 +3,57 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = filterCommands;
|
||||
if (root) root.filterCommands = filterCommands;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function filterCommands(commands, filter) {
|
||||
function filterCommands(commands, filter) {
|
||||
const term = String(filter || '').toLowerCase();
|
||||
return commands.filter(command => command.name.toLowerCase().includes(term));
|
||||
}
|
||||
|
||||
filterCommands.nextSelection = function nextSelection(current, key, count) {
|
||||
if (count < 1) return -1;
|
||||
if (key === 'ArrowDown') return (current + 1 + count) % count;
|
||||
if (key === 'ArrowUp') return (current - 1 + count) % count;
|
||||
return current;
|
||||
};
|
||||
|
||||
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
|
||||
const search = options.search;
|
||||
const onState = options.onState;
|
||||
const delay = options.delay === undefined ? 250 : options.delay;
|
||||
let timer = null;
|
||||
let generation = 0;
|
||||
let activeController = null;
|
||||
|
||||
return {
|
||||
setQuery(value) {
|
||||
const query = String(value || '').trim();
|
||||
generation += 1;
|
||||
const current = generation;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
if (activeController !== null) activeController.abort();
|
||||
activeController = null;
|
||||
if (query.length < 2) {
|
||||
onState({ status: 'idle', query, items: [] });
|
||||
return;
|
||||
}
|
||||
onState({ status: 'loading', query, items: [] });
|
||||
timer = setTimeout(async () => {
|
||||
const requestController = new AbortController();
|
||||
activeController = requestController;
|
||||
try {
|
||||
const result = await search(query, requestController.signal);
|
||||
const items = Array.isArray(result) ? result : result.items;
|
||||
const partial = !Array.isArray(result) && result.partial === true;
|
||||
if (current === generation) onState({ status: 'ready', query, items, partial });
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
if (current === generation) onState({ status: 'error', query, items: [], error });
|
||||
} finally {
|
||||
if (activeController === requestController) activeController = null;
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return filterCommands;
|
||||
});
|
||||
@@ -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, loadLabels, loadMilestones, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
};
|
||||
}
|
||||
|
||||
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||
@@ -0,0 +1,144 @@
|
||||
function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||
const indexKey = 'stackchain.draft-index.v1';
|
||||
|
||||
function readIndex() {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(indexKey) || '{}');
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
} catch (_error) { return {}; }
|
||||
}
|
||||
|
||||
function writeIndex(index) {
|
||||
try { storage?.setItem(indexKey, JSON.stringify(index)); }
|
||||
catch (_error) { /* Draft discovery remains available without metadata. */ }
|
||||
}
|
||||
|
||||
function keys() {
|
||||
try {
|
||||
return Array.from({ length: Number(storage?.length || 0) }, (_, index) => storage.key(index))
|
||||
.filter(Boolean);
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function textPreview(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 180);
|
||||
}
|
||||
|
||||
function parseTarget(value) {
|
||||
const match = String(value || '').match(/^(.+)#(\d+)$/);
|
||||
return match ? { repository: match[1], number: Number(match[2]) } : null;
|
||||
}
|
||||
|
||||
function parse(key, raw) {
|
||||
let match;
|
||||
if (key === 'stackchain.issue-capture.v1') {
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
|
||||
if (!preview && !value?.repository) return null;
|
||||
return {
|
||||
kind: 'new-issue', label: 'New issue', repository: String(value?.repository || ''),
|
||||
title: textPreview(value?.title) || 'Untitled new issue', preview,
|
||||
};
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
match = key.match(/^stackchain\.(issue|pull)-comment\.v1:(.+#\d+)$/);
|
||||
if (match) {
|
||||
const target = parseTarget(match[2]);
|
||||
const preview = textPreview(raw);
|
||||
if (!target || !preview) return null;
|
||||
const routeKind = match[1] === 'issue' ? 'issue' : 'pull';
|
||||
return {
|
||||
kind: routeKind + '-comment', label: routeKind === 'issue' ? 'Issue comment' : 'PR comment',
|
||||
...target, title: target.repository + '#' + target.number, preview,
|
||||
route: { kind: routeKind, ...target },
|
||||
};
|
||||
}
|
||||
match = key.match(/^stackchain\.issue-content\.v1:(.+#\d+)$/);
|
||||
if (match) {
|
||||
const target = parseTarget(match[1]);
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
|
||||
if (!target || !preview || typeof value?.expectedUpdatedAt !== 'string') return null;
|
||||
return {
|
||||
kind: 'issue-edit', label: 'Issue edit', ...target,
|
||||
title: target.repository + '#' + target.number, preview,
|
||||
route: { kind: 'issue', ...target },
|
||||
};
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
match = key.match(/^stackchain\.update-reply\.v1\.(\d+)$/);
|
||||
if (match) {
|
||||
const preview = textPreview(raw);
|
||||
if (!preview) return null;
|
||||
const notificationId = Number(match[1]);
|
||||
return {
|
||||
kind: 'update-reply', label: 'Update reply', notification_id: notificationId,
|
||||
title: 'Update #' + notificationId, preview,
|
||||
route: { kind: 'update', notification_id: notificationId },
|
||||
};
|
||||
}
|
||||
match = key.match(/^stackchain\.review-draft\.v1:(.+#\d+)@([^:]+)$/);
|
||||
if (match) {
|
||||
const target = parseTarget(match[1]);
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
const notes = Object.values(value?.notes || {});
|
||||
const comments = Array.isArray(value?.comments) ? value.comments.map(item => item?.body) : [];
|
||||
const preview = textPreview([value?.summary, ...notes, ...comments].filter(Boolean).join(' — '));
|
||||
if (!target || !preview) return null;
|
||||
return {
|
||||
kind: 'review', label: 'PR review', ...target, head_sha: match[2],
|
||||
title: target.repository + '#' + target.number, preview,
|
||||
route: { kind: 'review', ...target },
|
||||
};
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function list() {
|
||||
const index = readIndex();
|
||||
const seen = new Set();
|
||||
const drafts = [];
|
||||
keys().forEach(key => {
|
||||
if (key === indexKey || key.endsWith(':operation')) return;
|
||||
let raw;
|
||||
try { raw = storage.getItem(key); }
|
||||
catch (_error) { return; }
|
||||
const parsed = parse(key, raw);
|
||||
if (!parsed) return;
|
||||
seen.add(key);
|
||||
const fingerprint = String(raw);
|
||||
const previous = index[key];
|
||||
if (!previous || previous.fingerprint !== fingerprint) {
|
||||
index[key] = { fingerprint, updated_at: Number(now()) };
|
||||
}
|
||||
drafts.push({ id: key, ...parsed, updated_at: index[key].updated_at });
|
||||
});
|
||||
Object.keys(index).forEach(key => { if (!seen.has(key)) delete index[key]; });
|
||||
writeIndex(index);
|
||||
return drafts.sort((left, right) =>
|
||||
Number(right.updated_at || 0) - Number(left.updated_at || 0) || left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
if (typeof id !== 'string' || !parse(id, (() => {
|
||||
try { return storage?.getItem(id); } catch (_error) { return null; }
|
||||
})())) return false;
|
||||
try {
|
||||
storage?.removeItem(id);
|
||||
storage?.removeItem(id + ':operation');
|
||||
const index = readIndex();
|
||||
delete index[id];
|
||||
writeIndex(index);
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
return { list, discard };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 966 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
+1256
-117
File diff suppressed because it is too large.
Load diff
+142
-3
@@ -1,12 +1,39 @@
|
||||
function createIssueSheet({ fetchJson, storage }) {
|
||||
function createPlanningLoader({ loadLabels, loadMilestones }) {
|
||||
const requests = new Map();
|
||||
const keyFor = item => item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
open(item) {
|
||||
const key = keyFor(item);
|
||||
if (requests.has(key)) return requests.get(key);
|
||||
const request = Promise.all([loadLabels(item), loadMilestones(item)])
|
||||
.then(([labels, milestones]) => ({ labels, milestones }))
|
||||
.catch(error => {
|
||||
if (requests.get(key) === request) requests.delete(key);
|
||||
throw error;
|
||||
});
|
||||
requests.set(key, request);
|
||||
return request;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
let labelRequest = null;
|
||||
let editRequest = null;
|
||||
let dueDateRequest = null;
|
||||
let milestoneRequest = null;
|
||||
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
|
||||
const operationKey = item => draftKey(item) + ':operation';
|
||||
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
|
||||
const editDraftKey = item => 'stackchain.issue-content.v1:' + item.repository + '#' + item.number;
|
||||
const dueDateDraftKey = item => 'stackchain.issue-due-date.v1:' + item.repository + '#' + item.number;
|
||||
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
load(item) {
|
||||
@@ -14,19 +41,122 @@ function createIssueSheet({ fetchJson, storage }) {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
conversation(item, initialPage) {
|
||||
const pager = createConversationPager({
|
||||
loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
});
|
||||
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
|
||||
return pager;
|
||||
},
|
||||
loadLabels(item) {
|
||||
return fetchJson(issuePath(item) + '/labels', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadMilestones(item) {
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
return fetchJson('api/v1/repos/' + repository + '/milestones', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
},
|
||||
saveDraft(item, body) {
|
||||
try { storage?.setItem(draftKey(item), body); }
|
||||
try {
|
||||
if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item));
|
||||
storage?.setItem(draftKey(item), body);
|
||||
}
|
||||
catch (_error) { /* The textarea remains the fallback. */ }
|
||||
},
|
||||
loadEditDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
|
||||
return value && typeof value.title === 'string' && typeof value.body === 'string' &&
|
||||
typeof value.expectedUpdatedAt === 'string' ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
},
|
||||
saveEditDraft(item, draft) {
|
||||
try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); }
|
||||
catch (_error) { /* The edit fields remain the fallback. */ }
|
||||
},
|
||||
updateContent(item, draft) {
|
||||
if (editRequest) return editRequest;
|
||||
this.saveEditDraft(item, draft);
|
||||
editRequest = fetchJson(issuePath(item) + '/content', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: draft.title,
|
||||
body: draft.body,
|
||||
expected_updated_at: draft.expectedUpdatedAt,
|
||||
}),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || result?.title !== draft.title || result?.body !== draft.body) {
|
||||
throw new Error('Issue update was not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(editDraftKey(item)); }
|
||||
catch (_error) { /* Confirmed upstream content is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { editRequest = null; });
|
||||
return editRequest;
|
||||
},
|
||||
loadDueDateDraft(item) {
|
||||
try {
|
||||
const raw = storage?.getItem(dueDateDraftKey(item));
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const value = JSON.parse(raw);
|
||||
return typeof value === 'string' ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
},
|
||||
updateDueDate(item, dueDate) {
|
||||
if (dueDateRequest) return dueDateRequest;
|
||||
try { storage?.setItem(dueDateDraftKey(item), JSON.stringify(dueDate)); }
|
||||
catch (_error) { /* The date input remains the fallback. */ }
|
||||
dueDateRequest = fetchJson(issuePath(item) + '/due-date', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ due_date: dueDate }),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || result?.due_date !== dueDate) {
|
||||
throw new Error('Issue due date was not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(dueDateDraftKey(item)); }
|
||||
catch (_error) { /* Confirmed upstream deadline is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { dueDateRequest = null; });
|
||||
return dueDateRequest;
|
||||
},
|
||||
loadMilestoneDraft(item) {
|
||||
try {
|
||||
const raw = storage?.getItem(milestoneDraftKey(item));
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const value = JSON.parse(raw);
|
||||
return value === null || Number.isInteger(value) ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
},
|
||||
updateMilestone(item, milestoneId) {
|
||||
if (milestoneRequest) return milestoneRequest;
|
||||
try { storage?.setItem(milestoneDraftKey(item), JSON.stringify(milestoneId)); }
|
||||
catch (_error) { /* The select remains the fallback. */ }
|
||||
milestoneRequest = fetchJson(issuePath(item) + '/milestone', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ milestone_id: milestoneId }),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number ||
|
||||
(milestoneId === null ? result?.milestone !== null : result?.milestone?.id !== milestoneId)) {
|
||||
throw new Error('Issue milestone was not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(milestoneDraftKey(item)); }
|
||||
catch (_error) { /* Confirmed upstream milestone is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { milestoneRequest = null; });
|
||||
return milestoneRequest;
|
||||
},
|
||||
loadLabelDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
|
||||
@@ -82,13 +212,20 @@ function createIssueSheet({ fetchJson, storage }) {
|
||||
comment(item, body) {
|
||||
if (commentRequest) return commentRequest;
|
||||
this.saveDraft(item, body);
|
||||
let operationId;
|
||||
try {
|
||||
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
|
||||
storage?.setItem(operationKey(item), operationId);
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
commentRequest = fetchJson(issuePath(item) + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
|
||||
body: JSON.stringify({ body }),
|
||||
}).then(result => {
|
||||
try { storage?.removeItem(draftKey(item)); }
|
||||
catch (_error) { /* The upstream comment is authoritative. */ }
|
||||
try { storage?.removeItem(operationKey(item)); }
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
return result;
|
||||
}).finally(() => { commentRequest = null; });
|
||||
return commentRequest;
|
||||
@@ -96,4 +233,6 @@ function createIssueSheet({ fetchJson, storage }) {
|
||||
};
|
||||
}
|
||||
|
||||
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"}
|
||||
}
|
||||
}
|
||||
+212
-12
@@ -1,4 +1,20 @@
|
||||
function buildMyWork(data) {
|
||||
function issueDueState(dueDate, now) {
|
||||
if (!dueDate) return null;
|
||||
const due = new Date(dueDate);
|
||||
if (Number.isNaN(due.getTime())) return null;
|
||||
const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(value.getDate()).padStart(2, '0');
|
||||
const dueDay = day(due);
|
||||
const today = day(now);
|
||||
if (dueDay < today) return { label: 'Overdue', priority: 2 };
|
||||
if (dueDay === today) return { label: 'Due today', priority: 2.5 };
|
||||
return {
|
||||
label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
priority: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMyWork(data, now = new Date()) {
|
||||
const login = data.user?.login || '';
|
||||
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
|
||||
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
|
||||
@@ -11,15 +27,19 @@ function buildMyWork(data) {
|
||||
);
|
||||
const assigned = (item.assignees || []).includes(login);
|
||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
||||
return {
|
||||
...item,
|
||||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
is_review: isReview,
|
||||
is_assigned: assigned,
|
||||
has_update: false,
|
||||
...(due ? { due_label: due.label } : {}),
|
||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')),
|
||||
_priority: priorityLabel ? 0 : (isReview ? 2 : (assigned ? 3 : 4)),
|
||||
(due && due.priority < 4 ? due.label :
|
||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work'))),
|
||||
_priority: priorityLabel ? 0 :
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -229,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;
|
||||
@@ -243,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) {
|
||||
@@ -255,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;
|
||||
@@ -286,27 +343,41 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationReplier({ post, storage, onStatus }) {
|
||||
function createNotificationReplier({
|
||||
post, storage, onStatus,
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
}) {
|
||||
let pending = false;
|
||||
const keyFor = item => 'stackchain.update-reply.v1.' + item.notification_id;
|
||||
const operationKeyFor = item => keyFor(item) + ':operation';
|
||||
return {
|
||||
loadDraft(item) {
|
||||
try { return storage.getItem(keyFor(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
},
|
||||
saveDraft(item, body) {
|
||||
try { storage.setItem(keyFor(item), body); }
|
||||
try {
|
||||
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
|
||||
storage.setItem(keyFor(item), body);
|
||||
}
|
||||
catch (_error) { /* Keep the editable textarea as the fallback. */ }
|
||||
},
|
||||
async submit(item, body) {
|
||||
if (pending) return false;
|
||||
pending = true;
|
||||
this.saveDraft(item, body);
|
||||
let operationId;
|
||||
try {
|
||||
operationId = storage.getItem(operationKeyFor(item)) || String(createOperationId()).slice(0, 128);
|
||||
storage.setItem(operationKeyFor(item), operationId);
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
onStatus('Sending reply…');
|
||||
try {
|
||||
const result = await post(item.notification_id, body);
|
||||
const result = await post(item.notification_id, body, operationId);
|
||||
try { storage.removeItem(keyFor(item)); }
|
||||
catch (_error) { /* The posted reply is still authoritative. */ }
|
||||
try { storage.removeItem(operationKeyFor(item)); }
|
||||
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) {
|
||||
@@ -319,11 +390,107 @@ function createNotificationReplier({ post, storage, onStatus }) {
|
||||
};
|
||||
}
|
||||
|
||||
function filterMyWork(items, selectedFilter) {
|
||||
if (selectedFilter === 'all') return items;
|
||||
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
|
||||
if (selectedFilter === 'update') return items.filter((item) => item.has_update);
|
||||
return items.filter((item) => item.kind === selectedFilter);
|
||||
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
let filtered = items;
|
||||
if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||
if (selectedMilestone === 'all') return filtered;
|
||||
if (selectedMilestone === 'unplanned') {
|
||||
return filtered.filter(item => item.kind === 'issue' && !item.milestone);
|
||||
}
|
||||
return filtered.filter(item =>
|
||||
item.kind === 'issue' && String(item.milestone?.id || '') === String(selectedMilestone)
|
||||
);
|
||||
}
|
||||
|
||||
function milestoneLanes(items) {
|
||||
const lanes = new Map();
|
||||
(items || []).forEach(item => {
|
||||
if (item?.kind === 'issue' && Number.isInteger(item.milestone?.id) &&
|
||||
typeof item.milestone?.title === 'string') {
|
||||
lanes.set(item.milestone.id, { id: item.milestone.id, title: item.milestone.title });
|
||||
}
|
||||
});
|
||||
return Array.from(lanes.values()).sort((left, right) => left.title.localeCompare(right.title));
|
||||
}
|
||||
|
||||
function workIdentity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const repository = item.repository || '';
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, repository, number, notification].join(':');
|
||||
}
|
||||
|
||||
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) {
|
||||
let currentIdentity = '';
|
||||
let currentIndex = -1;
|
||||
let running = false;
|
||||
|
||||
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||
const report = (items, index) => onProgress({
|
||||
index: index + 1,
|
||||
total: items.length,
|
||||
can_previous: index > 0,
|
||||
can_next: index < items.length - 1,
|
||||
});
|
||||
const finish = () => {
|
||||
running = false;
|
||||
currentIdentity = '';
|
||||
currentIndex = -1;
|
||||
onFinish();
|
||||
return false;
|
||||
};
|
||||
const openAt = (items, index) => {
|
||||
if (!items.length || index < 0 || index >= items.length) return finish();
|
||||
currentIndex = index;
|
||||
currentIdentity = workIdentity(items[index]);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
start() {
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
return openAt(items, 0);
|
||||
},
|
||||
reconcile() {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (index < 0) return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
|
||||
currentIndex = index;
|
||||
report(items, index);
|
||||
return true;
|
||||
},
|
||||
previous() {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
return index > 0 ? openAt(items, index - 1) : false;
|
||||
},
|
||||
next() {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
|
||||
},
|
||||
complete() {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (stillPresent >= 0) {
|
||||
return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
|
||||
}
|
||||
return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIssueLabels(data, repository, number, labels) {
|
||||
@@ -335,6 +502,33 @@ function replaceIssueLabels(data, repository, number, labels) {
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIssueContent(data, repository, number, content) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, ...content } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIssueDueDate(data, repository, number, dueDate) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, due_date: dueDate } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIssueMilestone(data, repository, number, milestone) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, milestone } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function removeIssue(data, repository, number) {
|
||||
return {
|
||||
...data,
|
||||
@@ -366,7 +560,13 @@ function countMyWork(items) {
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.milestoneLanes = milestoneLanes;
|
||||
buildMyWork.createWorkSession = createWorkSession;
|
||||
buildMyWork.workIdentity = workIdentity;
|
||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||
buildMyWork.replaceIssueContent = replaceIssueContent;
|
||||
buildMyWork.replaceIssueDueDate = replaceIssueDueDate;
|
||||
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
+93
-4
@@ -1,4 +1,4 @@
|
||||
function mergeEligibility(detail) {
|
||||
function mergeEligibility(detail, reviewState) {
|
||||
if (!detail || detail.state !== 'open' || detail.merged) {
|
||||
return { allowed: false, reason: 'Pull request is not open' };
|
||||
}
|
||||
@@ -8,38 +8,126 @@ function mergeEligibility(detail) {
|
||||
return { allowed: false, reason: 'CI must succeed before merging' };
|
||||
}
|
||||
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
|
||||
if (reviewState && !reviewState.complete) {
|
||||
return { allowed: false, reason: 'Review every changed file before merging' };
|
||||
}
|
||||
return { allowed: true, reason: 'Ready to merge' };
|
||||
}
|
||||
|
||||
function createPullSheet({ fetchJson, storage }) {
|
||||
function renderFile(file, index, reviewed, escapeHtml) {
|
||||
const filename = escapeHtml(file.filename || 'Unknown file');
|
||||
const panelId = 'pull-diff-' + index;
|
||||
const lines = (file.diff_lines || []).map(line => {
|
||||
const text = String(line);
|
||||
const kind = text.startsWith('@@') ? 'hunk' : text.startsWith('+') ? 'added' :
|
||||
text.startsWith('-') ? 'removed' : 'context';
|
||||
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(text) + '</span>';
|
||||
}).join('');
|
||||
const preview = file.diff_available
|
||||
? '<pre class="pull-diff" id="' + panelId + '" hidden>' + lines +
|
||||
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
|
||||
: '<div class="pull-diff-empty" id="' + panelId + '" hidden>' +
|
||||
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') +
|
||||
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
|
||||
return '<article class="pull-file" data-pull-filename="' + filename + '">' +
|
||||
'<button type="button" class="pull-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
|
||||
'<strong>' + filename + '</strong><span class="small">' + escapeHtml(file.status || 'changed') +
|
||||
' · +' + Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</span></button>' + preview +
|
||||
'<button type="button" class="pull-review-file" data-pull-review-file="' + filename +
|
||||
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
|
||||
}
|
||||
|
||||
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;
|
||||
const operationKey = item => draftKey(item) + ':operation';
|
||||
const reviewKey = (item, detail) => 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + detail.head_sha;
|
||||
const fileNames = detail => (detail?.files || []).map(file => file.filename).filter(Boolean);
|
||||
|
||||
function reviewState(item, detail) {
|
||||
const files = fileNames(detail);
|
||||
let saved = [];
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(reviewKey(item, detail)) || '[]');
|
||||
if (Array.isArray(value)) saved = value;
|
||||
} catch (_error) { /* Corrupt progress safely starts over. */ }
|
||||
const reviewed = files.filter(filename => saved.includes(filename));
|
||||
return { reviewed, total: files.length, complete: reviewed.length === files.length };
|
||||
}
|
||||
|
||||
return {
|
||||
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);
|
||||
const reviewed = new Set(state.reviewed);
|
||||
if (reviewed.has(filename)) reviewed.delete(filename);
|
||||
else if (fileNames(detail).includes(filename)) reviewed.add(filename);
|
||||
try { storage?.setItem(reviewKey(item, detail), JSON.stringify(Array.from(reviewed))); }
|
||||
catch (_error) { /* In-memory controls still work for this render. */ }
|
||||
return reviewState(item, detail);
|
||||
},
|
||||
nextUnreviewed(detail, state) {
|
||||
return fileNames(detail).find(filename => !state.reviewed.includes(filename)) || null;
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
},
|
||||
saveDraft(item, body) {
|
||||
try { storage?.setItem(draftKey(item), body); }
|
||||
try {
|
||||
if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item));
|
||||
storage?.setItem(draftKey(item), body);
|
||||
}
|
||||
catch (_error) { /* The textarea remains the fallback. */ }
|
||||
},
|
||||
comment(item, body) {
|
||||
if (commentRequest) return commentRequest;
|
||||
this.saveDraft(item, body);
|
||||
let operationId;
|
||||
try {
|
||||
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
|
||||
storage?.setItem(operationKey(item), operationId);
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
commentRequest = fetchJson(pathFor(item) + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
|
||||
body: JSON.stringify({ body }),
|
||||
}).then(result => {
|
||||
try { storage?.removeItem(draftKey(item)); }
|
||||
catch (_error) { /* The upstream comment is authoritative. */ }
|
||||
try { storage?.removeItem(operationKey(item)); }
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
return result;
|
||||
}).finally(() => { commentRequest = null; });
|
||||
return commentRequest;
|
||||
@@ -60,4 +148,5 @@ function createPullSheet({ fetchJson, storage }) {
|
||||
}
|
||||
|
||||
createPullSheet.mergeEligibility = mergeEligibility;
|
||||
createPullSheet.renderFile = renderFile;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
||||
@@ -1,4 +1,8 @@
|
||||
function createReviewController({ fetchJson }) {
|
||||
function createReviewController({
|
||||
fetchJson,
|
||||
storage,
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
}) {
|
||||
let pendingSubmission = null;
|
||||
|
||||
function endpoint(item) {
|
||||
@@ -16,10 +20,26 @@ function createReviewController({ fetchJson }) {
|
||||
|
||||
function submit(item, payload) {
|
||||
if (pendingSubmission) return pendingSubmission;
|
||||
const operationKey = 'stackchain.review-submit.v1:' + item.repository + '#' + item.number;
|
||||
const fingerprint = JSON.stringify(payload);
|
||||
let operationId;
|
||||
try {
|
||||
const saved = JSON.parse(storage?.getItem(operationKey) || 'null');
|
||||
operationId = saved?.fingerprint === fingerprint && saved?.operationId
|
||||
? saved.operationId : String(createOperationId()).slice(0, 128);
|
||||
storage?.setItem(operationKey, JSON.stringify({ fingerprint, operationId }));
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
pendingSubmission = fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': operationId,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).then(result => {
|
||||
try { storage?.removeItem(operationKey); }
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
return result;
|
||||
}).finally(() => { pendingSubmission = null; });
|
||||
return pendingSubmission;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
(function (root, factory) {
|
||||
const createSearchPreview = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
|
||||
if (root) root.createSearchPreview = createSearchPreview;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, claim, onState }) {
|
||||
let generation = 0;
|
||||
let current = null;
|
||||
let claimRequest = null;
|
||||
|
||||
return {
|
||||
open(item) {
|
||||
generation += 1;
|
||||
const requestGeneration = generation;
|
||||
current = { ...item };
|
||||
onState({ status: 'loading', item: current });
|
||||
return fetchJson(current).then(detail => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'ready', item: current, detail });
|
||||
}
|
||||
return detail;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'error', item: current, error });
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
close() {
|
||||
generation += 1;
|
||||
current = null;
|
||||
onState({ status: 'closed' });
|
||||
},
|
||||
claim(detail) {
|
||||
if (claimRequest) return claimRequest;
|
||||
onState({ status: 'claiming', item: current, detail });
|
||||
claimRequest = claim(detail).then(result => {
|
||||
onState({ status: 'claimed', item: current, detail, result });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status: 'ready', item: current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { claimRequest = null; });
|
||||
return claimRequest;
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
const CACHE = 'stackchain-dashboard-shell-v3';
|
||||
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/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',
|
||||
];
|
||||
|
||||
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 => {
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE);
|
||||
await cache.put(BASE, response.clone());
|
||||
}
|
||||
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)));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createWorkRoute = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
|
||||
function positiveInteger(value) {
|
||||
const number = Number(value);
|
||||
return Number.isSafeInteger(number) && number > 0 ? number : null;
|
||||
}
|
||||
|
||||
function parse(fragment) {
|
||||
const parts = String(fragment || '').split('/');
|
||||
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
|
||||
if (parts[2] === 'update' && parts.length === 4) {
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
}
|
||||
if (!['issue', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
|
||||
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
|
||||
const number = positiveInteger(parts[5]);
|
||||
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
|
||||
}
|
||||
|
||||
function serialize(item) {
|
||||
if (item?.kind === 'update') {
|
||||
const notificationId = positiveInteger(item.notification_id);
|
||||
return notificationId ? '#/my-work/update/' + notificationId : '';
|
||||
}
|
||||
if (!['issue', 'pull', 'review'].includes(item?.kind)) return '';
|
||||
const repository = String(item.repository || '').split('/');
|
||||
const number = positiveInteger(item.number);
|
||||
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
|
||||
return '#/my-work/' + item.kind + '/' + repository.join('/') + '/' + number;
|
||||
}
|
||||
|
||||
function sameRoute(item, route) {
|
||||
if (route.kind === 'update') {
|
||||
return Number(item.notification_id) === route.notification_id;
|
||||
}
|
||||
const itemKind = item.is_review ? 'review' : item.kind;
|
||||
return itemKind === route.kind && item.repository === route.repository &&
|
||||
Number(item.number) === route.number;
|
||||
}
|
||||
|
||||
function createController({
|
||||
location, history, eventTarget, onOpen, onClose, onInvalid,
|
||||
resolve, onResolving = function () {}, onError = function () {},
|
||||
}) {
|
||||
let items = [];
|
||||
let started = false;
|
||||
let active = '';
|
||||
let ready = false;
|
||||
let resolving = '';
|
||||
let resolution = 0;
|
||||
|
||||
function resolveMissing(fragment, route) {
|
||||
if (typeof resolve !== 'function') {
|
||||
onInvalid();
|
||||
return;
|
||||
}
|
||||
if (resolving === fragment) return;
|
||||
resolving = fragment;
|
||||
const request = ++resolution;
|
||||
onResolving(route);
|
||||
Promise.resolve(resolve(route)).then(item => {
|
||||
if (request !== resolution || String(location.hash || '') !== fragment) return;
|
||||
resolving = '';
|
||||
if (!item || !sameRoute(item, route)) {
|
||||
onInvalid();
|
||||
return;
|
||||
}
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
}).catch(error => {
|
||||
if (request !== resolution || String(location.hash || '') !== fragment) return;
|
||||
resolving = '';
|
||||
if (error?.unavailable) onInvalid();
|
||||
else onError(error, route);
|
||||
});
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const fragment = String(location.hash || '');
|
||||
if (!fragment) {
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
if (active) onClose();
|
||||
active = '';
|
||||
return;
|
||||
}
|
||||
const route = parse(fragment);
|
||||
if (!route) {
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
if (fragment.startsWith('#/my-work/')) onInvalid();
|
||||
active = '';
|
||||
return;
|
||||
}
|
||||
const item = items.find(candidate => sameRoute(candidate, route));
|
||||
if (!item) {
|
||||
if (ready) resolveMissing(fragment, route);
|
||||
return;
|
||||
}
|
||||
if (active === fragment) return;
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
eventTarget.addEventListener('popstate', sync);
|
||||
eventTarget.addEventListener('hashchange', sync);
|
||||
sync();
|
||||
},
|
||||
setItems(nextItems) {
|
||||
items = Array.isArray(nextItems) ? nextItems.slice() : [];
|
||||
ready = true;
|
||||
sync();
|
||||
},
|
||||
open(item, options = {}) {
|
||||
const fragment = serialize(item);
|
||||
if (!fragment) return false;
|
||||
const method = options.replace ? 'replaceState' : 'pushState';
|
||||
history[method]({ workRoute: fragment }, '', fragment);
|
||||
active = fragment;
|
||||
onOpen(item);
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
if (parse(location.hash)) history.back();
|
||||
else {
|
||||
active = '';
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
sync,
|
||||
};
|
||||
}
|
||||
|
||||
async function share(url, navigatorObject, clipboard) {
|
||||
if (typeof navigatorObject?.share === 'function') {
|
||||
await navigatorObject.share({ url });
|
||||
return 'shared';
|
||||
}
|
||||
if (typeof clipboard?.writeText === 'function') {
|
||||
await clipboard.writeText(url);
|
||||
return 'copied';
|
||||
}
|
||||
throw new Error('Sharing is unavailable.');
|
||||
}
|
||||
|
||||
return { parse, serialize, createController, share };
|
||||
});
|
||||
+512
-36
@@ -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."""
|
||||
|
||||
@@ -42,6 +47,10 @@ class IssueNotAvailableError(ValueError):
|
||||
"""Raised before assignment when an issue is no longer open and unassigned."""
|
||||
|
||||
|
||||
class IssueEditConflictError(ValueError):
|
||||
"""Raised when an issue changed after the editor loaded it."""
|
||||
|
||||
|
||||
def _auth() -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if GITEA_TOKEN:
|
||||
@@ -179,6 +188,112 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
repository = item.get("repository")
|
||||
repository = repository if isinstance(repository, dict) else {}
|
||||
url = _safe_web_url(item.get("html_url"))
|
||||
if not (
|
||||
isinstance(item.get("number"), int)
|
||||
and isinstance(item.get("title"), str)
|
||||
and isinstance(item.get("state"), str)
|
||||
and isinstance(repository.get("full_name"), str)
|
||||
and repository.get("full_name")
|
||||
and url
|
||||
):
|
||||
return None
|
||||
return {
|
||||
"kind": kind,
|
||||
"repository": repository["full_name"],
|
||||
"number": item["number"],
|
||||
"title": item["title"],
|
||||
"state": item["state"],
|
||||
"url": url,
|
||||
}
|
||||
|
||||
|
||||
async def global_search(query: str, limit: int = 10) -> dict:
|
||||
"""Search accessible issues and pulls concurrently with a bounded result set."""
|
||||
async def load(item_type: str) -> Any:
|
||||
response = await _get_client().get(
|
||||
"/api/v1/repos/issues/search",
|
||||
headers=_auth(),
|
||||
params={"q": query, "type": item_type, "limit": limit, "page": 1},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea global search response was not a list")
|
||||
return payload
|
||||
|
||||
outcomes = await asyncio.gather(
|
||||
load("issues"), load("pulls"), return_exceptions=True
|
||||
)
|
||||
for outcome in outcomes:
|
||||
if isinstance(outcome, asyncio.CancelledError):
|
||||
raise outcome
|
||||
if all(isinstance(outcome, BaseException) for outcome in outcomes):
|
||||
raise outcomes[0]
|
||||
|
||||
results = []
|
||||
seen: set[tuple[str, str, int]] = set()
|
||||
for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True):
|
||||
if isinstance(outcome, BaseException):
|
||||
continue
|
||||
for item in outcome:
|
||||
normalized = _normalize_global_search_item(item, kind)
|
||||
if normalized is not None:
|
||||
identity = (kind, normalized["repository"], normalized["number"])
|
||||
if identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
results.append(normalized)
|
||||
return {
|
||||
"items": results[:limit],
|
||||
"partial": any(isinstance(outcome, BaseException) for outcome in outcomes),
|
||||
}
|
||||
|
||||
|
||||
async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
"""Load bounded, read-only context for a global search result."""
|
||||
issue, user = await asyncio.gather(
|
||||
fetch(f"repos/{repository}/issues/{number}"), current_user()
|
||||
)
|
||||
if not isinstance(issue, dict):
|
||||
raise ValueError("Gitea work preview response was not an object")
|
||||
labels_value = issue.get("labels")
|
||||
assignees_value = issue.get("assignees")
|
||||
labels = labels_value if isinstance(labels_value, list) else []
|
||||
assignees = assignees_value if isinstance(assignees_value, list) else []
|
||||
assignee_names = [
|
||||
assignee["login"] for assignee in assignees
|
||||
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
|
||||
]
|
||||
author = issue.get("user")
|
||||
author = author if isinstance(author, dict) else {}
|
||||
login = user.get("login") if isinstance(user, dict) else ""
|
||||
state = issue.get("state") if isinstance(issue.get("state"), str) else ""
|
||||
actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue"
|
||||
return {
|
||||
"kind": actual_kind,
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
|
||||
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
|
||||
"state": state,
|
||||
"author": author.get("login", "") if isinstance(author.get("login"), str) else "",
|
||||
"labels": [
|
||||
label["name"] for label in labels
|
||||
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
||||
],
|
||||
"assignees": assignee_names,
|
||||
"url": _safe_web_url(issue.get("html_url")),
|
||||
"claimable": actual_kind == "issue" and state == "open" and not assignee_names,
|
||||
"assigned_to_me": bool(login and login in assignee_names),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_available_issue(item: Any) -> dict | None:
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
@@ -214,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(),
|
||||
@@ -228,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))
|
||||
@@ -398,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")
|
||||
@@ -438,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):
|
||||
@@ -469,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:
|
||||
@@ -547,16 +725,42 @@ async def repo_labels(repository: str) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
async def repo_milestones(repository: str) -> list[dict]:
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/milestones",
|
||||
headers=_auth(),
|
||||
params={"state": "open", "limit": 50, "page": 1},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea milestones response was not a list")
|
||||
return [
|
||||
{"id": item["id"], "title": item["title"]}
|
||||
for item in payload
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("id"), int)
|
||||
and isinstance(item.get("title"), str)
|
||||
and item.get("state") == "open"
|
||||
]
|
||||
|
||||
|
||||
async def create_issue(
|
||||
repository: str,
|
||||
title: str,
|
||||
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(),
|
||||
@@ -577,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"],
|
||||
@@ -593,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 "",
|
||||
@@ -728,10 +950,138 @@ async def update_issue_labels(repository: str, number: int, label_ids: list[int]
|
||||
}
|
||||
|
||||
|
||||
async def update_assigned_issue_due_date(
|
||||
repository: str, number: int, due_date: str | None
|
||||
) -> dict:
|
||||
path = f"repos/{repository}/issues/{number}"
|
||||
login, issue = await _current_login_and_target(path)
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or isinstance(issue.get("pull_request"), dict)
|
||||
or not _login_in_users(login, issue.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
|
||||
payload = {"due_date": due_date} if due_date else {"unset_due_date": True}
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/{path}", headers=_auth(), json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
confirmed_due_date = confirmed.get("due_date") if isinstance(confirmed, dict) else None
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or confirmed_due_date != due_date
|
||||
):
|
||||
raise ValueError("Gitea did not confirm the issue due date update")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"due_date": confirmed_due_date,
|
||||
}
|
||||
|
||||
|
||||
async def update_assigned_issue_milestone(
|
||||
repository: str, number: int, milestone_id: int | None
|
||||
) -> dict:
|
||||
path = f"repos/{repository}/issues/{number}"
|
||||
login, issue = await _current_login_and_target(path)
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or isinstance(issue.get("pull_request"), dict)
|
||||
or not _login_in_users(login, issue.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
|
||||
selected = None
|
||||
if milestone_id is not None:
|
||||
selected = next(
|
||||
(item for item in await repo_milestones(repository) if item["id"] == milestone_id),
|
||||
None,
|
||||
)
|
||||
if selected is None:
|
||||
raise ValueError("Unknown open repository milestone")
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/{path}", headers=_auth(), json={"milestone": milestone_id or 0}
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
milestone_value = confirmed.get("milestone") if isinstance(confirmed, dict) else None
|
||||
normalized = (
|
||||
{"id": milestone_value["id"], "title": milestone_value["title"]}
|
||||
if isinstance(milestone_value, dict)
|
||||
and isinstance(milestone_value.get("id"), int)
|
||||
and isinstance(milestone_value.get("title"), str)
|
||||
else None
|
||||
)
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or normalized != selected
|
||||
):
|
||||
raise ValueError("Gitea did not confirm the issue milestone update")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"milestone": normalized,
|
||||
}
|
||||
|
||||
|
||||
async def issue_conversation_page(
|
||||
repository: str,
|
||||
number: int,
|
||||
page: int | None = None,
|
||||
limit: int = 20,
|
||||
) -> dict:
|
||||
"""Return one bounded comment page, opening on the newest page by default."""
|
||||
bounded_limit = min(50, max(1, limit))
|
||||
requested_page = max(1, page or 1)
|
||||
path = f"/api/v1/repos/{repository}/issues/{number}/comments"
|
||||
|
||||
async def load(selected_page: int) -> tuple[list, int]:
|
||||
response = await _get_client().get(
|
||||
path,
|
||||
headers=_auth(),
|
||||
params={"limit": bounded_limit, "page": selected_page},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea issue comments response was not a list")
|
||||
comments = [item for item in payload if isinstance(item, dict)]
|
||||
try:
|
||||
total = max(len(comments), int(response.headers.get("X-Total-Count", len(comments))))
|
||||
except (TypeError, ValueError):
|
||||
total = len(comments)
|
||||
return comments, total
|
||||
|
||||
comments, total = await load(requested_page)
|
||||
newest_page = max(1, (total + bounded_limit - 1) // bounded_limit)
|
||||
if len(comments) >= total:
|
||||
selected_page = newest_page if page is None else min(requested_page, newest_page)
|
||||
start = (selected_page - 1) * bounded_limit
|
||||
comments = comments[start:start + bounded_limit]
|
||||
else:
|
||||
selected_page = requested_page
|
||||
if page is None and newest_page != requested_page:
|
||||
selected_page = newest_page
|
||||
comments, confirmed_total = await load(selected_page)
|
||||
total = max(total, confirmed_total)
|
||||
return {
|
||||
"comments": [_normalize_issue_comment(item) for item in comments],
|
||||
"page": selected_page,
|
||||
"older_page": selected_page - 1 if selected_page > 1 else None,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
async def issue_detail(repository: str, number: int) -> dict:
|
||||
base = f"repos/{repository}/issues/{number}"
|
||||
issue, comments = await asyncio.gather(
|
||||
fetch(base), fetch(f"{base}/comments?limit=20&page=1")
|
||||
issue, conversation = await asyncio.gather(
|
||||
fetch(base), issue_conversation_page(repository, number)
|
||||
)
|
||||
if not isinstance(issue, dict):
|
||||
raise ValueError("Gitea issue response was not an object")
|
||||
@@ -739,18 +1089,22 @@ 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,
|
||||
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
|
||||
"state": issue.get("state", "") if isinstance(issue.get("state"), str) else "",
|
||||
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
|
||||
"updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "",
|
||||
"due_date": issue.get("due_date") if isinstance(issue.get("due_date"), str) else None,
|
||||
"milestone": (
|
||||
{"id": issue["milestone"]["id"], "title": issue["milestone"]["title"]}
|
||||
if isinstance(issue.get("milestone"), dict)
|
||||
and isinstance(issue["milestone"].get("id"), int)
|
||||
and isinstance(issue["milestone"].get("title"), str)
|
||||
else None
|
||||
),
|
||||
"url": _safe_web_url(issue.get("html_url")),
|
||||
"labels": [
|
||||
label["name"] for label in labels
|
||||
@@ -761,6 +1115,50 @@ async def issue_detail(repository: str, number: int) -> dict:
|
||||
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
|
||||
],
|
||||
"comments": normalized_comments,
|
||||
"conversation": conversation,
|
||||
}
|
||||
|
||||
|
||||
async def update_assigned_issue(
|
||||
repository: str,
|
||||
number: int,
|
||||
title: str,
|
||||
body: str,
|
||||
expected_updated_at: str,
|
||||
) -> dict:
|
||||
path = f"repos/{repository}/issues/{number}"
|
||||
login, issue = await _current_login_and_target(path)
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or isinstance(issue.get("pull_request"), dict)
|
||||
or not _login_in_users(login, issue.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
if issue.get("updated_at") != expected_updated_at:
|
||||
raise IssueEditConflictError("issue changed upstream")
|
||||
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/{path}",
|
||||
headers=_auth(),
|
||||
json={"title": title, "body": body},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or confirmed.get("title") != title
|
||||
or confirmed.get("body", "") != body
|
||||
):
|
||||
raise ValueError("Gitea did not confirm the issue content update")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"updated_at": confirmed.get("updated_at", ""),
|
||||
"url": _safe_web_url(confirmed.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
@@ -778,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}"
|
||||
@@ -840,13 +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 = 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"),
|
||||
)
|
||||
user = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
return {
|
||||
"repository": repository,
|
||||
@@ -857,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,
|
||||
@@ -867,15 +1339,19 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
|
||||
"status": item.get("status") or "changed",
|
||||
"additions": item.get("additions") or 0,
|
||||
"deletions": item.get("deletions") or 0,
|
||||
**previews.get(
|
||||
item["filename"],
|
||||
{
|
||||
"diff_lines": [],
|
||||
"diff_available": False,
|
||||
"diff_binary": False,
|
||||
"diff_truncated": diff_truncated,
|
||||
},
|
||||
),
|
||||
}
|
||||
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)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Reservation:
|
||||
state: str
|
||||
response: Any = None
|
||||
|
||||
|
||||
class IdempotencyLedger:
|
||||
"""Small durable ledger for replaying successful Gitea mutations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
ttl_seconds: float,
|
||||
max_entries: int,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.max_entries = max_entries
|
||||
self.clock = clock
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=5)
|
||||
connection.execute("PRAGMA busy_timeout = 5000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS idempotency_operations (
|
||||
key TEXT PRIMARY KEY,
|
||||
fingerprint TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending', 'completed')),
|
||||
response_json TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
completed_at REAL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idempotency_completed_at_idx "
|
||||
"ON idempotency_operations(completed_at)"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _fingerprint(value: tuple[Any, ...]) -> str:
|
||||
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
def reserve(self, key: str, fingerprint: tuple[Any, ...]) -> Reservation:
|
||||
encoded = self._fingerprint(fingerprint)
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"DELETE FROM idempotency_operations "
|
||||
"WHERE status = 'completed' AND completed_at <= ?",
|
||||
(now - self.ttl_seconds,),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT fingerprint, status, response_json FROM idempotency_operations "
|
||||
"WHERE key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if row[0] != encoded:
|
||||
return Reservation("conflict")
|
||||
if row[1] == "completed":
|
||||
return Reservation("completed", json.loads(row[2]))
|
||||
return Reservation("pending")
|
||||
count = connection.execute(
|
||||
"SELECT COUNT(*) FROM idempotency_operations"
|
||||
).fetchone()[0]
|
||||
if count >= self.max_entries:
|
||||
completed = connection.execute(
|
||||
"SELECT key FROM idempotency_operations "
|
||||
"WHERE status = 'completed' ORDER BY completed_at, rowid LIMIT 1"
|
||||
).fetchone()
|
||||
if completed is None:
|
||||
return Reservation("busy")
|
||||
connection.execute(
|
||||
"DELETE FROM idempotency_operations WHERE key = ?",
|
||||
(completed[0],),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency_operations "
|
||||
"(key, fingerprint, status, created_at) VALUES (?, ?, 'pending', ?)",
|
||||
(key, encoded, now),
|
||||
)
|
||||
return Reservation("reserved")
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM idempotency_operations")
|
||||
|
||||
def complete(self, key: str, response: Any) -> None:
|
||||
encoded = json.dumps(response, separators=(",", ":"), sort_keys=True)
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET status = 'completed', "
|
||||
"response_json = ?, completed_at = ? WHERE key = ?",
|
||||
(encoded, self.clock(), key),
|
||||
)
|
||||
+559
-63
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Awaitable
|
||||
from collections.abc import Awaitable, Coroutine
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -24,7 +26,8 @@ from src.gitea_proxy import (
|
||||
pull_review_detail,
|
||||
repos,
|
||||
)
|
||||
from src.models import Issue, PullRequest, Repo, User
|
||||
from src.idempotency import IdempotencyLedger
|
||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||
from src.suggestion_engine import compute
|
||||
from src.views import router as frontend_router
|
||||
|
||||
@@ -61,9 +64,12 @@ REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
|
||||
ISSUE_ACTION_TIMEOUT_SECONDS = 5.0
|
||||
ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS = 600.0
|
||||
ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES = 256
|
||||
AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS = 600.0
|
||||
AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES = 256
|
||||
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
|
||||
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
|
||||
WORK_PAGE_TIMEOUT_SECONDS = 5.0
|
||||
GLOBAL_SEARCH_TIMEOUT_SECONDS = 3.0
|
||||
NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0
|
||||
BULK_NOTIFICATION_CONCURRENCY = 5
|
||||
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
|
||||
@@ -71,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
|
||||
@@ -87,12 +94,22 @@ _live_section_retry_at: dict[str, float | None] = {
|
||||
}
|
||||
_live_snapshot_refreshing_sections: set[str] = set()
|
||||
_read_notification_ids: set[int] = set()
|
||||
_issue_creation_operations: dict[
|
||||
_authored_action_operations: dict[
|
||||
str, tuple[tuple[Any, ...], asyncio.Task, float]
|
||||
] = {}
|
||||
_state_dir = Path(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"))
|
||||
_idempotency_ledger = IdempotencyLedger(
|
||||
os.getenv("STACKCHAIN_IDEMPOTENCY_DB", str(_state_dir / "idempotency.sqlite3")),
|
||||
ttl_seconds=AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS,
|
||||
max_entries=(
|
||||
AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES
|
||||
+ ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES
|
||||
),
|
||||
)
|
||||
_available_issue_snapshot_task: asyncio.Task | None = None
|
||||
_available_issue_snapshot_value: list[dict] | None = None
|
||||
_available_issue_snapshot_created_at: float | None = None
|
||||
_available_issue_snapshot_retry_at: float | None = None
|
||||
|
||||
|
||||
class ContextPayloadError(ValueError):
|
||||
@@ -135,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
|
||||
@@ -149,11 +172,49 @@ 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)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
expected_updated_at: str = Field(min_length=1, max_length=64)
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def strip_title(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("title must not be blank")
|
||||
return value
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def strip_body(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
class IssueLabelUpdate(BaseModel):
|
||||
label_ids: list[PositiveInt] = Field(max_length=20)
|
||||
|
||||
|
||||
class IssueDueDateUpdate(BaseModel):
|
||||
due_date: str | None = Field(
|
||||
default=None,
|
||||
pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$",
|
||||
max_length=20,
|
||||
)
|
||||
|
||||
|
||||
class IssueMilestoneUpdate(BaseModel):
|
||||
milestone_id: PositiveInt | None = None
|
||||
|
||||
|
||||
class PullReviewComment(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
@@ -193,6 +254,65 @@ class PullMergeSubmission(BaseModel):
|
||||
expected_head_sha: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
async def _run_idempotent_authored_action(
|
||||
operation: Coroutine[Any, Any, Any],
|
||||
*,
|
||||
idempotency_key: str | None,
|
||||
fingerprint: tuple[Any, ...],
|
||||
timeout: float,
|
||||
):
|
||||
if not idempotency_key:
|
||||
return await asyncio.wait_for(operation, timeout=timeout)
|
||||
|
||||
reservation = _idempotency_ledger.reserve(idempotency_key, fingerprint)
|
||||
if reservation.state == "conflict":
|
||||
operation.close()
|
||||
raise HTTPException(status_code=409, detail="Idempotency key already used")
|
||||
if reservation.state == "completed":
|
||||
operation.close()
|
||||
return reservation.response
|
||||
if reservation.state == "busy":
|
||||
operation.close()
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Authored action queue is busy; please retry",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
existing = _authored_action_operations.get(idempotency_key)
|
||||
if reservation.state == "pending":
|
||||
operation.close()
|
||||
if existing is None or existing[0] != fingerprint:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"This action may still be completing; verify its result before retrying"
|
||||
),
|
||||
headers={"Retry-After": "5"},
|
||||
)
|
||||
task = existing[1]
|
||||
else:
|
||||
async def persist_result():
|
||||
result = await operation
|
||||
_idempotency_ledger.complete(idempotency_key, result)
|
||||
return result
|
||||
|
||||
task = asyncio.create_task(persist_result())
|
||||
_authored_action_operations[idempotency_key] = (
|
||||
fingerprint,
|
||||
task,
|
||||
time.monotonic(),
|
||||
)
|
||||
|
||||
def discard_completed(completed: asyncio.Task) -> None:
|
||||
existing_operation = _authored_action_operations.get(idempotency_key)
|
||||
if existing_operation is not None and existing_operation[1] is completed:
|
||||
_authored_action_operations.pop(idempotency_key, None)
|
||||
|
||||
task.add_done_callback(discard_completed)
|
||||
return await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
|
||||
|
||||
|
||||
def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||
user_model = User(
|
||||
id=user_data["id"],
|
||||
@@ -216,7 +336,12 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||
labels=[label.get("name", "") for label in (i.get("labels") or []) if isinstance(label, dict)],
|
||||
assignees=[assignee.get("login", "") for assignee in (i.get("assignees") or []) if isinstance(assignee, dict)],
|
||||
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
|
||||
updated_at=i.get("updated_at") or "", url=i["html_url"],
|
||||
updated_at=i.get("updated_at") or "",
|
||||
due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None,
|
||||
milestone=Milestone(**i["milestone"]) if isinstance(i.get("milestone"), dict)
|
||||
and isinstance(i["milestone"].get("id"), int)
|
||||
and isinstance(i["milestone"].get("title"), str) else None,
|
||||
url=i["html_url"],
|
||||
)
|
||||
for i in (issues_data or [])[:50]
|
||||
if isinstance(i, dict)
|
||||
@@ -254,7 +379,13 @@ def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
|
||||
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
|
||||
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)],
|
||||
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
|
||||
updated_at=item.get("updated_at") or "", url=item["html_url"],
|
||||
updated_at=item.get("updated_at") or "",
|
||||
due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None,
|
||||
milestone=Milestone(**item["milestone"])
|
||||
if isinstance(item.get("milestone"), dict)
|
||||
and isinstance(item["milestone"].get("id"), int)
|
||||
and isinstance(item["milestone"].get("title"), str) else None,
|
||||
url=item["html_url"],
|
||||
).model_dump()
|
||||
for item in items
|
||||
if all(field in item for field in ("id", "number", "title", "state", "html_url"))
|
||||
@@ -282,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"} 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 (
|
||||
@@ -293,7 +424,7 @@ async def prevent_live_api_caching(request, call_next):
|
||||
and request.url.path.endswith("/issues")
|
||||
) or (
|
||||
request.url.path.startswith("/api/v1/repos/")
|
||||
and request.url.path.endswith("/labels")
|
||||
and request.url.path.endswith(("/labels", "/milestones"))
|
||||
):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
@@ -389,6 +520,76 @@ async def context() -> JSONResponse:
|
||||
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
|
||||
|
||||
|
||||
@app.get("/api/v1/search")
|
||||
async def global_search(
|
||||
q: str = Query(min_length=2, max_length=100),
|
||||
limit: int = Query(default=10, ge=1, le=25),
|
||||
) -> JSONResponse:
|
||||
query = q.strip()
|
||||
if len(query) < 2:
|
||||
raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters")
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.global_search(query, limit),
|
||||
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Search is temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse({"query": query, **result})
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview")
|
||||
async def global_search_preview(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
kind: Literal["issue", "pull"] = Query(),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
preview = await asyncio.wait_for(
|
||||
gitea_proxy.work_preview(f"{owner}/{repo}", kind, number),
|
||||
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "This work item is temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(preview)
|
||||
|
||||
|
||||
@app.get("/api/v1/work-route")
|
||||
async def resolve_work_route(
|
||||
kind: Literal["issue", "pull", "review", "update"] = Query(),
|
||||
repository: str | None = Query(default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"),
|
||||
number: int | None = Query(default=None, gt=0),
|
||||
notification_id: int | None = Query(default=None, gt=0),
|
||||
) -> JSONResponse:
|
||||
repository_route = repository is not None and number is not None and notification_id is None
|
||||
update_route = kind == "update" and notification_id is not None and repository is None and number is None
|
||||
if (kind == "update" and not update_route) or (kind != "update" and not repository_route):
|
||||
raise HTTPException(status_code=422, detail="Invalid work route identity")
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.resolve_work_route(kind, repository, number, notification_id),
|
||||
timeout=WORK_PAGE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.WorkRouteUnavailableError:
|
||||
raise HTTPException(status_code=404, detail="Work item is no longer in My Work")
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The shared work item is temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/work/{stream}")
|
||||
async def paged_work(
|
||||
stream: Literal["issue", "pull", "review"],
|
||||
@@ -415,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 (
|
||||
@@ -430,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
|
||||
|
||||
|
||||
@@ -457,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:
|
||||
@@ -469,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)
|
||||
|
||||
|
||||
@@ -884,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:
|
||||
@@ -908,13 +1159,19 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
|
||||
@app.post("/api/v1/notifications/{thread_id}/reply", status_code=201)
|
||||
async def reply_to_notification(
|
||||
reply: NotificationReply, thread_id: int = PathParam(gt=0)
|
||||
reply: NotificationReply,
|
||||
thread_id: int = PathParam(gt=0),
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
result = await _run_idempotent_authored_action(
|
||||
gitea_proxy.reply_to_notification(thread_id, reply.body),
|
||||
idempotency_key=idempotency_key,
|
||||
fingerprint=("notification-reply", thread_id, reply.body),
|
||||
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -1013,6 +1270,132 @@ async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(g
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content")
|
||||
async def update_assigned_issue_content(
|
||||
update: IssueContentUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.update_assigned_issue(
|
||||
repository,
|
||||
number,
|
||||
update.title,
|
||||
update.body,
|
||||
update.expected_updated_at,
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueEditConflictError:
|
||||
return JSONResponse(
|
||||
{"error": "This issue changed in Gitea. Your draft is safe; reload the latest issue before saving."},
|
||||
status_code=409,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The issue could not be updated. Your draft is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/due-date")
|
||||
async def update_assigned_issue_due_date(
|
||||
update: IssueDueDateUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.update_assigned_issue_due_date(
|
||||
repository, number, update.due_date
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The due date could not be updated. Your selection is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/milestone")
|
||||
async def update_assigned_issue_milestone(
|
||||
update: IssueMilestoneUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.update_assigned_issue_milestone(
|
||||
repository, number, update.milestone_id
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
except ValueError as exc:
|
||||
if str(exc) == "Unknown open repository milestone":
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
return JSONResponse(
|
||||
{"error": "The milestone could not be updated. Your selection is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The milestone could not be updated. Your selection is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/milestones")
|
||||
async def repository_milestones(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_milestones():
|
||||
available = await gitea_proxy.repos()
|
||||
accessible = {
|
||||
item.get("full_name")
|
||||
for item in (available if isinstance(available, list) else [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if repository not in accessible:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
return await gitea_proxy.repo_milestones(repository)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
load_milestones(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Milestones could not be loaded. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/labels")
|
||||
async def repository_labels(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
@@ -1070,52 +1453,46 @@ 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
|
||||
)
|
||||
|
||||
operation = create_issue()
|
||||
if idempotency_key:
|
||||
now = time.monotonic()
|
||||
expired = [
|
||||
key for key, (_, task, created_at) in _issue_creation_operations.items()
|
||||
if task.done()
|
||||
and now - created_at >= ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS
|
||||
]
|
||||
for key in expired:
|
||||
_issue_creation_operations.pop(key, None)
|
||||
fingerprint = (
|
||||
repository, creation.title, creation.body, tuple(creation.label_ids)
|
||||
)
|
||||
existing = _issue_creation_operations.get(idempotency_key)
|
||||
if existing is not None:
|
||||
operation.close()
|
||||
if existing[0] != fingerprint:
|
||||
raise HTTPException(status_code=409, detail="Idempotency key already used")
|
||||
task = existing[1]
|
||||
else:
|
||||
while len(_issue_creation_operations) >= ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES:
|
||||
completed = [
|
||||
key for key, (_, task, _) in _issue_creation_operations.items()
|
||||
if task.done()
|
||||
]
|
||||
if not completed:
|
||||
operation.close()
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Issue creation is busy; please retry",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
oldest = min(
|
||||
completed, key=lambda key: _issue_creation_operations[key][2]
|
||||
)
|
||||
_issue_creation_operations.pop(oldest)
|
||||
task = asyncio.create_task(operation)
|
||||
_issue_creation_operations[idempotency_key] = (fingerprint, task, now)
|
||||
operation = asyncio.shield(task)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(operation, timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
||||
result = await _run_idempotent_authored_action(
|
||||
create_issue(),
|
||||
idempotency_key=idempotency_key,
|
||||
fingerprint=(
|
||||
"issue-create",
|
||||
repository,
|
||||
creation.title,
|
||||
creation.body,
|
||||
tuple(creation.label_ids),
|
||||
creation.milestone_id,
|
||||
creation.due_date,
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
@@ -1127,9 +1504,42 @@ 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, owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
comment: IssueComment,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
@@ -1139,7 +1549,12 @@ async def comment_on_assigned_issue(
|
||||
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(post_comment(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
||||
result = await _run_idempotent_authored_action(
|
||||
post_comment(),
|
||||
idempotency_key=idempotency_key,
|
||||
fingerprint=("issue-comment", repository, number, comment.body),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
@@ -1290,9 +1705,73 @@ 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, owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
comment: IssueComment,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
@@ -1302,8 +1781,11 @@ async def comment_on_assigned_pull(
|
||||
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
post_comment(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
result = await _run_idempotent_authored_action(
|
||||
post_comment(),
|
||||
idempotency_key=idempotency_key,
|
||||
fingerprint=("pull-comment", repository, number, comment.body),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -1358,7 +1840,11 @@ async def merge_assigned_pull(
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201)
|
||||
async def submit_review(
|
||||
submission: PullReviewSubmission, owner: str, repo: str, number: int
|
||||
submission: PullReviewSubmission,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
@@ -1380,8 +1866,18 @@ async def submit_review(
|
||||
return await gitea_proxy.submit_pull_review(*args)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
submit_requested_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
|
||||
comment_fingerprint = tuple(
|
||||
(comment.path, comment.body, comment.new_position, comment.old_position)
|
||||
for comment in submission.comments
|
||||
)
|
||||
result = await _run_idempotent_authored_action(
|
||||
submit_requested_review(),
|
||||
idempotency_key=idempotency_key,
|
||||
fingerprint=(
|
||||
"pull-review", repository, number, submission.expected_head_sha,
|
||||
submission.decision, submission.body, comment_fingerprint,
|
||||
),
|
||||
timeout=REVIEW_DETAIL_TIMEOUT_SECONDS,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -17,6 +17,11 @@ class Repo(BaseModel):
|
||||
updated_at: str = ""
|
||||
|
||||
|
||||
class Milestone(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
|
||||
|
||||
class Issue(BaseModel):
|
||||
id: int
|
||||
number: int
|
||||
@@ -26,6 +31,8 @@ class Issue(BaseModel):
|
||||
assignees: list[str] = []
|
||||
repository: str = ""
|
||||
updated_at: str = ""
|
||||
due_date: str | None = None
|
||||
milestone: Milestone | None = None
|
||||
url: str
|
||||
|
||||
|
||||
|
||||
+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"},
|
||||
)
|
||||
@@ -19,5 +19,6 @@ def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_authored_operations():
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
yield
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_comment_replays_one_upstream_result_for_concurrent_requests(monkeypatch):
|
||||
calls = 0
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, body):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
started.set()
|
||||
await release.wait()
|
||||
return {"id": 82, "body": body}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "issue-comment-185"}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = asyncio.create_task(client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Ship it"}, headers=headers,
|
||||
))
|
||||
await started.wait()
|
||||
second = asyncio.create_task(client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Ship it"}, headers=headers,
|
||||
))
|
||||
await asyncio.sleep(0)
|
||||
release.set()
|
||||
responses = await asyncio.gather(first, second)
|
||||
|
||||
assert [response.status_code for response in responses] == [201, 201]
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_comment_rejects_changed_payload_for_same_key(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, body):
|
||||
calls.append(body)
|
||||
return {"id": len(calls), "body": body}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "pull-comment-185"}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
created = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/comments",
|
||||
json={"body": "Ship it"}, headers=headers,
|
||||
)
|
||||
conflict = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/comments",
|
||||
json={"body": "Changed"}, headers=headers,
|
||||
)
|
||||
|
||||
assert created.status_code == 201
|
||||
assert conflict.status_code == 409
|
||||
assert calls == ["Ship it"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_reply_retry_recovers_after_caller_timeout(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def reply(_thread_id, body):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
await asyncio.sleep(0.03)
|
||||
return {"id": 91, "body": body}
|
||||
|
||||
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "notification-reply-185"}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
timed_out = await client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": "Retry"}, headers=headers,
|
||||
)
|
||||
await asyncio.sleep(0.03)
|
||||
recovered = await client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": "Retry"}, headers=headers,
|
||||
)
|
||||
|
||||
assert timed_out.status_code == 503
|
||||
assert recovered.status_code == 201
|
||||
assert recovered.json()["id"] == 91
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_replays_same_key_and_rejects_changed_head(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def requested(_repository, _number):
|
||||
return True
|
||||
|
||||
async def submit(_repository, _number, head, decision, body):
|
||||
calls.append((head, decision, body))
|
||||
return {"id": 93, "state": "APPROVED"}
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "review-185"}
|
||||
payload = {"expected_head_sha": "abc", "decision": "approve", "body": "Good"}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
created = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review", json=payload, headers=headers,
|
||||
)
|
||||
replayed = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review", json=payload, headers=headers,
|
||||
)
|
||||
conflict = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={**payload, "expected_head_sha": "def"}, headers=headers,
|
||||
)
|
||||
|
||||
assert [created.status_code, replayed.status_code, conflict.status_code] == [201, 201, 409]
|
||||
assert calls == [("abc", "approve", "Good")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_completed_comment_replays_after_ledger_reconstruction(monkeypatch, tmp_path):
|
||||
calls = 0
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, body):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": 201, "body": body}
|
||||
|
||||
database = tmp_path / "actions.sqlite3"
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_idempotency_ledger",
|
||||
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "restart-comment-201"}
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
created = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Persist me"},
|
||||
headers=headers,
|
||||
)
|
||||
main._authored_action_operations.clear()
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_idempotency_ledger",
|
||||
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
|
||||
)
|
||||
replayed = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Persist me"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert [created.status_code, replayed.status_code] == [201, 201]
|
||||
assert replayed.json() == {"id": 201, "body": "Persist me"}
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orphaned_pending_comment_fails_closed_without_upstream_retry(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
calls = 0
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, body):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": 202, "body": body}
|
||||
|
||||
ledger = IdempotencyLedger(
|
||||
tmp_path / "orphan.sqlite3", ttl_seconds=600, max_entries=256
|
||||
)
|
||||
fingerprint = ("issue-comment", "stackchain/api", 7, "Verify first")
|
||||
assert ledger.reserve("orphan-comment-201", fingerprint).state == "reserved"
|
||||
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Verify first"},
|
||||
headers={"Idempotency-Key": "orphan-comment-201"},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == (
|
||||
"This action may still be completing; verify its result before retrying"
|
||||
)
|
||||
assert calls == 0
|
||||
@@ -7,6 +7,7 @@ from urllib.parse import urljoin
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
COMMANDS = FRONTEND / "commands.js"
|
||||
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
@@ -46,3 +47,194 @@ def test_command_script_resolves_inside_dashboard_subpath():
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/", command_source
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
|
||||
|
||||
|
||||
def test_remote_command_search_ignores_stale_responses():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const pending = new Map();
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: query => new Promise(resolve => pending.set(query, resolve)),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setQuery('release');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get('release')([{{ title: 'Current result' }}]);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get('mobile')([{{ title: 'Stale result' }}]);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.filter(state => state.status === 'ready');
|
||||
if (ready.length !== 1) throw new Error(`expected one ready state, got ${{ready.length}}`);
|
||||
if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result') {{
|
||||
throw new Error(`stale response replaced current state: ${{JSON.stringify(ready)}}`);
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(
|
||||
["node", "-e", script],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_remote_command_search_aborts_superseded_and_cleared_queries():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const signals = [];
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal) => {{
|
||||
signals.push(signal);
|
||||
return new Promise((resolve, reject) => signal.addEventListener('abort', () => {{
|
||||
const error = new Error('aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
}}));
|
||||
}},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setQuery('release');
|
||||
if (!signals[0].aborted) throw new Error('superseded request was not aborted');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setQuery('');
|
||||
if (!signals[1].aborted) throw new Error('cleared query request was not aborted');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
if (states.some(state => state.status === 'error')) throw new Error('abort rendered an error');
|
||||
if (states.at(-1).status !== 'idle') throw new Error('clear did not restore idle state');
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_preserves_partial_result_status():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: () => Promise.resolve({{ items:[{{ title:'Useful result' }}], partial:true }}),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.find(state => state.status === 'ready');
|
||||
if (!ready || ready.items.length !== 1 || ready.partial !== true) {{
|
||||
throw new Error('partial result metadata was lost: ' + JSON.stringify(states));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_command_selection_wraps_for_arrow_keys():
|
||||
script = f"""
|
||||
const commands = require({json.dumps(str(COMMANDS))});
|
||||
if (commands.nextSelection(-1, 'ArrowDown', 3) !== 0) throw new Error('should select first');
|
||||
if (commands.nextSelection(2, 'ArrowDown', 3) !== 0) throw new Error('should wrap down');
|
||||
if (commands.nextSelection(0, 'ArrowUp', 3) !== 2) throw new Error('should wrap up');
|
||||
if (commands.nextSelection(1, 'Enter', 3) !== 1) throw new Error('other keys should not move');
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
|
||||
assert 'role="combobox"' in html
|
||||
assert 'aria-controls="cmd-results"' in html
|
||||
assert 'role="listbox"' in html
|
||||
assert "fetch('api/v1/search?q='" in html
|
||||
assert "async function searchGlobalWork(query, signal)" in html
|
||||
assert "signal," in html
|
||||
assert "Some results are temporarily unavailable." in html
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/",
|
||||
"api/v1/search?q=mobile",
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile"
|
||||
|
||||
|
||||
def test_search_preview_ignores_stale_result_details():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const pending = new Map();
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => new Promise(resolve => pending.set(item.number, resolve)),
|
||||
claim: () => Promise.resolve(),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
preview.open({{ repository:'stackchain/api', number:1, kind:'issue' }});
|
||||
preview.open({{ repository:'stackchain/api', number:2, kind:'issue' }});
|
||||
pending.get(2)({{ number:2, title:'Current' }});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get(1)({{ number:1, title:'Stale' }});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.filter(state => state.status === 'ready');
|
||||
if (ready.length !== 1 || ready[0].detail.number !== 2) {{
|
||||
throw new Error('stale detail replaced current preview: ' + JSON.stringify(ready));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_claim_is_single_flight_and_reports_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let claims = 0;
|
||||
let resolveClaim;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item),
|
||||
claim: () => {{ claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
|
||||
const detail = {{ repository:'stackchain/api', number:42, claimable:true }};
|
||||
const first = preview.claim(detail);
|
||||
const second = preview.claim(detail);
|
||||
if (claims !== 1 || first !== second) throw new Error('claim was not single-flight');
|
||||
resolveClaim({{ assignees:['timmy'] }});
|
||||
await first;
|
||||
if (!states.some(state => state.status === 'claimed')) throw new Error('claim confirmation missing');
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
|
||||
run_item = html.split("function runCommandItem(item)", 1)[1].split(
|
||||
"function renderCommands", 1
|
||||
)[0]
|
||||
assert "searchPreview.open(item.result)" in run_item
|
||||
assert "window.location.assign" not in run_item
|
||||
remote_branch = run_item.split("} else {", 1)[1]
|
||||
assert "qs('#cmd-input').value = ''" not in remote_branch
|
||||
|
||||
|
||||
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
|
||||
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
|
||||
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
|
||||
assert "openPreviewIssueInMyWork" in html
|
||||
@@ -0,0 +1,117 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_draft_inbox_discovers_existing_formats_orders_activity_and_ignores_corruption():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-capture.v1', JSON.stringify({{repository:'stackchain/api',title:'Ship recovery',body:'Detailed plan',labelIds:[]}})],
|
||||
['stackchain.issue-comment.v1:stackchain/api#17', 'Please add a regression test'],
|
||||
['stackchain.pull-comment.v1:stackchain/web#9', 'Looks good on mobile'],
|
||||
['stackchain.update-reply.v1.44', 'I can take this'],
|
||||
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{'app.js':'Handle offline state'}},comments:[],summary:'Needs one fix',decision:'request_changes'}})],
|
||||
['stackchain.issue-content.v1:stackchain/api#18', '{{broken-json'],
|
||||
['stackchain.issue-comment.v1:stackchain/api#20:operation', 'not-a-draft'],
|
||||
]);
|
||||
const storage = {{
|
||||
get length() {{ return values.size; }},
|
||||
key: index => Array.from(values.keys())[index] || null,
|
||||
getItem: key => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
let clock = 1000;
|
||||
const inbox = createDraftInbox({{storage, now:() => ++clock}});
|
||||
const first = inbox.list();
|
||||
values.set('stackchain.issue-comment.v1:stackchain/api#17', 'Newest comment text');
|
||||
const second = inbox.list();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
kinds:first.map(item => item.kind).sort(),
|
||||
newest:second[0],
|
||||
count:second.length,
|
||||
}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["kinds"] == ["issue-comment", "new-issue", "pull-comment", "review", "update-reply"]
|
||||
assert output["count"] == 5
|
||||
assert output["newest"]["kind"] == "issue-comment"
|
||||
assert output["newest"]["repository"] == "stackchain/api"
|
||||
assert output["newest"]["number"] == 17
|
||||
assert output["newest"]["preview"] == "Newest comment text"
|
||||
assert output["newest"]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 17}
|
||||
|
||||
|
||||
def test_draft_discard_removes_only_selected_content_and_operation_identity():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-comment.v1:stackchain/api#17', 'One'],
|
||||
['stackchain.issue-comment.v1:stackchain/api#17:operation', 'operation-one'],
|
||||
['stackchain.pull-comment.v1:stackchain/web#9', 'Two'],
|
||||
]);
|
||||
const storage = {{
|
||||
get length() {{ return values.size; }}, key:i => Array.from(values.keys())[i] || null,
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key),
|
||||
}};
|
||||
const inbox = createDraftInbox({{storage, now:() => 1000}});
|
||||
const selected = inbox.list().find(item => item.kind === 'issue-comment');
|
||||
inbox.discard(selected.id);
|
||||
process.stdout.write(JSON.stringify({{remaining:inbox.list(), keys:Array.from(values.keys()).sort()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [item["kind"] for item in output["remaining"]] == ["pull-comment"]
|
||||
assert "stackchain.issue-comment.v1:stackchain/api#17" not in output["keys"]
|
||||
assert "stackchain.issue-comment.v1:stackchain/api#17:operation" not in output["keys"]
|
||||
assert "stackchain.pull-comment.v1:stackchain/web#9" in output["keys"]
|
||||
|
||||
|
||||
def test_draft_inbox_recovers_issue_edit_and_review_content_without_exposing_empty_records():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-content.v1:stackchain/api#18', JSON.stringify({{title:'Clarify acceptance',body:'Describe mobile flow',expectedUpdatedAt:'2026-08-07T10:00:00Z'}})],
|
||||
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{}},comments:[],summary:'',decision:'approve'}})],
|
||||
['stackchain.pull-comment.v1:stackchain/web#10', ' '],
|
||||
]);
|
||||
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const drafts = createDraftInbox({{storage,now:()=>42}}).list();
|
||||
process.stdout.write(JSON.stringify(drafts));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert len(output) == 1
|
||||
assert output[0]["kind"] == "issue-edit"
|
||||
assert output[0]["preview"] == "Clarify acceptance — Describe mobile flow"
|
||||
assert output[0]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 18}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/drafts.js"></script>' in html
|
||||
assert 'data-work-filter="draft"' in html
|
||||
assert 'data-work-count="draft"' in html
|
||||
assert 'class="draft-resume"' in html
|
||||
assert 'class="draft-discard"' in html
|
||||
assert 'Discard this unfinished draft?' in html
|
||||
assert '.draft-actions button { min-height:44px;' in html
|
||||
assert 'createDraftInbox({ storage: localStorage' in html
|
||||
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -421,6 +530,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"repository": {"full_name": "stackchain/mobile"},
|
||||
"updated_at": "2026-08-06T12:00:00Z",
|
||||
"due_date": "2026-08-09T23:59:59Z",
|
||||
"html_url": "https://forge.example/stackchain/mobile/issues/7",
|
||||
}]
|
||||
|
||||
@@ -448,6 +558,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
|
||||
|
||||
assert payload["issues"][0]["repository"] == "stackchain/mobile"
|
||||
assert payload["issues"][0]["updated_at"] == "2026-08-06T12:00:00Z"
|
||||
assert payload["issues"][0]["due_date"] == "2026-08-09T23:59:59Z"
|
||||
assert payload["pull_requests"][0]["repository"] == "stackchain/api"
|
||||
assert payload["pull_requests"][0]["updated_at"] == "2026-08-06T11:00:00Z"
|
||||
assert payload["pull_requests"][0]["labels"] == ["priority-high"]
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def search(query, limit):
|
||||
requested.append((query, limit))
|
||||
return {
|
||||
"items": [{
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Repair mobile queue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}],
|
||||
"partial": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/search?q=mobile&limit=7")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert requested == [("mobile", 7)]
|
||||
assert response.json() == {"query": "mobile", "items": [{
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Repair mobile queue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}], "partial": False}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_returns_normalized_action_context(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
requested.append((repository, kind, number))
|
||||
return {
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Repair mobile queue",
|
||||
"body": "Restore the queue before release.",
|
||||
"state": "open",
|
||||
"author": "alex",
|
||||
"labels": ["P1"],
|
||||
"assignees": [],
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"claimable": True,
|
||||
"assigned_to_me": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview?kind=issue"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert requested == [("stackchain/api", "issue", 42)]
|
||||
assert response.json()["claimable"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(dict(request.url.params))
|
||||
kind = request.url.params["type"]
|
||||
if kind == "issues":
|
||||
return httpx.Response(200, json=[{
|
||||
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
}, {
|
||||
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
}, {
|
||||
"id": 5, "number": 43, "title": "Unsafe", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "javascript:alert(1)",
|
||||
}])
|
||||
return httpx.Response(200, json=[{
|
||||
"id": 8, "number": 9, "title": "Improve search", "state": "closed",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
results = await gitea_proxy.global_search("mobile queue", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert {request["type"] for request in requests} == {"issues", "pulls"}
|
||||
assert all(request["q"] == "mobile queue" and request["limit"] == "7" for request in requests)
|
||||
assert results == {
|
||||
"items": [{
|
||||
"kind": "issue", "repository": "stackchain/api", "number": 42,
|
||||
"title": "Repair queue", "state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}, {
|
||||
"kind": "pull", "repository": "stackchain/web", "number": 9,
|
||||
"title": "Improve search", "state": "closed",
|
||||
"url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}],
|
||||
"partial": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_returns_healthy_stream_when_other_stream_fails():
|
||||
async def handler(request):
|
||||
if request.url.params["type"] == "issues":
|
||||
return httpx.Response(503, json={"message": "upstream details must stay private"})
|
||||
return httpx.Response(200, json=[{
|
||||
"number": 9,
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.global_search("search", 10)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"items": [{
|
||||
"kind": "pull",
|
||||
"repository": "stackchain/web",
|
||||
"number": 9,
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}],
|
||||
"partial": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_caps_combined_results_to_requested_limit():
|
||||
async def handler(request):
|
||||
kind = request.url.params["type"]
|
||||
items = [{
|
||||
"number": number,
|
||||
"title": f"{kind} {number}",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": f"https://forge.example/stackchain/web/{kind}/{number}",
|
||||
} for number in (1, 2)]
|
||||
return httpx.Response(200, json=items)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.global_search("search", 2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(result["items"]) == 2
|
||||
assert result["partial"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_propagates_cancellation_to_stop_obsolete_work():
|
||||
async def handler(request):
|
||||
if request.url.params["type"] == "issues":
|
||||
raise asyncio.CancelledError()
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await gitea_proxy.global_search("obsolete", 10)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_issues():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 42,
|
||||
"title": "Repair queue",
|
||||
"body": "Keep mobile operators moving.",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
"user": {"login": "alex"},
|
||||
"labels": [{"name": "P1"}, None],
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
preview = await gitea_proxy.work_preview("stackchain/api", "issue", 42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert preview == {
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Repair queue",
|
||||
"body": "Keep mobile operators moving.",
|
||||
"state": "open",
|
||||
"author": "alex",
|
||||
"labels": ["P1"],
|
||||
"assignees": [],
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"claimable": True,
|
||||
"assigned_to_me": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 9,
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
"pull_request": {"merged": False},
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
preview = await gitea_proxy.work_preview("stackchain/web", "issue", 9)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert preview["kind"] == "pull"
|
||||
assert preview["claimable"] is False
|
||||
@@ -0,0 +1,73 @@
|
||||
import sqlite3
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
def test_completed_result_replays_after_ledger_reconstruction(tmp_path):
|
||||
database = tmp_path / "idempotency.sqlite3"
|
||||
first = IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
|
||||
|
||||
reservation = first.reserve("create-201", ("issue-create", "stackchain/api", "Ship it"))
|
||||
assert reservation.state == "reserved"
|
||||
first.complete("create-201", {"number": 42, "title": "Ship it"})
|
||||
|
||||
reconstructed = IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
|
||||
replay = reconstructed.reserve(
|
||||
"create-201", ("issue-create", "stackchain/api", "Ship it")
|
||||
)
|
||||
|
||||
assert replay.state == "completed"
|
||||
assert replay.response == {"number": 42, "title": "Ship it"}
|
||||
assert sqlite3.connect(database).execute(
|
||||
"SELECT status FROM idempotency_operations WHERE key = ?", ("create-201",)
|
||||
).fetchone() == ("completed",)
|
||||
|
||||
|
||||
def test_reservation_expires_completed_rows_and_enforces_capacity(tmp_path):
|
||||
now = [1_000.0]
|
||||
ledger = IdempotencyLedger(
|
||||
tmp_path / "bounded.sqlite3",
|
||||
ttl_seconds=10,
|
||||
max_entries=2,
|
||||
clock=lambda: now[0],
|
||||
)
|
||||
assert ledger.reserve("old", ("comment", "old")).state == "reserved"
|
||||
ledger.complete("old", {"id": 1})
|
||||
now[0] += 11
|
||||
|
||||
assert ledger.reserve("new-1", ("comment", "new-1")).state == "reserved"
|
||||
ledger.complete("new-1", {"id": 2})
|
||||
assert ledger.reserve("new-2", ("comment", "new-2")).state == "reserved"
|
||||
ledger.complete("new-2", {"id": 3})
|
||||
replacement = ledger.reserve("new-3", ("comment", "new-3"))
|
||||
|
||||
assert replacement.state == "reserved"
|
||||
with sqlite3.connect(ledger.path) as connection:
|
||||
keys = connection.execute(
|
||||
"SELECT key FROM idempotency_operations ORDER BY key"
|
||||
).fetchall()
|
||||
indexes = connection.execute(
|
||||
"PRAGMA index_list(idempotency_operations)"
|
||||
).fetchall()
|
||||
assert keys == [("new-2",), ("new-3",)]
|
||||
assert any(index[1] == "idempotency_completed_at_idx" for index in indexes)
|
||||
|
||||
|
||||
def test_independent_connections_atomically_reserve_one_operation(tmp_path):
|
||||
database = tmp_path / "workers.sqlite3"
|
||||
ledgers = [
|
||||
IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
|
||||
for _ in range(2)
|
||||
]
|
||||
barrier = Barrier(2)
|
||||
|
||||
def reserve(ledger):
|
||||
barrier.wait()
|
||||
return ledger.reserve("worker-key-201", ("review", "stackchain/api", 7)).state
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
states = list(executor.map(reserve, ledgers))
|
||||
|
||||
assert sorted(states) == ["pending", "reserved"]
|
||||
+578
-6
@@ -1,16 +1,322 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_milestone_update_revalidates_assignment_and_open_repository_milestone():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "GET" and request.url.path.endswith("/issues/17"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "pull_request": None,
|
||||
"assignees": [{"login": "timmy"}],
|
||||
})
|
||||
if request.method == "GET" and request.url.path.endswith("/milestones"):
|
||||
return httpx.Response(200, json=[
|
||||
{"id": 9, "title": "August RC", "state": "open"},
|
||||
{"id": 10, "title": "Old release", "state": "closed"},
|
||||
{"id": "bad", "title": "Invalid", "state": "open"},
|
||||
])
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open",
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
options = await gitea_proxy.repo_milestones("stackchain/api")
|
||||
result = await gitea_proxy.update_assigned_issue_milestone(
|
||||
"stackchain/api", 17, 9
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert options == [{"id": 9, "title": "August RC"}]
|
||||
patch_request = next(request for request in requests if request.method == "PATCH")
|
||||
assert patch_request.url.path == "/api/v1/repos/stackchain/api/issues/17"
|
||||
assert json.loads(patch_request.content) == {"milestone": 9}
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "state": "open",
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def available_repos():
|
||||
return [{"full_name": "stackchain/api"}]
|
||||
|
||||
async def milestones(repository):
|
||||
calls.append(("list", repository))
|
||||
return [{"id": 9, "title": "August RC"}]
|
||||
|
||||
async def update(repository, number, milestone_id):
|
||||
calls.append(("update", repository, number, milestone_id))
|
||||
return {
|
||||
"repository": repository, "number": number, "state": "open",
|
||||
"milestone": {"id": milestone_id, "title": "August RC"} if milestone_id else None,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue_milestone", update, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
listed = await client.get("/api/v1/repos/stackchain/api/milestones")
|
||||
updated = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/milestone", json={"milestone_id": 9}
|
||||
)
|
||||
missing = await client.get("/api/v1/repos/other/private/milestones")
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.headers["cache-control"] == "no-store"
|
||||
assert listed.json() == [{"id": 9, "title": "August RC"}]
|
||||
assert updated.status_code == 200
|
||||
assert updated.headers["cache-control"] == "no-store"
|
||||
assert updated.json()["milestone"] == {"id": 9, "title": "August RC"}
|
||||
assert missing.status_code == 404
|
||||
assert calls == [
|
||||
("list", "stackchain/api"),
|
||||
("update", "stackchain/api", 17, 9),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("due_date", "expected_payload", "confirmed_due_date"),
|
||||
[
|
||||
("2026-08-09T23:59:59Z", {"due_date": "2026-08-09T23:59:59Z"}, "2026-08-09T23:59:59Z"),
|
||||
(None, {"unset_due_date": True}, None),
|
||||
],
|
||||
)
|
||||
async def test_gitea_due_date_update_revalidates_assignment_and_confirms_result(
|
||||
due_date, expected_payload, confirmed_due_date
|
||||
):
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "pull_request": None,
|
||||
"assignees": [{"login": "timmy"}],
|
||||
})
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "due_date": confirmed_due_date,
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.update_assigned_issue_due_date(
|
||||
"stackchain/api", 17, due_date
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
]
|
||||
assert json.loads(requests[2].content) == expected_payload
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17,
|
||||
"state": "open", "due_date": confirmed_due_date,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_due_date_endpoint_sets_or_clears_assigned_issue(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def update(repository, number, due_date):
|
||||
calls.append((repository, number, due_date))
|
||||
return {
|
||||
"repository": repository, "number": number, "state": "open",
|
||||
"due_date": due_date,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "update_assigned_issue_due_date", update, raising=False
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
set_response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/due-date",
|
||||
json={"due_date": "2026-08-09T23:59:59Z"},
|
||||
)
|
||||
clear_response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/due-date",
|
||||
json={"due_date": None},
|
||||
)
|
||||
|
||||
assert [set_response.status_code, clear_response.status_code] == [200, 200]
|
||||
assert set_response.headers["cache-control"] == "no-store"
|
||||
assert set_response.json()["due_date"] == "2026-08-09T23:59:59Z"
|
||||
assert clear_response.json()["due_date"] is None
|
||||
assert calls == [
|
||||
("stackchain/api", 17, "2026-08-09T23:59:59Z"),
|
||||
("stackchain/api", 17, None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_edit_assigned_issue_updates_title_and_body_at_expected_revision(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def update(repository, number, title, body, expected_updated_at):
|
||||
calls.append((repository, number, title, body, expected_updated_at))
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-07T10:01:00Z",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "update_assigned_issue", update, raising=False
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/content",
|
||||
json={
|
||||
"title": " Clarified scope ",
|
||||
"body": " Updated acceptance criteria ",
|
||||
"expected_updated_at": "2026-08-07T10:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["title"] == "Clarified scope"
|
||||
assert calls == [(
|
||||
"stackchain/api", 17, "Clarified scope", "Updated acceptance criteria",
|
||||
"2026-08-07T10:00:00Z",
|
||||
)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_edit_issue_rejects_stale_revision_without_patch():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 17,
|
||||
"title": "Changed upstream",
|
||||
"body": "Newer body",
|
||||
"state": "open",
|
||||
"updated_at": "2026-08-07T10:02:00Z",
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"pull_request": None,
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.IssueEditConflictError):
|
||||
await gitea_proxy.update_assigned_issue(
|
||||
"stackchain/api", 17, "My draft", "Draft body",
|
||||
"2026-08-07T10:00:00Z",
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "title": "Old", "body": "Old body", "state": "open",
|
||||
"updated_at": "2026-08-07T10:00:00Z",
|
||||
"assignees": [{"login": "timmy"}], "pull_request": None,
|
||||
})
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "title": "Clarified", "body": "New body", "state": "open",
|
||||
"updated_at": "2026-08-07T10:01:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.update_assigned_issue(
|
||||
"stackchain/api", 17, "Clarified", "New body", "2026-08-07T10:00:00Z"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
]
|
||||
assert requests[2].content == b'{"title":"Clarified","body":"New body"}'
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "title": "Clarified",
|
||||
"body": "New body", "state": "open", "updated_at": "2026-08-07T10:01:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_edit_assigned_issue_reports_revision_conflict_without_mutation(monkeypatch):
|
||||
async def update(*_args):
|
||||
raise gitea_proxy.IssueEditConflictError("changed")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue", update)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/content",
|
||||
json={
|
||||
"title": "My draft",
|
||||
"body": "Still safe",
|
||||
"expected_updated_at": "2026-08-07T10:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": "This issue changed in Gitea. Your draft is safe; reload the latest issue before saving."
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_issue_creation_operations():
|
||||
main._issue_creation_operations.clear()
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
yield
|
||||
main._issue_creation_operations.clear()
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -67,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
|
||||
@@ -113,6 +468,61 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_completed_issue_creation_replays_after_ledger_reconstruction(monkeypatch, tmp_path):
|
||||
calls = 0
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def available_repos():
|
||||
return [{"full_name": "stackchain/api"}]
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {
|
||||
"id": 201, "number": 201, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-07T15:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/201",
|
||||
}
|
||||
|
||||
database = tmp_path / "issues.sqlite3"
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_idempotency_ledger",
|
||||
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "restart-create-201"}
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
created = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues",
|
||||
json={"title": "Durable capture"},
|
||||
headers=headers,
|
||||
)
|
||||
main._authored_action_operations.clear()
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_idempotency_ledger",
|
||||
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
|
||||
)
|
||||
replayed = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues",
|
||||
json={"title": "Durable capture"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert [created.status_code, replayed.status_code] == [201, 201]
|
||||
assert replayed.json()["number"] == 201
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_key(monkeypatch):
|
||||
calls = []
|
||||
@@ -194,7 +604,9 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_limit(monkeypatch):
|
||||
async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
@@ -209,7 +621,10 @@ async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_lim
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES", 2)
|
||||
ledger = IdempotencyLedger(
|
||||
tmp_path / "bounded-issues.sqlite3", ttl_seconds=600, max_entries=2
|
||||
)
|
||||
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
@@ -223,8 +638,11 @@ async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_lim
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert len(main._issue_creation_operations) == 2
|
||||
assert "capture-177-bounded-0" not in main._issue_creation_operations
|
||||
replay = ledger.reserve(
|
||||
"capture-177-bounded-0",
|
||||
("issue-create", "stackchain/api", "Capture 0", "", ()),
|
||||
)
|
||||
assert replay.state == "reserved"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -261,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 = []
|
||||
@@ -333,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
|
||||
@@ -543,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()
|
||||
@@ -749,6 +1246,8 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"title": "Fix mobile flow",
|
||||
"state": "open",
|
||||
"body": "Full issue context",
|
||||
"updated_at": "2026-08-07T09:59:00Z",
|
||||
"milestone": {"id": 9, "title": "August RC", "description": "not exposed"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"labels": [{"name": "P1"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
@@ -771,6 +1270,9 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"title": "Fix mobile flow",
|
||||
"state": "open",
|
||||
"body": "Full issue context",
|
||||
"updated_at": "2026-08-07T09:59:00Z",
|
||||
"due_date": None,
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
"url": "https://forge.example/stackchain/api/issues/7",
|
||||
"labels": ["P1"],
|
||||
"assignees": ["timmy"],
|
||||
@@ -783,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 = []
|
||||
|
||||
+1231
-4
File diff suppressed because it is too large.
Load diff
@@ -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,104 @@
|
||||
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
|
||||
@@ -53,6 +53,151 @@ async def test_assigned_pull_detail_rejects_unassigned_pull(monkeypatch):
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
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"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 7,
|
||||
"title": "Review this patch",
|
||||
"state": "open",
|
||||
"mergeable": True,
|
||||
"head": {"sha": "abc123"},
|
||||
})
|
||||
if path.endswith("/pulls/7/files"):
|
||||
return httpx.Response(200, json=[
|
||||
{"filename": "src/api.py", "status": "modified", "additions": 1, "deletions": 1},
|
||||
{"filename": "static/logo.png", "status": "modified"},
|
||||
])
|
||||
if path.endswith("/commits/abc123/status"):
|
||||
return httpx.Response(200, json={"state": "success"})
|
||||
if path.endswith("/issues/7/comments"):
|
||||
return httpx.Response(200, json=[])
|
||||
if path.endswith("/pulls/7.diff"):
|
||||
return httpx.Response(200, text=(
|
||||
"diff --git a/src/api.py b/src/api.py\n"
|
||||
"--- a/src/api.py\n+++ b/src/api.py\n"
|
||||
"@@ -1 +1 @@\n-old\n+new\n"
|
||||
"diff --git a/static/logo.png b/static/logo.png\n"
|
||||
"Binary files a/static/logo.png and b/static/logo.png differ\n"
|
||||
))
|
||||
raise AssertionError(f"unexpected request: {request.method} {path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
detail = await gitea_proxy.pull_completion_review("stackchain/api", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert detail["files"][0]["diff_available"] is True
|
||||
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
|
||||
async def test_assigned_pull_comment_posts_only_after_assignment_check(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
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_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/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",
|
||||
}
|
||||
|
||||
|
||||
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"] == []
|
||||
|
||||
|
||||
def test_failed_online_navigation_does_not_replace_working_cached_shell():
|
||||
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, state }));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["status"] == 503
|
||||
assert result["state"]["puts"] == []
|
||||
@@ -19,3 +19,17 @@ async def test_dashboard_viewport_allows_mobile_zoom():
|
||||
assert 'name="viewport"' in html
|
||||
assert "width=device-width" in html
|
||||
assert "user-scalable=no" not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="search-preview" role="dialog" aria-modal="true"' in html
|
||||
assert 'aria-labelledby="search-preview-title"' in html
|
||||
assert 'id="claim-search-result"' in html
|
||||
assert 'id="open-search-result-gitea"' in html
|
||||
assert 'src="static/search-preview.js"' in html
|
||||
assert ".search-preview-panel" in html
|
||||
assert "height:100dvh" in html
|
||||
assert "env(safe-area-inset-bottom)" in html
|
||||
@@ -20,6 +20,8 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
|
||||
"labels": [], "assignees": [{"login": "timmy"}],
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"updated_at": "2026-08-07T10:00:00Z",
|
||||
"due_date": "2026-08-09T23:59:59Z",
|
||||
"milestone": {"id": 9, "title": "August RC", "state": "open"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/51",
|
||||
}],
|
||||
}
|
||||
@@ -38,6 +40,8 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
|
||||
"id": 51, "number": 51, "title": "Older issue", "state": "open",
|
||||
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
|
||||
"updated_at": "2026-08-07T10:00:00Z",
|
||||
"due_date": "2026-08-09T23:59:59Z",
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
"url": "https://forge.example/stackchain/api/issues/51",
|
||||
}],
|
||||
}
|
||||
@@ -57,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