Compare commits

...
Author SHA1 Message Date
timmy 174dac05b9 feat: make assigned pull sheets read-first (#217)
CI / lint (pull_request) Successful in 20s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 18:00:00 +00:00
rockachopa ea1f4ad837 Make mobile issue sheets read-first with on-demand planning (#216)
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Closes #215
2026-08-07 17:39:18 +00:00
timmy d09ceb8650 feat: make mobile issue sheets read-first (#215)
CI / lint (pull_request) Successful in 18s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 17:38:01 +00:00
timmy c0cc6ec46e Merge pull request 'Keep Find Work instant while its catalog refreshes' (#214) from timmy/213-instant-find-work into main
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 17:16:30 +00:00
timmy aaff109ede feat: keep Find Work instant during refresh (#213)
CI / lint (pull_request) Successful in 18s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 17:15:31 +00:00
timmy 2fe9b6cc3d Merge pull request 'Recover unfinished mobile drafts from My Work' (#212) from timmy/211-mobile-draft-recovery into main
CI / lint (push) Successful in 18s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 16:56:08 +00:00
timmy a08ce31858 feat: recover mobile drafts from My Work (#211)
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 16:55:00 +00:00
timmy 092234af38 Merge pull request 'Load complete mobile conversations without leaving My Work' (#210) from timmy/209-mobile-conversation-history into main
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 16:23:32 +00:00
timmy c63b23865c feat: load complete mobile conversations (#209)
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 16:22:30 +00:00
rockachopa e04d6753ac Merge pull request 'Resolve shared mobile My Work routes beyond page one' (#208) from timmy/207-resolve-mobile-work-routes into main
CI / lint (push) Successful in 21s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-07 16:00:02 +00:00
timmy b5545e380e feat: resolve shared My Work routes
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 15:58:56 +00:00
timmy 2fecccfbde Make mobile My Work sheets URL-addressable and back-button safe (#206)
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Closes #205
2026-08-07 15:18:23 +00:00
timmy 9e697dc63f feat: route mobile My Work sheets (#205)
CI / lint (pull_request) Successful in 16s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 15:17:21 +00:00
timmy 5e826bcc7e Merge pull request 'Plan and focus mobile My Work by milestone' (#204) from timmy/203-mobile-milestone-planning into main
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 15:01:56 +00:00
timmy 2be29b684a feat: plan mobile work by milestone (#203)
CI / lint (pull_request) Successful in 16s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 15:01:02 +00:00
timmy 09b2898d1c Merge pull request 'Persist authored action idempotency across restarts and workers' (#202) from timmy/201-durable-idempotency into main
CI / lint (push) Successful in 16s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-07 14:38:07 +00:00
18 changed files with 2680 additions and 178 deletions

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
function createConversationPager({ loadPage }) {
let state = { comments: [], page: 1, older_page: null, total: 0 };
let olderRequest = null;
const validComments = comments => Array.isArray(comments)
? comments.filter(comment => comment && Number.isInteger(comment.id))
: [];
const unique = comments => {
const seen = new Set();
return comments.filter(comment => {
if (seen.has(comment.id)) return false;
seen.add(comment.id);
return true;
}).sort((left, right) => {
const leftTime = Date.parse(left.created_at || '');
const rightTime = Date.parse(right.created_at || '');
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return left.id - right.id;
});
};
const snapshot = () => ({ ...state, comments: state.comments.map(comment => ({ ...comment })) });
return {
reset(page) {
const comments = unique(validComments(page?.comments));
state = {
comments,
page: Number.isInteger(page?.page) ? page.page : 1,
older_page: Number.isInteger(page?.older_page) ? page.older_page : null,
total: Number.isInteger(page?.total) ? Math.max(page.total, comments.length) : comments.length,
};
olderRequest = null;
return snapshot();
},
snapshot,
loadOlder() {
if (olderRequest) return olderRequest;
if (!Number.isInteger(state.older_page)) return Promise.resolve(snapshot());
const requestedPage = state.older_page;
olderRequest = Promise.resolve(loadPage(requestedPage)).then(page => {
state = {
comments: unique(validComments(page?.comments).concat(state.comments)),
page: Number.isInteger(page?.page) ? page.page : requestedPage,
older_page: Number.isInteger(page?.older_page) ? page.older_page : null,
total: Number.isInteger(page?.total) ? Math.max(page.total, state.comments.length) : state.total,
};
return snapshot();
}).finally(() => { olderRequest = null; });
return olderRequest;
},
append(comment) {
if (!comment || !Number.isInteger(comment.id)) return snapshot();
if (!state.comments.some(existing => existing.id === comment.id)) {
state = {
...state,
comments: state.comments.concat(comment),
total: Math.max(state.total + 1, state.comments.length + 1),
};
}
return snapshot();
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationPager;
+144
View File
@@ -0,0 +1,144 @@
function createDraftInbox({ storage, now = () => Date.now() }) {
const indexKey = 'stackchain.draft-index.v1';
function readIndex() {
try {
const value = JSON.parse(storage?.getItem(indexKey) || '{}');
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch (_error) { return {}; }
}
function writeIndex(index) {
try { storage?.setItem(indexKey, JSON.stringify(index)); }
catch (_error) { /* Draft discovery remains available without metadata. */ }
}
function keys() {
try {
return Array.from({ length: Number(storage?.length || 0) }, (_, index) => storage.key(index))
.filter(Boolean);
} catch (_error) { return []; }
}
function textPreview(value) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 180);
}
function parseTarget(value) {
const match = String(value || '').match(/^(.+)#(\d+)$/);
return match ? { repository: match[1], number: Number(match[2]) } : null;
}
function parse(key, raw) {
let match;
if (key === 'stackchain.issue-capture.v1') {
try {
const value = JSON.parse(raw);
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
if (!preview && !value?.repository) return null;
return {
kind: 'new-issue', label: 'New issue', repository: String(value?.repository || ''),
title: textPreview(value?.title) || 'Untitled new issue', preview,
};
} catch (_error) { return null; }
}
match = key.match(/^stackchain\.(issue|pull)-comment\.v1:(.+#\d+)$/);
if (match) {
const target = parseTarget(match[2]);
const preview = textPreview(raw);
if (!target || !preview) return null;
const routeKind = match[1] === 'issue' ? 'issue' : 'pull';
return {
kind: routeKind + '-comment', label: routeKind === 'issue' ? 'Issue comment' : 'PR comment',
...target, title: target.repository + '#' + target.number, preview,
route: { kind: routeKind, ...target },
};
}
match = key.match(/^stackchain\.issue-content\.v1:(.+#\d+)$/);
if (match) {
const target = parseTarget(match[1]);
try {
const value = JSON.parse(raw);
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
if (!target || !preview || typeof value?.expectedUpdatedAt !== 'string') return null;
return {
kind: 'issue-edit', label: 'Issue edit', ...target,
title: target.repository + '#' + target.number, preview,
route: { kind: 'issue', ...target },
};
} catch (_error) { return null; }
}
match = key.match(/^stackchain\.update-reply\.v1\.(\d+)$/);
if (match) {
const preview = textPreview(raw);
if (!preview) return null;
const notificationId = Number(match[1]);
return {
kind: 'update-reply', label: 'Update reply', notification_id: notificationId,
title: 'Update #' + notificationId, preview,
route: { kind: 'update', notification_id: notificationId },
};
}
match = key.match(/^stackchain\.review-draft\.v1:(.+#\d+)@([^:]+)$/);
if (match) {
const target = parseTarget(match[1]);
try {
const value = JSON.parse(raw);
const notes = Object.values(value?.notes || {});
const comments = Array.isArray(value?.comments) ? value.comments.map(item => item?.body) : [];
const preview = textPreview([value?.summary, ...notes, ...comments].filter(Boolean).join(' — '));
if (!target || !preview) return null;
return {
kind: 'review', label: 'PR review', ...target, head_sha: match[2],
title: target.repository + '#' + target.number, preview,
route: { kind: 'review', ...target },
};
} catch (_error) { return null; }
}
return null;
}
function list() {
const index = readIndex();
const seen = new Set();
const drafts = [];
keys().forEach(key => {
if (key === indexKey || key.endsWith(':operation')) return;
let raw;
try { raw = storage.getItem(key); }
catch (_error) { return; }
const parsed = parse(key, raw);
if (!parsed) return;
seen.add(key);
const fingerprint = String(raw);
const previous = index[key];
if (!previous || previous.fingerprint !== fingerprint) {
index[key] = { fingerprint, updated_at: Number(now()) };
}
drafts.push({ id: key, ...parsed, updated_at: index[key].updated_at });
});
Object.keys(index).forEach(key => { if (!seen.has(key)) delete index[key]; });
writeIndex(index);
return drafts.sort((left, right) =>
Number(right.updated_at || 0) - Number(left.updated_at || 0) || left.id.localeCompare(right.id)
);
}
function discard(id) {
if (typeof id !== 'string' || !parse(id, (() => {
try { return storage?.getItem(id); } catch (_error) { return null; }
})())) return false;
try {
storage?.removeItem(id);
storage?.removeItem(id + ':operation');
const index = readIndex();
delete index[id];
writeIndex(index);
return true;
} catch (_error) { return false; }
}
return { list, discard };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
+508 -121
View File
File diff suppressed because it is too large. Load diff
+67 -1
View File
@@ -1,10 +1,31 @@
function createIssueSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function createPlanningLoader({ loadLabels, loadMilestones }) {
const requests = new Map();
const keyFor = item => item.repository + '#' + item.number;
return {
open(item) {
const key = keyFor(item);
if (requests.has(key)) return requests.get(key);
const request = Promise.all([loadLabels(item), loadMilestones(item)])
.then(([labels, milestones]) => ({ labels, milestones }))
.catch(error => {
if (requests.get(key) === request) requests.delete(key);
throw error;
});
requests.set(key, request);
return request;
},
};
}
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
let labelRequest = null;
let editRequest = null;
let dueDateRequest = null;
let milestoneRequest = null;
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
@@ -12,6 +33,7 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
const editDraftKey = item => 'stackchain.issue-content.v1:' + item.repository + '#' + item.number;
const dueDateDraftKey = item => 'stackchain.issue-due-date.v1:' + item.repository + '#' + item.number;
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
return {
load(item) {
@@ -19,11 +41,26 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
headers: { Accept: 'application/json' },
});
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
}),
});
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
return pager;
},
loadLabels(item) {
return fetchJson(issuePath(item) + '/labels', {
headers: { Accept: 'application/json' },
});
},
loadMilestones(item) {
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
return fetchJson('api/v1/repos/' + repository + '/milestones', {
headers: { Accept: 'application/json' },
});
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
@@ -93,6 +130,33 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
}).finally(() => { dueDateRequest = null; });
return dueDateRequest;
},
loadMilestoneDraft(item) {
try {
const raw = storage?.getItem(milestoneDraftKey(item));
if (raw === null || raw === undefined) return null;
const value = JSON.parse(raw);
return value === null || Number.isInteger(value) ? value : null;
} catch (_error) { return null; }
},
updateMilestone(item, milestoneId) {
if (milestoneRequest) return milestoneRequest;
try { storage?.setItem(milestoneDraftKey(item), JSON.stringify(milestoneId)); }
catch (_error) { /* The select remains the fallback. */ }
milestoneRequest = fetchJson(issuePath(item) + '/milestone', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ milestone_id: milestoneId }),
}).then(result => {
if (result?.number !== item.number ||
(milestoneId === null ? result?.milestone !== null : result?.milestone?.id !== milestoneId)) {
throw new Error('Issue milestone was not confirmed.');
}
try { storage?.removeItem(milestoneDraftKey(item)); }
catch (_error) { /* Confirmed upstream milestone is authoritative. */ }
return result;
}).finally(() => { milestoneRequest = null; });
return milestoneRequest;
},
loadLabelDraft(item) {
try {
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
@@ -169,4 +233,6 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
};
}
createIssueSheet.createPlanningLoader = createPlanningLoader;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
+36 -7
View File
@@ -353,11 +353,29 @@ function createNotificationReplier({
};
}
function filterMyWork(items, selectedFilter) {
if (selectedFilter === 'all') return items;
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
if (selectedFilter === 'update') return items.filter((item) => item.has_update);
return items.filter((item) => item.kind === selectedFilter);
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
let filtered = items;
if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
if (selectedMilestone === 'all') return filtered;
if (selectedMilestone === 'unplanned') {
return filtered.filter(item => item.kind === 'issue' && !item.milestone);
}
return filtered.filter(item =>
item.kind === 'issue' && String(item.milestone?.id || '') === String(selectedMilestone)
);
}
function milestoneLanes(items) {
const lanes = new Map();
(items || []).forEach(item => {
if (item?.kind === 'issue' && Number.isInteger(item.milestone?.id) &&
typeof item.milestone?.title === 'string') {
lanes.set(item.milestone.id, { id: item.milestone.id, title: item.milestone.title });
}
});
return Array.from(lanes.values()).sort((left, right) => left.title.localeCompare(right.title));
}
function workIdentity(item) {
@@ -369,12 +387,12 @@ function workIdentity(item) {
return [kind, repository, number, notification].join(':');
}
function createWorkSession({ getItems, getFilter, onOpen, onProgress, onFinish }) {
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) {
let currentIdentity = '';
let currentIndex = -1;
let running = false;
const queue = () => filterMyWork(getItems() || [], getFilter());
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
const report = (items, index) => onProgress({
index: index + 1,
total: items.length,
@@ -465,6 +483,15 @@ function replaceIssueDueDate(data, repository, number, dueDate) {
};
}
function replaceIssueMilestone(data, repository, number, milestone) {
return {
...data,
issues: (data.issues || []).map(item =>
item.repository === repository && item.number === number ? { ...item, milestone } : item
),
};
}
function removeIssue(data, repository, number) {
return {
...data,
@@ -496,11 +523,13 @@ function countMyWork(items) {
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.milestoneLanes = milestoneLanes;
buildMyWork.createWorkSession = createWorkSession;
buildMyWork.workIdentity = workIdentity;
buildMyWork.replaceIssueLabels = replaceIssueLabels;
buildMyWork.replaceIssueContent = replaceIssueContent;
buildMyWork.replaceIssueDueDate = replaceIssueDueDate;
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
buildMyWork.removeIssue = removeIssue;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.countMyWork = countMyWork;
+5
View File
@@ -39,6 +39,11 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
headers: { Accept: 'application/json' },
}).then(result => {
apply(result, append);
if (result?.refresh_failed === true) {
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
} else if (result?.revalidating === true) {
onStatus('Showing saved available work while the catalog refreshes…');
}
return result;
}).finally(() => { loadRequest = null; });
return loadRequest;
+26 -1
View File
@@ -37,9 +37,11 @@ function renderFile(file, index, reviewed, escapeHtml) {
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
}
function createPullSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
const reviewRequests = new Map();
const reviewCache = new Map();
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
.map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number;
@@ -62,6 +64,29 @@ function createPullSheet({ fetchJson, storage, createOperationId = () => globalT
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
loadReview(item, headSha) {
const key = item.repository + '#' + item.number + ':' + headSha;
if (reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
if (reviewRequests.has(key)) return reviewRequests.get(key);
const request = fetchJson(pathFor(item) + '/review-data', {
headers: { Accept: 'application/json' },
}).then(detail => {
const confirmedKey = item.repository + '#' + item.number + ':' + detail.head_sha;
reviewCache.set(confirmedKey, detail);
return detail;
}).finally(() => reviewRequests.delete(key));
reviewRequests.set(key, request);
return request;
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
}),
});
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
return pager;
},
reviewState,
toggleReviewed(item, detail, filename) {
const state = reviewState(item, detail);
+161
View File
@@ -0,0 +1,161 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createWorkRoute = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
function positiveInteger(value) {
const number = Number(value);
return Number.isSafeInteger(number) && number > 0 ? number : null;
}
function parse(fragment) {
const parts = String(fragment || '').split('/');
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
if (parts[2] === 'update' && parts.length === 4) {
const notificationId = positiveInteger(parts[3]);
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
}
if (!['issue', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
const number = positiveInteger(parts[5]);
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
}
function serialize(item) {
if (item?.kind === 'update') {
const notificationId = positiveInteger(item.notification_id);
return notificationId ? '#/my-work/update/' + notificationId : '';
}
if (!['issue', 'pull', 'review'].includes(item?.kind)) return '';
const repository = String(item.repository || '').split('/');
const number = positiveInteger(item.number);
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
return '#/my-work/' + item.kind + '/' + repository.join('/') + '/' + number;
}
function sameRoute(item, route) {
if (route.kind === 'update') {
return Number(item.notification_id) === route.notification_id;
}
const itemKind = item.is_review ? 'review' : item.kind;
return itemKind === route.kind && item.repository === route.repository &&
Number(item.number) === route.number;
}
function createController({
location, history, eventTarget, onOpen, onClose, onInvalid,
resolve, onResolving = function () {}, onError = function () {},
}) {
let items = [];
let started = false;
let active = '';
let ready = false;
let resolving = '';
let resolution = 0;
function resolveMissing(fragment, route) {
if (typeof resolve !== 'function') {
onInvalid();
return;
}
if (resolving === fragment) return;
resolving = fragment;
const request = ++resolution;
onResolving(route);
Promise.resolve(resolve(route)).then(item => {
if (request !== resolution || String(location.hash || '') !== fragment) return;
resolving = '';
if (!item || !sameRoute(item, route)) {
onInvalid();
return;
}
active = fragment;
onOpen({ ...item, kind: route.kind });
}).catch(error => {
if (request !== resolution || String(location.hash || '') !== fragment) return;
resolving = '';
if (error?.unavailable) onInvalid();
else onError(error, route);
});
}
function sync() {
const fragment = String(location.hash || '');
if (!fragment) {
resolution += 1;
resolving = '';
if (active) onClose();
active = '';
return;
}
const route = parse(fragment);
if (!route) {
resolution += 1;
resolving = '';
if (fragment.startsWith('#/my-work/')) onInvalid();
active = '';
return;
}
const item = items.find(candidate => sameRoute(candidate, route));
if (!item) {
if (ready) resolveMissing(fragment, route);
return;
}
if (active === fragment) return;
resolution += 1;
resolving = '';
active = fragment;
onOpen({ ...item, kind: route.kind });
}
return {
start() {
if (started) return;
started = true;
eventTarget.addEventListener('popstate', sync);
eventTarget.addEventListener('hashchange', sync);
sync();
},
setItems(nextItems) {
items = Array.isArray(nextItems) ? nextItems.slice() : [];
ready = true;
sync();
},
open(item, options = {}) {
const fragment = serialize(item);
if (!fragment) return false;
const method = options.replace ? 'replaceState' : 'pushState';
history[method]({ workRoute: fragment }, '', fragment);
active = fragment;
onOpen(item);
return true;
},
close() {
if (parse(location.hash)) history.back();
else {
active = '';
onClose();
}
},
sync,
};
}
async function share(url, navigatorObject, clipboard) {
if (typeof navigatorObject?.share === 'function') {
await navigatorObject.share({ url });
return 'shared';
}
if (typeof clipboard?.writeText === 'function') {
await clipboard.writeText(url);
return 'copied';
}
throw new Error('Sharing is unavailable.');
}
return { parse, serialize, createController, share };
});
+242 -35
View File
@@ -11,6 +11,7 @@ GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000").rstrip("/")
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
REVIEW_DIFF_MAX_BYTES = 64 * 1024
REVIEW_DIFF_MAX_LINES = 400
AVAILABLE_ISSUE_PAGE_CONCURRENCY = 3
_client: httpx.AsyncClient | None = None
@@ -22,6 +23,10 @@ class WorkItems(list[dict]):
self.pagination = pagination
class WorkRouteUnavailableError(ValueError):
"""Raised when a shared route no longer belongs in the current user's queue."""
class StaleReviewError(ValueError):
"""Raised before mutation when a pull request head changed during review."""
@@ -324,8 +329,8 @@ async def available_issue_snapshot(max_pages: int = 10, upstream_limit: int = 50
"""Load, filter, and globally rank a bounded snapshot of available issues."""
items: list[dict] = []
seen_ids: set[Any] = set()
upstream_total: int | None = None
for upstream_page in range(1, max_pages + 1):
async def load_page(upstream_page: int) -> tuple[list[Any], int | None]:
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
@@ -338,21 +343,41 @@ async def available_issue_snapshot(max_pages: int = 10, upstream_limit: int = 50
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea available issue search response was not a list")
if upstream_total is None:
try:
upstream_total = max(0, int(response.headers["X-Total-Count"]))
except (KeyError, TypeError, ValueError):
upstream_total = None
try:
total = max(0, int(response.headers["X-Total-Count"]))
except (KeyError, TypeError, ValueError):
total = None
return payload, total
first_payload, upstream_total = await load_page(1)
pages: list[list[Any]] = [first_payload]
if upstream_total is not None:
page_count = min(max_pages, max(1, (upstream_total + upstream_limit - 1) // upstream_limit))
semaphore = asyncio.Semaphore(AVAILABLE_ISSUE_PAGE_CONCURRENCY)
async def load_bounded(page: int) -> list[Any]:
async with semaphore:
payload, _ = await load_page(page)
return payload
if page_count > 1:
pages.extend(await asyncio.gather(*(
load_bounded(page) for page in range(2, page_count + 1)
)))
else:
previous = first_payload
for page in range(2, max_pages + 1):
if not previous or len(previous) < upstream_limit:
break
previous, _ = await load_page(page)
pages.append(previous)
for payload in pages:
for raw_item in payload:
item = _normalize_available_issue(raw_item)
if item is not None and item["id"] not in seen_ids:
seen_ids.add(item["id"])
items.append(item)
loaded = upstream_page * upstream_limit
if not payload or len(payload) < upstream_limit or (
upstream_total is not None and loaded >= upstream_total
):
break
priority = {"p0", "priority-high", "critical"}
items.sort(key=lambda item: (item["repository"], item["number"] or 0))
@@ -657,6 +682,26 @@ async def repo_labels(repository: str) -> list[dict]:
]
async def repo_milestones(repository: str) -> list[dict]:
response = await _get_client().get(
f"/api/v1/repos/{repository}/milestones",
headers=_auth(),
params={"state": "open", "limit": 50, "page": 1},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea milestones response was not a list")
return [
{"id": item["id"], "title": item["title"]}
for item in payload
if isinstance(item, dict)
and isinstance(item.get("id"), int)
and isinstance(item.get("title"), str)
and item.get("state") == "open"
]
async def create_issue(
repository: str,
title: str,
@@ -871,10 +916,105 @@ async def update_assigned_issue_due_date(
}
async def update_assigned_issue_milestone(
repository: str, number: int, milestone_id: int | None
) -> dict:
path = f"repos/{repository}/issues/{number}"
login, issue = await _current_login_and_target(path)
if (
issue.get("state") != "open"
or isinstance(issue.get("pull_request"), dict)
or not _login_in_users(login, issue.get("assignees"))
):
raise IssueNotAvailableError("assigned issue not found")
selected = None
if milestone_id is not None:
selected = next(
(item for item in await repo_milestones(repository) if item["id"] == milestone_id),
None,
)
if selected is None:
raise ValueError("Unknown open repository milestone")
response = await _get_client().patch(
f"/api/v1/{path}", headers=_auth(), json={"milestone": milestone_id or 0}
)
response.raise_for_status()
confirmed = response.json()
milestone_value = confirmed.get("milestone") if isinstance(confirmed, dict) else None
normalized = (
{"id": milestone_value["id"], "title": milestone_value["title"]}
if isinstance(milestone_value, dict)
and isinstance(milestone_value.get("id"), int)
and isinstance(milestone_value.get("title"), str)
else None
)
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or normalized != selected
):
raise ValueError("Gitea did not confirm the issue milestone update")
return {
"repository": repository,
"number": number,
"state": confirmed.get("state", "open"),
"milestone": normalized,
}
async def issue_conversation_page(
repository: str,
number: int,
page: int | None = None,
limit: int = 20,
) -> dict:
"""Return one bounded comment page, opening on the newest page by default."""
bounded_limit = min(50, max(1, limit))
requested_page = max(1, page or 1)
path = f"/api/v1/repos/{repository}/issues/{number}/comments"
async def load(selected_page: int) -> tuple[list, int]:
response = await _get_client().get(
path,
headers=_auth(),
params={"limit": bounded_limit, "page": selected_page},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea issue comments response was not a list")
comments = [item for item in payload if isinstance(item, dict)]
try:
total = max(len(comments), int(response.headers.get("X-Total-Count", len(comments))))
except (TypeError, ValueError):
total = len(comments)
return comments, total
comments, total = await load(requested_page)
newest_page = max(1, (total + bounded_limit - 1) // bounded_limit)
if len(comments) >= total:
selected_page = newest_page if page is None else min(requested_page, newest_page)
start = (selected_page - 1) * bounded_limit
comments = comments[start:start + bounded_limit]
else:
selected_page = requested_page
if page is None and newest_page != requested_page:
selected_page = newest_page
comments, confirmed_total = await load(selected_page)
total = max(total, confirmed_total)
return {
"comments": [_normalize_issue_comment(item) for item in comments],
"page": selected_page,
"older_page": selected_page - 1 if selected_page > 1 else None,
"total": total,
}
async def issue_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/issues/{number}"
issue, comments = await asyncio.gather(
fetch(base), fetch(f"{base}/comments?limit=20&page=1")
issue, conversation = await asyncio.gather(
fetch(base), issue_conversation_page(repository, number)
)
if not isinstance(issue, dict):
raise ValueError("Gitea issue response was not an object")
@@ -882,12 +1022,7 @@ async def issue_detail(repository: str, number: int) -> dict:
labels: list = labels_value if isinstance(labels_value, list) else []
assignees_value = issue.get("assignees")
assignees: list = assignees_value if isinstance(assignees_value, list) else []
comments_value: list = comments if isinstance(comments, list) else []
normalized_comments = [
_normalize_issue_comment(comment)
for comment in comments_value
if isinstance(comment, dict)
]
normalized_comments = conversation["comments"]
return {
"repository": repository,
"number": number,
@@ -896,6 +1031,13 @@ async def issue_detail(repository: str, number: int) -> dict:
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
"updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "",
"due_date": issue.get("due_date") if isinstance(issue.get("due_date"), str) else None,
"milestone": (
{"id": issue["milestone"]["id"], "title": issue["milestone"]["title"]}
if isinstance(issue.get("milestone"), dict)
and isinstance(issue["milestone"].get("id"), int)
and isinstance(issue["milestone"].get("title"), str)
else None
),
"url": _safe_web_url(issue.get("html_url")),
"labels": [
label["name"] for label in labels
@@ -906,6 +1048,7 @@ async def issue_detail(repository: str, number: int) -> dict:
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
],
"comments": normalized_comments,
"conversation": conversation,
}
@@ -966,6 +1109,57 @@ def _login_in_users(login: str, value: object) -> bool:
)
async def resolve_work_route(
kind: str,
repository: str | None,
number: int | None,
notification_id: int | None,
) -> dict:
if kind == "update":
if notification_id is None:
raise WorkRouteUnavailableError("Notification identity is missing")
thread = await fetch(f"notifications/threads/{notification_id}")
if not isinstance(thread, dict) or thread.get("unread") is not True:
raise WorkRouteUnavailableError("Notification is no longer unread")
detail = await notification_detail(notification_id)
return {
"kind": "update",
"notification_id": notification_id,
"has_update": True,
"repository": detail.get("repository", ""),
"title": detail.get("title", ""),
"url": detail.get("url", ""),
}
if repository is None or number is None or kind not in {"issue", "pull", "review"}:
raise WorkRouteUnavailableError("Work identity is missing")
target_path = "issues" if kind == "issue" else "pulls"
login, target = await _current_login_and_target(
f"repos/{repository}/{target_path}/{number}"
)
assigned = _login_in_users(login, target.get("assignees"))
requested = _login_in_users(login, target.get("requested_reviewers"))
eligible = (
target.get("state") == "open"
and (
(kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned)
or (kind == "pull" and assigned)
or (kind == "review" and requested)
)
)
if not eligible:
raise WorkRouteUnavailableError("Work item is no longer in My Work")
return {
"kind": kind,
"repository": repository,
"number": number,
"title": target.get("title", "") if isinstance(target.get("title"), str) else "",
"state": "open",
"url": _safe_web_url(target.get("html_url")),
**({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}),
}
async def is_assigned_issue(repository: str, number: int) -> bool:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
@@ -1028,18 +1222,9 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
conversation = await issue_conversation_page(repository, number)
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
sha = head.get("sha") if isinstance(head.get("sha"), str) else ""
files, status, comments, diff_result = await asyncio.gather(
fetch(f"{base}/files"),
fetch(f"repos/{repository}/commits/{sha}/status"),
fetch(f"repos/{repository}/issues/{number}/comments?limit=20&page=1"),
fetch_text(
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
),
)
diff, diff_truncated = diff_result
previews = _diff_previews(diff, diff_truncated)
user = pull.get("user") if isinstance(pull.get("user"), dict) else {}
return {
"repository": repository,
@@ -1050,6 +1235,33 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
"author": user.get("login") if isinstance(user.get("login"), str) else "",
"head_sha": sha,
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
"conversation": conversation,
}
async def pull_completion_review(repository: str, number: int) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
head_value = pull.get("head")
head: dict = head_value if isinstance(head_value, dict) else {}
sha_value = head.get("sha")
sha = sha_value if isinstance(sha_value, str) else ""
files, status, diff_result = await asyncio.gather(
fetch(f"{base}/files"),
fetch(f"repos/{repository}/commits/{sha}/status"),
fetch_text(
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
),
)
diff, diff_truncated = diff_result
previews = _diff_previews(diff, diff_truncated)
return {
"repository": repository,
"number": number,
"head_sha": sha,
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
"draft": pull.get("draft") is True,
"mergeable": pull.get("mergeable") is True,
"merged": pull.get("merged") is True,
@@ -1073,11 +1285,6 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
for item in (files if isinstance(files, list) else [])[:100]
if isinstance(item, dict) and isinstance(item.get("filename"), str)
],
"comments": [
_normalize_issue_comment(item)
for item in (comments if isinstance(comments, list) else [])[:20]
if isinstance(item, dict)
],
}
+224 -8
View File
@@ -26,7 +26,7 @@ from src.gitea_proxy import (
repos,
)
from src.idempotency import IdempotencyLedger
from src.models import Issue, PullRequest, Repo, User
from src.models import Issue, Milestone, PullRequest, Repo, User
from src.suggestion_engine import compute
from src.views import router as frontend_router
@@ -76,6 +76,7 @@ LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
LIVE_SNAPSHOT_RETRY_BASE_SECONDS = 5.0
LIVE_SNAPSHOT_RETRY_MAX_SECONDS = 60.0
AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS = 15.0
AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS = 5.0
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
_live_snapshot_task: asyncio.Task | None = None
_live_snapshot_value: dict | None = None
@@ -107,6 +108,7 @@ _idempotency_ledger = IdempotencyLedger(
_available_issue_snapshot_task: asyncio.Task | None = None
_available_issue_snapshot_value: list[dict] | None = None
_available_issue_snapshot_created_at: float | None = None
_available_issue_snapshot_retry_at: float | None = None
class ContextPayloadError(ValueError):
@@ -195,6 +197,10 @@ class IssueDueDateUpdate(BaseModel):
)
class IssueMilestoneUpdate(BaseModel):
milestone_id: PositiveInt | None = None
class PullReviewComment(BaseModel):
path: str = Field(min_length=1, max_length=1_000)
body: str = Field(min_length=1, max_length=10_000)
@@ -318,6 +324,9 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
updated_at=i.get("updated_at") or "",
due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None,
milestone=Milestone(**i["milestone"]) if isinstance(i.get("milestone"), dict)
and isinstance(i["milestone"].get("id"), int)
and isinstance(i["milestone"].get("title"), str) else None,
url=i["html_url"],
)
for i in (issues_data or [])[:50]
@@ -358,6 +367,10 @@ def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
updated_at=item.get("updated_at") or "",
due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None,
milestone=Milestone(**item["milestone"])
if isinstance(item.get("milestone"), dict)
and isinstance(item["milestone"].get("id"), int)
and isinstance(item["milestone"].get("title"), str) else None,
url=item["html_url"],
).model_dump()
for item in items
@@ -386,7 +399,7 @@ app.include_router(frontend_router)
@app.middleware("http")
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search"} or request.url.path.startswith("/api/v1/work/") or (
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route"} or request.url.path.startswith("/api/v1/work/") or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/review")
) or request.url.path.startswith("/api/v1/notifications") or (
@@ -397,7 +410,7 @@ async def prevent_live_api_caching(request, call_next):
and request.url.path.endswith("/issues")
) or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/labels")
and request.url.path.endswith(("/labels", "/milestones"))
):
response.headers["Cache-Control"] = "no-store"
return response
@@ -536,6 +549,33 @@ async def global_search_preview(
return JSONResponse(preview)
@app.get("/api/v1/work-route")
async def resolve_work_route(
kind: Literal["issue", "pull", "review", "update"] = Query(),
repository: str | None = Query(default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"),
number: int | None = Query(default=None, gt=0),
notification_id: int | None = Query(default=None, gt=0),
) -> JSONResponse:
repository_route = repository is not None and number is not None and notification_id is None
update_route = kind == "update" and notification_id is not None and repository is None and number is None
if (kind == "update" and not update_route) or (kind != "update" and not repository_route):
raise HTTPException(status_code=422, detail="Invalid work route identity")
try:
result = await asyncio.wait_for(
gitea_proxy.resolve_work_route(kind, repository, number, notification_id),
timeout=WORK_PAGE_TIMEOUT_SECONDS,
)
except gitea_proxy.WorkRouteUnavailableError:
raise HTTPException(status_code=404, detail="Work item is no longer in My Work")
except Exception:
return JSONResponse(
{"error": "The shared work item is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/work/{stream}")
async def paged_work(
stream: Literal["issue", "pull", "review"],
@@ -562,13 +602,24 @@ async def paged_work(
async def _refresh_available_issue_snapshot() -> list[dict]:
global _available_issue_snapshot_value, _available_issue_snapshot_created_at
global _available_issue_snapshot_retry_at
result = await gitea_proxy.available_issue_snapshot()
_available_issue_snapshot_value = result
_available_issue_snapshot_created_at = time.monotonic()
_available_issue_snapshot_retry_at = None
return result
async def _available_issue_snapshot() -> tuple[list[dict], bool]:
def _observe_available_issue_refresh(task: asyncio.Task) -> None:
global _available_issue_snapshot_retry_at
if not task.cancelled():
if task.exception() is not None:
_available_issue_snapshot_retry_at = (
time.monotonic() + AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS
)
async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
global _available_issue_snapshot_task
now = time.monotonic()
if (
@@ -577,16 +628,25 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool]:
and now - _available_issue_snapshot_created_at
< AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS
):
return _available_issue_snapshot_value, False
return _available_issue_snapshot_value, False, False, False
if (
_available_issue_snapshot_value is not None
and _available_issue_snapshot_retry_at is not None
and now < _available_issue_snapshot_retry_at
):
return _available_issue_snapshot_value, True, False, True
if _available_issue_snapshot_task is None or _available_issue_snapshot_task.done():
_available_issue_snapshot_task = asyncio.create_task(
_refresh_available_issue_snapshot()
)
_available_issue_snapshot_task.add_done_callback(_observe_available_issue_refresh)
if _available_issue_snapshot_value is not None:
return _available_issue_snapshot_value, True, True, False
try:
return await asyncio.shield(_available_issue_snapshot_task), False
return await asyncio.shield(_available_issue_snapshot_task), False, False, False
except Exception:
if _available_issue_snapshot_value is not None:
return _available_issue_snapshot_value, True
return _available_issue_snapshot_value, True, False, True
raise
@@ -604,7 +664,7 @@ def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict
@app.get("/api/v1/available-issues")
async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONResponse:
try:
items, stale = await asyncio.wait_for(
items, stale, revalidating, refresh_failed = await asyncio.wait_for(
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
)
except Exception:
@@ -616,6 +676,10 @@ async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONRe
result = _available_issue_page(items, page)
if stale:
result["stale"] = True
if revalidating:
result["revalidating"] = True
if refresh_failed:
result["refresh_failed"] = True
return JSONResponse(result)
@@ -1229,6 +1293,69 @@ async def update_assigned_issue_due_date(
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/milestone")
async def update_assigned_issue_milestone(
update: IssueMilestoneUpdate,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.update_assigned_issue_milestone(
repository, number, update.milestone_id
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
raise HTTPException(status_code=404, detail="Assigned issue not found")
except ValueError as exc:
if str(exc) == "Unknown open repository milestone":
raise HTTPException(status_code=422, detail=str(exc))
return JSONResponse(
{"error": "The milestone could not be updated. Your selection is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "The milestone could not be updated. Your selection is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/milestones")
async def repository_milestones(owner: str, repo: str):
repository = f"{owner}/{repo}"
async def load_milestones():
available = await gitea_proxy.repos()
accessible = {
item.get("full_name")
for item in (available if isinstance(available, list) else [])
if isinstance(item, dict)
}
if repository not in accessible:
raise HTTPException(status_code=404, detail="Repository not found")
return await gitea_proxy.repo_milestones(repository)
try:
return await asyncio.wait_for(
load_milestones(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Milestones could not be loaded. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/repos/{owner}/{repo}/labels")
async def repository_labels(owner: str, repo: str):
repository = f"{owner}/{repo}"
@@ -1314,6 +1441,35 @@ async def create_assigned_issue(
return JSONResponse(result, status_code=201)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/comments")
async def assigned_issue_conversation(
owner: str,
repo: str,
number: int = PathParam(gt=0),
page: int | None = Query(default=None, ge=1, le=100),
limit: int = Query(default=20, ge=1, le=50),
):
repository = f"{owner}/{repo}"
async def load_conversation():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.issue_conversation_page(repository, number, page, limit)
try:
return await asyncio.wait_for(
load_conversation(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The conversation could not be loaded. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/comments", status_code=201)
async def comment_on_assigned_issue(
comment: IssueComment,
@@ -1486,6 +1642,66 @@ async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data")
async def assigned_pull_review_data(
owner: str, repo: str, number: int = PathParam(gt=0)
):
repository = f"{owner}/{repo}"
async def load_assigned_pull_review():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.pull_completion_review(repository, number)
try:
return await asyncio.wait_for(
load_assigned_pull_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
)
except HTTPException:
raise
except TimeoutError:
return JSONResponse(
{"error": "Loading review and merge data timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "Review and merge data is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments")
async def assigned_pull_conversation(
owner: str,
repo: str,
number: int = PathParam(gt=0),
page: int | None = Query(default=None, ge=1, le=100),
limit: int = Query(default=20, ge=1, le=50),
):
repository = f"{owner}/{repo}"
async def load_conversation():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.issue_conversation_page(repository, number, page, limit)
try:
return await asyncio.wait_for(
load_conversation(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The conversation could not be loaded. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", status_code=201)
async def comment_on_assigned_pull(
comment: IssueComment,
+6
View File
@@ -17,6 +17,11 @@ class Repo(BaseModel):
updated_at: str = ""
class Milestone(BaseModel):
id: int
title: str
class Issue(BaseModel):
id: int
number: int
@@ -27,6 +32,7 @@ class Issue(BaseModel):
repository: str = ""
updated_at: str = ""
due_date: str | None = None
milestone: Milestone | None = None
url: str
+117
View File
@@ -0,0 +1,117 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_draft_inbox_discovers_existing_formats_orders_activity_and_ignores_corruption():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-capture.v1', JSON.stringify({{repository:'stackchain/api',title:'Ship recovery',body:'Detailed plan',labelIds:[]}})],
['stackchain.issue-comment.v1:stackchain/api#17', 'Please add a regression test'],
['stackchain.pull-comment.v1:stackchain/web#9', 'Looks good on mobile'],
['stackchain.update-reply.v1.44', 'I can take this'],
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{'app.js':'Handle offline state'}},comments:[],summary:'Needs one fix',decision:'request_changes'}})],
['stackchain.issue-content.v1:stackchain/api#18', '{{broken-json'],
['stackchain.issue-comment.v1:stackchain/api#20:operation', 'not-a-draft'],
]);
const storage = {{
get length() {{ return values.size; }},
key: index => Array.from(values.keys())[index] || null,
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
let clock = 1000;
const inbox = createDraftInbox({{storage, now:() => ++clock}});
const first = inbox.list();
values.set('stackchain.issue-comment.v1:stackchain/api#17', 'Newest comment text');
const second = inbox.list();
process.stdout.write(JSON.stringify({{
kinds:first.map(item => item.kind).sort(),
newest:second[0],
count:second.length,
}}));
"""
output = run_node(script)
assert output["kinds"] == ["issue-comment", "new-issue", "pull-comment", "review", "update-reply"]
assert output["count"] == 5
assert output["newest"]["kind"] == "issue-comment"
assert output["newest"]["repository"] == "stackchain/api"
assert output["newest"]["number"] == 17
assert output["newest"]["preview"] == "Newest comment text"
assert output["newest"]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 17}
def test_draft_discard_removes_only_selected_content_and_operation_identity():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-comment.v1:stackchain/api#17', 'One'],
['stackchain.issue-comment.v1:stackchain/api#17:operation', 'operation-one'],
['stackchain.pull-comment.v1:stackchain/web#9', 'Two'],
]);
const storage = {{
get length() {{ return values.size; }}, key:i => Array.from(values.keys())[i] || null,
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key),
}};
const inbox = createDraftInbox({{storage, now:() => 1000}});
const selected = inbox.list().find(item => item.kind === 'issue-comment');
inbox.discard(selected.id);
process.stdout.write(JSON.stringify({{remaining:inbox.list(), keys:Array.from(values.keys()).sort()}}));
"""
output = run_node(script)
assert [item["kind"] for item in output["remaining"]] == ["pull-comment"]
assert "stackchain.issue-comment.v1:stackchain/api#17" not in output["keys"]
assert "stackchain.issue-comment.v1:stackchain/api#17:operation" not in output["keys"]
assert "stackchain.pull-comment.v1:stackchain/web#9" in output["keys"]
def test_draft_inbox_recovers_issue_edit_and_review_content_without_exposing_empty_records():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-content.v1:stackchain/api#18', JSON.stringify({{title:'Clarify acceptance',body:'Describe mobile flow',expectedUpdatedAt:'2026-08-07T10:00:00Z'}})],
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{}},comments:[],summary:'',decision:'approve'}})],
['stackchain.pull-comment.v1:stackchain/web#10', ' '],
]);
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const drafts = createDraftInbox({{storage,now:()=>42}}).list();
process.stdout.write(JSON.stringify(drafts));
"""
output = run_node(script)
assert len(output) == 1
assert output[0]["kind"] == "issue-edit"
assert output[0]["preview"] == "Clarify acceptance — Describe mobile flow"
assert output[0]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 18}
@pytest.mark.anyio
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
html = await dashboard()
assert '<script src="static/drafts.js"></script>' in html
assert 'data-work-filter="draft"' in html
assert 'data-work-count="draft"' in html
assert 'class="draft-resume"' in html
assert 'class="draft-discard"' in html
assert 'Discard this unfinished draft?' in html
assert '.draft-actions button { min-height:44px;' in html
assert 'createDraftInbox({ storage: localStorage' in html
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
+110 -1
View File
@@ -13,6 +13,7 @@ def reset_available_issue_snapshot():
main._available_issue_snapshot_task = None
main._available_issue_snapshot_value = None
main._available_issue_snapshot_created_at = None
main._available_issue_snapshot_retry_at = None
yield
task = main._available_issue_snapshot_task
if task is not None and not task.done():
@@ -20,6 +21,7 @@ def reset_available_issue_snapshot():
main._available_issue_snapshot_task = None
main._available_issue_snapshot_value = None
main._available_issue_snapshot_created_at = None
main._available_issue_snapshot_retry_at = None
@pytest.mark.anyio
@@ -136,6 +138,53 @@ async def test_available_issue_page_ranks_all_upstream_pages_before_logical_pagi
}
@pytest.mark.anyio
async def test_available_issue_snapshot_loads_known_remaining_pages_concurrently():
active = 0
peak_active = 0
remaining_started = asyncio.Event()
release = asyncio.Event()
async def upstream(request):
nonlocal active, peak_active
page = int(request.url.params["page"])
if page == 1:
return httpx.Response(
200, headers={"X-Total-Count": "200"},
json=[{
"id": number, "number": number, "title": f"Issue {number}",
"state": "open", "assignees": [], "pull_request": None,
"labels": [], "repository": {"full_name": "stackchain/api"},
} for number in range(1, 51)],
)
active += 1
peak_active = max(peak_active, active)
if peak_active == 3:
remaining_started.set()
await release.wait()
active -= 1
start = (page - 1) * 50 + 1
return httpx.Response(200, headers={"X-Total-Count": "200"}, json=[{
"id": number, "number": number, "title": f"Issue {number}",
"state": "open", "assignees": [], "pull_request": None,
"labels": [], "repository": {"full_name": "stackchain/api"},
} for number in range(start, start + 50)])
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
task = asyncio.create_task(gitea_proxy.available_issue_snapshot())
try:
await asyncio.wait_for(remaining_started.wait(), timeout=1)
assert peak_active == 3
release.set()
result = await asyncio.wait_for(task, timeout=1)
finally:
release.set()
await gitea_proxy.stop_client()
assert len(result) == 200
assert peak_active == 3
@pytest.mark.anyio
async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkeypatch):
calls = []
@@ -204,7 +253,67 @@ async def test_available_issue_endpoint_retains_last_snapshot_on_refresh_failure
assert response.status_code == 200
assert response.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"has_more": False, "stale": True,
"has_more": False, "stale": True, "revalidating": True,
}
@pytest.mark.anyio
async def test_available_issue_endpoint_serves_expired_snapshot_while_one_refresh_runs(monkeypatch):
calls = 0
started = asyncio.Event()
release = asyncio.Event()
async def refresh():
nonlocal calls
calls += 1
started.set()
await release.wait()
return [{"number": 8}]
main._available_issue_snapshot_value = [{"number": 7}]
main._available_issue_snapshot_created_at = 0.0
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", refresh)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first, second = await asyncio.gather(
client.get("/api/v1/available-issues?page=1"),
client.get("/api/v1/available-issues?page=1"),
)
await asyncio.wait_for(started.wait(), timeout=1)
assert calls == 1
assert first.json() == second.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"has_more": False, "stale": True, "revalidating": True,
}
release.set()
await asyncio.wait_for(main._available_issue_snapshot_task, timeout=1)
assert main._available_issue_snapshot_value == [{"number": 8}]
@pytest.mark.anyio
async def test_available_issue_endpoint_backs_off_after_background_refresh_failure(monkeypatch):
calls = 0
async def unavailable():
nonlocal calls
calls += 1
raise httpx.ConnectError("offline")
main._available_issue_snapshot_value = [{"number": 7}]
main._available_issue_snapshot_created_at = 0.0
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", unavailable)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = await client.get("/api/v1/available-issues?page=1")
await asyncio.gather(main._available_issue_snapshot_task, return_exceptions=True)
second = await client.get("/api/v1/available-issues?page=1")
assert calls == 1
assert first.json()["revalidating"] is True
assert second.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"has_more": False, "stale": True, "refresh_failed": True,
}
+195
View File
@@ -8,6 +8,91 @@ from src import gitea_proxy, main
from src.idempotency import IdempotencyLedger
@pytest.mark.anyio
async def test_gitea_milestone_update_revalidates_assignment_and_open_repository_milestone():
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if request.method == "GET" and request.url.path.endswith("/issues/17"):
return httpx.Response(200, json={
"number": 17, "state": "open", "pull_request": None,
"assignees": [{"login": "timmy"}],
})
if request.method == "GET" and request.url.path.endswith("/milestones"):
return httpx.Response(200, json=[
{"id": 9, "title": "August RC", "state": "open"},
{"id": 10, "title": "Old release", "state": "closed"},
{"id": "bad", "title": "Invalid", "state": "open"},
])
return httpx.Response(200, json={
"number": 17, "state": "open",
"milestone": {"id": 9, "title": "August RC"},
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
options = await gitea_proxy.repo_milestones("stackchain/api")
result = await gitea_proxy.update_assigned_issue_milestone(
"stackchain/api", 17, 9
)
finally:
await gitea_proxy.stop_client()
assert options == [{"id": 9, "title": "August RC"}]
patch_request = next(request for request in requests if request.method == "PATCH")
assert patch_request.url.path == "/api/v1/repos/stackchain/api/issues/17"
assert json.loads(patch_request.content) == {"milestone": 9}
assert result == {
"repository": "stackchain/api", "number": 17, "state": "open",
"milestone": {"id": 9, "title": "August RC"},
}
@pytest.mark.anyio
async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch):
calls = []
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def milestones(repository):
calls.append(("list", repository))
return [{"id": 9, "title": "August RC"}]
async def update(repository, number, milestone_id):
calls.append(("update", repository, number, milestone_id))
return {
"repository": repository, "number": number, "state": "open",
"milestone": {"id": milestone_id, "title": "August RC"} if milestone_id else None,
}
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones, raising=False)
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue_milestone", update, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
listed = await client.get("/api/v1/repos/stackchain/api/milestones")
updated = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/milestone", json={"milestone_id": 9}
)
missing = await client.get("/api/v1/repos/other/private/milestones")
assert listed.status_code == 200
assert listed.headers["cache-control"] == "no-store"
assert listed.json() == [{"id": 9, "title": "August RC"}]
assert updated.status_code == 200
assert updated.headers["cache-control"] == "no-store"
assert updated.json()["milestone"] == {"id": 9, "title": "August RC"}
assert missing.status_code == 404
assert calls == [
("list", "stackchain/api"),
("update", "stackchain/api", 17, 9),
]
@pytest.mark.anyio
@pytest.mark.parametrize(
("due_date", "expected_payload", "confirmed_due_date"),
@@ -827,6 +912,44 @@ async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkey
}
@pytest.mark.anyio
async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no_store(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return repository == "stackchain/api"
async def conversation(repository, number, page, limit):
calls.append(("conversation", repository, number, page, limit))
return {"comments": [{"id": 21}], "page": 2, "older_page": 1, "total": 21}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/repos/stackchain/api/issues/7/comments?page=2&limit=20"
)
missing = await client.get(
"/api/v1/repos/private/secret/issues/7/comments?page=2&limit=20"
)
invalid = await client.get(
"/api/v1/repos/stackchain/api/issues/7/comments?page=999&limit=999"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["older_page"] == 1
assert missing.status_code == 404
assert invalid.status_code == 422
assert calls == [
("assigned", "stackchain/api", 7),
("conversation", "stackchain/api", 7, 2, 20),
("assigned", "private/secret", 7),
]
@pytest.mark.anyio
async def test_issue_detail_deadline_is_retryable_sanitized_and_cancels_work(monkeypatch):
cancelled = asyncio.Event()
@@ -1034,6 +1157,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"state": "open",
"body": "Full issue context",
"updated_at": "2026-08-07T09:59:00Z",
"milestone": {"id": 9, "title": "August RC", "description": "not exposed"},
"html_url": "https://forge.example/stackchain/api/issues/7",
"labels": [{"name": "P1"}],
"assignees": [{"login": "timmy"}],
@@ -1058,6 +1182,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"body": "Full issue context",
"updated_at": "2026-08-07T09:59:00Z",
"due_date": None,
"milestone": {"id": 9, "title": "August RC"},
"url": "https://forge.example/stackchain/api/issues/7",
"labels": ["P1"],
"assignees": ["timmy"],
@@ -1070,9 +1195,79 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
}
],
"conversation": {
"comments": [
{
"id": 81,
"author": "sam",
"body": "Latest update",
"created_at": "2026-08-07T10:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
}
],
"page": 1,
"older_page": None,
"total": 1,
},
}
@pytest.mark.anyio
async def test_gitea_conversation_page_opens_newest_page_and_reports_older_history():
requests = []
async def handler(request):
requests.append((request.url.path, request.url.query.decode()))
page = request.url.params.get("page")
comments = {
"1": [{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
for value in range(1, 21)],
"3": [{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
for value in range(41, 48)],
}[page]
return httpx.Response(200, json=comments, headers={"X-Total-Count": "47"})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_conversation_page("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert requests == [
("/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
("/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=3"),
]
assert [comment["id"] for comment in result["comments"]] == list(range(41, 48))
assert result["page"] == 3
assert result["older_page"] == 2
assert result["total"] == 47
@pytest.mark.anyio
async def test_gitea_conversation_page_locally_bounds_unpaginated_gitea_comments():
requests = []
all_comments = [
{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
for value in range(1, 48)
]
async def handler(request):
requests.append(request.url.query.decode())
return httpx.Response(200, json=all_comments, headers={"X-Total-Count": "47"})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_conversation_page("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert requests == ["limit=20&page=1"]
assert [comment["id"] for comment in result["comments"]] == list(range(41, 48))
assert result["page"] == 3
assert result["older_page"] == 2
assert result["total"] == 47
@pytest.mark.anyio
async def test_update_assigned_issue_labels_validates_and_returns_confirmed_labels(monkeypatch):
calls = []
+564 -2
View File
@@ -12,7 +12,207 @@ REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const inputs = [
{{kind:'issue',repository:'stackchain/api',number:17}},
{{kind:'pull',repository:'stackchain/dashboard',number:42}},
{{kind:'review',repository:'stackchain/dashboard',number:42}},
{{kind:'update',notification_id:913}},
];
process.stdout.write(JSON.stringify({{
paths: inputs.map(routes.serialize),
parsed: inputs.map(item => routes.parse(routes.serialize(item))),
invalid: [
'#/my-work/issue/../../etc/1',
'#/my-work/issue/stackchain/api/not-a-number',
'#/my-work/update/-1',
'#/other/issue/stackchain/api/1',
].map(routes.parse),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"paths": [
"#/my-work/issue/stackchain/api/17",
"#/my-work/pull/stackchain/dashboard/42",
"#/my-work/review/stackchain/dashboard/42",
"#/my-work/update/913",
],
"parsed": [
{"kind": "issue", "repository": "stackchain/api", "number": 17},
{"kind": "pull", "repository": "stackchain/dashboard", "number": 42},
{"kind": "review", "repository": "stackchain/dashboard", "number": 42},
{"kind": "update", "notification_id": 913},
],
"invalid": [None, None, None, None],
}
def test_work_route_controller_restores_direct_links_and_uses_history_for_close():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const listeners = {{}};
const location = {{hash:'#/my-work/issue/stackchain/api/17', href:'https://forge.example/dashboard/#/my-work/issue/stackchain/api/17'}};
const calls = [];
const history = {{
state: null,
pushState(state, _, hash) {{ this.state = state; location.hash = hash; calls.push(['push', hash]); }},
replaceState(state, _, hash) {{ this.state = state; location.hash = hash; calls.push(['replace', hash]); }},
back() {{ calls.push(['back']); location.hash = ''; listeners.popstate(); }},
}};
const controller = routes.createController({{
location, history,
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
onOpen(item) {{ calls.push(['open', item.kind, item.repository, item.number || item.notification_id]); }},
onClose() {{ calls.push(['close']); }},
onInvalid() {{ calls.push(['invalid']); }},
}});
controller.start();
controller.setItems([{{kind:'issue',repository:'stackchain/api',number:17}}]);
controller.close();
controller.open({{kind:'pull',repository:'stackchain/dashboard',number:9}});
controller.open({{kind:'review',repository:'stackchain/dashboard',number:10}}, {{replace:true}});
process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == [
["open", "issue", "stackchain/api", 17],
["back"],
["close"],
["push", "#/my-work/pull/stackchain/dashboard/9"],
["open", "pull", "stackchain/dashboard", 9],
["replace", "#/my-work/review/stackchain/dashboard/10"],
["open", "review", "stackchain/dashboard", 10],
]
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
def test_work_route_controller_resolves_cold_routes_without_erasing_the_fragment():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const location = {{hash:'#/my-work/issue/stackchain/api/87'}};
const calls = [];
const controller = routes.createController({{
location,
history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
eventTarget: {{addEventListener() {{}}}},
resolve: async route => {{
calls.push(['resolve', route.kind, route.repository, route.number]);
return {{kind:'issue', repository:'stackchain/api', number:87, title:'Older assigned issue'}};
}},
onResolving: route => calls.push(['resolving', route.number]),
onOpen: item => calls.push(['open', item.number, item.title]),
onClose() {{}},
onInvalid: () => calls.push(['invalid']),
onError: () => calls.push(['error']),
}});
(async () => {{
controller.start();
controller.setItems([]);
await new Promise(resolve => setTimeout(resolve, 0));
process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"calls": [
["resolving", 87],
["resolve", "issue", "stackchain/api", 87],
["open", 87, "Older assigned issue"],
],
"hash": "#/my-work/issue/stackchain/api/87",
}
def test_work_route_controller_dismisses_only_confirmed_unavailable_routes():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const location = {{hash:'#/my-work/update/913'}};
const calls = [];
const unavailable = new Error('gone'); unavailable.unavailable = true;
const controller = routes.createController({{
location,
history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
eventTarget: {{addEventListener() {{}}}},
resolve: async () => {{ throw unavailable; }},
onResolving() {{}}, onOpen() {{}}, onClose() {{}},
onInvalid: () => calls.push('invalid'),
onError: () => calls.push('error'),
}});
(async () => {{
controller.start(); controller.setItems([]);
await new Promise(resolve => setTimeout(resolve, 0));
process.stdout.write(JSON.stringify(calls));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == ["invalid"]
def test_work_route_share_prefers_native_share_and_falls_back_to_clipboard():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const calls = [];
(async () => {{
const native = await routes.share('https://forge.example/dashboard/#/my-work/update/9', {{
share: async payload => calls.push(['native', payload.url]),
}}, null);
const fallback = await routes.share('https://forge.example/dashboard/#/my-work/update/10', {{}}, {{
writeText: async text => calls.push(['clipboard', text]),
}});
process.stdout.write(JSON.stringify({{native, fallback, calls}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"native": "shared",
"fallback": "copied",
"calls": [
["native", "https://forge.example/dashboard/#/my-work/update/9"],
["clipboard", "https://forge.example/dashboard/#/my-work/update/10"],
],
}
@pytest.mark.anyio
async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share():
html = await dashboard()
assert '<script src="static/work-route.js"></script>' in html
assert 'const workRoute = createWorkRoute.createController({' in html
assert 'workRoute.setItems(lastMyWork);' in html
assert "api('api/v1/work-route?' + params.toString())" in html
assert "Loading shared work item…" in html
assert 'id="retry-work-route"' in html
assert 'href="' + "' + escAttr(createWorkRoute.serialize(" in html
assert '.read-update { min-height:44px; width:100%; display:flex;' in html
assert 'workRoute.close();' in html
assert html.count('class="share-work-route"') == 4
assert 'createWorkRoute.share(window.location.href, navigator, navigator.clipboard)' in html
assert 'Route unavailable · this item is no longer in My Work.' in html
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
@@ -321,6 +521,85 @@ async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_b
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
@pytest.mark.anyio
async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor():
html = await dashboard()
assert 'id="work-milestone-filter"' in html
assert '<option value="unplanned">Unplanned</option>' in html
assert 'id="issue-milestone"' in html
assert 'id="save-issue-milestone"' in html
assert 'class="pill milestone-badge"' in html
assert '.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px;' in html
assert 'getMilestone: () => selectedWorkMilestone' in html
assert 'buildMyWork.replaceIssueMilestone(' in html
@pytest.mark.anyio
async def test_mobile_issue_sheet_puts_reading_before_collapsed_planning_controls():
html = await dashboard()
body = html.index('id="issue-sheet-body"')
conversation = html.index('<h2>Full conversation</h2>', body)
planning = html.index('id="issue-planning"', conversation)
labels = html.index('id="issue-label-editor"', planning)
milestone = html.index('id="issue-milestone"', planning)
assert '<summary>Plan &amp; edit</summary>' in html[planning:labels]
assert '<details class="issue-planning" id="issue-planning">' in html
assert body < conversation < planning < labels < milestone
def test_issue_planning_metadata_is_lazy_cached_and_retryable_after_failure():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const calls = [];
let fail = true;
const planning = createIssueSheet.createPlanningLoader({{
loadLabels: async item => {{ calls.push('labels:' + item.number); return [{{id:1,name:'P0'}}]; }},
loadMilestones: async item => {{
calls.push('milestones:' + item.number);
if (fail) throw new Error('offline');
return [{{id:9,title:'RC'}}];
}},
}});
const item = {{repository:'stackchain/api', number:17}};
(async () => {{
const before = calls.slice();
let failed = false;
try {{ await planning.open(item); }} catch (_error) {{ failed = true; }}
fail = false;
const first = await planning.open(item);
const second = await planning.open(item);
process.stdout.write(JSON.stringify({{before, failed, calls, same:first === second, first}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"before": [],
"failed": True,
"calls": ["labels:17", "milestones:17", "labels:17", "milestones:17"],
"same": True,
"first": {"labels": [{"id": 1, "name": "P0"}], "milestones": [{"id": 9, "title": "RC"}]},
}
@pytest.mark.anyio
async def test_opening_issue_defers_planning_requests_until_disclosure_expands():
html = await dashboard()
open_handler = html[html.index('async function openIssueSheet'):html.index('function closeIssueSheet')]
assert 'loadIssueLabelEditor(' not in open_handler
assert 'loadIssueMilestoneEditor(' not in open_handler
assert "if (qs('#issue-planning').open) loadIssuePlanning();" in open_handler
assert "qs('#issue-planning').addEventListener('toggle'" in html
assert 'planningLoader.open(selectedIssue)' in html
assert 'id="retry-issue-planning"' in html
def test_my_work_reviews_filter_and_summary_are_actionable():
items = [
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
@@ -345,6 +624,88 @@ process.stdout.write(JSON.stringify({{
assert output["summary"] == "1 review · 2 assigned"
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
payload = {
"issues": [
{"number": 1, "repository": "stackchain/api", "title": "RC issue",
"milestone": {"id": 9, "title": "August RC"}},
{"number": 2, "repository": "stackchain/api", "title": "Unplanned",
"milestone": None},
],
"pull_requests": [
{"number": 3, "repository": "stackchain/api", "title": "PR"},
],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = {json.dumps(payload)};
const queue = buildMyWork(original);
const updated = buildMyWork.replaceIssueMilestone(
original, 'stackchain/api', 2, {{id:9,title:'August RC'}}
);
process.stdout.write(JSON.stringify({{
rc: buildMyWork.filterMyWork(queue, 'issue', '9').map(item => item.title),
unplanned: buildMyWork.filterMyWork(queue, 'all', 'unplanned').map(item => item.title),
options: buildMyWork.milestoneLanes(queue),
updated: updated.issues[1].milestone,
original: original.issues[1].milestone,
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"rc": ["RC issue"],
"unplanned": ["Unplanned"],
"options": [{"id": 9, "title": "August RC"}],
"updated": {"id": 9, "title": "August RC"},
"original": None,
}
def test_issue_milestone_editor_is_single_flight_and_keeps_scoped_draft_until_confirmed():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let calls = [];
let finish;
const controller = createIssueSheet({{
storage,
fetchJson:(url, options) => {{
calls.push({{url, options}});
return new Promise(resolve => {{ finish = resolve; }});
}},
}});
const item = {{repository:'stackchain/api', number:17}};
const first = controller.updateMilestone(item, 9);
const duplicate = controller.updateMilestone(item, 9);
const during = controller.loadMilestoneDraft(item);
finish({{repository:'stackchain/api',number:17,state:'open',milestone:{{id:9,title:'August RC'}}}});
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)}})),
same:first === duplicate, during, after:controller.loadMilestoneDraft(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/17/milestone",
"method": "PATCH", "body": {"milestone_id": 9},
}]
assert output["same"] is True
assert output["during"] == 9
assert output["after"] is None
assert output["results"][0]["milestone"] == {"id": 9, "title": "August RC"}
def test_mobile_work_session_follows_filter_and_reconciles_by_identity():
items = [
{"kind": "issue", "repository": "stackchain/api", "number": 1, "title": "First"},
@@ -624,6 +985,40 @@ controller.load().then(() => controller.loadMore()).then(() =>
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
def test_find_work_keeps_retained_cards_and_announces_refresh_freshness():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
const statuses = [];
const states = [];
const responses = [
{{items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:1,has_more:false,
stale:true,revalidating:true}},
{{items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:1,has_more:false,
stale:true,refresh_failed:true}},
];
const controller = createFindWork({{
fetchJson: () => Promise.resolve(responses.shift()),
onItems: items => states.push(items),
onPagination: () => {{}},
onStatus: status => statuses.push(status),
}});
controller.load().then(() => controller.load()).then(() =>
process.stdout.write(JSON.stringify({{statuses,states,items:controller.items()}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["statuses"] == [
"Showing saved available work while the catalog refreshes…",
"Showing saved available work. Catalog refresh failed; retrying shortly.",
]
assert [item["number"] for item in output["items"]] == [1]
assert len(output["states"]) == 2
def test_find_work_preview_stays_with_issue_across_pagination_and_clears_when_claimed():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
@@ -704,6 +1099,9 @@ async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe
assert '.find-work-action { min-height:44px;' in html
assert 'padding-bottom:calc(18px + env(safe-area-inset-bottom))' in html
assert '@media(max-width:320px)' in html
assert 'const retainedItems = findWorkController.items();' in html
assert 'if (retainedItems.length) renderAvailableIssues(retainedItems);' in html
assert 'Refreshing available issues…' in html
@pytest.mark.anyio
@@ -1274,6 +1672,100 @@ reader.open(item, [item]).then(() => {{
]
def test_conversation_pager_prepends_older_pages_deduplicates_and_appends_once():
script = f"""
const createConversationPager = require({json.dumps(str(CONVERSATION))});
let calls = 0;
const pager = createConversationPager({{
loadPage: async page => {{ calls += 1; return {{
comments:[{{id:20,body:'duplicate'}},{{id:1,body:'oldest'}}],
page, older_page:null, total:3
}}; }}
}});
pager.reset({{comments:[{{id:20,body:'middle'}},{{id:21,body:'newest'}}],page:2,older_page:1,total:3}});
const first = pager.loadOlder();
const duplicate = pager.loadOlder();
Promise.all([first, duplicate]).then(() => {{
pager.append({{id:22,body:'posted'}});
pager.append({{id:22,body:'posted'}});
process.stdout.write(JSON.stringify({{calls,same:first===duplicate,state:pager.snapshot()}}));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert output["same"] is True
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 20, 21, 22]
assert output["state"]["older_page"] is None
assert output["state"]["total"] == 4
def test_conversation_pager_keeps_loaded_messages_when_older_page_fails():
script = f"""
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const pager = createConversationPager({{loadPage: async () => {{ throw new Error('offline'); }}}});
pager.reset({{comments:[{{id:21,body:'newest'}}],page:2,older_page:1,total:21}});
pager.loadOlder().catch(error => process.stdout.write(JSON.stringify({{
error:error.message, state:pager.snapshot()
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["error"] == "offline"
assert output["state"]["comments"] == [{"id": 21, "body": "newest"}]
assert output["state"]["older_page"] == 1
def test_issue_sheet_conversation_loads_older_history_through_assigned_boundary():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const calls = [];
const controller = createIssueSheet({{
createConversationPager,
fetchJson: async url => {{ calls.push(url); return {{comments:[{{id:1}}],page:1,older_page:null,total:21}}; }}
}});
const pager = controller.conversation(
{{repository:'stackchain/api',number:7}},
{{comments:[{{id:21}}],page:2,older_page:1,total:21}}
);
pager.loadOlder().then(state => process.stdout.write(JSON.stringify({{calls,state}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == ["api/v1/repos/stackchain/api/issues/7/comments?page=1&limit=20"]
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 21]
def test_pull_sheet_conversation_loads_older_history_through_assigned_boundary():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const calls = [];
const controller = createPullSheet({{
createConversationPager,
fetchJson: async url => {{ calls.push(url); return {{comments:[{{id:1}}],page:1,older_page:null,total:21}}; }}
}});
const pager = controller.conversation(
{{repository:'stackchain/api',number:7}},
{{comments:[{{id:21}}],page:2,older_page:1,total:21}}
);
pager.loadOlder().then(state => process.stdout.write(JSON.stringify({{calls,state}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == ["api/v1/repos/stackchain/api/pulls/7/comments?page=1&limit=20"]
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 21]
def test_issue_sheet_loads_encoded_assigned_issue_detail_path():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -1531,6 +2023,63 @@ process.stdout.write(JSON.stringify({{
assert output["next"] == "frontend/app.js"
def test_pull_sheet_lazy_review_is_single_flight_cached_by_head_and_retryable():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const calls = [];
let rejectFirst;
const fetchJson = url => {{
calls.push(url);
if (calls.length === 1) return new Promise((_resolve, reject) => {{ rejectFirst = reject; }});
return Promise.resolve({{head_sha:'abc123', files:[]}});
}};
const sheet = createPullSheet({{fetchJson, storage:null}});
const item = {{repository:'stackchain/api', number:7}};
const first = sheet.loadReview(item, 'abc123');
const concurrent = sheet.loadReview(item, 'abc123');
rejectFirst(new Error('diff unavailable'));
Promise.allSettled([first, concurrent]).then(async failed => {{
const retried = await sheet.loadReview(item, 'abc123');
const cached = await sheet.loadReview(item, 'abc123');
process.stdout.write(JSON.stringify({{
same:first === concurrent,
failed:failed.map(result => result.status),
retried,
cached,
calls,
}}));
}});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert output["same"] is True
assert output["failed"] == ["rejected", "rejected"]
assert output["retried"]["head_sha"] == "abc123"
assert output["cached"] == output["retried"]
assert output["calls"] == [
"api/v1/repos/stackchain/api/pulls/7/review-data",
"api/v1/repos/stackchain/api/pulls/7/review-data",
]
@pytest.mark.anyio
async def test_mobile_pull_sheet_puts_reading_before_collapsed_review_controls():
html = await dashboard()
body = html.index('id="pull-sheet-body"')
conversation = html.index('<h2>Full conversation</h2>', body)
composer = html.index('id="pull-comment-title"', conversation)
review = html.index('id="pull-review"', composer)
files = html.index('id="pull-files"', review)
assert '<summary><h2>Review &amp; merge</h2></summary>' in html
assert '<details class="pull-review" id="pull-review">' in html
assert 'id="pull-review-retry"' in html
assert 'id="pull-review-status"' in html
assert body < conversation < composer < review < files
def test_pull_sheet_renders_mobile_diff_fallbacks_and_review_controls():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -1565,6 +2114,12 @@ async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
assert 'id="pull-files"' in html and 'id="pull-comments"' in html
assert 'id="pull-review-progress"' in html and 'aria-live="polite"' in html
assert 'id="next-unreviewed-pull-file"' in html
assert 'id="load-older-pull-comments"' in html
assert 'id="pull-conversation-status"' in html and 'aria-live="assertive"' in html
assert '<script src="static/conversation.js"></script>' in html
assert "pullController.conversation(item, detail.conversation)" in html
assert "pullConversation.loadOlder()" in html
assert "pullConversation.append(comment)" in html
assert 'id="pull-comment"' in html and 'maxlength="10000"' in html
assert 'id="merge-pull"' in html and 'id="open-pull-gitea"' in html
assert '<script src="static/pull-sheet.js"></script>' in html
@@ -1590,6 +2145,12 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut
assert 'id="issue-sheet-body"' in html
assert 'id="issue-labels"' in html and 'id="issue-assignees"' in html
assert 'id="issue-comments"' in html
assert 'id="load-older-issue-comments"' in html
assert 'id="issue-conversation-status"' in html and 'aria-live="assertive"' in html
assert "issueController.conversation(item, detail.conversation)" in html
assert "issueConversation.loadOlder()" in html
assert "issueConversation.append(comment)" in html
assert '.conversation-more { min-height:44px;' in html
assert 'id="issue-comment"' in html and 'maxlength="10000"' in html
assert 'id="send-issue-comment"' in html
assert 'id="close-issue"' in html
@@ -1675,7 +2236,7 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
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 'openRoutedWork(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
@@ -1848,7 +2409,8 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
assert 'data-work-count="pull"' in html
assert 'data-work-count="review"' in html
assert 'data-work-count="update"' in html
assert "['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)" in html
assert "['all', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)" in html
assert 'data-work-count="draft"' in html
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
+104 -2
View File
@@ -54,7 +54,77 @@ async def test_assigned_pull_detail_rejects_unassigned_pull(monkeypatch):
@pytest.mark.anyio
async def test_gitea_assigned_pull_detail_includes_bounded_diff_previews():
async def test_gitea_assigned_pull_detail_loads_reading_without_review_resources():
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7,
"title": "Read this first",
"body": "The discussion should not wait for the diff.",
"state": "open",
"mergeable": True,
"head": {"sha": "abc123"},
"user": {"login": "alex"},
})
if request.url.path.endswith("/issues/7/comments"):
return httpx.Response(200, json=[{
"id": 9, "body": "Question", "user": {"login": "sam"},
"created_at": "2026-08-07T18:00:00Z",
}], headers={"X-Total-Count": "1"})
raise AssertionError(f"review resource requested during read load: {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
detail = await gitea_proxy.pull_completion_detail("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert requests == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/issues/7/comments"),
]
assert detail["head_sha"] == "abc123"
assert detail["conversation"]["comments"][0]["body"] == "Question"
assert "files" not in detail
assert "ci_state" not in detail
@pytest.mark.anyio
async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def review(repository, number):
calls.append(("review", repository, number))
return {
"repository": repository, "number": number, "head_sha": "abc123",
"state": "open", "draft": False, "mergeable": True, "merged": False,
"ci_state": "success", "files": [{"filename": "src/api.py"}],
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", review, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/repos/stackchain/api/pulls/7/review-data")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["files"] == [{"filename": "src/api.py"}]
assert calls == [
("assigned", "stackchain/api", 7),
("review", "stackchain/api", 7),
]
@pytest.mark.anyio
async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
async def handler(request):
path = request.url.path
if path.endswith("/pulls/7"):
@@ -86,7 +156,7 @@ async def test_gitea_assigned_pull_detail_includes_bounded_diff_previews():
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
detail = await gitea_proxy.pull_completion_detail("stackchain/api", 7)
detail = await gitea_proxy.pull_completion_review("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
@@ -94,6 +164,38 @@ async def test_gitea_assigned_pull_detail_includes_bounded_diff_previews():
assert "+new" in detail["files"][0]["diff_lines"]
assert detail["files"][1]["diff_binary"] is True
assert detail["files"][1]["diff_available"] is False
assert "conversation" not in detail
@pytest.mark.anyio
async def test_assigned_pull_conversation_endpoint_reuses_issue_thread_with_pull_authorization(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def conversation(repository, number, page, limit):
calls.append(("conversation", repository, number, page, limit))
return {"comments": [{"id": 41}], "page": 3, "older_page": 2, "total": 47}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/repos/stackchain/api/pulls/7/comments?page=3&limit=20"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"comments": [{"id": 41}], "page": 3, "older_page": 2, "total": 47
}
assert calls == [
("assigned", "stackchain/api", 7),
("conversation", "stackchain/api", 7, 3, 20),
]
@pytest.mark.anyio
+2
View File
@@ -21,6 +21,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
"repository": {"full_name": "stackchain/api"},
"updated_at": "2026-08-07T10:00:00Z",
"due_date": "2026-08-09T23:59:59Z",
"milestone": {"id": 9, "title": "August RC", "state": "open"},
"html_url": "https://forge.example/stackchain/api/issues/51",
}],
}
@@ -40,6 +41,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
"updated_at": "2026-08-07T10:00:00Z",
"due_date": "2026-08-09T23:59:59Z",
"milestone": {"id": 9, "title": "August RC"},
"url": "https://forge.example/stackchain/api/issues/51",
}],
}
+100
View File
@@ -0,0 +1,100 @@
import httpx
import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_work_route_endpoint_resolves_one_authorized_item(monkeypatch):
requested = []
async def resolve(kind, repository, number, notification_id):
requested.append((kind, repository, number, notification_id))
return {
"kind": "review",
"repository": "stackchain/dashboard",
"number": 87,
"title": "Review the mobile resolver",
"is_review": True,
"url": "https://forge.example/stackchain/dashboard/pulls/87",
}
monkeypatch.setattr(main.gitea_proxy, "resolve_work_route", resolve, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/work-route?kind=review&repository=stackchain/dashboard&number=87"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("review", "stackchain/dashboard", 87, None)]
assert response.json() == {
"kind": "review",
"repository": "stackchain/dashboard",
"number": 87,
"title": "Review the mobile resolver",
"is_review": True,
"url": "https://forge.example/stackchain/dashboard/pulls/87",
}
@pytest.mark.anyio
async def test_resolve_work_route_returns_only_a_requested_review():
requests = []
async def upstream(request):
requests.append(request.url.path)
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 700,
"number": 87,
"title": "Review the resolver",
"state": "open",
"html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/pulls/87",
"repository": {"full_name": "stackchain/dashboard"},
"requested_reviewers": [{"login": "timmy"}],
"assignees": [],
"user": {"login": "alex"},
})
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.resolve_work_route(
"review", "stackchain/dashboard", 87, None
)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 2
assert result == {
"kind": "review",
"repository": "stackchain/dashboard",
"number": 87,
"title": "Review the resolver",
"state": "open",
"url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/pulls/87",
"is_review": True,
"work_reasons": ["review_requested"],
}
@pytest.mark.anyio
async def test_resolve_work_route_rejects_a_notification_that_is_already_read():
async def upstream(request):
if request.url.path.endswith("/notifications/threads/913"):
return httpx.Response(200, json={
"id": 913,
"unread": False,
"repository": {"full_name": "stackchain/dashboard"},
"subject": {"title": "Already handled", "type": "Issue"},
})
return httpx.Response(404)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
with pytest.raises(gitea_proxy.WorkRouteUnavailableError):
await gitea_proxy.resolve_work_route("update", None, None, 913)
finally:
await gitea_proxy.stop_client()