Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5406ee2297 | ||
|
|
937df5d20b | ||
|
|
c0eabbf865 | ||
|
|
2d8a659ebe | ||
|
|
3ff173ca89 | ||
|
|
0bc6e01eb5 | ||
|
|
4da3b198d0 | ||
|
|
87058d72e6 | ||
|
|
e96ff42dff | ||
|
|
b6c7ec83e4 | ||
|
|
b4f984e331 | ||
|
|
b4b13a7db1 | ||
|
|
1947f878e9 | ||
|
|
6f54d5764b | ||
|
|
9293dde1af | ||
|
|
1f997cc504 | ||
|
|
f9e11fca6a | ||
|
|
390ad0d4d2 | ||
|
|
b673911e36 | ||
|
|
484c0d4aeb | ||
|
|
5ce29fbe7d | ||
|
|
625e65e7e4 | ||
|
|
29a786cea3 | ||
|
|
4870b58c5b | ||
|
|
5fe0b28747 | ||
|
|
58376f6d45 | ||
|
|
4b7405aa65 | ||
|
|
b6390d3298 | ||
|
|
e5f001f2b0 | ||
|
|
745c3779c2 | ||
|
|
35e46f81f1 | ||
|
|
bb394be8b2 | ||
|
|
8c889e3ffc | ||
|
|
1c0cacb2b8 | ||
|
|
30a014b619 | ||
|
|
9b81dce8aa | ||
|
|
7f5e671f8d | ||
|
|
83de2a7499 | ||
|
|
62ebabb132 | ||
|
|
f7073e1393 | ||
|
|
5b864c028e | ||
|
|
8ee7dbf79e | ||
|
|
db0354a6e5 | ||
|
|
a86de5cbf4 | ||
|
|
953f59fb19 | ||
|
|
36d7fb9ce8 | ||
|
|
955343a428 | ||
|
|
b51542363c | ||
|
|
54638d2686 | ||
|
|
db9e51e683 | ||
|
|
cafc062b9f | ||
|
|
ea7bd15721 | ||
|
|
3807e7afd1 | ||
|
|
0dc5c2c831 | ||
|
|
efb3ae3400 | ||
|
|
6e900e31e3 | ||
|
|
678005ece9 | ||
|
|
f34fc8b5f5 | ||
|
|
d75a55f3f7 | ||
|
|
30f92d18a2 | ||
|
|
e2a4b60143 | ||
|
|
331eb99c1e | ||
|
|
bd0ae619db | ||
|
|
173af9f698 | ||
|
|
3bf635fb89 | ||
|
|
d8937f0096 | ||
|
|
c4a9c76781 | ||
|
|
6c4ce5cf48 | ||
|
|
f662361b09 | ||
|
|
83788c6b82 | ||
|
|
329591ebd6 | ||
|
|
32d9d9413f | ||
|
|
b5830cf86f | ||
|
|
b7d4bd8137 | ||
|
|
26ffa1f1b3 | ||
|
|
de2f8d8fcc | ||
|
|
dce7373845 | ||
|
|
6be016d658 | ||
|
|
2891a0cfd8 | ||
|
|
f2540fb1db | ||
|
|
7f236c5a79 | ||
|
|
7c13bb1663 | ||
|
|
37dd7710b4 | ||
|
|
c21d968cc2 | ||
|
|
d86e86e1fb | ||
|
|
bfdc042e38 | ||
|
|
acc60d11e7 | ||
|
|
b055644be9 | ||
|
|
4602347a80 | ||
|
|
64b6ff6c5b | ||
|
|
0427b3079e | ||
|
|
f4e2969fec | ||
|
|
7d5d1181c0 | ||
|
|
b41d7ad03a | ||
|
|
b2de3b7eeb | ||
|
|
1b43116fe6 | ||
|
|
8f3bbde733 | ||
|
|
6e24d7db17 | ||
|
|
43a2804562 |
No files matched your search
@@ -14,11 +14,30 @@ python3 -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
||||
read-scoped access token, then start the API and bundled frontend:
|
||||
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
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||
issue-comment API; mobile issue capture requires issue
|
||||
creation and assignment permission. Issue capture and authored mobile actions (issue
|
||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||
idempotency keys, so retrying after a timeout or reload replays a confirmed result instead
|
||||
of posting duplicate content. Direct API callers should preserve the `Idempotency-Key`
|
||||
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
|
||||
comment path and submits the summary, decision, and inline comments in one review
|
||||
request. The dashboard rechecks the current pull-request head, CI success, draft
|
||||
state, and mergeability immediately before every merge.
|
||||
Serve the dashboard
|
||||
only to trusted users on its own origin; cross-origin API
|
||||
access is intentionally disabled. Then start the API and bundled frontend:
|
||||
|
||||
```bash
|
||||
export GITEA_URL='https://forge.example.com'
|
||||
export GITEA_TOKEN='<read-scoped-token>'
|
||||
export GITEA_TOKEN='<read-notification-and-issue-write-token>'
|
||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
function createContextPoller({
|
||||
fetchContext,
|
||||
onSnapshot,
|
||||
onError,
|
||||
isHidden = () => false,
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
intervalMs = 8000,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
cancelTimer();
|
||||
if (stopped || isHidden()) return;
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
refresh();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (stopped || isHidden()) return Promise.resolve(null);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
let request;
|
||||
try {
|
||||
request = fetchContext();
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
inFlight = Promise.resolve(request)
|
||||
.then((snapshot) => {
|
||||
onSnapshot(snapshot);
|
||||
return snapshot;
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(error);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
schedule();
|
||||
});
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
function setVisible(visible) {
|
||||
cancelTimer();
|
||||
if (!visible) return Promise.resolve(null);
|
||||
return refresh();
|
||||
}
|
||||
|
||||
return {
|
||||
start: refresh,
|
||||
refresh,
|
||||
setVisible,
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelTimer();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createContextPoller;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
function newIssueOperationId() {
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
|
||||
}
|
||||
|
||||
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
||||
const storageKey = 'stackchain.issue-capture.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: [] });
|
||||
|
||||
function loadStored() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
||||
if (!parsed || typeof parsed !== 'object') return {...emptyDraft(), operationId: ''};
|
||||
return {
|
||||
repository: String(parsed.repository || ''),
|
||||
title: String(parsed.title || ''),
|
||||
body: String(parsed.body || ''),
|
||||
labelIds: safeLabelIds(parsed.labelIds),
|
||||
operationId: String(parsed.operationId || '').slice(0, 128),
|
||||
};
|
||||
} catch (_error) {
|
||||
return {...emptyDraft(), operationId: ''};
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(record) {
|
||||
try { storage.setItem(storageKey, JSON.stringify(record)); }
|
||||
catch (_error) { /* Keep the form as the in-memory fallback. */ }
|
||||
}
|
||||
|
||||
function saveDraft(draft) {
|
||||
const previous = loadStored();
|
||||
const safe = {
|
||||
repository: String(draft?.repository || ''),
|
||||
title: String(draft?.title || ''),
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: safeLabelIds(draft?.labelIds),
|
||||
};
|
||||
const unchanged = ['repository', 'title', 'body'].every(key => previous[key] === safe[key]) &&
|
||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
|
||||
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
|
||||
return safe;
|
||||
}
|
||||
|
||||
function loadDraft() {
|
||||
const {operationId: _operationId, ...draft} = loadStored();
|
||||
return draft;
|
||||
}
|
||||
|
||||
function clearDraft() {
|
||||
try { storage.removeItem(storageKey); }
|
||||
catch (_error) { /* Confirmed creation remains authoritative. */ }
|
||||
}
|
||||
|
||||
function loadLabels(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||
return fetchJson('api/v1/repos/' + encoded + '/labels').then(labels =>
|
||||
(Array.isArray(labels) ? labels : []).slice().sort((left, right) => {
|
||||
const leftPriority = priorities.has(String(left?.name || '').toLowerCase());
|
||||
const rightPriority = priorities.has(String(right?.name || '').toLowerCase());
|
||||
return Number(rightPriority) - Number(leftPriority);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function submit(draft) {
|
||||
if (pending) return pending;
|
||||
const saved = saveDraft(draft);
|
||||
const stored = loadStored();
|
||||
const operationId = stored.operationId || String(createOperationId()).slice(0, 128);
|
||||
writeStored({...saved, operationId});
|
||||
const repository = saved.repository.split('/').map(encodeURIComponent).join('/');
|
||||
pending = fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: saved.title, body: saved.body, label_ids: saved.labelIds,
|
||||
}),
|
||||
}).then(issue => {
|
||||
clearDraft();
|
||||
return issue;
|
||||
}).finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return { saveDraft, loadDraft, loadLabels, submit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||
+1893
-83
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,144 @@
|
||||
function createIssueSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
let labelRequest = null;
|
||||
let editRequest = 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;
|
||||
|
||||
return {
|
||||
load(item) {
|
||||
return fetchJson(issuePath(item) + '/detail', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadLabels(item) {
|
||||
return fetchJson(issuePath(item) + '/labels', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
},
|
||||
saveDraft(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;
|
||||
},
|
||||
loadLabelDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
|
||||
return Array.isArray(value) ? value.filter(Number.isInteger) : [];
|
||||
} catch (_error) { return []; }
|
||||
},
|
||||
updateLabels(item, labelIds) {
|
||||
if (labelRequest) return labelRequest;
|
||||
try { storage?.setItem(labelDraftKey(item), JSON.stringify(labelIds)); }
|
||||
catch (_error) { /* The checked controls remain the fallback. */ }
|
||||
labelRequest = fetchJson(issuePath(item) + '/labels', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label_ids: labelIds }),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || !Array.isArray(result.labels)) {
|
||||
throw new Error('Issue labels were not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(labelDraftKey(item)); }
|
||||
catch (_error) { /* The upstream label set is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { labelRequest = null; });
|
||||
return labelRequest;
|
||||
},
|
||||
close(item) {
|
||||
if (closeRequest) return closeRequest;
|
||||
closeRequest = fetchJson(issuePath(item) + '/close', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
return result;
|
||||
}).finally(() => { closeRequest = null; });
|
||||
return closeRequest;
|
||||
},
|
||||
release(item, currentLogin) {
|
||||
if (releaseRequest) return releaseRequest;
|
||||
releaseRequest = fetchJson(issuePath(item) + '/release', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (
|
||||
result?.number !== item.number ||
|
||||
!Array.isArray(result.assignees) ||
|
||||
result.assignees.includes(currentLogin)
|
||||
) {
|
||||
throw new Error('Issue release was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { releaseRequest = null; });
|
||||
return releaseRequest;
|
||||
},
|
||||
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', '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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
|
||||
@@ -0,0 +1,406 @@
|
||||
function buildMyWork(data) {
|
||||
const login = data.user?.login || '';
|
||||
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
|
||||
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
|
||||
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
||||
|
||||
const work = issues.concat(pulls).map((item) => {
|
||||
const labels = item.labels || [];
|
||||
const priorityLabel = labels.find((label) =>
|
||||
priorityLabels.includes(String(label).toLowerCase())
|
||||
);
|
||||
const assigned = (item.assignees || []).includes(login);
|
||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||
return {
|
||||
...item,
|
||||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
is_review: isReview,
|
||||
is_assigned: assigned,
|
||||
has_update: false,
|
||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')),
|
||||
_priority: priorityLabel ? 0 : (isReview ? 2 : (assigned ? 3 : 4)),
|
||||
};
|
||||
});
|
||||
|
||||
const byKey = new Map(work.map((item) => [item.kind + ':' + item.key, item]));
|
||||
(data.notifications || []).filter((item) => item && item.unread).forEach((update) => {
|
||||
const key = (update.repository || 'unknown') + '#' + update.number;
|
||||
const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue';
|
||||
const existing = byKey.get(subjectKind + ':' + key);
|
||||
if (existing) {
|
||||
existing.has_update = true;
|
||||
existing.notification_id = update.id;
|
||||
existing.url = update.url || existing.url;
|
||||
existing.updated_at = update.updated_at || existing.updated_at;
|
||||
existing._priority = Math.min(existing._priority, 1);
|
||||
return;
|
||||
}
|
||||
if (!update.url) return;
|
||||
const item = {
|
||||
...update,
|
||||
key,
|
||||
kind: 'update',
|
||||
is_review: false,
|
||||
is_assigned: false,
|
||||
has_update: true,
|
||||
notification_id: update.id,
|
||||
reason: 'Unread update',
|
||||
_priority: 1,
|
||||
};
|
||||
work.push(item);
|
||||
byKey.set(subjectKind + ':' + key, item);
|
||||
});
|
||||
|
||||
return work.sort((left, right) =>
|
||||
left._priority - right._priority ||
|
||||
String(right.updated_at || '').localeCompare(String(left.updated_at || '')) ||
|
||||
left.key.localeCompare(right.key)
|
||||
).map(({ _priority, ...item }) => item);
|
||||
}
|
||||
|
||||
function acknowledgeNotification(items, notificationId) {
|
||||
return items.flatMap((item) => {
|
||||
if (item.notification_id !== notificationId) return [item];
|
||||
if (item.kind === 'update') return [];
|
||||
const { notification_id, ...acknowledged } = item;
|
||||
return [{ ...acknowledged, has_update: false }];
|
||||
});
|
||||
}
|
||||
|
||||
function createNotificationAcknowledger({ markRead, onItems, onStatus }) {
|
||||
const pending = new Set();
|
||||
return {
|
||||
async acknowledge(items, notificationId) {
|
||||
if (pending.has(notificationId)) return false;
|
||||
pending.add(notificationId);
|
||||
onItems(acknowledgeNotification(items, notificationId));
|
||||
onStatus('Marking update read…');
|
||||
try {
|
||||
await markRead(notificationId);
|
||||
onStatus('Update marked read.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onItems(items);
|
||||
onStatus('Could not mark update read. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending.delete(notificationId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function notificationIds(items) {
|
||||
return Array.from(new Set((items || [])
|
||||
.filter((item) => item && item.has_update && Number.isInteger(item.notification_id))
|
||||
.map((item) => item.notification_id)));
|
||||
}
|
||||
|
||||
function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
|
||||
let pending = false;
|
||||
return {
|
||||
async acknowledge(items, requestedIds) {
|
||||
if (pending) return false;
|
||||
const ids = Array.from(new Set((requestedIds || []).filter(Number.isInteger)));
|
||||
if (!ids.length) return false;
|
||||
pending = true;
|
||||
onStatus('Marking ' + ids.length + ' updates read…');
|
||||
try {
|
||||
const result = await markRead(ids);
|
||||
const marked = (result.marked || []).filter(Number.isInteger);
|
||||
const failed = (result.failed || []).filter(Number.isInteger);
|
||||
const updated = marked.reduce(
|
||||
(current, notificationId) => acknowledgeNotification(current, notificationId),
|
||||
items
|
||||
);
|
||||
onItems(updated);
|
||||
onStatus(failed.length ?
|
||||
marked.length + ' marked read · ' + failed.length + ' could not be updated — retry.' :
|
||||
marked.length + ' updates marked read.');
|
||||
return { marked, failed };
|
||||
} catch (_error) {
|
||||
onStatus('Could not mark updates read. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let pending = false;
|
||||
return {
|
||||
reset(next) {
|
||||
pagination = { ...pagination, ...(next || {}) };
|
||||
onPagination(pagination);
|
||||
},
|
||||
async loadMore(existing) {
|
||||
if (pending || !pagination.has_more) return false;
|
||||
pending = true;
|
||||
onStatus('Loading older updates…');
|
||||
try {
|
||||
const result = await load(pagination.page + 1);
|
||||
const byId = new Map((existing || [])
|
||||
.filter(item => item && Number.isInteger(item.id))
|
||||
.map(item => [item.id, item]));
|
||||
(result.items || []).forEach(item => {
|
||||
if (item && Number.isInteger(item.id) && !byId.has(item.id)) byId.set(item.id, item);
|
||||
});
|
||||
pagination = {
|
||||
page: result.page,
|
||||
total: result.total,
|
||||
has_more: result.has_more === true,
|
||||
};
|
||||
const loaded = Math.min(pagination.total, pagination.page * 50);
|
||||
onNotifications(Array.from(byId.values()));
|
||||
onPagination(pagination);
|
||||
onStatus(loaded + ' of ' + pagination.total + ' unread updates loaded.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus('Could not load older updates. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
let pagination = {};
|
||||
const pending = new Set();
|
||||
const labels = {
|
||||
issue: 'issues',
|
||||
pull: 'pull requests',
|
||||
review: 'review requests',
|
||||
};
|
||||
return {
|
||||
reset(next) {
|
||||
Object.entries(next || {}).forEach(([stream, value]) => {
|
||||
const current = pagination[stream];
|
||||
pagination[stream] = current && current.page > 1 ?
|
||||
{ ...value, page: current.page, has_more: current.page * 50 < value.total } :
|
||||
{ ...value };
|
||||
});
|
||||
onPagination({ ...pagination });
|
||||
},
|
||||
async loadMore(stream, existing) {
|
||||
const page = pagination[stream];
|
||||
if (pending.has(stream) || !page?.has_more) return false;
|
||||
pending.add(stream);
|
||||
const label = labels[stream] || 'work';
|
||||
onStatus('Loading older ' + label + '…');
|
||||
try {
|
||||
const result = await load(stream, page.page + 1);
|
||||
const merged = new Map((existing || [])
|
||||
.filter(item => item && Number.isInteger(item.id))
|
||||
.map(item => [item.id, { ...item }]));
|
||||
(result.items || []).forEach(item => {
|
||||
if (!item || !Number.isInteger(item.id)) return;
|
||||
const current = merged.get(item.id);
|
||||
if (!current) {
|
||||
merged.set(item.id, { ...item });
|
||||
return;
|
||||
}
|
||||
const reasons = Array.from(new Set(
|
||||
(current.work_reasons || []).concat(item.work_reasons || [])
|
||||
));
|
||||
merged.set(item.id, {
|
||||
...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}),
|
||||
});
|
||||
});
|
||||
pagination[stream] = {
|
||||
page: result.page, total: result.total, has_more: result.has_more === true,
|
||||
};
|
||||
onItems(stream, Array.from(merged.values()));
|
||||
onPagination({ ...pagination });
|
||||
onStatus(Math.min(result.total, result.page * 50) + ' of ' + result.total + ' ' + label + ' loaded.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus('Could not load older ' + label + '. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending.delete(stream);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose }) {
|
||||
let selected = null;
|
||||
let loadVersion = 0;
|
||||
let marking = false;
|
||||
|
||||
async function open(item) {
|
||||
selected = item;
|
||||
const version = ++loadVersion;
|
||||
onOpen(item);
|
||||
onStatus('Loading update…');
|
||||
try {
|
||||
const detail = await load(item.notification_id);
|
||||
if (selected !== item || version !== loadVersion) return false;
|
||||
onDetail(detail);
|
||||
onStatus('Update ready.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (selected === item && version === loadVersion) {
|
||||
onStatus('Could not load update. Retry or open it in Gitea.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
async markReadAndNext(items) {
|
||||
if (!selected || marking) return false;
|
||||
const current = selected;
|
||||
marking = true;
|
||||
onStatus('Marking update read…');
|
||||
try {
|
||||
await markRead(current.notification_id);
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item =>
|
||||
item && item.has_update && Number.isInteger(item.notification_id)
|
||||
);
|
||||
if (next) await open(next);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
} catch (_error) {
|
||||
onStatus('Could not mark update read. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
marking = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
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, 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) {
|
||||
onStatus('Could not send reply. Your draft is safe; retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 replaceIssueLabels(data, repository, number, labels) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, labels: [...labels] } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIssueContent(data, repository, number, content) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, ...content } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function removeIssue(data, repository, number) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).filter(item =>
|
||||
item.repository !== repository || item.number !== number
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeMyWork(items) {
|
||||
const updates = items.filter((item) => item.has_update).length;
|
||||
const reviews = items.filter((item) => item.is_review).length;
|
||||
const assigned = items.filter((item) => item.is_assigned).length;
|
||||
const updateLabel = updates + ' unread update' + (updates === 1 ? '' : 's');
|
||||
const reviewLabel = reviews + ' review' + (reviews === 1 ? '' : 's');
|
||||
const assignedLabel = assigned + ' assigned';
|
||||
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
|
||||
}
|
||||
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
update: items.filter((item) => item.has_update).length,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||
buildMyWork.replaceIssueContent = replaceIssueContent;
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
buildMyWork.notificationIds = notificationIds;
|
||||
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
||||
buildMyWork.createNotificationPager = createNotificationPager;
|
||||
buildMyWork.createWorkPager = createWorkPager;
|
||||
buildMyWork.createNotificationReader = createNotificationReader;
|
||||
buildMyWork.createNotificationReplier = createNotificationReplier;
|
||||
module.exports = buildMyWork;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
let available = [];
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let loadRequest = null;
|
||||
let claimRequest = null;
|
||||
const previewed = new Set();
|
||||
|
||||
function itemKey(item) {
|
||||
return String(item?.repository || '') + '#' + String(item?.number || '');
|
||||
}
|
||||
|
||||
function apply(result, append) {
|
||||
const incoming = Array.isArray(result?.items) ? result.items : [];
|
||||
if (append) {
|
||||
const byId = new Map(available.map(item => [item.id, item]));
|
||||
incoming.forEach(item => {
|
||||
if (item && !byId.has(item.id)) byId.set(item.id, { ...item });
|
||||
});
|
||||
available = Array.from(byId.values());
|
||||
} else {
|
||||
available = incoming.map(item => ({ ...item }));
|
||||
}
|
||||
const availableKeys = new Set(available.map(itemKey));
|
||||
previewed.forEach(key => {
|
||||
if (!availableKeys.has(key)) previewed.delete(key);
|
||||
});
|
||||
pagination = {
|
||||
page: Number(result?.page) || 1,
|
||||
total: Number(result?.total) || available.length,
|
||||
has_more: result?.has_more === true,
|
||||
};
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
}
|
||||
|
||||
function loadPage(page, append) {
|
||||
if (loadRequest) return loadRequest;
|
||||
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
apply(result, append);
|
||||
return result;
|
||||
}).finally(() => { loadRequest = null; });
|
||||
return loadRequest;
|
||||
}
|
||||
|
||||
return {
|
||||
reset(result) {
|
||||
apply(result, false);
|
||||
},
|
||||
load() {
|
||||
return loadPage(1, false);
|
||||
},
|
||||
loadMore() {
|
||||
if (!pagination.has_more) return Promise.resolve(false);
|
||||
return loadPage(pagination.page + 1, true);
|
||||
},
|
||||
items() {
|
||||
return available.slice();
|
||||
},
|
||||
togglePreview(item) {
|
||||
const key = itemKey(item);
|
||||
if (previewed.has(key)) previewed.delete(key);
|
||||
else previewed.add(key);
|
||||
return previewed.has(key);
|
||||
},
|
||||
isPreviewed(item) {
|
||||
return previewed.has(itemKey(item));
|
||||
},
|
||||
claim(item) {
|
||||
if (claimRequest) return claimRequest;
|
||||
const key = item.repository + '#' + item.number;
|
||||
onStatus('Assigning ' + key + '…');
|
||||
const path = 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number) + '/claim';
|
||||
claimRequest = fetchJson(path, {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(confirmed => {
|
||||
if (!Array.isArray(confirmed?.assignees) || !confirmed.assignees.length) {
|
||||
throw new Error('Issue assignment was not confirmed.');
|
||||
}
|
||||
available = available.filter(candidate =>
|
||||
candidate.repository !== item.repository || candidate.number !== item.number
|
||||
);
|
||||
pagination.total = Math.max(available.length, pagination.total - 1);
|
||||
pagination.has_more = available.length < pagination.total;
|
||||
previewed.delete(itemKey(item));
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
onStatus('Assigned ' + key + ' to you.');
|
||||
return confirmed;
|
||||
}).finally(() => { claimRequest = null; });
|
||||
return claimRequest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
|
||||
@@ -0,0 +1,127 @@
|
||||
function mergeEligibility(detail, reviewState) {
|
||||
if (!detail || detail.state !== 'open' || detail.merged) {
|
||||
return { allowed: false, reason: 'Pull request is not open' };
|
||||
}
|
||||
if (detail.draft) return { allowed: false, reason: 'Draft pull requests cannot be merged' };
|
||||
if (!detail.mergeable) return { allowed: false, reason: 'Resolve conflicts before merging' };
|
||||
if (detail.ci_state !== 'success') {
|
||||
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 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, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let mergeRequest = null;
|
||||
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
|
||||
.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' } });
|
||||
},
|
||||
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 {
|
||||
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', '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;
|
||||
},
|
||||
merge(item, expectedHeadSha) {
|
||||
if (mergeRequest) return mergeRequest;
|
||||
mergeRequest = fetchJson(pathFor(item) + '/merge', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
|
||||
}).then(result => {
|
||||
if (!result?.merged) throw new Error('Pull request merge was not confirmed.');
|
||||
return result;
|
||||
}).finally(() => { mergeRequest = null; });
|
||||
return mergeRequest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createPullSheet.mergeEligibility = mergeEligibility;
|
||||
createPullSheet.renderFile = renderFile;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
||||
@@ -0,0 +1,322 @@
|
||||
function createReviewController({
|
||||
fetchJson,
|
||||
storage,
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
}) {
|
||||
let pendingSubmission = null;
|
||||
|
||||
function endpoint(item) {
|
||||
const [owner, repo] = String(item.repository || '').split('/');
|
||||
if (!owner || !repo || !Number.isInteger(Number(item.number))) {
|
||||
throw new Error('This review link is invalid.');
|
||||
}
|
||||
return 'api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) +
|
||||
'/pulls/' + Number(item.number) + '/review';
|
||||
}
|
||||
|
||||
async function load(item) {
|
||||
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
|
||||
}
|
||||
|
||||
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',
|
||||
'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;
|
||||
}
|
||||
|
||||
return { load, submit };
|
||||
}
|
||||
|
||||
function diffLineClass(line) {
|
||||
if (line.startsWith('@@')) return 'hunk';
|
||||
if (line.startsWith('+')) return 'added';
|
||||
if (line.startsWith('-')) return 'removed';
|
||||
return 'context';
|
||||
}
|
||||
|
||||
function parseDiffLines(lines) {
|
||||
let oldLine = null;
|
||||
let newLine = null;
|
||||
return (lines || []).map(value => {
|
||||
const text = String(value);
|
||||
const hunk = text.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (hunk) {
|
||||
oldLine = Number(hunk[1]);
|
||||
newLine = Number(hunk[2]);
|
||||
return { text, kind: 'hunk', commentable: false };
|
||||
}
|
||||
if (text.startsWith('\\')) return { text, kind: 'note', commentable: false };
|
||||
if (oldLine === null || newLine === null) {
|
||||
return { text, kind: diffLineClass(text), commentable: false };
|
||||
}
|
||||
if (text.startsWith('-')) {
|
||||
const row = { text, kind: 'removed', commentable: true, old_position: oldLine };
|
||||
oldLine += 1;
|
||||
return row;
|
||||
}
|
||||
if (text.startsWith('+')) {
|
||||
const row = { text, kind: 'added', commentable: true, new_position: newLine };
|
||||
newLine += 1;
|
||||
return row;
|
||||
}
|
||||
const row = { text, kind: 'context', commentable: true, new_position: newLine };
|
||||
oldLine += 1;
|
||||
newLine += 1;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function renderDiffFile(file, index, escapeHtml) {
|
||||
const panelId = 'review-diff-' + index;
|
||||
let preview;
|
||||
if (file.diff_available) {
|
||||
const lines = parseDiffLines(file.diff_lines).map(row => {
|
||||
if (!row.commentable) {
|
||||
return '<span class="review-diff-line ' + row.kind + '">' + escapeHtml(row.text) + '</span>';
|
||||
}
|
||||
const position = row.old_position
|
||||
? ' data-old-position="' + row.old_position + '"'
|
||||
: ' data-new-position="' + row.new_position + '"';
|
||||
return '<button type="button" class="review-diff-line review-inline-target ' + row.kind +
|
||||
'" data-review-filename="' + escapeHtml(file.filename || '') + '"' + position +
|
||||
' aria-label="Comment on ' + escapeHtml(file.filename || 'changed file') + ' line ' +
|
||||
(row.old_position || row.new_position) + '">' + escapeHtml(row.text) + '</button>';
|
||||
}).join('');
|
||||
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
|
||||
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
|
||||
'</pre>';
|
||||
} else {
|
||||
const message = file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.';
|
||||
preview = '<div class="review-diff-empty" id="' + panelId + '" hidden>' + message +
|
||||
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
|
||||
}
|
||||
const filename = escapeHtml(file.filename || 'Unknown file');
|
||||
return '<div class="review-file" data-review-filename="' + filename + '"><button class="review-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 +
|
||||
'<label class="small" for="review-note-' + index + '">Note for ' + filename + '</label>' +
|
||||
'<textarea class="review-note" id="review-note-' + index + '" data-review-filename="' + filename + '" placeholder="Capture feedback for this file"></textarea>' +
|
||||
'<button class="review-mark" data-review-filename="' + filename + '" aria-pressed="false">Mark reviewed</button></div>';
|
||||
}
|
||||
|
||||
function toggleDiff(button, panel) {
|
||||
const expanded = button.getAttribute('aria-expanded') === 'true';
|
||||
button.setAttribute('aria-expanded', String(!expanded));
|
||||
panel.hidden = expanded;
|
||||
}
|
||||
|
||||
function createProgress({ storage, repository, number, headSha, files }) {
|
||||
const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
|
||||
const key = 'stackchain.review-progress.v1:' + repository + '#' + number + '@' + headSha;
|
||||
const headKey = 'stackchain.review-progress.v1:' + repository + '#' + number + ':head';
|
||||
let reviewed = [];
|
||||
let newHead = false;
|
||||
try {
|
||||
const previousHead = storage.getItem(headKey);
|
||||
newHead = Boolean(previousHead && previousHead !== headSha);
|
||||
storage.setItem(headKey, headSha);
|
||||
const saved = JSON.parse(storage.getItem(key) || '[]');
|
||||
if (Array.isArray(saved)) reviewed = filenames.filter(filename => saved.includes(filename));
|
||||
} catch (error) {
|
||||
reviewed = [];
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const pending = filenames.find(filename => !reviewed.includes(filename)) || null;
|
||||
return {
|
||||
reviewed: [...reviewed],
|
||||
reviewedCount: reviewed.length,
|
||||
total: filenames.length,
|
||||
nextFilename: pending,
|
||||
...(newHead ? { newHead: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function markReviewed(filename) {
|
||||
if (filenames.includes(filename) && !reviewed.includes(filename)) {
|
||||
reviewed.push(filename);
|
||||
reviewed = filenames.filter(item => reviewed.includes(item));
|
||||
try { storage.setItem(key, JSON.stringify(reviewed)); } catch (error) { /* local progress remains usable */ }
|
||||
}
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
reviewed = [];
|
||||
newHead = false;
|
||||
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, markReviewed, clear };
|
||||
}
|
||||
|
||||
function createDraft({ storage, repository, number, headSha, files }) {
|
||||
const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
|
||||
const key = 'stackchain.review-draft.v1:' + repository + '#' + number + '@' + headSha;
|
||||
let draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || '{}');
|
||||
if (saved && typeof saved === 'object') {
|
||||
draft.notes = Object.fromEntries(filenames
|
||||
.filter(filename => typeof saved.notes?.[filename] === 'string' && saved.notes[filename])
|
||||
.map(filename => [filename, saved.notes[filename]]));
|
||||
if (Array.isArray(saved.comments)) {
|
||||
draft.comments = saved.comments.filter(comment =>
|
||||
comment && filenames.includes(comment.path) && typeof comment.body === 'string' &&
|
||||
Boolean(comment.body.trim()) &&
|
||||
((Number.isInteger(comment.new_position) && !comment.old_position) ||
|
||||
(Number.isInteger(comment.old_position) && !comment.new_position))
|
||||
).map(comment => ({
|
||||
path: comment.path, body: comment.body,
|
||||
...(comment.new_position ? { new_position: comment.new_position } : {}),
|
||||
...(comment.old_position ? { old_position: comment.old_position } : {}),
|
||||
}));
|
||||
}
|
||||
if (typeof saved.summary === 'string') draft.summary = saved.summary;
|
||||
if (['comment', 'approve', 'request_changes'].includes(saved.decision)) draft.decision = saved.decision;
|
||||
}
|
||||
} catch (error) { /* start with an empty in-memory draft */ }
|
||||
|
||||
function persist() {
|
||||
try { storage.setItem(key, JSON.stringify(draft)); } catch (error) { /* draft remains usable */ }
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
notes: { ...draft.notes }, comments: draft.comments.map(comment => ({ ...comment })),
|
||||
summary: draft.summary, decision: draft.decision,
|
||||
};
|
||||
}
|
||||
|
||||
function setNote(filename, note) {
|
||||
if (!filenames.includes(filename)) return snapshot();
|
||||
if (String(note)) draft.notes[filename] = String(note);
|
||||
else delete draft.notes[filename];
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function inlineKey(comment) {
|
||||
return comment.path + ':' + (comment.new_position ? 'new:' + comment.new_position : 'old:' + comment.old_position);
|
||||
}
|
||||
|
||||
function setInlineComment(anchor, body) {
|
||||
if (!anchor || !filenames.includes(anchor.path)) return snapshot();
|
||||
const position = Number.isInteger(anchor.new_position) && anchor.new_position > 0
|
||||
? { new_position: anchor.new_position }
|
||||
: Number.isInteger(anchor.old_position) && anchor.old_position > 0
|
||||
? { old_position: anchor.old_position } : null;
|
||||
const text = String(body || '').trim();
|
||||
if (!position || !text) return snapshot();
|
||||
const comment = { path: anchor.path, body: text, ...position };
|
||||
const key = inlineKey(comment);
|
||||
const index = draft.comments.findIndex(item => inlineKey(item) === key);
|
||||
if (index >= 0) draft.comments[index] = comment;
|
||||
else draft.comments.push(comment);
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function removeInlineComment(anchor) {
|
||||
if (!anchor) return snapshot();
|
||||
const key = inlineKey(anchor);
|
||||
draft.comments = draft.comments.filter(comment => inlineKey(comment) !== key);
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setSummary(summary) {
|
||||
draft.summary = String(summary || '');
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setDecision(decision) {
|
||||
if (['comment', 'approve', 'request_changes'].includes(decision)) {
|
||||
draft.decision = decision;
|
||||
persist();
|
||||
}
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
|
||||
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear };
|
||||
}
|
||||
|
||||
function formatFeedback(draft, files) {
|
||||
const decisions = {
|
||||
comment: 'Comment',
|
||||
approve: 'Approve',
|
||||
request_changes: 'Request changes',
|
||||
};
|
||||
const sections = [
|
||||
'## Intended decision\n' + (decisions[draft.decision] || decisions.comment),
|
||||
];
|
||||
if (String(draft.summary || '').trim()) {
|
||||
sections.push('## Summary\n' + String(draft.summary).trim());
|
||||
}
|
||||
const fileNotes = (files || []).flatMap(file => {
|
||||
const filename = file && file.filename;
|
||||
const note = filename && String(draft.notes?.[filename] || '').trim();
|
||||
if (!note) return [];
|
||||
return ['### `' + String(filename).replaceAll('`', '\\`') + '`\n' + note];
|
||||
});
|
||||
if (fileNotes.length) sections.push('## File notes\n' + fileNotes.join('\n\n'));
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
async function copyAndContinue({ text, url, copy, open, fallback }) {
|
||||
const destination = open();
|
||||
try {
|
||||
await copy(text);
|
||||
} catch (error) {
|
||||
destination?.close?.();
|
||||
fallback(text);
|
||||
return { copied: false, opened: false };
|
||||
}
|
||||
if (!destination) {
|
||||
fallback(text);
|
||||
return { copied: true, opened: false };
|
||||
}
|
||||
destination.location.href = url;
|
||||
return { copied: true, opened: true };
|
||||
}
|
||||
|
||||
createReviewController.renderDiffFile = renderDiffFile;
|
||||
createReviewController.parseDiffLines = parseDiffLines;
|
||||
createReviewController.toggleDiff = toggleDiff;
|
||||
createReviewController.createProgress = createProgress;
|
||||
createReviewController.createDraft = createDraft;
|
||||
createReviewController.formatFeedback = formatFeedback;
|
||||
createReviewController.copyAndContinue = copyAndContinue;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createReviewController;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
function renderRepoMix(element, data) {
|
||||
const repos = data.repos || [];
|
||||
const issues = (data.issues || []).filter((issue) => issue.state === 'open');
|
||||
const pullRequests = data.pull_requests || [];
|
||||
element.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + pullRequests.length + '</div></div>';
|
||||
}
|
||||
|
||||
async function updateRepoMix(element, fetchContext = fetch) {
|
||||
try {
|
||||
const response = await fetchContext('api/v1/context', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
renderRepoMix(element, await response.json());
|
||||
} catch (error) {
|
||||
element.innerHTML = '<div class="muted">Widget unavailable.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
updateRepoMix.renderRepoMix = renderRepoMix;
|
||||
module.exports = updateRepoMix;
|
||||
}
|
||||
+1089
-16
File diff suppressed because it is too large.
Load diff
+1428
-71
File diff suppressed because it is too large.
Load diff
@@ -24,6 +24,8 @@ class Issue(BaseModel):
|
||||
state: str
|
||||
labels: list[str] = []
|
||||
assignees: list[str] = []
|
||||
repository: str = ""
|
||||
updated_at: str = ""
|
||||
url: str
|
||||
|
||||
|
||||
@@ -33,6 +35,11 @@ class PullRequest(BaseModel):
|
||||
title: str
|
||||
state: str
|
||||
user: str
|
||||
labels: list[str] = []
|
||||
assignees: list[str] = []
|
||||
work_reasons: list[str] = []
|
||||
repository: str = ""
|
||||
updated_at: str = ""
|
||||
url: str
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
async def empty_events(user_data=None):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
monkeypatch.setattr(main, "activity_events", empty_events)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
for path in ("/api/v1/context", "/api/v1/events", "/api/v1/live"):
|
||||
response = await client.get(path)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
@@ -15,6 +15,9 @@ def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
urljoin("https://forge.alexanderwhitestone.com/dashboard/", path)
|
||||
for path in api_paths
|
||||
} == {
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/context",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/events",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/live",
|
||||
"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/work/",
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_authored_operations():
|
||||
main._authored_action_operations.clear()
|
||||
yield
|
||||
main._authored_action_operations.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")]
|
||||
@@ -0,0 +1,65 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_context_poller_is_single_flight_and_pauses_until_visible():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
let calls = 0;
|
||||
let hidden = false;
|
||||
let resolveRequest;
|
||||
const timers = [];
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: () => {{
|
||||
calls += 1;
|
||||
if (calls > 1) return Promise.resolve({{ repos: [] }});
|
||||
return new Promise((resolve) => {{ resolveRequest = resolve; }});
|
||||
}},
|
||||
onSnapshot: () => {{}},
|
||||
onError: () => {{}},
|
||||
isHidden: () => hidden,
|
||||
setTimer: (callback) => {{ timers.push(callback); return timers.length; }},
|
||||
clearTimer: () => {{}},
|
||||
intervalMs: 8000,
|
||||
}});
|
||||
|
||||
(async () => {{
|
||||
const first = poller.start();
|
||||
const joined = poller.refresh();
|
||||
const callsWhilePending = calls;
|
||||
resolveRequest({{ repos: [] }});
|
||||
await Promise.all([first, joined]);
|
||||
|
||||
hidden = true;
|
||||
poller.setVisible(false);
|
||||
const scheduled = timers.shift();
|
||||
if (scheduled) scheduled();
|
||||
await Promise.resolve();
|
||||
const callsWhileHidden = calls;
|
||||
|
||||
hidden = false;
|
||||
await poller.setVisible(true);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
callsWhilePending,
|
||||
callsWhileHidden,
|
||||
callsAfterResume: calls,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"callsWhilePending": 1,
|
||||
"callsWhileHidden": 1,
|
||||
"callsAfterResume": 2,
|
||||
}
|
||||
@@ -34,6 +34,27 @@ async def test_context_returns_fallback_when_gitea_exceeds_deadline(monkeypatch)
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_hides_upstream_exception_details(monkeypatch):
|
||||
async def failing_user():
|
||||
raise RuntimeError("connection refused by secret.internal.example")
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", failing_user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
body = bytes(response.body)
|
||||
context = json.loads(body)
|
||||
|
||||
assert context["error"] == "Gitea context is temporarily unavailable"
|
||||
assert b"secret.internal.example" not in body
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_normalizes_nullable_issue_collections(monkeypatch):
|
||||
async def user():
|
||||
@@ -203,6 +224,29 @@ async def test_context_normalizes_null_collection_payloads(monkeypatch):
|
||||
assert context["pull_requests"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_returns_fallback_for_malformed_collection_payloads(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def malformed_collection():
|
||||
return {"message": "unexpected upstream shape"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", malformed_collection)
|
||||
monkeypatch.setattr(main, "issues", malformed_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", malformed_collection)
|
||||
|
||||
response = await main.context()
|
||||
context = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert context["repos"] == []
|
||||
assert context["issues"] == []
|
||||
assert context["pull_requests"] == []
|
||||
assert context["error"] == "Gitea collection response was not a list"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_normalizes_nullable_user_profile_fields(monkeypatch):
|
||||
async def user():
|
||||
|
||||
@@ -17,7 +17,9 @@ def test_dashboard_has_realtime_gitea_event_stream_widget():
|
||||
assert "Gitea event stream" in html
|
||||
assert "id=\"gitea-events\"" in html
|
||||
assert "function paintEventStream" in html
|
||||
assert "setInterval(loadEventStream, 5000)" in html
|
||||
assert "fetch('api/v1/live'" in html
|
||||
assert "paintEventStream(snapshot.events)" in html
|
||||
assert "loadEventStream" not in html
|
||||
assert "event.actor?.login" in html
|
||||
assert "event.repo?.full_name" in html
|
||||
|
||||
@@ -26,7 +28,6 @@ def test_event_stream_reports_when_activity_was_refreshed():
|
||||
html = DASHBOARD.read_text()
|
||||
|
||||
assert 'id="gitea-events-status"' in html
|
||||
assert "setEventStreamStatus('Updating…')" in html
|
||||
assert "setEventStreamStatus('Updated ' + fmt(new Date()))" in html
|
||||
|
||||
|
||||
@@ -120,6 +121,21 @@ async def test_activity_events_fetches_feed_for_authenticated_user(monkeypatch):
|
||||
assert paths == ["users/timmy/activities/feeds?limit=20"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_reuses_supplied_authenticated_user(monkeypatch):
|
||||
async def unexpected_current_user():
|
||||
raise AssertionError("current user must not be fetched twice")
|
||||
|
||||
async def fake_fetch(path):
|
||||
assert path == "users/timmy/activities/feeds?limit=20"
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "current_user", unexpected_current_user)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
assert await gitea_proxy.activity_events({"login": "timmy"}) == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_skips_malformed_feed_entries(monkeypatch):
|
||||
async def fake_current_user():
|
||||
@@ -148,3 +164,60 @@ async def test_activity_events_skips_malformed_feed_entries(monkeypatch):
|
||||
"repo": {"full_name": "stackchain/stackchain-dashboard"},
|
||||
"created_at": "2026-08-06T08:00:00Z",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_normalizes_null_feed_payload(monkeypatch):
|
||||
async def fake_current_user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def fake_fetch(path):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
assert await gitea_proxy.activity_events() == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_event_stream_rejects_malformed_activity_feed_payload(monkeypatch):
|
||||
async def fake_current_user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def fake_fetch(path):
|
||||
return {"message": "unexpected upstream shape"}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
response = await main.event_stream()
|
||||
|
||||
assert getattr(response, "status_code", None) == 503
|
||||
assert json.loads(response.body) == {
|
||||
"error": "Gitea event stream is temporarily unavailable"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_normalizes_malformed_nested_metadata(monkeypatch):
|
||||
async def fake_current_user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def fake_fetch(path):
|
||||
return [{
|
||||
"op_type": None,
|
||||
"act_user": "unknown",
|
||||
"repo": None,
|
||||
"created": None,
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
assert await gitea_proxy.activity_events() == [{
|
||||
"type": "activity",
|
||||
"actor": {},
|
||||
"repo": {},
|
||||
"created_at": "",
|
||||
}]
|
||||
@@ -0,0 +1,250 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_page_preserves_upstream_total_without_loading_other_pages():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "125"},
|
||||
json=[{
|
||||
"id": 42,
|
||||
"unread": True,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"title": "Retry failed deploy",
|
||||
"type": "Issue",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
},
|
||||
}],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.notification_page(page=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/notifications?status-types=unread&limit=50&page=2"
|
||||
]
|
||||
assert result["page"] == 2
|
||||
assert result["total"] == 125
|
||||
assert result["has_more"] is True
|
||||
assert [item["id"] for item in result["items"]] == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notifications_are_normalized_for_mobile_handoff():
|
||||
result = gitea_proxy._normalize_notifications(
|
||||
[
|
||||
{
|
||||
"id": 42,
|
||||
"unread": True,
|
||||
"updated_at": "2026-08-06T12:30:00Z",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"title": "Retry failed deploy",
|
||||
"type": "Issue",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
},
|
||||
{"id": 43, "repository": None, "subject": None},
|
||||
{
|
||||
"id": 44,
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"subject": {"title": "Unsafe", "html_url": "javascript:alert(1)"},
|
||||
},
|
||||
"malformed",
|
||||
]
|
||||
)
|
||||
assert result == [
|
||||
{
|
||||
"id": 42,
|
||||
"unread": True,
|
||||
"updated_at": "2026-08-06T12:30:00Z",
|
||||
"repository": "stackchain/api",
|
||||
"number": 7,
|
||||
"title": "Retry failed deploy",
|
||||
"subject_type": "Issue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"subject_url": "https://forge.example/stackchain/api/issues/7",
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"unread": False,
|
||||
"updated_at": "",
|
||||
"repository": "",
|
||||
"number": None,
|
||||
"title": "Untitled update",
|
||||
"subject_type": "Update",
|
||||
"state": "",
|
||||
"url": "",
|
||||
"subject_url": "",
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"unread": False,
|
||||
"updated_at": "",
|
||||
"repository": "stackchain/web",
|
||||
"number": None,
|
||||
"title": "Unsafe",
|
||||
"subject_type": "Update",
|
||||
"state": "",
|
||||
"url": "",
|
||||
"subject_url": "",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_collection_rejects_non_list_payload():
|
||||
with pytest.raises(ValueError, match="notification response was not a list"):
|
||||
gitea_proxy._normalize_notifications({"message": "unexpected"})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_reader():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
if request.url.path.endswith("/notifications/threads/42"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"title": "Retry failed deploy",
|
||||
"type": "Issue",
|
||||
"state": "open",
|
||||
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
|
||||
"latest_comment_url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
})
|
||||
if request.url.path.endswith("/issues/7"):
|
||||
return httpx.Response(200, json={"body": "Deploy fails after **three** retries."})
|
||||
if request.url.path.endswith("/issues/comments/9"):
|
||||
return httpx.Response(200, json={
|
||||
"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",
|
||||
})
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.notification_detail(42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"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",
|
||||
]
|
||||
assert result == {
|
||||
"id": 42,
|
||||
"repository": "stackchain/api",
|
||||
"title": "Retry failed deploy",
|
||||
"subject_type": "Issue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"subject_body": "Deploy fails after **three** retries.",
|
||||
"latest_comment": {
|
||||
"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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_never_follows_foreign_api_urls():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"title": "Suspicious update",
|
||||
"url": "https://evil.example/api/v1/user",
|
||||
"latest_comment_url": "https://evil.example/api/v1/admin/users",
|
||||
"html_url": "javascript:alert(1)",
|
||||
},
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.notification_detail(42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/notifications/threads/42"
|
||||
]
|
||||
assert result["url"] == ""
|
||||
assert result["subject_body"] == ""
|
||||
assert result["latest_comment"]["body"] == ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("subject_kind", ["issues", "pulls"])
|
||||
async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kind):
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append((request.method, str(request.url), request.content))
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"type": "Pull" if subject_kind == "pulls" else "Issue",
|
||||
"url": (
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/"
|
||||
f"{subject_kind}/7"
|
||||
),
|
||||
},
|
||||
})
|
||||
return httpx.Response(201, json={
|
||||
"id": 91,
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.reply_to_notification(42, "Please retry the worker.")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
(
|
||||
"GET",
|
||||
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
|
||||
b"",
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
b'{"body":"Please retry the worker."}',
|
||||
),
|
||||
]
|
||||
assert result == {
|
||||
"id": 91,
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_transport_is_reused_across_requests_and_closed():
|
||||
client_ids = []
|
||||
|
||||
async def handler(request):
|
||||
client_ids.append(id(gitea_proxy._client))
|
||||
return httpx.Response(200, json={"path": request.url.path})
|
||||
|
||||
client = gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
first = await gitea_proxy.fetch("user")
|
||||
second = await gitea_proxy.fetch("user/repos")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert first == {"path": "/api/v1/user"}
|
||||
assert second == {"path": "/api/v1/user/repos"}
|
||||
assert client_ids == [id(client), id(client)]
|
||||
assert client.is_closed
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_application_lifespan_opens_and_closes_gitea_transport(monkeypatch):
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
|
||||
|
||||
async with main.app.router.lifespan_context(main.app):
|
||||
assert calls == ["start"]
|
||||
|
||||
assert calls == ["start", "stop"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_application_shutdown_finishes_snapshot_before_closing_transport(monkeypatch):
|
||||
calls = []
|
||||
started = asyncio.Event()
|
||||
|
||||
async def active_snapshot():
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
calls.append("snapshot cancelled")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
|
||||
async def stop_client():
|
||||
assert main._live_snapshot_task is not None
|
||||
state = "done" if main._live_snapshot_task.done() else "active"
|
||||
calls.append(f"stop ({state})")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
|
||||
|
||||
try:
|
||||
async with main.app.router.lifespan_context(main.app):
|
||||
main._live_snapshot_task = asyncio.create_task(active_snapshot())
|
||||
await started.wait()
|
||||
finally:
|
||||
task = main._live_snapshot_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
main._live_snapshot_task = None
|
||||
|
||||
assert calls == ["start", "snapshot cancelled", "stop (done)"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_application_shutdown_cancels_available_work_scan_before_transport(monkeypatch):
|
||||
calls = []
|
||||
started = asyncio.Event()
|
||||
|
||||
async def active_scan():
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
calls.append("available scan cancelled")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
|
||||
async def stop_client():
|
||||
task = main._available_issue_snapshot_task
|
||||
calls.append(f"stop ({'done' if task is not None and task.done() else 'active'})")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
|
||||
|
||||
try:
|
||||
async with main.app.router.lifespan_context(main.app):
|
||||
main._available_issue_snapshot_task = asyncio.create_task(active_scan())
|
||||
await started.wait()
|
||||
finally:
|
||||
task = main._available_issue_snapshot_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
main._available_issue_snapshot_task = None
|
||||
|
||||
assert calls == ["start", "available scan cancelled", "stop (done)"]
|
||||
@@ -0,0 +1,601 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_available_issue_snapshot():
|
||||
main._available_issue_snapshot_task = None
|
||||
main._available_issue_snapshot_value = None
|
||||
main._available_issue_snapshot_created_at = None
|
||||
yield
|
||||
task = main._available_issue_snapshot_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
main._available_issue_snapshot_task = None
|
||||
main._available_issue_snapshot_value = None
|
||||
main._available_issue_snapshot_created_at = None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_page_preserves_total_and_reason_without_loading_other_pages():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "84"},
|
||||
json=[{
|
||||
"id": 51,
|
||||
"number": 51,
|
||||
"title": "Older review",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/pulls/51",
|
||||
}],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.work_page("review", page=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&review_requested=true&type=pulls&limit=50&page=2"
|
||||
]
|
||||
assert result["page"] == 2
|
||||
assert result["total"] == 84
|
||||
assert result["has_more"] is False
|
||||
assert result["items"][0]["work_reasons"] == ["review_requested"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_priority():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "4"},
|
||||
json=[
|
||||
{"id": 1, "number": 1, "title": "Ordinary", "state": "open",
|
||||
"updated_at": "2026-08-07T12:00:00Z", "assignees": None,
|
||||
"pull_request": None,
|
||||
"labels": [], "repository": {"full_name": "stackchain/api"}},
|
||||
{"id": 2, "number": 2, "title": "Claimed", "state": "open",
|
||||
"assignees": [{"login": "alex"}], "repository": {"full_name": "stackchain/api"}},
|
||||
{"id": 3, "number": 3, "title": "A pull", "state": "open",
|
||||
"assignees": [], "pull_request": {"merged": False},
|
||||
"repository": {"full_name": "stackchain/api"}},
|
||||
{"id": 4, "number": 4, "title": "Critical", "state": "open",
|
||||
"updated_at": "2026-08-07T10:00:00Z", "assignees": [],
|
||||
"pull_request": None,
|
||||
"labels": [{"name": "critical"}],
|
||||
"repository": {"full_name": "stackchain/web"}},
|
||||
],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.available_issue_page(page=1)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&type=issues&limit=50&page=1"
|
||||
]
|
||||
assert [item["title"] for item in result["items"]] == ["Critical", "Ordinary"]
|
||||
assert result == {
|
||||
"items": result["items"], "page": 1, "total": 2, "has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_page_ranks_all_upstream_pages_before_logical_pagination():
|
||||
requests = []
|
||||
|
||||
def issue(number, *, assigned=True, critical=False, updated_at="2026-08-07T12:00:00Z"):
|
||||
return {
|
||||
"id": number, "number": number, "title": f"Issue {number}", "state": "open",
|
||||
"updated_at": updated_at, "assignees": [{"login": "alex"}] if assigned else None,
|
||||
"pull_request": None,
|
||||
"labels": [{"name": "critical"}] if critical else [],
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
}
|
||||
|
||||
first_page = [issue(number) for number in range(1, 51)]
|
||||
first_page[0] = issue(1, assigned=False, updated_at="2026-08-07T13:00:00Z")
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
page = int(request.url.params["page"])
|
||||
payload = first_page if page == 1 else [
|
||||
issue(51, assigned=False, critical=True, updated_at="2026-08-07T10:00:00Z"),
|
||||
issue(52, assigned=False, updated_at="2026-08-07T11:00:00Z"),
|
||||
]
|
||||
return httpx.Response(200, headers={"X-Total-Count": "52"}, json=payload)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.available_issue_page(page=1, limit=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 2
|
||||
assert [item["number"] for item in result["items"]] == [51, 1]
|
||||
assert result == {
|
||||
"items": result["items"], "page": 1, "total": 3, "has_more": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def available():
|
||||
calls.append(True)
|
||||
return [{"number": number} for number in range(1, 52)]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"items": [{"number": number} for number in range(1, 51)],
|
||||
"page": 1, "total": 51, "has_more": True,
|
||||
}
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_coalesces_cold_scan_and_reuses_it_for_pages(monkeypatch):
|
||||
calls = 0
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def available():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
started.set()
|
||||
await release.wait()
|
||||
return [{"number": number} for number in range(1, 64)]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = asyncio.create_task(client.get("/api/v1/available-issues?page=1"))
|
||||
second = asyncio.create_task(client.get("/api/v1/available-issues?page=2"))
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
release.set()
|
||||
first_response, second_response = await asyncio.gather(first, second)
|
||||
repeated_response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert calls == 1
|
||||
assert len(first_response.json()["items"]) == 50
|
||||
assert len(second_response.json()["items"]) == 13
|
||||
assert second_response.json()["total"] == 63
|
||||
assert second_response.json()["has_more"] is False
|
||||
assert repeated_response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_retains_last_snapshot_on_refresh_failure(monkeypatch):
|
||||
async def unavailable():
|
||||
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:
|
||||
response = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"items": [{"number": 7}], "page": 1, "total": 1,
|
||||
"has_more": False, "stale": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_confirmed_claim_is_removed_from_retained_available_snapshot(monkeypatch):
|
||||
main._available_issue_snapshot_value = [
|
||||
{"number": 7, "repository": "stackchain/api"},
|
||||
{"number": 8, "repository": "stackchain/web"},
|
||||
]
|
||||
main._available_issue_snapshot_created_at = 10**12
|
||||
|
||||
async def claim(repository, number):
|
||||
return {"repository": repository, "number": number, "assignees": ["timmy"]}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
claimed = await client.patch("/api/v1/repos/stackchain/api/issues/7/claim")
|
||||
available = await client.get("/api/v1/available-issues?page=1")
|
||||
|
||||
assert claimed.status_code == 200
|
||||
assert available.json() == {
|
||||
"items": [{"number": 8, "repository": "stackchain/web"}],
|
||||
"page": 1, "total": 1, "has_more": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
assert page == 1
|
||||
totals = {"issue": 84, "pull": 61, "review": 73}
|
||||
return {
|
||||
"items": [], "page": 1, "total": totals[stream],
|
||||
"has_more": True, "stream": stream,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
|
||||
assigned_issues = await gitea_proxy.issues()
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
assert assigned_issues.pagination == {
|
||||
"issue": {"page": 1, "total": 84, "has_more": True}
|
||||
}
|
||||
assert pulls.pagination == {
|
||||
"pull": {"page": 1, "total": 61, "has_more": True},
|
||||
"review": {"page": 1, "total": 73, "has_more": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_collections_include_supported_review_request_search(monkeypatch):
|
||||
requested_streams = []
|
||||
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
requested_streams.append((stream, page, limit))
|
||||
return {"stream": stream, "items": [], "page": page, "total": 0, "has_more": False}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
|
||||
assert await gitea_proxy.issues() == []
|
||||
assert await gitea_proxy.pull_requests() == []
|
||||
assert requested_streams == [
|
||||
("issue", 1, 50),
|
||||
("pull", 1, 50),
|
||||
("review", 1, 50),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_requests_merge_assignment_and_review_responsibilities(monkeypatch):
|
||||
assigned = {
|
||||
"id": 11,
|
||||
"number": 7,
|
||||
"title": "Review API",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
}
|
||||
review_only = {
|
||||
"id": 12,
|
||||
"number": 8,
|
||||
"title": "Review mobile",
|
||||
"repository": {"full_name": "stackchain/mobile"},
|
||||
}
|
||||
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
items = [assigned] if stream == "pull" else [assigned.copy(), review_only]
|
||||
reason = "assigned_to_me" if stream == "pull" else "review_requested"
|
||||
return {
|
||||
"stream": stream,
|
||||
"items": [{**item, "work_reasons": [reason]} for item in items],
|
||||
"page": page, "total": len(items), "has_more": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
assert [pull["id"] for pull in pulls] == [11, 12]
|
||||
assert pulls[0]["work_reasons"] == ["assigned_to_me", "review_requested"]
|
||||
assert pulls[1]["work_reasons"] == ["review_requested"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_request_searches_start_concurrently(monkeypatch):
|
||||
started = [asyncio.Event(), asyncio.Event()]
|
||||
release = asyncio.Event()
|
||||
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
index = 0 if stream == "pull" else 1
|
||||
started[index].set()
|
||||
await release.wait()
|
||||
return {"stream": stream, "items": [], "page": page, "total": 0, "has_more": False}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
|
||||
task = asyncio.create_task(gitea_proxy.pull_requests())
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*(event.wait() for event in started)), timeout=1
|
||||
)
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
assert await task == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_requested_review_guard_uses_direct_pull_and_current_user(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested.append(path)
|
||||
if path == "user":
|
||||
return {"login": "timmy"}
|
||||
assert path == "repos/stackchain/api/pulls/77"
|
||||
return {
|
||||
"state": "open",
|
||||
"requested_reviewers": [{"login": "timmy"}],
|
||||
}
|
||||
|
||||
async def reject_search(*args, **kwargs):
|
||||
raise AssertionError("authorization must not scan review search pages")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", reject_search)
|
||||
|
||||
assert await gitea_proxy.is_requested_review("stackchain/api", 77) is True
|
||||
assert sorted(requested) == ["repos/stackchain/api/pulls/77", "user"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_guard_uses_direct_target_and_current_user(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested.append(path)
|
||||
if path == "user":
|
||||
return {"login": "timmy"}
|
||||
assert path == "repos/stackchain/api/issues/77"
|
||||
return {
|
||||
"state": "open",
|
||||
"assignees": [{"login": "timmy"}],
|
||||
}
|
||||
|
||||
async def reject_search(*args, **kwargs):
|
||||
raise AssertionError("authorization must not scan work search pages")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", reject_search)
|
||||
|
||||
assert await gitea_proxy.is_assigned_issue("stackchain/api", 77) is True
|
||||
assert sorted(requested) == ["repos/stackchain/api/issues/77", "user"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_guard_uses_direct_target_and_current_user(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested.append(path)
|
||||
if path == "user":
|
||||
return {"login": "timmy"}
|
||||
assert path == "repos/stackchain/api/pulls/77"
|
||||
return {
|
||||
"state": "open",
|
||||
"assignees": [{"login": "timmy"}],
|
||||
}
|
||||
|
||||
async def reject_search(*args, **kwargs):
|
||||
raise AssertionError("authorization must not scan work search pages")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", reject_search)
|
||||
|
||||
assert await gitea_proxy.is_assigned_pull("stackchain/api", 77) is True
|
||||
assert sorted(requested) == ["repos/stackchain/api/pulls/77", "user"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_preserves_repository_and_update_time_for_cross_repo_work(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def repositories():
|
||||
return []
|
||||
|
||||
async def issues():
|
||||
return [{
|
||||
"id": 10,
|
||||
"number": 7,
|
||||
"title": "Ship mobile flow",
|
||||
"state": "open",
|
||||
"labels": [{"name": "P0"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"repository": {"full_name": "stackchain/mobile"},
|
||||
"updated_at": "2026-08-06T12:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/mobile/issues/7",
|
||||
}]
|
||||
|
||||
async def pulls():
|
||||
return [{
|
||||
"id": 11,
|
||||
"number": 7,
|
||||
"title": "Review API",
|
||||
"state": "open",
|
||||
"user": {"login": "alex"},
|
||||
"labels": [{"name": "priority-high"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"work_reasons": ["assigned_to_me", "review_requested"],
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"updated_at": "2026-08-06T11:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/pulls/7",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", repositories)
|
||||
monkeypatch.setattr(main, "issues", issues)
|
||||
monkeypatch.setattr(main, "pull_requests", pulls)
|
||||
|
||||
payload = json.loads((await main.context()).body)
|
||||
|
||||
assert payload["issues"][0]["repository"] == "stackchain/mobile"
|
||||
assert payload["issues"][0]["updated_at"] == "2026-08-06T12:00:00Z"
|
||||
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"]
|
||||
assert payload["pull_requests"][0]["assignees"] == ["timmy"]
|
||||
assert payload["pull_requests"][0]["work_reasons"] == [
|
||||
"assigned_to_me",
|
||||
"review_requested",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_review_detail_combines_pr_files_status_and_reviews(monkeypatch):
|
||||
requested_paths = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested_paths.append(path)
|
||||
if path.endswith("/pulls/7"):
|
||||
return {
|
||||
"title": "Review API",
|
||||
"body": "Please check the retry flow.",
|
||||
"html_url": "https://forge.example/stackchain/api/pulls/7",
|
||||
"user": {"login": "alex"},
|
||||
"head": {"sha": "abc123"},
|
||||
}
|
||||
if path.endswith("/files"):
|
||||
return [
|
||||
{"filename": "src/api.py", "status": "modified", "additions": 8, "deletions": 2},
|
||||
None,
|
||||
]
|
||||
if "/commits/abc123/status" in path:
|
||||
return {"state": "success"}
|
||||
return [
|
||||
{"user": {"login": "sam"}, "state": "APPROVED", "body": "Looks good"},
|
||||
"malformed",
|
||||
]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
async def fake_fetch_text(path, max_bytes):
|
||||
assert path == "repos/stackchain/api/pulls/7.diff"
|
||||
assert max_bytes == gitea_proxy.REVIEW_DIFF_MAX_BYTES
|
||||
return "", False
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch_text", fake_fetch_text)
|
||||
|
||||
detail = await gitea_proxy.pull_review_detail("stackchain/api", 7)
|
||||
|
||||
assert requested_paths == [
|
||||
"repos/stackchain/api/pulls/7",
|
||||
"repos/stackchain/api/pulls/7/files",
|
||||
"repos/stackchain/api/commits/abc123/status",
|
||||
"repos/stackchain/api/pulls/7/reviews",
|
||||
]
|
||||
assert detail["author"] == "alex"
|
||||
assert detail["head_sha"] == "abc123"
|
||||
assert detail["ci_state"] == "success"
|
||||
assert detail["files"][0]["filename"] == "src/api.py"
|
||||
assert detail["reviews"][0]["state"] == "APPROVED"
|
||||
assert len(detail["files"]) == 1
|
||||
assert len(detail["reviews"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_review_detail_fetches_independent_head_resources_concurrently(monkeypatch):
|
||||
started = {name: asyncio.Event() for name in ("files", "status", "reviews", "diff")}
|
||||
release = asyncio.Event()
|
||||
|
||||
async def wait_for_release(name, result):
|
||||
started[name].set()
|
||||
await release.wait()
|
||||
return result
|
||||
|
||||
async def fake_fetch(path):
|
||||
if path.endswith("/pulls/7"):
|
||||
return {"head": {"sha": "abc123"}, "user": {"login": "alex"}}
|
||||
if path.endswith("/files"):
|
||||
return await wait_for_release("files", [])
|
||||
if path.endswith("/reviews"):
|
||||
return await wait_for_release("reviews", [])
|
||||
return await wait_for_release("status", {"state": "success"})
|
||||
|
||||
async def fake_fetch_text(path, max_bytes):
|
||||
return await wait_for_release("diff", ("", False))
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch_text", fake_fetch_text)
|
||||
|
||||
detail_task = asyncio.create_task(
|
||||
gitea_proxy.pull_review_detail("stackchain/api", 7)
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*(event.wait() for event in started.values())), timeout=1
|
||||
)
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
detail = await detail_task
|
||||
assert detail["head_sha"] == "abc123"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_review_detail_attaches_bounded_per_file_diff_previews(monkeypatch):
|
||||
async def fake_fetch(path):
|
||||
if path.endswith("/pulls/7"):
|
||||
return {
|
||||
"title": "Review API",
|
||||
"head": {"sha": "abc123"},
|
||||
"user": {"login": "alex"},
|
||||
}
|
||||
if path.endswith("/files"):
|
||||
return [
|
||||
{"filename": "src/api.py", "status": "modified"},
|
||||
{"filename": "assets/logo.png", "status": "modified"},
|
||||
]
|
||||
if path.endswith("/reviews"):
|
||||
return []
|
||||
return {"state": "success"}
|
||||
|
||||
diff = """diff --git a/src/api.py b/src/api.py
|
||||
index 123..456 100644
|
||||
--- a/src/api.py
|
||||
+++ b/src/api.py
|
||||
@@ -1,2 +1,3 @@
|
||||
context
|
||||
-old <value>
|
||||
+new <value>
|
||||
diff --git a/assets/logo.png b/assets/logo.png
|
||||
Binary files a/assets/logo.png and b/assets/logo.png differ
|
||||
"""
|
||||
|
||||
async def fake_fetch_text(path, max_bytes):
|
||||
assert path == "repos/stackchain/api/pulls/7.diff"
|
||||
return diff, True
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch_text", fake_fetch_text)
|
||||
|
||||
detail = await gitea_proxy.pull_review_detail("stackchain/api", 7)
|
||||
|
||||
source, binary = detail["files"]
|
||||
assert source["diff_lines"] == [
|
||||
"@@ -1,2 +1,3 @@",
|
||||
" context",
|
||||
"-old <value>",
|
||||
"+new <value>",
|
||||
]
|
||||
assert source["diff_truncated"] is True
|
||||
assert source["diff_available"] is True
|
||||
assert binary["diff_available"] is False
|
||||
assert binary["diff_binary"] is True
|
||||
@@ -29,17 +29,21 @@ async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_returns_503_when_gitea_is_unavailable(monkeypatch):
|
||||
async def test_readiness_endpoint_hides_unexpected_upstream_error_details(monkeypatch):
|
||||
async def unavailable_user():
|
||||
raise RuntimeError("connection refused")
|
||||
raise RuntimeError("secret internal upstream detail")
|
||||
|
||||
monkeypatch.setattr(main, "current_user", unavailable_user)
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert b'"status":"not_ready"' in response.body
|
||||
assert b'"error":"connection refused"' in response.body
|
||||
assert json.loads(response.body) == {
|
||||
"status": "not_ready",
|
||||
"service": "stackchain-dashboard",
|
||||
"error": "Gitea readiness check is temporarily unavailable",
|
||||
}
|
||||
assert b"secret internal upstream detail" not in response.body
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,548 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_live_snapshot_task():
|
||||
main._live_snapshot_task = None
|
||||
main._live_snapshot_value = None
|
||||
main._live_snapshot_created_at = None
|
||||
main._live_section_created_at = {
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_section_failure_count = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_section_retry_at = {
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_snapshot_refreshing_sections = set()
|
||||
yield
|
||||
main._live_snapshot_task = None
|
||||
main._live_snapshot_value = None
|
||||
main._live_snapshot_created_at = None
|
||||
main._live_section_created_at = {
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_section_failure_count = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_section_retry_at = {
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_snapshot_refreshing_sections = set()
|
||||
|
||||
|
||||
def payload(response):
|
||||
return json.loads(response.body)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(monkeypatch):
|
||||
calls = {"user": 0}
|
||||
|
||||
async def user():
|
||||
calls["user"] += 1
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def events(authenticated_user):
|
||||
assert authenticated_user["login"] == "timmy"
|
||||
return [{"type": "push"}]
|
||||
|
||||
async def updates():
|
||||
return {
|
||||
"items": [{"id": 42, "title": "Mentioned you"}],
|
||||
"page": 1,
|
||||
"total": 125,
|
||||
"has_more": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", events)
|
||||
monkeypatch.setattr(main, "notifications", updates)
|
||||
|
||||
response = await main.live_snapshot()
|
||||
result = payload(response)
|
||||
|
||||
assert calls["user"] == 1
|
||||
assert result["context"]["user"]["login"] == "timmy"
|
||||
assert result["events"] == [{"type": "push"}]
|
||||
assert result["notifications"] == [{"id": 42, "title": "Mentioned you"}]
|
||||
assert result["notification_pagination"] == {
|
||||
"page": 1, "total": 125, "has_more": True
|
||||
}
|
||||
assert result["sections"] == {
|
||||
"context": "fresh", "events": "fresh", "notifications": "fresh"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_keeps_fresh_context_when_activity_fails(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def failing_events(authenticated_user):
|
||||
raise ConnectionError("secret upstream detail")
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", failing_events)
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
|
||||
result = payload(await main.live_snapshot())
|
||||
|
||||
assert result["context"]["user"]["login"] == "timmy"
|
||||
assert result["events"] is None
|
||||
assert result["sections"] == {
|
||||
"context": "fresh",
|
||||
"events": "temporarily unavailable",
|
||||
"notifications": "fresh",
|
||||
}
|
||||
assert "secret" not in json.dumps(result)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_keeps_work_and_activity_when_notifications_fail(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def events(_authenticated_user):
|
||||
return [{"type": "push"}]
|
||||
|
||||
async def failing_updates():
|
||||
raise ConnectionError("private notification failure")
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", events)
|
||||
monkeypatch.setattr(main, "notifications", failing_updates)
|
||||
|
||||
result = payload(await main.live_snapshot())
|
||||
|
||||
assert result["context"]["user"]["login"] == "timmy"
|
||||
assert result["events"] == [{"type": "push"}]
|
||||
assert result["notifications"] is None
|
||||
assert result["sections"]["notifications"] == "temporarily unavailable"
|
||||
assert "private" not in json.dumps(result)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_keeps_fresh_activity_when_work_fails(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def failing_repos():
|
||||
raise ConnectionError("work unavailable")
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def events(authenticated_user):
|
||||
return [{"type": "push"}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", failing_repos)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", events)
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
|
||||
result = payload(await main.live_snapshot())
|
||||
|
||||
assert result["context"] is None
|
||||
assert result["events"] == [{"type": "push"}]
|
||||
assert result["sections"] == {
|
||||
"context": "temporarily unavailable",
|
||||
"events": "fresh",
|
||||
"notifications": "fresh",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_reuses_completed_snapshot_inside_freshness_window(monkeypatch):
|
||||
user_calls = 0
|
||||
release = asyncio.Event()
|
||||
|
||||
async def user():
|
||||
nonlocal user_calls
|
||||
user_calls += 1
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def blocked_repos():
|
||||
await release.wait()
|
||||
return []
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def events(authenticated_user):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", blocked_repos)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", events)
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
|
||||
first = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
second = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
assert user_calls == 1
|
||||
|
||||
release.set()
|
||||
await asyncio.gather(first, second)
|
||||
await main.live_snapshot()
|
||||
|
||||
assert user_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_snapshot_returns_immediately_while_one_refresh_revalidates(monkeypatch):
|
||||
now = 100.0
|
||||
refresh_started = asyncio.Event()
|
||||
release_refresh = asyncio.Event()
|
||||
builds = 0
|
||||
|
||||
async def snapshot(_sections=None):
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds == 2:
|
||||
refresh_started.set()
|
||||
await release_refresh.wait()
|
||||
return {
|
||||
"context": {"generation": builds},
|
||||
"events": [],
|
||||
"notifications": [],
|
||||
"sections": {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
|
||||
|
||||
first = payload(await main.live_snapshot())
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
stale_request = asyncio.create_task(main.live_snapshot())
|
||||
await refresh_started.wait()
|
||||
await asyncio.sleep(0)
|
||||
returned_immediately = stale_request.done()
|
||||
release_refresh.set()
|
||||
stale = payload(await stale_request)
|
||||
|
||||
assert returned_immediately is True
|
||||
assert first["context"]["generation"] == 1
|
||||
assert stale["context"]["generation"] == 1
|
||||
assert stale["freshness"]["stale"] is True
|
||||
assert stale["freshness"]["revalidating"] is True
|
||||
assert builds == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_failed_revalidation_enters_cooldown_and_keeps_last_snapshot(monkeypatch):
|
||||
now = 100.0
|
||||
builds = 0
|
||||
|
||||
async def snapshot(_sections=None):
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds > 1:
|
||||
raise ConnectionError("private upstream failure")
|
||||
return {
|
||||
"context": {"generation": 1},
|
||||
"events": [{"id": 1}],
|
||||
"notifications": [],
|
||||
"sections": {
|
||||
"context": "fresh",
|
||||
"events": "fresh",
|
||||
"notifications": "fresh",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
|
||||
|
||||
await main.live_snapshot()
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
stale = payload(await main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
degraded = payload(await main.live_snapshot())
|
||||
|
||||
assert stale["freshness"]["revalidating"] is True
|
||||
assert degraded["context"] == {"generation": 1}
|
||||
assert degraded["freshness"]["stale"] is True
|
||||
assert degraded["freshness"]["degraded"] is True
|
||||
assert degraded["freshness"]["last_refresh_failed"] is True
|
||||
assert degraded["freshness"]["revalidating"] is False
|
||||
assert degraded["freshness"]["retry_in_seconds"] > 0
|
||||
assert builds == 2
|
||||
assert "private" not in json.dumps(degraded)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_partial_refresh_updates_fresh_sections_and_retains_failed_sections(monkeypatch):
|
||||
now = 100.0
|
||||
builds = 0
|
||||
|
||||
async def snapshot(_sections=None):
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds == 1:
|
||||
return {
|
||||
"context": {"generation": 1},
|
||||
"events": [{"id": "last-known"}],
|
||||
"notifications": [{"id": 7}],
|
||||
"notification_pagination": {"page": 1, "total": 1, "has_more": False},
|
||||
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
|
||||
}
|
||||
return {
|
||||
"context": {"generation": 2},
|
||||
"events": None,
|
||||
"notifications": [{"id": 8}],
|
||||
"notification_pagination": {"page": 1, "total": 1, "has_more": False},
|
||||
"sections": {
|
||||
"context": "fresh",
|
||||
"events": "temporarily unavailable",
|
||||
"notifications": "fresh",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
|
||||
|
||||
await main.live_snapshot()
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
await main.live_snapshot()
|
||||
await asyncio.sleep(0)
|
||||
result = payload(await main.live_snapshot())
|
||||
|
||||
assert result["context"] == {"generation": 2}
|
||||
assert result["events"] == [{"id": "last-known"}]
|
||||
assert result["notifications"] == [{"id": 8}]
|
||||
assert result["sections"] == {
|
||||
"context": "fresh",
|
||||
"events": "stale",
|
||||
"notifications": "fresh",
|
||||
}
|
||||
assert result["freshness"]["degraded"] is True
|
||||
assert result["freshness"]["revalidating"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_cooldown_does_not_stop_due_work_and_activity_refreshes(monkeypatch):
|
||||
now = 100.0
|
||||
calls = {"context": 0, "events": 0, "notifications": 0}
|
||||
event_refresh_times = []
|
||||
notification_outage = False
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def work():
|
||||
calls["context"] += 1
|
||||
return []
|
||||
|
||||
async def empty_work():
|
||||
return []
|
||||
|
||||
async def events(_authenticated_user):
|
||||
calls["events"] += 1
|
||||
event_refresh_times.append(now)
|
||||
return [{"generation": calls["events"]}]
|
||||
|
||||
async def updates():
|
||||
calls["notifications"] += 1
|
||||
if notification_outage:
|
||||
raise ConnectionError("notifications unavailable")
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", work)
|
||||
monkeypatch.setattr(main, "issues", empty_work)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_work)
|
||||
monkeypatch.setattr(main, "activity_events", events)
|
||||
monkeypatch.setattr(main, "notifications", updates)
|
||||
|
||||
await main.live_snapshot()
|
||||
notification_outage = True
|
||||
|
||||
for elapsed in (
|
||||
main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1,
|
||||
main.LIVE_SNAPSHOT_RETRY_BASE_SECONDS,
|
||||
):
|
||||
now += elapsed
|
||||
await main.live_snapshot()
|
||||
assert main._live_snapshot_task is not None
|
||||
await main._live_snapshot_task
|
||||
|
||||
# Notification backoff is now 10 seconds. The healthy sections become due
|
||||
# one second before that cooldown expires and must refresh independently.
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
response = payload(await main.live_snapshot())
|
||||
assert main._live_snapshot_task is not None
|
||||
await main._live_snapshot_task
|
||||
refreshed = payload(await main.live_snapshot())
|
||||
|
||||
assert calls == {"context": 3, "events": 3, "notifications": 3}
|
||||
assert event_refresh_times == [100.0, 109.0, 123.0]
|
||||
assert response["freshness"]["sections"]["context"]["revalidating"] is True
|
||||
assert refreshed["events"] == [{"generation": 3}]
|
||||
assert refreshed["sections"]["notifications"] == "stale"
|
||||
assert refreshed["freshness"]["sections"]["notifications"]["retry_in_seconds"] == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_retry_window_starts_one_refresh_and_success_clears_degraded_state(monkeypatch):
|
||||
now = 100.0
|
||||
builds = 0
|
||||
retry_started = asyncio.Event()
|
||||
release_retry = asyncio.Event()
|
||||
|
||||
async def snapshot(_sections=None):
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds == 1:
|
||||
return {
|
||||
"context": {"generation": 1}, "events": [], "notifications": [],
|
||||
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
|
||||
}
|
||||
if builds == 2:
|
||||
return {
|
||||
"context": None, "events": [], "notifications": [],
|
||||
"sections": {
|
||||
"context": "temporarily unavailable",
|
||||
"events": "fresh",
|
||||
"notifications": "fresh",
|
||||
},
|
||||
}
|
||||
retry_started.set()
|
||||
await release_retry.wait()
|
||||
return {
|
||||
"context": {"generation": 3}, "events": [], "notifications": [],
|
||||
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
|
||||
|
||||
await main.live_snapshot()
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
await main.live_snapshot()
|
||||
await asyncio.sleep(0)
|
||||
now += main.LIVE_SNAPSHOT_RETRY_BASE_SECONDS
|
||||
|
||||
first = asyncio.create_task(main.live_snapshot())
|
||||
second = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
assert retry_started.is_set()
|
||||
assert builds == 3
|
||||
assert payload(await first)["freshness"]["revalidating"] is True
|
||||
assert payload(await second)["freshness"]["revalidating"] is True
|
||||
|
||||
release_retry.set()
|
||||
refresh_task = main._live_snapshot_task
|
||||
assert refresh_task is not None
|
||||
await refresh_task
|
||||
recovered = payload(await main.live_snapshot())
|
||||
|
||||
assert recovered["context"] == {"generation": 3}
|
||||
assert recovered["freshness"]["degraded"] is False
|
||||
assert recovered["freshness"]["last_refresh_failed"] is False
|
||||
assert recovered["freshness"]["retry_in_seconds"] == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancelling_one_waiter_does_not_cancel_the_shared_snapshot(monkeypatch):
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
snapshot_calls = 0
|
||||
|
||||
async def blocked_snapshot(_sections=None):
|
||||
nonlocal snapshot_calls
|
||||
snapshot_calls += 1
|
||||
started.set()
|
||||
await release.wait()
|
||||
return {
|
||||
"context": {},
|
||||
"events": [],
|
||||
"notifications": [],
|
||||
"sections": {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", blocked_snapshot)
|
||||
|
||||
disconnected = asyncio.create_task(main.live_snapshot())
|
||||
await started.wait()
|
||||
survivor = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
disconnected.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await disconnected
|
||||
|
||||
assert main._live_snapshot_task is not None
|
||||
assert not main._live_snapshot_task.done()
|
||||
|
||||
release.set()
|
||||
response = await survivor
|
||||
|
||||
assert response.status_code == 200
|
||||
assert snapshot_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shared_snapshot_deadline_cancels_upstream_work_for_all_waiters(monkeypatch):
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def snapshot_that_exceeds_deadline(_sections=None):
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot_that_exceeds_deadline)
|
||||
monkeypatch.setattr(main, "CONTEXT_TIMEOUT_SECONDS", 0.01)
|
||||
|
||||
first = asyncio.create_task(main.live_snapshot())
|
||||
await started.wait()
|
||||
second = asyncio.create_task(main.live_snapshot())
|
||||
|
||||
try:
|
||||
responses = await asyncio.gather(first, second)
|
||||
await asyncio.wait_for(cancelled.wait(), timeout=0.1)
|
||||
|
||||
assert [response.status_code for response in responses] == [503, 503]
|
||||
assert [payload(response)["error"] for response in responses] == [
|
||||
"Gitea live snapshot timed out after 0.01s",
|
||||
"Gitea live snapshot timed out after 0.01s",
|
||||
]
|
||||
finally:
|
||||
task = main._live_snapshot_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,76 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_api_is_bounded_and_never_cacheable(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def detail(thread_id):
|
||||
calls.append(thread_id)
|
||||
return {
|
||||
"id": thread_id,
|
||||
"repository": "stackchain/api",
|
||||
"title": "Retry failed deploy",
|
||||
"subject_type": "Issue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"subject_body": "Deploy fails after three retries.",
|
||||
"latest_comment": {
|
||||
"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",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "notification_detail", detail, 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")
|
||||
invalid = await client.get("/api/v1/notifications/0")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["latest_comment"]["author"] == "alexander"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert invalid.status_code == 422
|
||||
assert calls == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_timeout_is_sanitized_and_retryable(monkeypatch):
|
||||
async def detail(_thread_id):
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
monkeypatch.setattr(main, "notification_detail", detail)
|
||||
monkeypatch.setattr(main, "NOTIFICATION_DETAIL_TIMEOUT_SECONDS", 0.01)
|
||||
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/notifications/42")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"error": "Loading the update timed out. Please retry."}
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_upstream_failure_does_not_leak_exception(monkeypatch):
|
||||
async def detail(_thread_id):
|
||||
raise httpx.HTTPError("token=secret upstream exploded")
|
||||
|
||||
monkeypatch.setattr(main, "notification_detail", detail)
|
||||
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/notifications/42")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {
|
||||
"error": "The update is temporarily unavailable. Please retry."
|
||||
}
|
||||
assert "secret" not in response.text
|
||||
assert response.headers["retry-after"] == "1"
|
||||
@@ -0,0 +1,33 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_page_endpoint_loads_only_requested_page_and_is_not_cacheable(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def page_loader(page):
|
||||
requested.append(page)
|
||||
return {
|
||||
"items": [{"id": 51, "title": "Older update"}],
|
||||
"page": page,
|
||||
"total": 125,
|
||||
"has_more": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "notification_page", page_loader)
|
||||
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?page=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"items": [{"id": 51, "title": "Older update"}],
|
||||
"page": 2,
|
||||
"total": 125,
|
||||
"has_more": True,
|
||||
}
|
||||
assert requested == [2]
|
||||
@@ -0,0 +1,241 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
class Client:
|
||||
async def patch(self, path, headers):
|
||||
calls.append((path, headers))
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
|
||||
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
|
||||
|
||||
await gitea_proxy.mark_notification_read(42)
|
||||
|
||||
assert calls == [
|
||||
("/api/v1/notifications/threads/42?to-status=read", {"Authorization": "token test"})
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeypatch):
|
||||
marked = []
|
||||
|
||||
async def mark(thread_id):
|
||||
marked.append(thread_id)
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch("/api/v1/notifications/42/read")
|
||||
invalid = await client.patch("/api/v1/notifications/0/read")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": 42, "status": "read"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert invalid.status_code == 422
|
||||
assert marked == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bulk_mark_read_reports_partial_progress_and_retains_only_failures(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def mark(thread_id):
|
||||
calls.append(thread_id)
|
||||
if thread_id == 43:
|
||||
raise httpx.HTTPError("upstream unavailable")
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_live_snapshot_value",
|
||||
{"notifications": [{"id": 42}, {"id": 43}, {"id": 44}]},
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/notifications/read", json={"ids": [42, 43, 42, 44]}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"marked": [42, 44], "failed": [43]}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert sorted(calls) == [42, 43, 44]
|
||||
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bulk_mark_read_limits_upstream_concurrency(monkeypatch):
|
||||
active = 0
|
||||
peak_active = 0
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(main, "_read_notification_ids", set())
|
||||
monkeypatch.setattr(main, "_live_snapshot_value", None)
|
||||
|
||||
async def mark(thread_id):
|
||||
nonlocal active, peak_active
|
||||
calls.append(thread_id)
|
||||
active += 1
|
||||
peak_active = max(peak_active, active)
|
||||
if active == 5:
|
||||
started.set()
|
||||
try:
|
||||
await release.wait()
|
||||
finally:
|
||||
active -= 1
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
request = asyncio.create_task(
|
||||
client.patch("/api/v1/notifications/read", json={"ids": list(range(1, 51))})
|
||||
)
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
await asyncio.sleep(0)
|
||||
assert peak_active == 5
|
||||
assert len(calls) == 5
|
||||
release.set()
|
||||
response = await request
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"marked": list(range(1, 51)), "failed": []}
|
||||
assert sorted(calls) == list(range(1, 51))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bulk_mark_read_cancels_unfinished_work_at_batch_deadline(monkeypatch):
|
||||
active = 0
|
||||
cancelled = []
|
||||
|
||||
async def mark(thread_id):
|
||||
nonlocal active
|
||||
active += 1
|
||||
try:
|
||||
await asyncio.sleep(0.08)
|
||||
except asyncio.CancelledError:
|
||||
cancelled.append(thread_id)
|
||||
raise
|
||||
finally:
|
||||
active -= 1
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
monkeypatch.setattr(main, "BULK_NOTIFICATION_DEADLINE_SECONDS", 0.01, raising=False)
|
||||
monkeypatch.setattr(main, "_read_notification_ids", set())
|
||||
monkeypatch.setattr(main, "_live_snapshot_value", None)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
started_at = time.monotonic()
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/notifications/read", json={"ids": list(range(1, 11))}
|
||||
)
|
||||
elapsed = time.monotonic() - started_at
|
||||
|
||||
assert elapsed < 0.06
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"marked": [], "failed": list(range(1, 11))}
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert active == 0
|
||||
assert sorted(cancelled) == list(range(1, 6))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bulk_mark_read_rejects_empty_invalid_and_oversized_batches(monkeypatch):
|
||||
marked = []
|
||||
|
||||
async def mark(thread_id):
|
||||
marked.append(thread_id)
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
empty = await client.patch("/api/v1/notifications/read", json={"ids": []})
|
||||
invalid = await client.patch(
|
||||
"/api/v1/notifications/read", json={"ids": [42, 0]}
|
||||
)
|
||||
oversized = await client.patch(
|
||||
"/api/v1/notifications/read", json={"ids": list(range(1, 52))}
|
||||
)
|
||||
|
||||
assert [empty.status_code, invalid.status_code, oversized.status_code] == [
|
||||
422,
|
||||
422,
|
||||
422,
|
||||
]
|
||||
assert all(response.headers["cache-control"] == "no-store" for response in [empty, invalid, oversized])
|
||||
assert marked == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mark_notification_read_removes_thread_from_retained_live_snapshot(monkeypatch):
|
||||
async def mark(_thread_id):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_live_snapshot_value",
|
||||
{
|
||||
"context": {},
|
||||
"events": [],
|
||||
"notifications": [{"id": 42}, {"id": 43}],
|
||||
"sections": {"notifications": "fresh"},
|
||||
},
|
||||
)
|
||||
|
||||
response = await main.read_notification(42)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert main._live_snapshot_value is not None
|
||||
assert main._live_snapshot_value["notifications"] == [{"id": 43}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_inflight_refresh_cannot_restore_a_notification_marked_read(monkeypatch):
|
||||
refresh_started = asyncio.Event()
|
||||
release_refresh = asyncio.Event()
|
||||
|
||||
async def mark(_thread_id):
|
||||
return None
|
||||
|
||||
async def stale_upstream_snapshot(_sections):
|
||||
refresh_started.set()
|
||||
await release_refresh.wait()
|
||||
return {
|
||||
"context": {},
|
||||
"events": [],
|
||||
"notifications": [{"id": 42}, {"id": 43}],
|
||||
"sections": {"notifications": "fresh"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
monkeypatch.setattr(
|
||||
main, "_build_live_snapshot_before_deadline", stale_upstream_snapshot
|
||||
)
|
||||
monkeypatch.setattr(main, "_live_snapshot_task", None)
|
||||
monkeypatch.setattr(main, "_live_snapshot_value", {"notifications": [{"id": 42}]})
|
||||
|
||||
refresh = main._start_live_snapshot_refresh(set(main.LIVE_SNAPSHOT_SECTIONS))
|
||||
await refresh_started.wait()
|
||||
await main.read_notification(42)
|
||||
release_refresh.set()
|
||||
await refresh
|
||||
|
||||
assert main._live_snapshot_value is not None
|
||||
assert main._live_snapshot_value["notifications"] == [{"id": 43}]
|
||||
@@ -0,0 +1,90 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_reply_api_validates_and_posts_without_marking_read(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def reply(thread_id, body):
|
||||
calls.append((thread_id, body))
|
||||
return {
|
||||
"id": 91,
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
|
||||
async def must_not_mark_read(_thread_id):
|
||||
raise AssertionError("replying must not mark the update read")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
|
||||
monkeypatch.setattr(main, "mark_notification_read", must_not_mark_read)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/notifications/42/reply",
|
||||
json={"body": " Please retry the worker. "},
|
||||
)
|
||||
empty = await client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": " "}
|
||||
)
|
||||
oversized = await client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": "x" * 10_001}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {
|
||||
"id": 91,
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert empty.status_code == 422
|
||||
assert oversized.status_code == 422
|
||||
assert calls == [(42, "Please retry the worker.")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("failure", ["timeout", "upstream"])
|
||||
async def test_notification_reply_failure_is_sanitized_retryable_and_no_store(
|
||||
monkeypatch, failure
|
||||
):
|
||||
async def reply(_thread_id, _body):
|
||||
if failure == "timeout":
|
||||
await asyncio.sleep(0.05)
|
||||
raise httpx.HTTPError("token=secret upstream exploded")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
|
||||
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 0.01)
|
||||
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": "Retry it"}
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {
|
||||
"error": "The reply could not be posted. Your draft is safe; please retry."
|
||||
}
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert "secret" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_reply_cannot_be_invoked_by_a_foreign_browser_origin():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.options(
|
||||
"/api/v1/notifications/42/reply",
|
||||
headers={
|
||||
"Origin": "https://evil.example",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "content-type",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code != 200
|
||||
assert "access-control-allow-origin" not in response.headers
|
||||
@@ -0,0 +1,192 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
|
||||
async def assigned(repository, number):
|
||||
return (repository, number) == ("stackchain/api", 7)
|
||||
|
||||
async def detail(repository, number):
|
||||
assert (repository, number) == ("stackchain/api", 7)
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": "Ship mobile flow",
|
||||
"body": "Ready to merge",
|
||||
"url": "https://forge.example/stackchain/api/pulls/7",
|
||||
"author": "alex",
|
||||
"head_sha": "abc123",
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"mergeable": True,
|
||||
"merged": False,
|
||||
"ci_state": "success",
|
||||
"files": [{"filename": "src/api.py", "additions": 8, "deletions": 2}],
|
||||
"comments": [{"id": 9, "author": "sam", "body": "Ship it"}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "pull_completion_detail", detail, 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/detail")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["head_sha"] == "abc123"
|
||||
assert response.json()["mergeable"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_detail_rejects_unassigned_pull(monkeypatch):
|
||||
async def assigned(repository, number):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned, 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/private/secret/pulls/9/detail")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_assigned_pull_detail_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_detail("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
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_comment_posts_only_after_assignment_check(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
return True
|
||||
|
||||
async def comment(repository, number, body):
|
||||
calls.append((repository, number, body))
|
||||
return {"id": 91, "author": "timmy", "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)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/comments",
|
||||
json={"body": " Ready to ship. "},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert calls == [("stackchain/api", 7, "Ready to ship.")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_merge_requires_current_eligible_head(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
return True
|
||||
|
||||
async def merge(repository, number, expected_head_sha):
|
||||
calls.append((repository, number, expected_head_sha))
|
||||
return {"number": number, "merged": True, "state": "closed"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge, raising=False)
|
||||
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/pulls/7/merge",
|
||||
json={"expected_head_sha": "abc123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"number": 7, "merged": True, "state": "closed"}
|
||||
assert calls == [("stackchain/api", 7, "abc123")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_merge_returns_conflict_without_mutating_stale_head(monkeypatch):
|
||||
async def assigned(repository, number):
|
||||
return True
|
||||
|
||||
async def merge(*args):
|
||||
raise gitea_proxy.StalePullError("changed")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge, raising=False)
|
||||
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/pulls/7/merge",
|
||||
json={"expected_head_sha": "abc123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "New commits" in response.json()["error"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_merge_rejects_failed_ci_without_upstream_mutation():
|
||||
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, "state": "open", "draft": False, "mergeable": True,
|
||||
"merged": False, "head": {"sha": "abc123"},
|
||||
})
|
||||
return httpx.Response(200, json={"state": "failure"})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.PullNotMergeableError):
|
||||
await gitea_proxy.merge_assigned_pull("stackchain/api", 7, "abc123")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("GET", "/api/v1/repos/stackchain/api/commits/abc123/status"),
|
||||
]
|
||||
@@ -0,0 +1,396 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_detail_endpoint_returns_normalized_no_store_payload(monkeypatch):
|
||||
async def requested(repository, number):
|
||||
return (repository, number) == ("stackchain/api", 7)
|
||||
|
||||
async def detail(repository, number):
|
||||
assert (repository, number) == ("stackchain/api", 7)
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": "Review API",
|
||||
"body": "Check retries",
|
||||
"url": "https://forge.example/stackchain/api/pulls/7",
|
||||
"author": "alex",
|
||||
"ci_state": "success",
|
||||
"files": [{"filename": "src/api.py", "status": "modified", "additions": 8, "deletions": 2}],
|
||||
"reviews": [{"user": {"login": "sam"}, "state": "APPROVED", "body": "Good"}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main, "pull_review_detail", detail)
|
||||
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")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["files"][0]["filename"] == "src/api.py"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_detail_rejects_pulls_not_requested_from_service_user(monkeypatch):
|
||||
async def requested(repository, number):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
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/private/secret/pulls/9/review")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_detail_has_one_retryable_deadline_and_cancels_pending_work(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def requested(repository, number):
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main, "REVIEW_DETAIL_TIMEOUT_SECONDS", 0.01)
|
||||
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")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert response.json() == {
|
||||
"error": "Pull request review details timed out. Please retry."
|
||||
}
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_submission_posts_validated_decision_for_requested_current_head(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def requested(repository, number):
|
||||
return (repository, number) == ("stackchain/api", 7)
|
||||
|
||||
async def submit(repository, number, expected_head_sha, decision, body):
|
||||
calls.append((repository, number, expected_head_sha, decision, body))
|
||||
return {"id": 91, "state": "APPROVED", "url": "https://forge.example/reviews/91"}
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit, raising=False)
|
||||
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/pulls/7/review",
|
||||
json={
|
||||
"decision": "approve",
|
||||
"body": "Looks good on mobile.",
|
||||
"expected_head_sha": "abc123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"id": 91,
|
||||
"state": "APPROVED",
|
||||
"url": "https://forge.example/reviews/91",
|
||||
}
|
||||
assert calls == [
|
||||
("stackchain/api", 7, "abc123", "approve", "Looks good on mobile.")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_submission_forwards_valid_inline_comments(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def requested(repository, number):
|
||||
return True
|
||||
|
||||
async def submit(repository, number, expected_head_sha, decision, body, comments):
|
||||
calls.append((repository, number, expected_head_sha, decision, body, comments))
|
||||
return {"id": 92, "state": "REQUEST_CHANGES", "url": "https://forge.example/reviews/92"}
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
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/pulls/7/review",
|
||||
json={
|
||||
"decision": "request_changes",
|
||||
"body": "One inline blocker.",
|
||||
"expected_head_sha": "abc123",
|
||||
"comments": [{
|
||||
"path": "src/api.py", "body": "Handle the empty value.",
|
||||
"new_position": 42,
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert calls == [(
|
||||
"stackchain/api", 7, "abc123", "request_changes", "One inline blocker.",
|
||||
[{"path": "src/api.py", "body": "Handle the empty value.", "new_position": 42, "old_position": None}],
|
||||
)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_review_submission_checks_head_then_maps_decision_upstream():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={"head": {"sha": "abc123"}})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 91,
|
||||
"state": "APPROVED",
|
||||
"html_url": "https://forge.example/reviews/91",
|
||||
},
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.submit_pull_review(
|
||||
"stackchain/api", 7, "abc123", "approve", "Looks good."
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("POST", "/api/v1/repos/stackchain/api/pulls/7/reviews"),
|
||||
]
|
||||
assert requests[1].content == b'{"body":"Looks good.","event":"APPROVE","commit_id":"abc123"}'
|
||||
assert result == {
|
||||
"id": 91,
|
||||
"state": "APPROVED",
|
||||
"url": "https://forge.example/reviews/91",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_review_submission_sends_inline_comments_in_single_review_request():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path.endswith("/files"):
|
||||
return httpx.Response(200, json=[{"filename": "src/api.py"}])
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={"head": {"sha": "abc123"}})
|
||||
return httpx.Response(200, json={"id": 93, "state": "COMMENT"})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
await gitea_proxy.submit_pull_review(
|
||||
"stackchain/api", 7, "abc123", "comment", "Summary",
|
||||
[{"path": "src/api.py", "body": "Handle empty values.", "new_position": 42, "old_position": None}],
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7/files"),
|
||||
("POST", "/api/v1/repos/stackchain/api/pulls/7/reviews"),
|
||||
]
|
||||
assert requests[-1].content == (
|
||||
b'{"body":"Summary","event":"COMMENT","commit_id":"abc123","comments":'
|
||||
b'[{"path":"src/api.py","body":"Handle empty values.","new_position":42,"old_position":null}]}'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_review_submission_rejects_changed_head_without_posting():
|
||||
methods = []
|
||||
|
||||
async def handler(request):
|
||||
methods.append(request.method)
|
||||
return httpx.Response(200, json={"head": {"sha": "new456"}})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.StaleReviewError):
|
||||
await gitea_proxy.submit_pull_review(
|
||||
"stackchain/api", 7, "abc123", "request_changes", "Please revise."
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert methods == ["GET"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_review_submission_rejects_inline_comment_for_unchanged_path_without_posting():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.url.path.endswith("/files"):
|
||||
return httpx.Response(200, json=[{"filename": "src/api.py"}])
|
||||
return httpx.Response(200, json={"head": {"sha": "abc123"}})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.InvalidReviewCommentError):
|
||||
await gitea_proxy.submit_pull_review(
|
||||
"stackchain/api", 7, "abc123", "comment", "Review note.",
|
||||
[{"path": "src/unknown.py", "body": "Not in this change", "new_position": 4}],
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7/files"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_submission_returns_conflict_when_pull_head_changed(monkeypatch):
|
||||
async def requested(repository, number):
|
||||
return True
|
||||
|
||||
async def submit(*args):
|
||||
raise gitea_proxy.StaleReviewError("changed")
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
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/pulls/7/review",
|
||||
json={
|
||||
"decision": "request_changes",
|
||||
"body": "Please revise.",
|
||||
"expected_head_sha": "abc123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": "New commits were pushed. Refresh the review before submitting."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_submission_returns_validation_error_when_inline_path_is_stale(monkeypatch):
|
||||
async def requested(repository, number):
|
||||
return True
|
||||
|
||||
async def submit(*args):
|
||||
raise gitea_proxy.InvalidReviewCommentError("unknown path")
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
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/pulls/7/review",
|
||||
json={
|
||||
"decision": "comment", "body": "Note", "expected_head_sha": "abc123",
|
||||
"comments": [{"path": "gone.py", "body": "Stale", "new_position": 1}],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {
|
||||
"error": "An inline comment no longer matches this pull request. Refresh the review."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_submission_rejects_unsupported_decision_before_upstream(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def requested(repository, number):
|
||||
nonlocal called
|
||||
called = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
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/pulls/7/review",
|
||||
json={
|
||||
"decision": "merge",
|
||||
"body": "Ship it.",
|
||||
"expected_head_sha": "abc123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_submission_rejects_oversized_feedback_before_upstream(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def requested(repository, number):
|
||||
nonlocal called
|
||||
called = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
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/pulls/7/review",
|
||||
json={
|
||||
"decision": "comment",
|
||||
"body": "x" * 10_001,
|
||||
"expected_head_sha": "abc123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_submission_deadline_cancels_request_check(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def requested(repository, number):
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main, "REVIEW_DETAIL_TIMEOUT_SECONDS", 0.01)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await asyncio.wait_for(
|
||||
client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={
|
||||
"decision": "comment",
|
||||
"body": "Review note.",
|
||||
"expected_head_sha": "abc123",
|
||||
},
|
||||
),
|
||||
timeout=0.2,
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert cancelled.is_set()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_one_context_snapshot_updates_every_context_backed_panel():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/context-poller.js"></script>' in html
|
||||
assert "createContextPoller({" in html
|
||||
assert "renderContextSnapshot(data)" in html
|
||||
assert "renderRepoMix(qs('#repo-mix'), data)" in html
|
||||
assert "setInterval(tickWidgets, 2000)" not in html
|
||||
assert "updateRepoMix(qs('#repo-mix'), fetch)" not in html
|
||||
assert "setInterval(load, 8000)" not in html
|
||||
assert "document.addEventListener('visibilitychange'" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_one_live_snapshot_updates_work_and_activity_on_one_timer():
|
||||
html = await dashboard()
|
||||
|
||||
assert "fetch('api/v1/live'" in html
|
||||
assert "renderLiveSnapshot(snapshot)" in html
|
||||
assert "renderContextSnapshot(snapshot.context)" in html
|
||||
assert "paintEventStream(snapshot.events)" in html
|
||||
assert "loadEventStream" not in html
|
||||
assert "5000" not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_announces_when_recent_snapshot_is_revalidating():
|
||||
html = await dashboard()
|
||||
|
||||
assert "snapshot.freshness?.revalidating" in html
|
||||
assert "Refreshing · showing recent snapshot" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_announces_failed_refresh_and_retry_without_blanking_panels():
|
||||
html = await dashboard()
|
||||
|
||||
assert "snapshot.freshness?.degraded" in html
|
||||
assert "Refresh failed · showing last known data" in html
|
||||
assert "snapshot.freshness.retry_in_seconds" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_reports_each_live_section_from_its_own_freshness():
|
||||
html = await dashboard()
|
||||
|
||||
assert "snapshot.freshness?.sections?.notifications" in html
|
||||
assert "snapshot.freshness?.sections?.events" in html
|
||||
assert "const notificationsFresh = Array.isArray(snapshot.notifications)" not in html
|
||||
assert "Unread updates unavailable · showing last known updates" in html
|
||||
assert "Activity refresh failed · showing last activity" in html
|
||||
@@ -10,3 +10,12 @@ async def test_dashboard_renders_outside_repository_working_directory(monkeypatc
|
||||
html = await dashboard()
|
||||
|
||||
assert "Stackchain Dashboard" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_viewport_allows_mobile_zoom():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'name="viewport"' in html
|
||||
assert "width=device-width" in html
|
||||
assert "user-scalable=no" not in html
|
||||
@@ -1,11 +1,58 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
WIDGETS = Path(__file__).parents[1] / "frontend" / "widgets.js"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_repo_mix_placeholder_before_javascript_runs():
|
||||
html = await dashboard()
|
||||
server_rendered_html = html.split("<script>", 1)[0]
|
||||
|
||||
assert '<div class="small" id="repo-mix">Loading…</div>' in server_rendered_html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_marks_fallback_context_as_degraded_instead_of_live():
|
||||
html = await dashboard()
|
||||
|
||||
assert "data.error ? 'Degraded · ' + data.error : 'Live · updated just now'" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_does_not_offer_fabricated_work_when_context_fetch_fails():
|
||||
html = await dashboard()
|
||||
|
||||
assert "Work items unavailable." in html
|
||||
assert "Kickoff issue" not in html
|
||||
assert "Live UI implementation" not in html
|
||||
assert "Docker build and push" not in html
|
||||
assert "Agent UI delta binding" not in html
|
||||
|
||||
|
||||
def test_repo_mix_reports_unavailable_for_failed_context_response():
|
||||
script = f"""
|
||||
const updateRepoMix = require({json.dumps(str(WIDGETS))});
|
||||
const element = {{ innerHTML: 'stale counts' }};
|
||||
const failedFetch = async () => ({{
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({{ error: 'upstream unavailable' }}),
|
||||
}});
|
||||
|
||||
updateRepoMix(element, failedFetch).then(() => {{
|
||||
if (!element.innerHTML.includes('Widget unavailable.')) {{
|
||||
throw new Error(`expected unavailable state, got: ${{element.innerHTML}}`);
|
||||
}}
|
||||
}});
|
||||
"""
|
||||
|
||||
subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def page_loader(stream, page):
|
||||
requested.append((stream, page))
|
||||
return {
|
||||
"stream": stream,
|
||||
"page": page,
|
||||
"total": 84,
|
||||
"has_more": False,
|
||||
"items": [{
|
||||
"id": 51, "number": 51, "title": "Older issue", "state": "open",
|
||||
"labels": [], "assignees": [{"login": "timmy"}],
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"updated_at": "2026-08-07T10:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/51",
|
||||
}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_page", page_loader)
|
||||
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/issue?page=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert requested == [("issue", 2)]
|
||||
assert response.json() == {
|
||||
"stream": "issue", "page": 2, "total": 84, "has_more": False,
|
||||
"items": [{
|
||||
"id": 51, "number": 51, "title": "Older issue", "state": "open",
|
||||
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
|
||||
"updated_at": "2026-08-07T10:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/51",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_page_endpoint_rejects_unknown_stream_before_upstream_io(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def page_loader(stream, page):
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_page", page_loader)
|
||||
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/unknown?page=2")
|
||||
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
Reference in new issue
Block a user