Compare commits

...
Author SHA1 Message Date
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
22 changed files with 5277 additions and 196 deletions

No files matched your search

+17 -3
View File
@@ -14,12 +14,26 @@ 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 and claim open unassigned issues,
create issue comments, close assigned issues, inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue
creation and assignment permission. 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
```
+72
View File
@@ -0,0 +1,72 @@
function createIssueCapture({ fetchJson, storage }) {
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 saveDraft(draft) {
const safe = {
repository: String(draft?.repository || ''),
title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: safeLabelIds(draft?.labelIds),
};
try { storage.setItem(storageKey, JSON.stringify(safe)); }
catch (_error) { /* Keep the form as the in-memory fallback. */ }
return safe;
}
function loadDraft() {
try {
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
return parsed && typeof parsed === 'object' ? {
repository: String(parsed.repository || ''),
title: String(parsed.title || ''),
body: String(parsed.body || ''),
labelIds: safeLabelIds(parsed.labelIds),
} : emptyDraft();
} catch (_error) {
return emptyDraft();
}
}
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 repository = saved.repository.split('/').map(encodeURIComponent).join('/');
pending = fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
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;
+966 -10
View File
File diff suppressed because it is too large. Load diff
+81
View File
@@ -0,0 +1,81 @@
function createIssueSheet({ fetchJson, storage }) {
let commentRequest = null;
let closeRequest = 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;
},
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;
+105
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,15 @@ 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 summarizeMyWork(items) {
const updates = items.filter((item) => item.has_update).length;
const reviews = items.filter((item) => item.is_review).length;
@@ -255,6 +357,7 @@ function countMyWork(items) {
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.replaceIssueLabels = replaceIssueLabels;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
@@ -262,6 +365,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;
}
+77
View File
@@ -0,0 +1,77 @@
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
let available = [];
let pagination = { page: 1, total: 0, has_more: false };
let loadRequest = null;
let claimRequest = null;
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 }));
}
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();
},
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
);
onItems(available.slice());
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;
+589 -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,111 @@ 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,
}
async def available_issue_page(page: int = 1, limit: int = 50) -> dict:
"""Return one bounded page of open, unassigned issues visible to the user."""
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
params={"state": "open", "type": "issues", "limit": limit, "page": page},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea available issue search response was not a list")
items = []
for item in payload:
if (
not isinstance(item, dict)
or item.get("state") != "open"
or "pull_request" in item
or item.get("assignees") not in (None, [])
):
continue
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 {}
items.append({
"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")),
})
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
))
try:
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
except (TypeError, ValueError):
total = len(items)
return {"items": items, "page": page, "total": total, "has_more": page * limit < 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 +409,428 @@ 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 "pull_request" in issue
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 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 +900,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()
+706 -93
View File
@@ -1,14 +1,15 @@
import asyncio
import math
import time
from collections.abc import Awaitable
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, Literal
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
from src import gitea_proxy
from src.gitea_proxy import (
@@ -53,8 +54,10 @@ CONTEXT_TIMEOUT_SECONDS = 5.0
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
ISSUE_ACTION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
WORK_PAGE_TIMEOUT_SECONDS = 5.0
NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0
BULK_NOTIFICATION_CONCURRENCY = 5
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
@@ -65,8 +68,17 @@ FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
_live_snapshot_task: asyncio.Task | None = None
_live_snapshot_value: dict | None = None
_live_snapshot_created_at: float | None = None
_live_snapshot_failure_count = 0
_live_snapshot_retry_at: float | None = None
LIVE_SNAPSHOT_SECTIONS = ("context", "events", "notifications")
_live_section_created_at: dict[str, float | None] = {
section: None for section in LIVE_SNAPSHOT_SECTIONS
}
_live_section_failure_count: dict[str, int] = {
section: 0 for section in LIVE_SNAPSHOT_SECTIONS
}
_live_section_retry_at: dict[str, float | None] = {
section: None for section in LIVE_SNAPSHOT_SECTIONS
}
_live_snapshot_refreshing_sections: set[str] = set()
_read_notification_ids: set[int] = set()
@@ -82,6 +94,92 @@ class NotificationReadBatch(BaseModel):
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
class NotificationReply(BaseModel):
body: str = Field(min_length=1, max_length=10_000)
@field_validator("body")
@classmethod
def strip_body(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("reply must not be blank")
return value
class IssueComment(BaseModel):
body: str = Field(min_length=1, max_length=10_000)
@field_validator("body")
@classmethod
def strip_body(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("comment must not be blank")
return value
class IssueCreation(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
label_ids: list[int] = Field(default_factory=list, max_length=20)
@field_validator("title")
@classmethod
def strip_title(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("title must not be blank")
return value
@field_validator("body")
@classmethod
def strip_issue_body(cls, value: str) -> str:
return value.strip()
class IssueLabelUpdate(BaseModel):
label_ids: list[PositiveInt] = Field(max_length=20)
class PullReviewComment(BaseModel):
path: str = Field(min_length=1, max_length=1_000)
body: str = Field(min_length=1, max_length=10_000)
new_position: PositiveInt | None = None
old_position: PositiveInt | None = None
@field_validator("path", "body")
@classmethod
def strip_comment_text(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("inline comment values must not be blank")
return value
@model_validator(mode="after")
def require_one_position(self):
if (self.new_position is None) == (self.old_position is None):
raise ValueError("inline comments require exactly one old or new position")
return self
class PullReviewSubmission(BaseModel):
decision: str
body: str = Field(max_length=10_000)
expected_head_sha: str
comments: list[PullReviewComment] = Field(default_factory=list, max_length=50)
@field_validator("decision")
@classmethod
def validate_decision(cls, value: str) -> str:
if value not in {"comment", "approve", "request_changes"}:
raise ValueError("unsupported review decision")
return value
class PullMergeSubmission(BaseModel):
expected_head_sha: str = Field(min_length=1, max_length=128)
def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
user_model = User(
id=user_data["id"],
@@ -125,15 +223,44 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
if isinstance(p, dict)
and all(field in p for field in ("id", "number", "title", "state", "html_url"))
]
return compute(user_model, repo_models, issue_models, pr_models).model_dump()
payload = compute(user_model, repo_models, issue_models, pr_models).model_dump()
pagination = {}
pagination.update(getattr(issues_data, "pagination", {}))
pagination.update(getattr(prs_data, "pagination", {}))
if pagination:
payload["work_pagination"] = pagination
return payload
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
if stream == "issue":
return [
Issue(
id=item["id"], number=item["number"], title=item["title"],
state=item["state"],
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)],
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
updated_at=item.get("updated_at") or "", url=item["html_url"],
).model_dump()
for item in items
if all(field in item for field in ("id", "number", "title", "state", "html_url"))
]
reason = "assigned_to_me" if stream == "pull" else "review_requested"
return [
PullRequest(
id=item["id"], number=item["number"], title=item["title"],
state=item["state"],
user=item["user"].get("login", "") if isinstance(item.get("user"), dict) else "",
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)],
work_reasons=[reason],
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
updated_at=item.get("updated_at") or "", url=item["html_url"],
).model_dump()
for item in items
if all(field in item for field in ("id", "number", "title", "state", "html_url"))
]
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
app.include_router(frontend_router)
@@ -142,10 +269,19 @@ app.include_router(frontend_router)
@app.middleware("http")
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live"} or (
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues"} or request.url.path.startswith("/api/v1/work/") or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/review")
) or request.url.path.startswith("/api/v1/notifications"):
) or request.url.path.startswith("/api/v1/notifications") or (
request.url.path.startswith("/api/v1/repos/")
and ("/issues/" in request.url.path or "/pulls/" in request.url.path)
) or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/issues")
) or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/labels")
):
response.headers["Cache-Control"] = "no-store"
return response
@@ -240,6 +376,45 @@ async def context() -> JSONResponse:
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
@app.get("/api/v1/work/{stream}")
async def paged_work(
stream: Literal["issue", "pull", "review"],
page: int = Query(ge=2, le=100),
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.work_page(stream, page), timeout=WORK_PAGE_TIMEOUT_SECONDS
)
except Exception:
return JSONResponse(
{"error": "Work page is temporarily unavailable"},
status_code=503,
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
)
return JSONResponse({
"stream": stream,
"page": result["page"],
"total": result["total"],
"has_more": result["has_more"],
"items": _normalize_work_items(stream, result["items"]),
})
@app.get("/api/v1/available-issues")
async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.available_issue_page(page), timeout=WORK_PAGE_TIMEOUT_SECONDS
)
except Exception:
return JSONResponse(
{"error": "Available issues are temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
)
return JSONResponse(result)
async def _load_context_for_user(user_data: dict) -> dict:
repo_data, issues_data, prs_data = await asyncio.gather(
repos(), issues(), pull_requests()
@@ -254,19 +429,41 @@ async def _load_context_for_user(user_data: dict) -> dict:
return _context_payload(user_data, repo_data, issues_data, prs_data)
async def _build_live_snapshot() -> dict:
user_data = await current_user()
if not isinstance(user_data, dict) or not user_data.get("login"):
raise ContextPayloadError("Gitea current-user response was invalid")
context_result, events_result, notifications_result = await asyncio.gather(
_load_context_for_user(user_data),
activity_events(user_data),
notifications(),
return_exceptions=True,
async def _build_live_snapshot(sections: set[str] | None = None) -> dict:
requested = set(sections or LIVE_SNAPSHOT_SECTIONS)
results: dict[str, object] = {}
user_data: dict | None = None
if requested & {"context", "events"}:
try:
user_data = await current_user()
if not isinstance(user_data, dict) or not user_data.get("login"):
raise ContextPayloadError("Gitea current-user response was invalid")
except Exception as exc:
for section in requested & {"context", "events"}:
results[section] = exc
loads: dict[str, Awaitable[Any]] = {}
if "context" in requested and "context" not in results:
assert user_data is not None
loads["context"] = _load_context_for_user(user_data)
if "events" in requested and "events" not in results:
assert user_data is not None
loads["events"] = activity_events(user_data)
if "notifications" in requested:
loads["notifications"] = notifications()
if loads:
loaded = await asyncio.gather(*loads.values(), return_exceptions=True)
results.update(zip(loads, loaded))
context_result = results.get("context")
events_result = results.get("events")
notifications_result = results.get("notifications")
context_ok = "context" in requested and not isinstance(context_result, BaseException)
events_ok = "events" in requested and not isinstance(events_result, BaseException)
notifications_ok = (
"notifications" in requested
and not isinstance(notifications_result, BaseException)
)
context_ok = not isinstance(context_result, BaseException)
events_ok = not isinstance(events_result, BaseException)
notifications_ok = not isinstance(notifications_result, BaseException)
notification_items = notifications_result
notification_pagination = None
if notifications_ok and isinstance(notifications_result, dict):
@@ -283,64 +480,88 @@ async def _build_live_snapshot() -> dict:
"notifications": notification_items if notifications_ok else None,
"notification_pagination": notification_pagination if notifications_ok else None,
"sections": {
"context": "fresh" if context_ok else "temporarily unavailable",
"events": "fresh" if events_ok else "temporarily unavailable",
"notifications": "fresh" if notifications_ok else "temporarily unavailable",
section: "fresh" if ok else "temporarily unavailable"
for section, ok in (
("context", context_ok),
("events", events_ok),
("notifications", notifications_ok),
)
if section in requested
},
}
async def _build_live_snapshot_before_deadline() -> dict:
async def _build_live_snapshot_before_deadline(sections: set[str]) -> dict:
async with asyncio.timeout(CONTEXT_TIMEOUT_SECONDS):
return await _build_live_snapshot()
return await _build_live_snapshot(sections)
def _record_live_snapshot_failure() -> None:
global _live_snapshot_failure_count, _live_snapshot_retry_at
_live_snapshot_failure_count += 1
def _record_live_section_failure(section: str) -> None:
_live_section_failure_count[section] += 1
delay = min(
LIVE_SNAPSHOT_RETRY_MAX_SECONDS,
LIVE_SNAPSHOT_RETRY_BASE_SECONDS * (2 ** (_live_snapshot_failure_count - 1)),
LIVE_SNAPSHOT_RETRY_BASE_SECONDS
* (2 ** (_live_section_failure_count[section] - 1)),
)
_live_snapshot_retry_at = time.monotonic() + delay
_live_section_retry_at[section] = time.monotonic() + delay
def _due_live_sections(now: float) -> set[str]:
due = set()
for section in LIVE_SNAPSHOT_SECTIONS:
retry_at = _live_section_retry_at[section]
if retry_at is not None and now < retry_at:
continue
created_at = _live_section_created_at[section]
if created_at is None or now - created_at >= LIVE_SNAPSHOT_FRESHNESS_SECONDS:
due.add(section)
return due
def _merge_live_snapshot(previous: dict | None, refreshed: dict) -> dict:
if previous is None:
return refreshed
merged = dict(refreshed)
sections = dict(refreshed.get("sections") or {})
for section in ("context", "events", "notifications"):
if sections.get(section) == "fresh":
continue
if previous.get(section) is not None:
merged[section] = previous[section]
sections[section] = "stale"
merged = dict(previous)
sections = dict(previous.get("sections") or {})
for section, state in (refreshed.get("sections") or {}).items():
if state == "fresh":
merged[section] = refreshed.get(section)
sections[section] = "fresh"
if section == "notifications":
merged["notification_pagination"] = previous.get(
merged["notification_pagination"] = refreshed.get(
"notification_pagination"
)
elif previous.get(section) is not None:
sections[section] = "stale"
else:
merged[section] = None
sections[section] = "temporarily unavailable"
merged["sections"] = sections
return merged
async def _refresh_live_snapshot() -> dict:
async def _refresh_live_snapshot(sections: set[str]) -> dict:
global _live_snapshot_value, _live_snapshot_created_at
global _live_snapshot_failure_count, _live_snapshot_retry_at
try:
result = await _build_live_snapshot_before_deadline()
refreshed = await _build_live_snapshot_before_deadline(sections)
except Exception:
_record_live_snapshot_failure()
for section in sections:
_record_live_section_failure(section)
raise
result = _merge_live_snapshot(_live_snapshot_value, result)
now = time.monotonic()
refreshed_states = refreshed.get("sections") or {}
for section in sections:
if refreshed_states.get(section) == "fresh":
_live_section_created_at[section] = now
_live_section_failure_count[section] = 0
_live_section_retry_at[section] = None
else:
_record_live_section_failure(section)
result = _merge_live_snapshot(_live_snapshot_value, refreshed)
result = _without_read_notifications(result)
_live_snapshot_value = result
_live_snapshot_created_at = time.monotonic()
if any(state != "fresh" for state in result.get("sections", {}).values()):
_record_live_snapshot_failure()
else:
_live_snapshot_failure_count = 0
_live_snapshot_retry_at = None
successful_times = [value for value in _live_section_created_at.values() if value is not None]
_live_snapshot_created_at = max(successful_times) if successful_times else None
return result
@@ -350,34 +571,47 @@ def _consume_live_snapshot_failure(task: asyncio.Task) -> None:
task.exception()
def _start_live_snapshot_refresh() -> asyncio.Task:
def _start_live_snapshot_refresh(sections: set[str]) -> asyncio.Task:
global _live_snapshot_task
global _live_snapshot_refreshing_sections
if _live_snapshot_task is None or _live_snapshot_task.done():
_live_snapshot_task = asyncio.create_task(_refresh_live_snapshot())
_live_snapshot_refreshing_sections = set(sections)
_live_snapshot_task = asyncio.create_task(_refresh_live_snapshot(sections))
_live_snapshot_task.add_done_callback(_consume_live_snapshot_failure)
return _live_snapshot_task
def _live_snapshot_payload(value: dict, *, stale: bool, revalidating: bool) -> dict:
payload = dict(value)
age = (
max(0.0, time.monotonic() - _live_snapshot_created_at)
if _live_snapshot_created_at is not None
else 0.0
)
retry_in_seconds = (
max(0.0, _live_snapshot_retry_at - time.monotonic())
if _live_snapshot_retry_at is not None
else 0.0
)
now = time.monotonic()
section_freshness = {}
for section in LIVE_SNAPSHOT_SECTIONS:
created_at = _live_section_created_at[section]
retry_at = _live_section_retry_at[section]
age = max(0.0, now - created_at) if created_at is not None else 0.0
retry_in = max(0.0, retry_at - now) if retry_at is not None else 0.0
section_freshness[section] = {
"age_seconds": round(age, 3),
"stale": (value.get("sections") or {}).get(section) != "fresh"
or created_at is None
or age >= LIVE_SNAPSHOT_FRESHNESS_SECONDS,
"revalidating": revalidating
and section in _live_snapshot_refreshing_sections,
"degraded": retry_at is not None,
"retry_in_seconds": math.ceil(retry_in),
}
ages = [item["age_seconds"] for item in section_freshness.values()]
retries = [item["retry_in_seconds"] for item in section_freshness.values() if item["retry_in_seconds"]]
degraded = any(item["degraded"] for item in section_freshness.values())
payload["freshness"] = {
"age_seconds": round(age, 3),
"age_seconds": max(ages, default=0.0),
"fresh_for_seconds": LIVE_SNAPSHOT_FRESHNESS_SECONDS,
"stale": stale,
"stale": stale or any(item["stale"] for item in section_freshness.values()),
"revalidating": revalidating,
"degraded": _live_snapshot_retry_at is not None,
"last_refresh_failed": _live_snapshot_retry_at is not None,
"retry_in_seconds": math.ceil(retry_in_seconds),
"degraded": degraded,
"last_refresh_failed": degraded,
"retry_in_seconds": min(retries, default=0),
"sections": section_freshness,
}
return payload
@@ -425,33 +659,22 @@ async def live_snapshot() -> JSONResponse:
"""Return a freshness-bounded snapshot and share identical upstream loads."""
global _live_snapshot_task, _live_snapshot_value, _live_snapshot_created_at
now = time.monotonic()
due_sections = _due_live_sections(now)
if (
_live_snapshot_value is not None
and _live_snapshot_retry_at is not None
):
if now < _live_snapshot_retry_at:
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value, stale=True, revalidating=False
)
)
_start_live_snapshot_refresh()
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value, stale=True, revalidating=True
)
)
if (
_live_snapshot_value is not None
and _live_snapshot_created_at is not None
and now - _live_snapshot_created_at < LIVE_SNAPSHOT_FRESHNESS_SECONDS
and not due_sections
):
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value, stale=False, revalidating=False
_live_snapshot_value,
stale=any(
state != "fresh"
for state in _live_snapshot_value.get("sections", {}).values()
),
revalidating=False,
)
)
task = _start_live_snapshot_refresh()
task = _start_live_snapshot_refresh(due_sections)
if _live_snapshot_value is not None:
return JSONResponse(
_live_snapshot_payload(
@@ -626,6 +849,253 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
return JSONResponse({"id": thread_id, "status": "read"})
@app.post("/api/v1/notifications/{thread_id}/reply", status_code=201)
async def reply_to_notification(
reply: NotificationReply, thread_id: int = PathParam(gt=0)
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.reply_to_notification(thread_id, reply.body),
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{
"error": (
"The reply could not be posted. Your draft is safe; please retry."
)
},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, status_code=201)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/claim")
async def claim_available_issue(
owner: str, repo: str, number: int = PathParam(gt=0)
) -> JSONResponse:
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.claim_available_issue(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "This issue was already claimed or is no longer open. Refresh Find Work."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The issue could not be assigned. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
repository = f"{owner}/{repo}"
async def load_assigned_issue():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.issue_detail(repository, number)
try:
return await asyncio.wait_for(
load_assigned_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except TimeoutError:
return JSONResponse(
{"error": "Loading the issue timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "The issue is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/repos/{owner}/{repo}/labels")
async def repository_labels(owner: str, repo: str):
repository = f"{owner}/{repo}"
async def load_labels():
available = await gitea_proxy.repos()
accessible = {
item.get("full_name")
for item in (available if isinstance(available, list) else [])
if isinstance(item, dict)
}
if repository not in accessible:
raise HTTPException(status_code=404, detail="Repository not found")
return await gitea_proxy.repo_labels(repository)
try:
return await asyncio.wait_for(load_labels(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Labels could not be loaded. Issue creation is still available."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/issues", status_code=201)
async def create_assigned_issue(creation: IssueCreation, owner: str, repo: str):
repository = f"{owner}/{repo}"
async def create_issue():
user, available = await asyncio.gather(
gitea_proxy.current_user(), gitea_proxy.repos()
)
login = user.get("login") if isinstance(user, dict) else None
accessible = {
item.get("full_name")
for item in (available if isinstance(available, list) else [])
if isinstance(item, dict)
}
if not login or repository not in accessible:
raise HTTPException(status_code=404, detail="Repository not found")
if creation.label_ids:
available_labels = await gitea_proxy.repo_labels(repository)
valid_label_ids = {
item.get("id")
for item in available_labels
if isinstance(item, dict) and isinstance(item.get("id"), int)
}
if any(label_id not in valid_label_ids for label_id in creation.label_ids):
raise HTTPException(status_code=422, detail="Unknown repository label")
return await gitea_proxy.create_issue(
repository, creation.title, creation.body, login, creation.label_ids
)
try:
result = await asyncio.wait_for(create_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The issue could not be created. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, status_code=201)
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/comments", status_code=201)
async def comment_on_assigned_issue(
comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0)
):
repository = f"{owner}/{repo}"
async def post_comment():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
try:
result = await asyncio.wait_for(post_comment(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The comment could not be posted. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, status_code=201)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/close")
async def close_assigned_issue(owner: str, repo: str, number: int = PathParam(gt=0)):
repository = f"{owner}/{repo}"
async def close_issue():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.close_issue(repository, number)
try:
return await asyncio.wait_for(close_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The issue could not be closed. It remains in My Work; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/labels")
async def assigned_issue_label_options(
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
async def load_labels():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.repo_labels(repository)
try:
return await asyncio.wait_for(load_labels(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Labels could not be loaded. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/labels")
async def update_assigned_issue_labels(
update: IssueLabelUpdate,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
async def update_labels():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
available = await gitea_proxy.repo_labels(repository)
valid_ids = {
item.get("id") for item in available
if isinstance(item, dict) and isinstance(item.get("id"), int)
}
if any(label_id not in valid_ids for label_id in update.label_ids):
raise HTTPException(status_code=422, detail="Unknown repository label")
return await gitea_proxy.update_issue_labels(repository, number, update.label_ids)
try:
return await asyncio.wait_for(update_labels(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Labels could not be updated. Your selection is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review")
async def review_detail(owner: str, repo: str, number: int):
async def load_requested_review():
@@ -655,3 +1125,146 @@ async def review_detail(owner: str, repo: str, number: int):
{"error": "Pull request review details are temporarily unavailable"},
status_code=503,
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/detail")
async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
repository = f"{owner}/{repo}"
async def load_assigned_pull():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.pull_completion_detail(repository, number)
try:
return await asyncio.wait_for(
load_assigned_pull(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
)
except HTTPException:
raise
except TimeoutError:
return JSONResponse(
{"error": "Loading the pull request timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "The pull request is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", status_code=201)
async def comment_on_assigned_pull(
comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0)
):
repository = f"{owner}/{repo}"
async def post_comment():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
try:
result = await asyncio.wait_for(
post_comment(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The comment could not be posted. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, status_code=201)
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge")
async def merge_assigned_pull(
submission: PullMergeSubmission,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
async def merge_pull():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.merge_assigned_pull(
repository, number, submission.expected_head_sha
)
try:
return await asyncio.wait_for(
merge_pull(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except gitea_proxy.StalePullError:
return JSONResponse(
{"error": "New commits were pushed. Refresh before merging."},
status_code=409,
)
except gitea_proxy.PullNotMergeableError:
return JSONResponse(
{"error": "This pull request is not currently safe to merge. Refresh its status."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The pull request could not be merged. It remains in My Work; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201)
async def submit_review(
submission: PullReviewSubmission, owner: str, repo: str, number: int
):
repository = f"{owner}/{repo}"
async def submit_requested_review():
if not await is_requested_review(repository, number):
raise HTTPException(status_code=404, detail="Review request not found")
args = (
repository,
number,
submission.expected_head_sha,
submission.decision,
submission.body,
)
if submission.comments:
return await gitea_proxy.submit_pull_review(
*args,
[comment.model_dump() for comment in submission.comments],
)
return await gitea_proxy.submit_pull_review(*args)
try:
result = await asyncio.wait_for(
submit_requested_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
)
except HTTPException:
raise
except gitea_proxy.StaleReviewError:
return JSONResponse(
{"error": "New commits were pushed. Refresh the review before submitting."},
status_code=409,
)
except gitea_proxy.InvalidReviewCommentError:
return JSONResponse(
{"error": "An inline comment no longer matches this pull request. Refresh the review."},
status_code=422,
)
except Exception:
return JSONResponse(
{"error": "The review could not be submitted. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, status_code=201)
+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",
}
+206 -39
View File
@@ -2,27 +2,147 @@ import asyncio
import json
import pytest
import httpx
from src import gitea_proxy
from src import main
@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": "77"},
json=[
{"id": 1, "number": 1, "title": "Ordinary", "state": "open",
"updated_at": "2026-08-07T12:00:00Z", "assignees": [],
"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": [],
"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=2)
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=2"
]
assert [item["title"] for item in result["items"]] == ["Critical", "Ordinary"]
assert result == {
"items": result["items"], "page": 2, "total": 77, "has_more": False,
}
@pytest.mark.anyio
async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkeypatch):
calls = []
async def available(page):
calls.append(page)
return {"items": [{"number": 7}], "page": page, "total": 51, "has_more": True}
monkeypatch.setattr(main.gitea_proxy, "available_issue_page", 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": 7}], "page": 1, "total": 51, "has_more": True,
}
assert calls == [1]
@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 +161,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 +184,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 +203,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
+661
View File
@@ -0,0 +1,661 @@
import asyncio
import httpx
import pytest
from src import gitea_proxy, main
@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_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": [], "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_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()
+851 -1
View File
@@ -9,6 +9,10 @@ from src.views import dashboard
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
@@ -74,6 +78,38 @@ process.stdout.write(JSON.stringify(queue));
assert queue[2]["reason"] == "Assigned to you"
def test_confirmed_issue_labels_replace_snapshot_and_reprioritize_queue():
payload = {
"user": {"login": "timmy"},
"issues": [
{"number": 1, "title": "Older", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z"},
{"number": 2, "title": "Newer", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T12:00:00Z"},
],
"pull_requests": [],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = {json.dumps(payload)};
const updated = buildMyWork.replaceIssueLabels(original, 'stackchain/api', 1, ['P0']);
process.stdout.write(JSON.stringify({{
titles: buildMyWork(updated).map(item => item.title),
labels: updated.issues[0].labels,
original: original.issues[0].labels,
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"titles": ["Older", "Newer"],
"labels": ["P0"],
"original": [],
}
def test_my_work_reviews_filter_and_summary_are_actionable():
items = [
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
@@ -116,6 +152,275 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1, "update": 0}
def test_work_pager_is_single_flight_and_unions_pull_responsibilities():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let calls = 0;
let release;
const pages = [];
const states = [];
const statuses = [];
const pager = buildMyWork.createWorkPager({{
load: (stream, page) => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{
stream, page, total: 51, has_more: false,
items: [
{{id:1, title:'shared', work_reasons:['review_requested']}},
{{id:51, title:'older', work_reasons:['review_requested']}}
],
}}); }});
}},
onItems: (stream, items) => states.push({{stream, items}}),
onPagination: pagination => pages.push(pagination),
onStatus: status => statuses.push(status),
}});
pager.reset({{review:{{page:1,total:51,has_more:true}}}});
const existing = [{{id:1,title:'shared',work_reasons:['assigned_to_me']}}];
const first = pager.loadMore('review', existing);
const duplicate = pager.loadMore('review', existing);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, pages, states, statuses, results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert output["states"] == [{
"stream": "review",
"items": [
{"id": 1, "title": "shared", "work_reasons": ["assigned_to_me", "review_requested"]},
{"id": 51, "title": "older", "work_reasons": ["review_requested"]},
],
}]
assert output["pages"][-1]["review"] == {"page": 2, "total": 51, "has_more": False}
assert output["statuses"] == [
"Loading older review requests…", "51 of 51 review requests loaded."
]
assert output["results"] == [True, False]
def test_work_pager_keeps_items_and_retries_same_page_after_failure():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const requested = [];
const states = [];
const statuses = [];
const pager = buildMyWork.createWorkPager({{
load: async (stream, page) => {{ requested.push([stream,page]); throw new Error('offline'); }},
onItems: (stream, items) => states.push(items),
onPagination: () => {{}},
onStatus: status => statuses.push(status),
}});
pager.reset({{issue:{{page:2,total:125,has_more:true}}}});
pager.loadMore('issue', [{{id:1}}]).then(result =>
pager.loadMore('issue', [{{id:1}}]).then(retry =>
process.stdout.write(JSON.stringify({{requested,states,statuses,result,retry}}))
)
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"requested": [["issue", 3], ["issue", 3]],
"states": [],
"statuses": [
"Loading older issues…", "Could not load older issues. Retry.",
"Loading older issues…", "Could not load older issues. Retry.",
],
"result": False,
"retry": False,
}
def test_find_work_claim_is_single_flight_and_removes_only_confirmed_issue():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
let calls = 0;
let release;
const states = [];
const statuses = [];
const controller = createFindWork({{
fetchJson: (url, options) => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{
id:17,number:7,title:'Available',repository:'stackchain/api',
state:'open',labels:[],assignees:['timmy'],url:'https://forge.example/issues/7'
}}); }});
}},
onItems: items => states.push(items),
onPagination: () => {{}},
onStatus: status => statuses.push(status),
}});
controller.reset({{items:[
{{id:17,number:7,title:'Available',repository:'stackchain/api'}},
{{id:18,number:8,title:'Other',repository:'stackchain/web'}}
],page:1,total:2,has_more:false}});
const item = controller.items()[0];
const first = controller.claim(item);
const duplicate = controller.claim(item);
release();
Promise.all([first,duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls,states,statuses,results,remaining:controller.items()
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert [item["number"] for item in output["remaining"]] == [8]
assert [item["number"] for item in output["states"][-1]] == [8]
assert output["statuses"] == ["Assigning stackchain/api#7…", "Assigned stackchain/api#7 to you."]
assert output["results"][0]["assignees"] == ["timmy"]
assert output["results"][1]["assignees"] == ["timmy"]
def test_find_work_loads_paginated_results_without_duplicates():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
const calls = [];
const states = [];
const pages = [];
const controller = createFindWork({{
fetchJson: url => {{
calls.push(url);
const page = Number(new URL(url, 'https://example.test/').searchParams.get('page'));
return Promise.resolve(page === 1 ? {{
items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:2,has_more:true
}} : {{
items:[{{id:1,number:1,repository:'stackchain/api'}},{{id:2,number:2,repository:'stackchain/web'}}],
page:2,total:2,has_more:false
}});
}},
onItems: items => states.push(items),
onPagination: page => pages.push(page),
onStatus: () => {{}},
}});
controller.load().then(() => controller.loadMore()).then(() =>
process.stdout.write(JSON.stringify({{calls,states,pages,items:controller.items()}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == ["api/v1/available-issues?page=1", "api/v1/available-issues?page=2"]
assert [item["id"] for item in output["items"]] == [1, 2]
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
@pytest.mark.anyio
async def test_mobile_my_work_exposes_truthful_work_pagination_control():
html = await dashboard()
assert 'id="work-page-status"' in html
assert 'id="load-more-work"' in html
assert '>Load older work<' in html
assert '.load-more-work { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe():
html = await dashboard()
assert 'id="find-work"' in html
assert 'id="find-work-sheet" role="dialog" aria-modal="true"' in html
assert 'id="find-work-list"' in html
assert 'id="find-work-status" class="small" aria-live="assertive"' in html
assert 'id="load-more-available"' in html
assert 'static/pick-work.js' in html
assert '.find-work-action { min-height:44px;' in html
assert 'padding-bottom:calc(18px + env(safe-area-inset-bottom))' in html
assert '@media(max-width:320px)' in html
def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let calls = [];
let release;
const capture = createIssueCapture({{
storage,
fetchJson: (url, options) => {{
calls.push({{url, options}});
return new Promise(resolve => {{ release = () => resolve({{
id:81, number:17, title:'Capture work', state:'open', repository:'stackchain/api',
labels:[], assignees:['timmy'], url:'https://forge.example/issues/17'
}}); }});
}},
}});
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context', labelIds:[3]}};
capture.saveDraft(draft);
const first = capture.submit(draft);
const duplicate = capture.submit(draft);
const during = capture.loadDraft();
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls:calls.map(call => ({{url:call.url, method:call.options.method,
body:JSON.parse(call.options.body)}})), during, after:capture.loadDraft(), results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/api/issues",
"method": "POST",
"body": {"title": "Capture work", "body": "Context", "label_ids": [3]},
}]
assert output["during"] == {
"repository": "stackchain/api", "title": "Capture work", "body": "Context",
"labelIds": [3],
}
assert output["after"] == {
"repository": "", "title": "", "body": "", "labelIds": []
}
assert output["results"][0]["number"] == 17
assert output["results"][1]["number"] == 17
def test_issue_capture_loads_repository_labels_with_priorities_first():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const calls = [];
const capture = createIssueCapture({{
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
fetchJson: url => {{
calls.push(url);
return Promise.resolve([
{{id:8,name:'frontend',color:'1d76db'}},
{{id:3,name:'P0',color:'d73a4a'}},
{{id:5,name:'critical',color:'b60205'}}
]);
}},
}});
capture.loadLabels('stackchain/api').then(labels => process.stdout.write(JSON.stringify({{calls,labels}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == ["api/v1/repos/stackchain/api/labels"]
assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"]
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
payload = {
"user": {"login": "timmy"},
@@ -543,6 +848,313 @@ reader.open(item, [item]).then(() => {{
]
def test_issue_sheet_loads_encoded_assigned_issue_detail_path():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let request;
const controller = createIssueSheet({{ fetchJson: async (url, options) => {{
request = {{url, accept:options.headers.Accept}};
return {{title:'Fix mobile flow'}};
}} }});
controller.load({{repository:'stackchain/api', number:7}}).then(detail =>
process.stdout.write(JSON.stringify({{request, title:detail.title}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"request": {
"url": "api/v1/repos/stackchain/api/issues/7/detail",
"accept": "application/json",
},
"title": "Fix mobile flow",
}
def test_issue_sheet_loads_labels_through_assigned_issue_boundary():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let request;
const controller = createIssueSheet({{ fetchJson: async (url, options) => {{
request = {{url, accept:options.headers.Accept}};
return [{{id:3, name:'P0'}}];
}} }});
controller.loadLabels({{repository:'stackchain/api', number:7}}).then(labels =>
process.stdout.write(JSON.stringify({{request, labels}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"request": {
"url": "api/v1/repos/stackchain/api/issues/7/labels",
"accept": "application/json",
},
"labels": [{"id": 3, "name": "P0"}],
}
def test_issue_sheet_comment_is_single_flight_and_preserves_draft_until_success():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let calls = 0;
let release;
const controller = createIssueSheet({{
storage,
fetchJson: (url, options) => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{id:82, body:'Ready'}}); }});
}},
}});
const item = {{repository:'stackchain/api', number:7}};
controller.saveDraft(item, 'Ready');
const first = controller.comment(item, 'Ready');
const duplicate = controller.comment(item, 'Ready');
const during = controller.loadDraft(item);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, during, after:controller.loadDraft(item), results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert output["during"] == "Ready"
assert output["after"] == ""
assert output["results"] == [{"id": 82, "body": "Ready"}, {"id": 82, "body": "Ready"}]
def test_issue_sheet_label_save_is_single_flight_and_keeps_selection_until_confirmed():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const calls = [];
let release;
const controller = createIssueSheet({{
storage,
fetchJson: (url, options={{}}) => {{
calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
return new Promise(resolve => {{ release = () => resolve({{number:7, labels:['P0']}}); }});
}},
}});
const item = {{repository:'stackchain/api', number:7}};
const first = controller.updateLabels(item, [3]);
const duplicate = controller.updateLabels(item, [3]);
const during = controller.loadLabelDraft(item);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, during, after:controller.loadLabelDraft(item), results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/api/issues/7/labels",
"method": "PATCH",
"body": {"label_ids": [3]},
}]
assert output["during"] == [3]
assert output["after"] == []
assert output["results"] == [
{"number": 7, "labels": ["P0"]},
{"number": 7, "labels": ["P0"]},
]
def test_issue_sheet_close_is_single_flight_and_waits_for_confirmed_closed_state():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let calls = 0;
let release;
const controller = createIssueSheet({{
fetchJson: (url, options) => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{number:7, state:'closed'}}); }});
}},
}});
const item = {{repository:'stackchain/api', number:7}};
const first = controller.close(item);
const duplicate = controller.close(item);
release();
Promise.all([first, duplicate]).then(results =>
process.stdout.write(JSON.stringify({{calls, results}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"calls": 1,
"results": [
{"number": 7, "state": "closed"},
{"number": 7, "state": "closed"},
],
}
def test_pull_sheet_preserves_comment_draft_and_single_flights_mutations():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const calls = [];
let releaseComment;
const controller = createPullSheet({{
storage,
fetchJson: (url, options={{}}) => {{
calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
if (url.endsWith('/comments')) return new Promise(resolve => {{ releaseComment = () => resolve({{id:91, body:'Ship it'}}); }});
return Promise.resolve({{number:7, merged:true, state:'closed'}});
}},
}});
const item = {{repository:'stackchain/api', number:7}};
controller.saveDraft(item, 'Ship it');
const first = controller.comment(item, 'Ship it');
const duplicate = controller.comment(item, 'Ship it');
const during = controller.loadDraft(item);
releaseComment();
Promise.all([first, duplicate]).then(async comments => {{
const merges = await Promise.all([controller.merge(item, 'abc123'), controller.merge(item, 'abc123')]);
process.stdout.write(JSON.stringify({{calls, during, after:controller.loadDraft(item), comments, merges}}));
}});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert output["during"] == "Ship it"
assert output["after"] == ""
assert output["calls"] == [
{"url": "api/v1/repos/stackchain/api/pulls/7/comments", "method": "POST", "body": {"body": "Ship it"}},
{"url": "api/v1/repos/stackchain/api/pulls/7/merge", "method": "POST", "body": {"expected_head_sha": "abc123"}},
]
assert len(output["comments"]) == 2 and len(output["merges"]) == 2
def test_pull_sheet_enables_merge_only_for_current_safe_state():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const states = [
{{state:'open', draft:false, mergeable:true, merged:false, ci_state:'success', head_sha:'abc'}},
{{state:'open', draft:true, mergeable:true, merged:false, ci_state:'success', head_sha:'abc'}},
{{state:'open', draft:false, mergeable:true, merged:false, ci_state:'failure', head_sha:'abc'}},
{{state:'open', draft:false, mergeable:false, merged:false, ci_state:'success', head_sha:'abc'}},
];
process.stdout.write(JSON.stringify(states.map(createPullSheet.mergeEligibility)));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert output[0] == {"allowed": True, "reason": "Ready to merge"}
assert output[1]["allowed"] is False and "draft" in output[1]["reason"].lower()
assert output[2]["allowed"] is False and "CI" in output[2]["reason"]
assert output[3]["allowed"] is False and "conflict" in output[3]["reason"].lower()
@pytest.mark.anyio
async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
html = await dashboard()
assert 'id="pull-sheet"' in html and 'aria-modal="true"' in html
assert 'class="my-work-card-main pull-trigger"' in html
assert 'id="pull-sheet-status"' in html
assert 'id="pull-files"' in html and 'id="pull-comments"' in html
assert 'id="pull-comment"' in html and 'maxlength="10000"' in html
assert 'id="merge-pull"' in html and 'id="open-pull-gitea"' in html
assert '<script src="static/pull-sheet.js"></script>' in html
assert "pullController.load(item)" in html
assert "window.confirm('Merge ' + selectedPull.key" in html
assert "expected_head_sha" in html
assert "item.kind === 'pull'" in html and "pull-trigger" in html
@pytest.mark.anyio
async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mutations():
html = await dashboard()
assert 'id="issue-sheet"' in html and 'aria-modal="true"' in html
assert 'class="my-work-card-main issue-trigger"' in html
assert 'id="close-issue-sheet"' in html
assert 'id="retry-issue-load"' in html
assert 'id="issue-sheet-body"' in html
assert 'id="issue-labels"' in html and 'id="issue-assignees"' in html
assert 'id="issue-comments"' in html
assert 'id="issue-comment"' in html and 'maxlength="10000"' in html
assert 'id="send-issue-comment"' in html
assert 'id="close-issue"' in html
assert 'id="open-issue-gitea"' in html and 'rel="noopener noreferrer"' in html
assert '.issue-sheet-panel { width:min(560px,100%);' in html
assert '.issue-sheet-content { overflow-wrap:anywhere;' in html
assert '.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
assert '<script src="static/issue-sheet.js"></script>' in html
assert "issueController.load(item)" in html
assert "issueController.comment(selectedIssue" in html
assert "window.confirm('Close ' + selectedIssue.key + '?')" in html
assert "issueController.close(selectedIssue)" in html
assert "lastMyWork = lastMyWork.filter" in html
assert "if (issueTrigger?.isConnected) issueTrigger.focus()" in html
assert "e.key === 'Escape' && selectedIssue" in html
@pytest.mark.anyio
async def test_mobile_issue_sheet_edits_labels_and_repaints_confirmed_priority():
html = await dashboard()
assert 'id="issue-label-editor"' in html
assert 'aria-describedby="issue-label-status"' in html
assert 'id="issue-label-list"' in html
assert 'id="save-issue-labels"' in html
assert '.issue-label-option { min-height:44px;' in html
assert 'max-width:100%;' in html
assert "issueController.updateLabels(selectedIssue" in html
assert "buildMyWork.replaceIssueLabels" in html
assert "paintMyWork(lastContextSnapshot)" in html
@pytest.mark.anyio
async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet():
html = await dashboard()
assert 'id="new-issue"' in html and 'New issue' in html
assert 'id="create-issue-sheet"' in html and 'aria-modal="true"' in html
assert 'id="create-issue-repository"' in html
assert 'id="create-issue-title"' in html and 'maxlength="255"' in html
assert 'id="create-issue-body"' in html and 'maxlength="10000"' in html
assert 'id="create-issue-labels"' in html and 'aria-describedby="create-issue-label-status"' in html
assert 'id="create-issue-label-status"' in html and 'aria-live="polite"' in html
assert 'id="submit-new-issue"' in html
assert '.create-issue-sheet.open { display:flex; }' in html
assert 'height:100dvh;' in html
assert 'env(safe-area-inset-bottom)' in html
assert '<script src="static/create-issue-sheet.js"></script>' in html
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
assert 'openIssueSheet(created' in html
assert 'issueCapture.loadLabels(repository)' in html
assert "input[name=\"create-issue-label\"]:checked" in html
assert '.create-issue-label-option' in html and 'min-height:44px' in html
@pytest.mark.anyio
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
html = await dashboard()
@@ -596,6 +1208,78 @@ async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
assert "notificationReader.markReadAndNext(lastMyWork)" in html
def test_notification_replier_preserves_failed_draft_and_clears_only_after_success():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
const statuses = [];
let calls = 0;
let fail = true;
const replier = buildMyWork.createNotificationReplier({{
storage,
post: async (id, body) => {{
calls += 1;
await new Promise(resolve => setTimeout(resolve, 5));
if (fail) throw new Error('offline');
return {{id:91, url:'https://forge.example/comment/91'}};
}},
onStatus: status => statuses.push(status),
}});
const item = {{notification_id:42}};
replier.saveDraft(item, 'Please retry.');
Promise.all([replier.submit(item, 'Please retry.'), replier.submit(item, 'Please retry.')])
.then(async first => {{
const afterFailure = replier.loadDraft(item);
fail = false;
const success = await replier.submit(item, afterFailure);
process.stdout.write(JSON.stringify({{
first, afterFailure, success, afterSuccess:replier.loadDraft(item), calls, statuses,
}}));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["first"] == [False, False]
assert output["afterFailure"] == "Please retry."
assert output["success"]["id"] == 91
assert output["afterSuccess"] == ""
assert output["calls"] == 2
assert output["statuses"] == [
"Sending reply…",
"Could not send reply. Your draft is safe; retry.",
"Sending reply…",
"Reply posted. You can mark this update read when ready.",
]
@pytest.mark.anyio
async def test_mobile_update_sheet_has_persistent_accessible_reply_composer():
html = await dashboard()
assert 'id="update-reply"' in html
assert 'maxlength="10000"' in html
assert 'id="send-update-reply"' in html
assert 'id="update-reply-status"' in html
assert 'aria-live="assertive"' in html
assert '.update-reply textarea { width:100%;' in html
assert '.update-reply button { min-height:44px;' in html
assert '.update-sheet-header button { min-height:44px;' in html
assert 'createNotificationReplier' in html
assert "method: 'POST'" in html
assert "'/reply'" in html
assert "notificationReplier.loadDraft(item)" in html
assert "notificationReplier.saveDraft(selectedUpdate" in html
assert "notificationReplier.submit(selectedUpdate" in html
@pytest.mark.anyio
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
html = await dashboard()
@@ -696,11 +1380,43 @@ process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expand
assert 'Preview truncated' in output["html"]
assert 'class="review-mark"' in output["html"]
assert 'data-review-filename="src/&lt;api&gt;.py"' in output["html"]
assert 'class="review-diff-line review-inline-target removed"' in output["html"]
assert 'data-old-position="1"' in output["html"]
assert 'data-new-position="1"' in output["html"]
assert 'aria-label="Comment on src/&lt;api&gt;.py line 1"' in output["html"]
assert 'Mark reviewed' in output["html"]
assert output["expanded"] == "true"
assert output["hidden"] is False
def test_review_diff_parser_maps_multi_hunk_lines_to_old_or_new_positions():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const rows = reviewSheet.parseDiffLines([
'@@ -10,3 +20,4 @@ function run()',
' context', '-removed', '+added', '+second',
String.raw`\\ No newline at end of file`,
'@@ -40 +51 @@', '-old tail', '+new tail'
]);
process.stdout.write(JSON.stringify(rows));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == [
{"text": "@@ -10,3 +20,4 @@ function run()", "kind": "hunk", "commentable": False},
{"text": " context", "kind": "context", "commentable": True, "new_position": 20},
{"text": "-removed", "kind": "removed", "commentable": True, "old_position": 11},
{"text": "+added", "kind": "added", "commentable": True, "new_position": 21},
{"text": "+second", "kind": "added", "commentable": True, "new_position": 22},
{"text": "\\ No newline at end of file", "kind": "note", "commentable": False},
{"text": "@@ -40 +51 @@", "kind": "hunk", "commentable": False},
{"text": "-old tail", "kind": "removed", "commentable": True, "old_position": 40},
{"text": "+new tail", "kind": "added", "commentable": True, "new_position": 51},
]
def test_review_progress_is_explicit_and_restores_for_the_same_head_sha():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
@@ -782,11 +1498,42 @@ process.stdout.write(JSON.stringify(restored));
assert json.loads(result.stdout) == {
"notes": {"src/a.py": "Handle the empty state."},
"comments": [],
"summary": "One blocker remains.",
"decision": "request_changes",
}
def test_review_draft_persists_edits_and_removes_inline_comments_for_same_head():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const options = {{storage, repository:'stackchain/api', number:7, headSha:'abc123', files:[{{filename:'src/a.py'}}]}};
const first = reviewSheet.createDraft(options);
first.setInlineComment({{path:'src/a.py', new_position:42}}, 'Handle empty values.');
first.setInlineComment({{path:'src/a.py', new_position:42}}, 'Handle null and empty values.');
first.setInlineComment({{path:'src/a.py', old_position:9}}, 'Why remove this guard?');
const restored = reviewSheet.createDraft(options);
const beforeRemove = restored.snapshot().comments;
const afterRemove = restored.removeInlineComment({{path:'src/a.py', old_position:9}}).comments;
process.stdout.write(JSON.stringify({{beforeRemove, afterRemove}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"beforeRemove": [
{"path": "src/a.py", "body": "Handle null and empty values.", "new_position": 42},
{"path": "src/a.py", "body": "Why remove this guard?", "old_position": 9},
],
"afterRemove": [
{"path": "src/a.py", "body": "Handle null and empty values.", "new_position": 42},
],
}
def test_review_feedback_formats_non_empty_file_notes_in_changed_file_order():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
@@ -902,7 +1649,12 @@ async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
assert "review-files" in html
assert "review-history" in html
assert "open-review-gitea" in html
assert "reviewController.submit" not in html
assert 'id="submit-review"' in html
assert 'id="review-submit-status"' in html and 'aria-live="assertive"' in html
assert "reviewController.submit(selectedReview" in html
assert "window.confirm" in html
assert "expected_head_sha: selectedReviewHead" in html
assert "await load()" in html
@pytest.mark.anyio
@@ -931,6 +1683,104 @@ async def test_mobile_review_sheet_captures_and_safely_hands_off_feedback():
assert "handoffWindow.opener = null" in html
@pytest.mark.anyio
async def test_mobile_review_sheet_edits_inline_drafts_and_submits_them_with_review():
html = await dashboard()
assert 'id="review-inline-composer"' in html
assert 'id="review-inline-body"' in html and 'maxlength="10000"' in html
assert 'id="save-inline-comment"' in html
assert 'id="delete-inline-comment"' in html
assert '.review-inline-target' in html and 'min-height:44px' in html
assert '.review-inline-composer' in html and 'position:sticky' in html
assert "document.querySelectorAll('.review-inline-target')" in html
assert "draft.setInlineComment" in html
assert "draft.removeInlineComment" in html
assert "comments: snapshot.comments" in html
def test_review_controller_submits_once_while_request_is_in_flight():
script = f"""
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
let resolveRequest;
const calls = [];
const controller = createReviewController({{ fetchJson: (url, options) => {{
calls.push({{url, options}});
return new Promise(resolve => {{ resolveRequest = resolve; }});
}} }});
const item = {{repository:'stackchain/api', number:7}};
const payload = {{decision:'approve', body:'Looks good.', expected_head_sha:'abc123'}};
const first = controller.submit(item, payload);
const second = controller.submit(item, payload);
resolveRequest({{id:91, state:'APPROVED'}});
Promise.all([first, second]).then(results => process.stdout.write(JSON.stringify({{
calls, results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
payload = json.loads(result.stdout)
assert len(payload["calls"]) == 1
assert payload["calls"][0] == {
"url": "api/v1/repos/stackchain/api/pulls/7/review",
"options": {
"method": "POST",
"headers": {
"Accept": "application/json",
"Content-Type": "application/json",
},
"body": json.dumps(
{
"decision": "approve",
"body": "Looks good.",
"expected_head_sha": "abc123",
},
separators=(",", ":"),
),
},
}
assert payload["results"] == [
{"id": 91, "state": "APPROVED"},
{"id": 91, "state": "APPROVED"},
]
def test_successful_review_can_clear_head_scoped_draft_and_progress():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const removed = [];
const storage = {{
getItem: key => values.get(key) || null,
setItem: (key, value) => values.set(key, value),
removeItem: key => {{ removed.push(key); values.delete(key); }},
}};
const options = {{storage, repository:'stackchain/api', number:7, headSha:'abc123', files:[{{filename:'a.py'}}]}};
const draft = reviewSheet.createDraft(options);
const progress = reviewSheet.createProgress(options);
draft.setSummary('Looks good.');
progress.markReviewed('a.py');
draft.clear();
progress.clear();
process.stdout.write(JSON.stringify({{removed, draft:draft.snapshot(), progress:progress.snapshot()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
payload = json.loads(result.stdout)
assert payload["removed"] == [
"stackchain.review-draft.v1:stackchain/api#7@abc123",
"stackchain.review-progress.v1:stackchain/api#7@abc123",
]
assert payload["draft"] == {
"notes": {}, "comments": [], "summary": "", "decision": "comment"
}
assert payload["progress"]["reviewedCount"] == 0
@pytest.mark.anyio
async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item():
html = await dashboard()
+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