Compare commits

...
Author SHA1 Message Date
timmy 87058d72e6 feat: release mobile issue assignments (#183)
CI / lint (pull_request) Successful in 14s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 09:24:21 +00:00
rockachopa e96ff42dff Merge pull request 'Make Find Work production-schema-safe and truthfully paginated' (#182) from timmy/181-find-work-schema-pagination into main
CI / lint (push) Successful in 12s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 08:59:02 +00:00
timmy b6c7ec83e4 feat: make Find Work production-schema-safe (#181)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 08:58:07 +00:00
rockachopa b4f984e331 Preview issue details before claiming mobile work (#180)
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Closes #179
2026-08-07 08:23:29 +00:00
timmy b4b13a7db1 feat: preview issue details before claiming (#179)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 08:22:43 +00:00
rockachopa 1947f878e9 Make mobile issue capture idempotent across retries (#178)
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Closes #177
2026-08-07 07:55:30 +00:00
timmy 6f54d5764b feat: make mobile issue capture idempotent (#177)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 07:54:42 +00:00
rockachopa 9293dde1af Merge pull request 'Discover and claim unassigned issues from mobile My Work' (#176) from timmy/175-mobile-find-work into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 07:31:45 +00:00
timmy 1f997cc504 feat: discover and claim mobile work (#175)
CI / lint (pull_request) Successful in 14s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 07:30:43 +00:00
rockachopa f9e11fca6a Merge pull request 'Reprioritize assigned issues from the mobile issue sheet' (#174) from timmy/173-mobile-issue-labels into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 06:53:49 +00:00
timmy 390ad0d4d2 feat: reprioritize assigned issues from mobile (#173)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 06:52:53 +00:00
timmy b673911e36 Merge pull request 'Make My Work authorization constant-request and deadline-safe' (#172) from timmy/171-constant-request-work-auth into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 05:53:22 +00:00
timmy 484c0d4aeb perf: bound My Work authorization requests (#171)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 05:52:28 +00:00
rockachopa 5ce29fbe7d Merge pull request 'Paginate mobile My Work beyond the first 50 items' (#170) from timmy/169-paginate-mobile-my-work into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
2026-08-07 05:36:55 +00:00
timmy 625e65e7e4 feat: paginate mobile My Work queues (#169)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 05:35:45 +00:00
rockachopa 29a786cea3 Merge pull request 'Keep healthy live sections refreshing during partial outages' (#168) from timmy/167-section-isolated-live-refresh into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 04:54:28 +00:00
timmy 4870b58c5b feat: isolate live section refresh backoff (#167)
CI / lint (pull_request) Successful in 15s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 04:53:42 +00:00
timmy 5fe0b28747 Merge pull request 'Add draft-safe inline comments to mobile pull request reviews' (#166) from timmy/165-mobile-inline-review-comments into main
CI / lint (push) Successful in 12s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 6s
2026-08-07 04:29:40 +00:00
timmy 58376f6d45 feat: add inline mobile review comments (#165)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 04:28:44 +00:00
timmy 4b7405aa65 Merge pull request 'Select priority labels while creating mobile issues' (#164) from timmy/163-mobile-issue-labels into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 03:56:56 +00:00
timmy b6390d3298 feat: triage mobile issue capture with labels (#163)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 03:55:51 +00:00
timmy e5f001f2b0 Merge pull request 'Complete assigned pull requests from mobile My Work' (#162) from timmy/161-mobile-assigned-pr-completion into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 03:31:10 +00:00
timmy 745c3779c2 feat: complete assigned pull requests from mobile (#161)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 03:30:21 +00:00
timmy 35e46f81f1 Merge pull request 'Create and self-assign issues from mobile My Work' (#160) from timmy/159-mobile-issue-capture into main
CI / lint (push) Successful in 15s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 02:59:05 +00:00
timmy bb394be8b2 feat: capture issues from mobile My Work (#159)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 02:58:14 +00:00
timmy 8c889e3ffc Merge pull request 'Complete assigned issues from mobile My Work' (#158) from timmy/157-mobile-issue-actions into main
CI / lint (push) Successful in 12s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 02:41:57 +00:00
timmy 1c0cacb2b8 feat: complete assigned issues from mobile My Work (#157)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 02:40:57 +00:00
timmy 30a014b619 Merge pull request 'Submit pull request reviews from mobile My Work' (#156) from timmy/155-mobile-review-submit into main
CI / lint (push) Successful in 11s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
2026-08-07 02:02:03 +00:00
timmy 9b81dce8aa feat: submit pull reviews from mobile My Work (#155)
CI / lint (pull_request) Successful in 11s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 02:01:18 +00:00
timmy 7f5e671f8d Merge pull request 'Reply to unread updates from mobile My Work' (#154) from timmy/153-mobile-update-replies into main
CI / lint (push) Successful in 11s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 01:02:56 +00:00
timmy 83de2a7499 feat: reply to unread updates in My Work (#153)
CI / lint (pull_request) Successful in 11s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 01:01:59 +00:00
timmy 62ebabb132 Merge pull request 'Read and triage unread updates inside mobile My Work' (#152) from timmy/151-mobile-update-reader into main
CI / lint (push) Successful in 11s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 00:36:31 +00:00
23 changed files with 6204 additions and 206 deletions

No files matched your search

+20 -3
View File
@@ -14,12 +14,29 @@ python3 -m pip install -r requirements.txt
```
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
token that can read dashboard data and update the authenticated user's
notification threads, 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 persists a per-draft idempotency key,
so retrying after a timeout or reload replays a confirmed creation instead of posting a
duplicate; callers integrating directly should preserve the `Idempotency-Key` header
with the unchanged payload until a `201` response is confirmed. Closing an assigned issue, native Comment,
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
```
+102
View File
@@ -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;
+1010 -10
View File
File diff suppressed because it is too large. Load diff
+99
View File
@@ -0,0 +1,99 @@
function createIssueSheet({ fetchJson, storage }) {
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
let labelRequest = 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 labelDraftKey = item => 'stackchain.issue-labels.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 { storage?.setItem(draftKey(item), body); }
catch (_error) { /* The textarea remains the fallback. */ }
},
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);
commentRequest = fetchJson(issuePath(item) + '/comments', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
}).then(result => {
try { storage?.removeItem(draftKey(item)); }
catch (_error) { /* The upstream comment is authoritative. */ }
return result;
}).finally(() => { commentRequest = null; });
return commentRequest;
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
+115
View File
@@ -169,6 +169,66 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus
};
}
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;
@@ -226,6 +286,39 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
};
}
function createNotificationReplier({ post, storage, onStatus }) {
let pending = false;
const keyFor = item => 'stackchain.update-reply.v1.' + item.notification_id;
return {
loadDraft(item) {
try { return storage.getItem(keyFor(item)) || ''; }
catch (_error) { return ''; }
},
saveDraft(item, body) {
try { 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);
onStatus('Sending reply…');
try {
const result = await post(item.notification_id, body);
try { storage.removeItem(keyFor(item)); }
catch (_error) { /* The posted reply is still authoritative. */ }
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);
@@ -233,6 +326,24 @@ function filterMyWork(items, selectedFilter) {
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 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;
@@ -255,6 +366,8 @@ function countMyWork(items) {
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.replaceIssueLabels = replaceIssueLabels;
buildMyWork.removeIssue = removeIssue;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
@@ -262,6 +375,8 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.notificationIds = notificationIds;
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
buildMyWork.createNotificationPager = createNotificationPager;
buildMyWork.createWorkPager = createWorkPager;
buildMyWork.createNotificationReader = createNotificationReader;
buildMyWork.createNotificationReplier = createNotificationReplier;
module.exports = buildMyWork;
}
+99
View File
@@ -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;
+63
View File
@@ -0,0 +1,63 @@
function mergeEligibility(detail) {
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' };
return { allowed: true, reason: 'Ready to merge' };
}
function createPullSheet({ fetchJson, storage }) {
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;
return {
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
},
saveDraft(item, body) {
try { storage?.setItem(draftKey(item), body); }
catch (_error) { /* The textarea remains the fallback. */ }
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
commentRequest = fetchJson(pathFor(item) + '/comments', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
}).then(result => {
try { storage?.removeItem(draftKey(item)); }
catch (_error) { /* The upstream comment is authoritative. */ }
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;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
+120 -12
View File
@@ -1,4 +1,6 @@
function createReviewController({ fetchJson }) {
let pendingSubmission = null;
function endpoint(item) {
const [owner, repo] = String(item.repository || '').split('/');
if (!owner || !repo || !Number.isInteger(Number(item.number))) {
@@ -12,7 +14,17 @@ function createReviewController({ fetchJson }) {
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
}
return { load };
function submit(item, payload) {
if (pendingSubmission) return pendingSubmission;
pendingSubmission = fetchJson(endpoint(item), {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).finally(() => { pendingSubmission = null; });
return pendingSubmission;
}
return { load, submit };
}
function diffLineClass(line) {
@@ -22,14 +34,54 @@ function diffLineClass(line) {
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 = (file.diff_lines || []).map(line =>
'<span class="review-diff-line ' + diffLineClass(String(line)) + '">' +
escapeHtml(String(line)) + '</span>'
).join('');
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>';
@@ -90,23 +142,40 @@ function createProgress({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, markReviewed };
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: {}, summary: '', decision: 'comment' };
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 (typeof saved.summary === 'string') draft.summary = saved.summary;
if (['comment', 'approve', 'request_changes'].includes(saved.decision)) {
draft.decision = saved.decision;
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 */ }
@@ -115,7 +184,10 @@ function createDraft({ storage, repository, number, headSha, files }) {
}
function snapshot() {
return { notes: { ...draft.notes }, summary: draft.summary, decision: draft.decision };
return {
notes: { ...draft.notes }, comments: draft.comments.map(comment => ({ ...comment })),
summary: draft.summary, decision: draft.decision,
};
}
function setNote(filename, note) {
@@ -126,6 +198,35 @@ function createDraft({ storage, repository, number, headSha, files }) {
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();
@@ -140,7 +241,13 @@ function createDraft({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, setNote, setSummary, setDecision };
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) {
@@ -183,6 +290,7 @@ async function copyAndContinue({ text, url, copy, open, fallback }) {
}
createReviewController.renderDiffFile = renderDiffFile;
createReviewController.parseDiffLines = parseDiffLines;
createReviewController.toggleDiff = toggleDiff;
createReviewController.createProgress = createProgress;
createReviewController.createDraft = createDraft;
+668 -22
View File
@@ -1,5 +1,6 @@
import asyncio
import os
import re
import shlex
from typing import Any
from urllib.parse import urlsplit
@@ -13,6 +14,34 @@ REVIEW_DIFF_MAX_LINES = 400
_client: httpx.AsyncClient | None = None
class WorkItems(list[dict]):
"""A list-compatible first page carrying truthful per-stream totals."""
def __init__(self, items: list[dict], pagination: dict[str, dict]):
super().__init__(items)
self.pagination = pagination
class StaleReviewError(ValueError):
"""Raised before mutation when a pull request head changed during review."""
class InvalidReviewCommentError(ValueError):
"""Raised before mutation when an inline comment cannot target this change."""
class StalePullError(ValueError):
"""Raised before merge when an assigned pull request head changed."""
class PullNotMergeableError(ValueError):
"""Raised before merge when current pull state or checks prohibit it."""
class IssueNotAvailableError(ValueError):
"""Raised before assignment when an issue is no longer open and unassigned."""
def _auth() -> dict[str, str]:
headers: dict[str, str] = {"Accept": "application/json"}
if GITEA_TOKEN:
@@ -112,10 +141,143 @@ async def repos() -> list[dict]:
return await fetch("user/repos?limit=50")
async def issues() -> list[dict]:
return await fetch(
"repos/issues/search?state=open&assigned=true&type=issues&limit=50"
WORK_SEARCHES = {
"issue": ("assigned=true", "issues", None),
"pull": ("assigned=true", "pulls", "assigned_to_me"),
"review": ("review_requested=true", "pulls", "review_requested"),
}
async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
"""Load exactly one bounded My Work stream page with upstream totals."""
selector, item_type, reason = WORK_SEARCHES[stream]
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
params={
"state": "open", selector.split("=", 1)[0]: "true",
"type": item_type, "limit": limit, "page": page,
},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea work search response was not a list")
items = [item for item in payload if isinstance(item, dict)]
if reason:
items = [{**item, "work_reasons": [reason]} for item in items]
try:
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
except (TypeError, ValueError):
total = len(items)
return {
"stream": stream,
"items": items,
"page": page,
"total": total,
"has_more": page * limit < total,
}
def _normalize_available_issue(item: Any) -> dict | None:
if (
not isinstance(item, dict)
or item.get("state") != "open"
or item.get("pull_request") is not None
or item.get("assignees") not in (None, [])
):
return None
labels_value = item.get("labels")
labels = labels_value if isinstance(labels_value, list) else []
repository_value = item.get("repository")
repository = repository_value if isinstance(repository_value, dict) else {}
return {
"id": item.get("id"),
"number": item.get("number"),
"title": item.get("title", "") if isinstance(item.get("title"), str) else "",
"body": item.get("body", "") if isinstance(item.get("body"), str) else "",
"state": "open",
"repository": repository.get("full_name", "")
if isinstance(repository.get("full_name"), str) else "",
"labels": [
label["name"] for label in labels
if isinstance(label, dict) and isinstance(label.get("name"), str)
],
"assignees": [],
"updated_at": item.get("updated_at", "")
if isinstance(item.get("updated_at"), str) else "",
"url": _safe_web_url(item.get("html_url")),
}
async def available_issue_snapshot(max_pages: int = 10, upstream_limit: int = 50) -> list[dict]:
"""Load, filter, and globally rank a bounded snapshot of available issues."""
items: list[dict] = []
seen_ids: set[Any] = set()
upstream_total: int | None = None
for upstream_page in range(1, max_pages + 1):
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
params={
"state": "open", "type": "issues",
"limit": upstream_limit, "page": upstream_page,
},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea available issue search response was not a list")
if upstream_total is None:
try:
upstream_total = max(0, int(response.headers["X-Total-Count"]))
except (KeyError, TypeError, ValueError):
upstream_total = None
for raw_item in payload:
item = _normalize_available_issue(raw_item)
if item is not None and item["id"] not in seen_ids:
seen_ids.add(item["id"])
items.append(item)
loaded = upstream_page * upstream_limit
if not payload or len(payload) < upstream_limit or (
upstream_total is not None and loaded >= upstream_total
):
break
priority = {"p0", "priority-high", "critical"}
items.sort(key=lambda item: (item["repository"], item["number"] or 0))
items.sort(key=lambda item: item["updated_at"], reverse=True)
items.sort(key=lambda item: (
0 if any(str(label).lower() in priority for label in item["labels"]) else 1
))
return items
async def available_issue_page(page: int = 1, limit: int = 50) -> dict:
"""Return one logical page from a bounded, globally ranked available-work scan."""
items = await available_issue_snapshot()
total = len(items)
start = (page - 1) * limit
page_items = items[start:start + limit]
return {
"items": page_items,
"page": page,
"total": total,
"has_more": start + len(page_items) < total,
}
def _page_metadata(result: dict) -> dict:
return {
"page": result["page"],
"total": result["total"],
"has_more": result["has_more"],
}
async def issues() -> WorkItems:
result = await work_page("issue")
return WorkItems(result["items"], {"issue": _page_metadata(result)})
def _safe_web_url(value: Any) -> str:
@@ -279,39 +441,475 @@ async def notification_detail(thread_id: int) -> dict:
}
async def pull_requests() -> list[dict]:
async def reply_to_notification(thread_id: int, body: str) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
raise ValueError("Gitea notification thread response was not an object")
repository = thread.get("repository")
subject = thread.get("subject")
if not isinstance(repository, dict) or not isinstance(subject, dict):
raise ValueError("Notification does not identify a conversation")
repository_name = repository.get("full_name")
subject_path = _gitea_api_path(subject.get("url"))
match = re.fullmatch(
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
)
if (
not match
or match.group(1) != repository_name
or subject.get("type") not in {"Issue", "Pull"}
):
raise ValueError("Notification subject is not a supported conversation")
response = await _get_client().post(
f"/api/v1/repos/{match.group(1)}/issues/{match.group(3)}/comments",
headers=_auth(),
json={"body": body},
)
response.raise_for_status()
comment = response.json()
if not isinstance(comment, dict):
raise ValueError("Gitea comment response was not an object")
return {
"id": comment.get("id"),
"url": _safe_web_url(comment.get("html_url")),
}
async def close_issue(repository: str, number: int) -> dict:
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"state": "closed"},
)
response.raise_for_status()
issue = response.json()
if not isinstance(issue, dict) or issue.get("state") != "closed":
raise ValueError("Gitea did not confirm issue closure")
return {
"number": issue.get("number"),
"state": "closed",
"closed_at": issue.get("closed_at", "")
if isinstance(issue.get("closed_at"), str)
else "",
}
def _normalize_issue_comment(comment: dict) -> dict:
user_value = comment.get("user")
user: dict = user_value if isinstance(user_value, dict) else {}
return {
"id": comment.get("id"),
"author": user.get("login", "") if isinstance(user.get("login"), str) else "",
"body": comment.get("body", "") if isinstance(comment.get("body"), str) else "",
"created_at": comment.get("created_at", "")
if isinstance(comment.get("created_at"), str)
else "",
"url": _safe_web_url(comment.get("html_url")),
}
async def comment_on_issue(repository: str, number: int, body: str) -> dict:
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues/{number}/comments",
headers=_auth(),
json={"body": body},
)
response.raise_for_status()
comment = response.json()
if not isinstance(comment, dict):
raise ValueError("Gitea comment response was not an object")
return _normalize_issue_comment(comment)
async def repo_labels(repository: str) -> list[dict]:
response = await _get_client().get(
f"/api/v1/repos/{repository}/labels",
headers=_auth(),
params={"limit": 50, "page": 1},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea labels response was not a list")
return [
{
"id": item["id"],
"name": item["name"],
"color": item.get("color", "") if isinstance(item.get("color"), str) else "",
"description": item.get("description", "")
if isinstance(item.get("description"), str)
else "",
}
for item in payload
if isinstance(item, dict)
and isinstance(item.get("id"), int)
and isinstance(item.get("name"), str)
]
async def create_issue(
repository: str,
title: str,
body: str,
assignee: str,
label_ids: list[int] | None = None,
) -> dict:
payload: dict = {"title": title, "body": body, "assignee": assignee}
if label_ids:
payload["labels"] = label_ids
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues",
headers=_auth(),
json=payload,
)
response.raise_for_status()
issue = response.json()
if not isinstance(issue, dict) or not isinstance(issue.get("number"), int):
raise ValueError("Gitea did not confirm issue creation")
assignees_value = issue.get("assignees")
assignees = assignees_value if isinstance(assignees_value, list) else []
confirmed_assignees = [
item["login"]
for item in assignees
if isinstance(item, dict) and isinstance(item.get("login"), str)
]
if assignee not in confirmed_assignees:
raise ValueError("Gitea did not confirm issue self-assignment")
labels_value = issue.get("labels")
labels = labels_value if isinstance(labels_value, list) else []
return {
"id": issue.get("id"),
"number": issue["number"],
"title": issue.get("title", "")
if isinstance(issue.get("title"), str)
else "",
"state": issue.get("state", "")
if isinstance(issue.get("state"), str)
else "",
"repository": repository,
"labels": [
item["name"]
for item in labels
if isinstance(item, dict) and isinstance(item.get("name"), str)
],
"assignees": confirmed_assignees,
"updated_at": issue.get("updated_at", "")
if isinstance(issue.get("updated_at"), str)
else "",
"url": _safe_web_url(issue.get("html_url")),
}
async def claim_available_issue(repository: str, number: int) -> dict:
issue = await fetch(f"repos/{repository}/issues/{number}")
user = await current_user()
assignees_value = issue.get("assignees") if isinstance(issue, dict) else None
if (
not isinstance(issue, dict)
or issue.get("state") != "open"
or issue.get("pull_request") is not None
or assignees_value not in (None, [])
):
raise IssueNotAvailableError("Issue is no longer available")
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("Gitea current user did not include a login")
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"assignee": login},
)
response.raise_for_status()
confirmed = response.json()
if not isinstance(confirmed, dict) or confirmed.get("number") != number:
raise ValueError("Gitea did not confirm issue assignment")
confirmed_assignees_value = confirmed.get("assignees")
confirmed_assignees = (
confirmed_assignees_value if isinstance(confirmed_assignees_value, list) else []
)
logins = [
assignee["login"] for assignee in confirmed_assignees
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
]
if login not in logins:
raise ValueError("Gitea did not confirm issue assignment")
labels_value = confirmed.get("labels")
labels = labels_value if isinstance(labels_value, list) else []
return {
"id": confirmed.get("id"),
"number": number,
"title": confirmed.get("title", "")
if isinstance(confirmed.get("title"), str) else "",
"state": "open",
"repository": repository,
"labels": [
label["name"] for label in labels
if isinstance(label, dict) and isinstance(label.get("name"), str)
],
"assignees": logins,
"updated_at": confirmed.get("updated_at", "")
if isinstance(confirmed.get("updated_at"), str) else "",
"url": _safe_web_url(confirmed.get("html_url")),
}
async def release_assigned_issue(repository: str, number: int) -> dict:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
)
assignees_value = issue.get("assignees")
if (
issue.get("state") != "open"
or issue.get("pull_request") is not None
or not _login_in_users(login, assignees_value)
):
raise IssueNotAvailableError("Issue is not assigned to the current user")
assignees = assignees_value if isinstance(assignees_value, list) else []
remaining = [
assignee["login"] for assignee in assignees
if isinstance(assignee, dict)
and isinstance(assignee.get("login"), str)
and assignee["login"] != login
]
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"assignees": remaining},
)
response.raise_for_status()
confirmed = response.json()
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
confirmed_assignees = [
assignee["login"] for assignee in confirmed_value
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
] if isinstance(confirmed_value, list) else []
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or login in confirmed_assignees
or set(confirmed_assignees) != set(remaining)
):
raise ValueError("Gitea did not confirm issue release")
return {
"number": number,
"repository": repository,
"state": confirmed.get("state", "open"),
"assignees": confirmed_assignees,
"available": not confirmed_assignees,
}
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"labels": label_ids},
)
response.raise_for_status()
issue = response.json()
if not isinstance(issue, dict) or issue.get("number") != number:
raise ValueError("Gitea did not confirm the label update")
labels_value = issue.get("labels")
labels = labels_value if isinstance(labels_value, list) else []
confirmed_ids = {
item["id"] for item in labels
if isinstance(item, dict) and isinstance(item.get("id"), int)
}
if confirmed_ids != set(label_ids):
raise ValueError("Gitea did not confirm the requested label set")
return {
"number": number,
"labels": [
item["name"] for item in labels
if isinstance(item, dict) and isinstance(item.get("name"), str)
],
}
async def issue_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/issues/{number}"
issue, comments = await asyncio.gather(
fetch(base), fetch(f"{base}/comments?limit=20&page=1")
)
if not isinstance(issue, dict):
raise ValueError("Gitea issue response was not an object")
labels_value = issue.get("labels")
labels: list = labels_value if isinstance(labels_value, list) else []
assignees_value = issue.get("assignees")
assignees: list = assignees_value if isinstance(assignees_value, list) else []
comments_value: list = comments if isinstance(comments, list) else []
normalized_comments = [
_normalize_issue_comment(comment)
for comment in comments_value
if isinstance(comment, dict)
]
return {
"repository": repository,
"number": number,
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
"state": issue.get("state", "") if isinstance(issue.get("state"), str) else "",
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
"url": _safe_web_url(issue.get("html_url")),
"labels": [
label["name"] for label in labels
if isinstance(label, dict) and isinstance(label.get("name"), str)
],
"assignees": [
assignee["login"] for assignee in assignees
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
],
"comments": normalized_comments,
}
async def _current_login_and_target(path: str) -> tuple[str, dict]:
user, target = await asyncio.gather(current_user(), fetch(path))
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login or not isinstance(target, dict):
return "", {}
return login, target
def _login_in_users(login: str, value: object) -> bool:
return isinstance(value, list) and any(
isinstance(user, dict) and user.get("login") == login for user in value
)
async def is_assigned_issue(repository: str, number: int) -> bool:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
)
return (
issue.get("state") == "open"
and not isinstance(issue.get("pull_request"), dict)
and _login_in_users(login, issue.get("assignees"))
)
async def pull_requests() -> WorkItems:
assigned, review_requested = await asyncio.gather(
fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"),
fetch(
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50"
),
work_page("pull"),
work_page("review"),
)
merged: dict[int, dict] = {}
for reason, pulls in (
("assigned_to_me", assigned or []),
("review_requested", review_requested or []),
for result in (
assigned,
review_requested,
):
for pull in pulls:
for pull in result["items"]:
identity = pull.get("id")
if identity not in merged:
merged[identity] = {**pull, "work_reasons": []}
merged[identity]["work_reasons"].append(reason)
return list(merged.values())
for reason in pull.get("work_reasons", []):
if reason not in merged[identity]["work_reasons"]:
merged[identity]["work_reasons"].append(reason)
return WorkItems(
list(merged.values()),
{
"pull": _page_metadata(assigned),
"review": _page_metadata(review_requested),
},
)
async def is_requested_review(repository: str, number: int) -> bool:
pulls = await fetch(
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50"
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
return any(
isinstance(pull, dict)
and pull.get("number") == number
and isinstance(pull.get("repository"), dict)
and pull["repository"].get("full_name") == repository
for pull in (pulls or [])
return (
pull.get("state") == "open"
and _login_in_users(login, pull.get("requested_reviewers"))
)
async def is_assigned_pull(repository: str, number: int) -> bool:
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
return (
pull.get("state") == "open"
and _login_in_users(login, pull.get("assignees"))
)
async def pull_completion_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
sha = head.get("sha") if isinstance(head.get("sha"), str) else ""
files, status, comments = await asyncio.gather(
fetch(f"{base}/files"),
fetch(f"repos/{repository}/commits/{sha}/status"),
fetch(f"repos/{repository}/issues/{number}/comments?limit=20&page=1"),
)
user = pull.get("user") if isinstance(pull.get("user"), dict) else {}
return {
"repository": repository,
"number": number,
"title": pull.get("title") if isinstance(pull.get("title"), str) else "",
"body": pull.get("body") if isinstance(pull.get("body"), str) else "",
"url": _safe_web_url(pull.get("html_url")),
"author": user.get("login") if isinstance(user.get("login"), str) else "",
"head_sha": sha,
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
"draft": pull.get("draft") is True,
"mergeable": pull.get("mergeable") is True,
"merged": pull.get("merged") is True,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"files": [
{
"filename": item.get("filename", ""),
"status": item.get("status") or "changed",
"additions": item.get("additions") or 0,
"deletions": item.get("deletions") or 0,
}
for item in (files if isinstance(files, list) else [])[:100]
if isinstance(item, dict) and isinstance(item.get("filename"), str)
],
"comments": [
_normalize_issue_comment(item)
for item in (comments if isinstance(comments, list) else [])[:20]
if isinstance(item, dict)
],
}
async def merge_assigned_pull(
repository: str, number: int, expected_head_sha: str
) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if not isinstance(pull, dict):
raise PullNotMergeableError("Pull request state is unavailable")
head_value = pull.get("head")
head: dict = head_value if isinstance(head_value, dict) else {}
current_sha = head.get("sha")
if current_sha != expected_head_sha:
raise StalePullError("Pull request changed before merge")
status = await fetch(f"repos/{repository}/commits/{current_sha}/status")
ci_state = status.get("state") if isinstance(status, dict) else "unknown"
if (
pull.get("state") != "open"
or pull.get("draft") is True
or pull.get("mergeable") is not True
or pull.get("merged") is True
or ci_state != "success"
):
raise PullNotMergeableError("Pull request is not currently safe to merge")
response = await _get_client().post(
f"/api/v1/{base}/merge",
headers=_auth(),
json={"Do": "merge", "head_commit_id": current_sha},
)
response.raise_for_status()
return {"number": number, "merged": True, "state": "closed"}
async def pull_review_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
@@ -381,6 +979,54 @@ async def pull_review_detail(repository: str, number: int) -> dict:
}
async def submit_pull_review(
repository: str,
number: int,
expected_head_sha: str,
decision: str,
body: str,
comments: list[dict] | None = None,
) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
head = pull.get("head") if isinstance(pull, dict) else None
current_sha = head.get("sha") if isinstance(head, dict) else None
if current_sha != expected_head_sha:
raise StaleReviewError("Pull request changed while it was being reviewed")
if comments:
files = await fetch(f"{base}/files")
changed_paths = {
item.get("filename")
for item in (files if isinstance(files, list) else [])
if isinstance(item, dict) and isinstance(item.get("filename"), str)
}
if any(comment.get("path") not in changed_paths for comment in comments):
raise InvalidReviewCommentError("Inline comment path is not in this pull request")
response = await _get_client().post(
f"/api/v1/{base}/reviews",
headers=_auth(),
json={
"body": body,
"event": {
"comment": "COMMENT",
"approve": "APPROVE",
"request_changes": "REQUEST_CHANGES",
}[decision],
"commit_id": current_sha,
**({"comments": comments} if comments else {}),
},
)
response.raise_for_status()
review = response.json()
if not isinstance(review, dict):
raise ValueError("Gitea review response was not an object")
return {
"id": review.get("id"),
"state": review.get("state") or "COMMENT",
"url": _safe_web_url(review.get("html_url")),
}
async def activity_events(user: dict | None = None) -> list[dict]:
if user is None:
user = await current_user()
+850 -103
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -19,4 +19,5 @@ def test_api_requests_resolve_inside_dashboard_subpath():
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
}
+48
View File
@@ -200,3 +200,51 @@ async def test_notification_detail_never_follows_foreign_api_urls():
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",
}
+35
View File
@@ -79,3 +79,38 @@ async def test_application_shutdown_finishes_snapshot_before_closing_transport(m
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)"]
+335 -39
View File
@@ -2,27 +2,276 @@ 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_paths = []
requested_streams = []
async def fake_fetch(path):
requested_paths.append(path)
return []
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, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
assert await gitea_proxy.issues() == []
assert await gitea_proxy.pull_requests() == []
assert requested_paths == [
"repos/issues/search?state=open&assigned=true&type=issues&limit=50",
"repos/issues/search?state=open&assigned=true&type=pulls&limit=50",
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
assert requested_streams == [
("issue", 1, 50),
("pull", 1, 50),
("review", 1, 50),
]
@@ -41,12 +290,16 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
"repository": {"full_name": "stackchain/mobile"},
}
async def fake_fetch(path):
if "assigned=true" in path:
return [assigned]
return [assigned.copy(), review_only]
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, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
pulls = await gitea_proxy.pull_requests()
@@ -60,13 +313,13 @@ async def test_pull_request_searches_start_concurrently(monkeypatch):
started = [asyncio.Event(), asyncio.Event()]
release = asyncio.Event()
async def fake_fetch(path):
index = 0 if "assigned=true" in path else 1
async def fake_page(stream, page=1, limit=50):
index = 0 if stream == "pull" else 1
started[index].set()
await release.wait()
return []
return {"stream": stream, "items": [], "page": page, "total": 0, "has_more": False}
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
task = asyncio.create_task(gitea_proxy.pull_requests())
try:
await asyncio.wait_for(
@@ -79,32 +332,75 @@ async def test_pull_request_searches_start_concurrently(monkeypatch):
@pytest.mark.anyio
async def test_requested_review_guard_uses_only_dedicated_review_search(monkeypatch):
requested_paths = []
async def test_requested_review_guard_uses_direct_pull_and_current_user(monkeypatch):
requested = []
async def fake_fetch(path):
requested_paths.append(path)
return [
{
"number": 7,
"repository": {"full_name": "stackchain/api"},
},
{
"number": 8,
"repository": {"full_name": "stackchain/api"},
},
]
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", 7) is True
assert await gitea_proxy.is_requested_review("stackchain/api", 9) is False
assert await gitea_proxy.is_requested_review("stackchain/private", 7) is False
assert requested_paths == [
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
]
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
+890
View File
@@ -0,0 +1,890 @@
import asyncio
import httpx
import pytest
from src import gitea_proxy, main
@pytest.fixture(autouse=True)
def clear_issue_creation_operations():
main._issue_creation_operations.clear()
yield
main._issue_creation_operations.clear()
@pytest.mark.anyio
async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirmed_issue(monkeypatch):
calls = []
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def labels(repository):
assert repository == "stackchain/api"
return [
{"id": 3, "name": "P0", "color": "d73a4a"},
{"id": 8, "name": "frontend", "color": "1d76db"},
]
async def create(repository, title, body, assignee, label_ids):
calls.append((repository, title, body, assignee, label_ids))
return {
"id": 81,
"number": 17,
"title": title,
"state": "open",
"repository": repository,
"labels": ["P0"],
"assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels, raising=False)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create, 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/issues",
json={
"title": " Capture mobile work ",
"body": " Context ",
"label_ids": [3],
},
)
assert response.status_code == 201
assert response.headers["cache-control"] == "no-store"
assert response.json()["number"] == 17
assert response.json()["assignees"] == ["timmy"]
assert response.json()["labels"] == ["P0"]
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
@pytest.mark.anyio
async def test_create_issue_replays_one_upstream_result_for_concurrent_idempotent_requests(monkeypatch):
calls = 0
started = asyncio.Event()
release = asyncio.Event()
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee, label_ids):
nonlocal calls
calls += 1
started.set()
await release.wait()
return {
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-concurrent"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = asyncio.create_task(client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Capture work"}, headers=headers
))
await started.wait()
second = asyncio.create_task(client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Capture work"}, 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 [response.json()["number"] for response in responses] == [17, 17]
assert calls == 1
@pytest.mark.anyio
async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_key(monkeypatch):
calls = []
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee, label_ids):
calls.append(title)
return {
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-payload-conflict"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "First title"}, headers=headers
)
conflict = await client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Changed title"}, headers=headers
)
assert created.status_code == 201
assert conflict.status_code == 409
assert conflict.json()["detail"] == "Idempotency key already used"
assert calls == ["First title"]
@pytest.mark.anyio
async def test_create_issue_retry_recovers_result_after_the_first_request_times_out(monkeypatch):
calls = 0
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee, label_ids):
nonlocal calls
calls += 1
await asyncio.sleep(0.03)
return {
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-timeout-recovery"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
timed_out = await client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Slow capture"}, headers=headers
)
await asyncio.sleep(0.03)
recovered = await client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Slow capture"}, headers=headers
)
assert timed_out.status_code == 503
assert recovered.status_code == 201
assert recovered.json()["number"] == 17
assert calls == 1
@pytest.mark.anyio
async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_limit(monkeypatch):
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee, label_ids):
return {
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main, "ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES", 2)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
for index in range(3):
response = await client.post(
"/api/v1/repos/stackchain/api/issues",
json={"title": f"Capture {index}"},
headers={"Idempotency-Key": f"capture-177-bounded-{index}"},
)
assert response.status_code == 201
assert len(main._issue_creation_operations) == 2
assert "capture-177-bounded-0" not in main._issue_creation_operations
@pytest.mark.anyio
async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmation():
requests = []
async def handler(request):
requests.append(request)
return httpx.Response(201, json={
"id": 81, "number": 17, "title": "Capture mobile work", "state": "open",
"updated_at": "2026-08-07T03:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/17",
"assignees": [{"login": "timmy"}],
"labels": [{"id": 3, "name": "P0"}],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.create_issue(
"stackchain/api", "Capture mobile work", "Context", "timmy", [3]
)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 1
assert requests[0].method == "POST"
assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues"
assert requests[0].content == (
b'{"title":"Capture mobile work","body":"Context","assignee":"timmy","labels":[3]}'
)
assert result["repository"] == "stackchain/api"
assert result["number"] == 17
assert result["assignees"] == ["timmy"]
assert result["labels"] == ["P0"]
@pytest.mark.anyio
async def test_gitea_repo_labels_returns_safe_touch_picker_options():
requests = []
async def handler(request):
requests.append(request)
return httpx.Response(200, json=[
{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"},
{"id": "bad", "name": "invalid", "color": "000000"},
])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.repo_labels("stackchain/api")
finally:
await gitea_proxy.stop_client()
assert requests[0].url.path == "/api/v1/repos/stackchain/api/labels"
assert requests[0].url.params["limit"] == "50"
assert result == [{
"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"
}]
@pytest.mark.anyio
async def test_repo_labels_endpoint_only_loads_labels_for_accessible_repository(monkeypatch):
calls = []
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def labels(repository):
calls.append(repository)
return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
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/labels")
missing = await client.get("/api/v1/repos/other/private/labels")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == [{
"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"
}]
assert missing.status_code == 404
assert calls == ["stackchain/api"]
@pytest.mark.anyio
async def test_create_issue_rejects_blank_title_before_upstream(monkeypatch):
called = False
async def user():
nonlocal called
called = True
return {"login": "timmy"}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": " ", "body": "Context"}
)
assert response.status_code == 422
assert response.headers["cache-control"] == "no-store"
assert called is False
@pytest.mark.anyio
async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
created = False
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def labels(_repository):
return [{"id": 3, "name": "P0"}]
async def create(*_args):
nonlocal created
created = True
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues",
json={"title": "Urgent work", "label_ids": [999]},
)
assert response.status_code == 422
assert response.json()["detail"] == "Unknown repository label"
assert created is False
@pytest.mark.anyio
async def test_gitea_create_issue_requires_confirmed_self_assignment():
async def handler(_request):
return httpx.Response(201, json={
"id": 81, "number": 17, "title": "Capture mobile work", "state": "open",
"html_url": "https://forge.example/stackchain/api/issues/17", "assignees": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(ValueError, match="self-assignment"):
await gitea_proxy.create_issue(
"stackchain/api", "Capture mobile work", "", "timmy"
)
finally:
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_assignee():
requests = []
async def handler(request):
requests.append(request)
if request.method == "GET" and request.url.path.endswith("/issues/17"):
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Available", "state": "open",
"assignees": None, "pull_request": None, "labels": [],
"html_url": "https://forge.example/stackchain/api/issues/17",
})
if request.method == "GET" and request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Available", "state": "open",
"assignees": [{"login": "timmy"}], "labels": [],
"html_url": "https://forge.example/stackchain/api/issues/17",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.claim_available_issue("stackchain/api", 17)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("GET", "/api/v1/user"),
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
]
assert requests[2].content == b'{"assignee":"timmy"}'
assert result["repository"] == "stackchain/api"
assert result["assignees"] == ["timmy"]
@pytest.mark.anyio
async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkeypatch):
calls = []
async def claim(repository, number):
calls.append((repository, number))
return {
"id": 81, "number": number, "title": "Available", "state": "open",
"repository": repository, "labels": [], "assignees": ["timmy"],
"url": "https://forge.example/stackchain/api/issues/17",
}
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:
response = await client.patch("/api/v1/repos/stackchain/api/issues/17/claim")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["assignees"] == ["timmy"]
assert calls == [("stackchain/api", 17)]
@pytest.mark.anyio
async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflict(monkeypatch):
async def claim(_repository, _number):
raise gitea_proxy.IssueNotAvailableError("claimed")
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:
response = await client.patch("/api/v1/repos/stackchain/api/issues/17/claim")
assert response.status_code == 409
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"error": "This issue was already claimed or is no longer open. Refresh Find Work."
}
@pytest.mark.anyio
async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_peers():
requests = []
async def handler(request):
requests.append(request)
if request.method == "GET" and request.url.path.endswith("/issues/17"):
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Shared work", "state": "open",
"pull_request": None,
"assignees": [{"login": "timmy"}, {"login": "alex"}],
"labels": [],
})
if request.method == "GET" and request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Shared work", "state": "open",
"assignees": [{"login": "alex"}], "labels": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.release_assigned_issue("stackchain/api", 17)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
]
assert requests[2].content == b'{"assignees":["alex"]}'
assert result["assignees"] == ["alex"]
assert result["available"] is False
@pytest.mark.anyio
async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch):
calls = []
async def release(repository, number):
calls.append((repository, number))
return {
"number": number, "repository": repository, "state": "open",
"assignees": [], "available": True,
}
monkeypatch.setattr(main.gitea_proxy, "release_assigned_issue", release, raising=False)
monkeypatch.setattr(main, "_available_issue_snapshot_value", [{"number": 99}])
monkeypatch.setattr(main, "_available_issue_snapshot_created_at", 123.0)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch("/api/v1/repos/stackchain/api/issues/17/release")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["available"] is True
assert calls == [("stackchain/api", 17)]
assert main._available_issue_snapshot_value is None
assert main._available_issue_snapshot_created_at is None
@pytest.mark.anyio
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
async def assigned(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def detail(repository, number):
return {"repository": repository, "number": number, "title": "Fix mobile flow"}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned, raising=False)
monkeypatch.setattr(main.gitea_proxy, "issue_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/issues/7/detail")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"repository": "stackchain/api", "number": 7, "title": "Fix mobile flow"
}
@pytest.mark.anyio
async def test_issue_detail_deadline_is_retryable_sanitized_and_cancels_work(monkeypatch):
cancelled = asyncio.Event()
async def assigned(repository, number):
try:
await asyncio.Event().wait()
finally:
cancelled.set()
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main, "ISSUE_ACTION_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/issues/7/detail")
assert response.status_code == 503
assert response.headers["cache-control"] == "no-store"
assert response.headers["retry-after"] == "1"
assert response.json() == {"error": "Loading the issue timed out. Please retry."}
assert cancelled.is_set()
@pytest.mark.anyio
@pytest.mark.parametrize(
("method", "path", "json_body"),
[
("GET", "/api/v1/repos/stackchain/api/issues/7/detail", None),
("POST", "/api/v1/repos/stackchain/api/issues/7/comments", {"body": "Hello"}),
("PATCH", "/api/v1/repos/stackchain/api/issues/7/close", None),
],
)
async def test_issue_actions_reject_items_not_assigned_to_service_user(
monkeypatch, method, path, json_body
):
async def assigned(repository, number):
return False
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.request(method, path, json=json_body)
assert response.status_code == 404
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
@pytest.mark.parametrize("body", [" ", "x" * 10_001])
async def test_issue_comment_rejects_blank_or_oversized_body_before_upstream(
monkeypatch, body
):
called = False
async def assigned(repository, number):
nonlocal called
called = True
return True
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/7/comments", json={"body": body}
)
assert response.status_code == 422
assert response.headers["cache-control"] == "no-store"
assert called is False
@pytest.mark.anyio
async def test_issue_comment_endpoint_posts_only_to_assigned_issue(monkeypatch):
calls = []
async def assigned(repository, number):
return True
async def comment(repository, number, body):
calls.append((repository, number, body))
return {"id": 82, "author": "timmy", "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)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/7/comments",
json={"body": " Ready to ship "},
)
assert response.status_code == 201
assert response.headers["cache-control"] == "no-store"
assert response.json() == {"id": 82, "author": "timmy", "body": "Ready to ship"}
assert calls == [("stackchain/api", 7, "Ready to ship")]
@pytest.mark.anyio
async def test_issue_close_endpoint_mutates_assigned_issue_only_after_confirmation(monkeypatch):
calls = []
async def assigned(repository, number):
return True
async def close(repository, number):
calls.append((repository, number))
return {"number": number, "state": "closed", "closed_at": "now"}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch("/api/v1/repos/stackchain/api/issues/7/close")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["state"] == "closed"
assert calls == [("stackchain/api", 7)]
@pytest.mark.anyio
async def test_gitea_close_issue_patches_state_and_confirms_closed_response():
requests = []
async def handler(request):
requests.append(request)
return httpx.Response(
200,
json={"number": 7, "state": "closed", "closed_at": "2026-08-07T10:20:00Z"},
)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.close_issue("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 1
assert requests[0].method == "PATCH"
assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues/7"
assert requests[0].content == b'{"state":"closed"}'
assert result == {"number": 7, "state": "closed", "closed_at": "2026-08-07T10:20:00Z"}
@pytest.mark.anyio
async def test_gitea_issue_comment_posts_body_and_returns_safe_identity():
requests = []
async def handler(request):
requests.append(request)
return httpx.Response(
201,
json={
"id": 82,
"body": "Ready to ship",
"created_at": "2026-08-07T10:10:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-82",
"user": {"login": "timmy"},
},
)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.comment_on_issue("stackchain/api", 7, "Ready to ship")
finally:
await gitea_proxy.stop_client()
assert len(requests) == 1
assert requests[0].method == "POST"
assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues/7/comments"
assert requests[0].content == b'{"body":"Ready to ship"}'
assert result == {
"id": 82,
"author": "timmy",
"body": "Ready to ship",
"created_at": "2026-08-07T10:10:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-82",
}
@pytest.mark.anyio
async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments():
requests = []
async def handler(request):
requests.append((request.method, request.url.path, request.url.query.decode()))
if request.url.path.endswith("/comments"):
return httpx.Response(
200,
json=[
{
"id": 81,
"body": "Latest update",
"created_at": "2026-08-07T10:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
"user": {"login": "sam"},
}
],
)
return httpx.Response(
200,
json={
"number": 7,
"title": "Fix mobile flow",
"state": "open",
"body": "Full issue context",
"html_url": "https://forge.example/stackchain/api/issues/7",
"labels": [{"name": "P1"}],
"assignees": [{"login": "timmy"}],
},
)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_detail("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert requests == [
("GET", "/api/v1/repos/stackchain/api/issues/7", ""),
("GET", "/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
]
assert result == {
"repository": "stackchain/api",
"number": 7,
"title": "Fix mobile flow",
"state": "open",
"body": "Full issue context",
"url": "https://forge.example/stackchain/api/issues/7",
"labels": ["P1"],
"assignees": ["timmy"],
"comments": [
{
"id": 81,
"author": "sam",
"body": "Latest update",
"created_at": "2026-08-07T10:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
}
],
}
@pytest.mark.anyio
async def test_update_assigned_issue_labels_validates_and_returns_confirmed_labels(monkeypatch):
calls = []
async def assigned(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def labels(repository):
assert repository == "stackchain/api"
return [
{"id": 3, "name": "P0", "color": "d73a4a"},
{"id": 8, "name": "frontend", "color": "1d76db"},
]
async def update(repository, number, label_ids):
calls.append((repository, number, label_ids))
return {"number": number, "labels": ["P0", "frontend"]}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
monkeypatch.setattr(main.gitea_proxy, "update_issue_labels", update, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/repos/stackchain/api/issues/7/labels",
json={"label_ids": [3, 8]},
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {"number": 7, "labels": ["P0", "frontend"]}
assert calls == [("stackchain/api", 7, [3, 8])]
@pytest.mark.anyio
async def test_assigned_issue_label_options_do_not_depend_on_repository_listing(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def labels(repository):
calls.append(("labels", repository))
return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/repos/stackchain/api/issues/7/labels")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()[0]["name"] == "P0"
assert calls == [
("assigned", "stackchain/api", 7),
("labels", "stackchain/api"),
]
@pytest.mark.anyio
async def test_gitea_update_issue_labels_patches_ids_and_normalizes_confirmation():
requests = []
async def handler(request):
requests.append(request)
return httpx.Response(200, json={
"number": 7,
"labels": [
{"id": 3, "name": "P0"},
{"id": 8, "name": "frontend"},
],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.update_issue_labels("stackchain/api", 7, [3, 8])
finally:
await gitea_proxy.stop_client()
assert len(requests) == 1
assert requests[0].method == "PATCH"
assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues/7"
assert requests[0].content == b'{"labels":[3,8]}'
assert result == {"number": 7, "labels": ["P0", "frontend"]}
@pytest.mark.anyio
async def test_gitea_update_issue_labels_rejects_unconfirmed_label_set():
async def handler(_request):
return httpx.Response(200, json={
"number": 7,
"labels": [{"id": 8, "name": "frontend"}],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(ValueError, match="confirm"):
await gitea_proxy.update_issue_labels("stackchain/api", 7, [3])
finally:
await gitea_proxy.stop_client()
+90 -10
View File
@@ -11,14 +11,30 @@ def reset_live_snapshot_task():
main._live_snapshot_task = None
main._live_snapshot_value = None
main._live_snapshot_created_at = None
main._live_snapshot_failure_count = 0
main._live_snapshot_retry_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_snapshot_failure_count = 0
main._live_snapshot_retry_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):
@@ -209,7 +225,7 @@ async def test_stale_snapshot_returns_immediately_while_one_refresh_revalidates(
release_refresh = asyncio.Event()
builds = 0
async def snapshot():
async def snapshot(_sections=None):
nonlocal builds
builds += 1
if builds == 2:
@@ -247,7 +263,7 @@ async def test_failed_revalidation_enters_cooldown_and_keeps_last_snapshot(monke
now = 100.0
builds = 0
async def snapshot():
async def snapshot(_sections=None):
nonlocal builds
builds += 1
if builds > 1:
@@ -288,7 +304,7 @@ async def test_partial_refresh_updates_fresh_sections_and_retains_failed_section
now = 100.0
builds = 0
async def snapshot():
async def snapshot(_sections=None):
nonlocal builds
builds += 1
if builds == 1:
@@ -332,6 +348,70 @@ async def test_partial_refresh_updates_fresh_sections_and_retains_failed_section
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
@@ -339,7 +419,7 @@ async def test_retry_window_starts_one_refresh_and_success_clears_degraded_state
retry_started = asyncio.Event()
release_retry = asyncio.Event()
async def snapshot():
async def snapshot(_sections=None):
nonlocal builds
builds += 1
if builds == 1:
@@ -399,7 +479,7 @@ async def test_cancelling_one_waiter_does_not_cancel_the_shared_snapshot(monkeyp
release = asyncio.Event()
snapshot_calls = 0
async def blocked_snapshot():
async def blocked_snapshot(_sections=None):
nonlocal snapshot_calls
snapshot_calls += 1
started.set()
@@ -437,7 +517,7 @@ async def test_shared_snapshot_deadline_cancels_upstream_work_for_all_waiters(mo
started = asyncio.Event()
cancelled = asyncio.Event()
async def snapshot_that_exceeds_deadline():
async def snapshot_that_exceeds_deadline(_sections=None):
started.set()
try:
await asyncio.Event().wait()
+1035 -1
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -214,7 +214,7 @@ async def test_inflight_refresh_cannot_restore_a_notification_marked_read(monkey
async def mark(_thread_id):
return None
async def stale_upstream_snapshot():
async def stale_upstream_snapshot(_sections):
refresh_started.set()
await release_refresh.wait()
return {
@@ -231,7 +231,7 @@ async def test_inflight_refresh_cannot_restore_a_notification_marked_read(monkey
monkeypatch.setattr(main, "_live_snapshot_task", None)
monkeypatch.setattr(main, "_live_snapshot_value", {"notifications": [{"id": 42}]})
refresh = main._start_live_snapshot_refresh()
refresh = main._start_live_snapshot_refresh(set(main.LIVE_SNAPSHOT_SECTIONS))
await refresh_started.wait()
await main.read_notification(42)
release_refresh.set()
+90
View File
@@ -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
+149
View File
@@ -0,0 +1,149 @@
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_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"),
]
+312 -4
View File
@@ -3,7 +3,7 @@ import asyncio
import httpx
import pytest
from src import main
from src import gitea_proxy, main
@pytest.mark.anyio
@@ -76,13 +76,321 @@ async def test_review_detail_has_one_retryable_deadline_and_cancels_pending_work
@pytest.mark.anyio
async def test_public_review_endpoint_does_not_expose_service_token_mutations():
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={"action": "approve"},
json={
"decision": "approve",
"body": "Looks good on mobile.",
"expected_head_sha": "abc123",
},
)
assert response.status_code == 405
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()
+11
View File
@@ -44,3 +44,14 @@ async def test_dashboard_announces_failed_refresh_and_retry_without_blanking_pan
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
+60
View File
@@ -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