feat: review watched pull request changes (Closes #1328)
Some checks failed
CI / lint (pull_request) Successful in 3m22s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 5m6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-24 00:40:28 +00:00
parent e39e92b025
commit df90fb1e0e
22 changed files with 344 additions and 45 deletions

View File

@ -1042,6 +1042,12 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; } .search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
.search-preview-comment.new-since-review { padding-left:10px; border-left:3px solid var(--accent); background:#10233a; } .search-preview-comment.new-since-review { padding-left:10px; border-left:3px solid var(--accent); background:#10233a; }
.search-preview-conversation button { min-height:44px; width:100%; } .search-preview-conversation button { min-height:44px; width:100%; }
.search-preview-review { display:grid; gap:8px; min-width:0; padding-top:8px; border-top:1px solid #2a496e; }
.search-preview-review[hidden] { display:none; }
.search-preview-review h2 { margin:0; font-size:1rem; }
.search-preview-review button { min-height:44px; width:100%; }
.search-preview-file { min-width:0; margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; overflow:hidden; }
.search-preview-file > strong, .search-preview-file > .small { display:block; overflow-wrap:anywhere; }
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; } .search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
.search-preview-reply[hidden] { display:none; } .search-preview-reply[hidden] { display:none; }
.search-preview-reply h2 { margin:0; font-size:1rem; } .search-preview-reply h2 { margin:0; font-size:1rem; }
@ -1083,7 +1089,7 @@ textarea { resize: vertical; min-height: 120px; }
padding-bottom:calc(6px + env(safe-area-inset-bottom)); padding-bottom:calc(6px + env(safe-area-inset-bottom));
background:rgba(11,21,38,.98); border-block:1px solid #2a496e; background:rgba(11,21,38,.98); border-block:1px solid #2a496e;
} }
#search-preview-overview, #search-preview-conversation, #search-preview-overview, #search-preview-conversation, #search-preview-review,
#search-preview-reply-workspace, #search-preview-actions { scroll-margin-top:72px; } #search-preview-reply-workspace, #search-preview-actions { scroll-margin-top:72px; }
#search-preview-actions { position:static; } #search-preview-actions { position:static; }
} }

View File

@ -5693,7 +5693,7 @@
watchButton.hidden = true; watchButton.hidden = true;
watchButton.disabled = false; watchButton.disabled = false;
shareButton.disabled = true; shareButton.disabled = true;
renderSearchPreviewReply(state, null, searchPreview, document); renderSearchPreviewWorkspaces(state,null,searchPreview,document,escapeHtml);
if (state.status === 'loading') { if (state.status === 'loading') {
searchReplyAttachmentTarget = { ...state.item }; searchReplyAttachmentTarget = { ...state.item };
@ -5727,7 +5727,7 @@
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : ''); (detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.'); qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
renderSearchConversation(state.conversation); renderSearchConversation(state.conversation);
renderSearchPreviewReply(state, detail, searchPreview, document); renderSearchPreviewWorkspaces(state,detail,searchPreview,document,escapeHtml);
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#'; qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
claimButton.hidden = !(detail.claimable || detail.assigned_to_me); claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'; claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
@ -5766,13 +5766,10 @@
const searchSubscription = searchPreviewSubscriptionOptions(fetchReviewJson); const searchSubscription = searchPreviewSubscriptionOptions(fetchReviewJson);
const searchPreview = createSearchPreview({ const searchPreview = createSearchPreview({
fetchJson:searchSubscription.preview, fetchJson:searchSubscription.preview,
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page),{ fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page)),
headers:{Accept:'application/json'}, fetchReview:searchSubscription.review,
}),
mutate:(detail,action)=>fetchReviewJson( mutate:(detail,action)=>fetchReviewJson(
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') + searchPreviewPath(detail).replace(/\?.*$/, '') + '/' + action, {method:'PATCH'}
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
{ method:'PATCH', headers:{ Accept:'application/json' } }
), ),
watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result => watch:(detail,watching) => searchSubscription.watch(detail, watching).then(result =>
followingQueue.load().catch(() => {}).then(() => result)), followingQueue.load().catch(() => {}).then(() => result)),

View File

@ -886,6 +886,7 @@
<nav class="mobile-search-preview-nav" aria-label="Search preview sections"> <nav class="mobile-search-preview-nav" aria-label="Search preview sections">
<button type="button" data-search-preview-section="overview">Overview</button> <button type="button" data-search-preview-section="overview">Overview</button>
<button type="button" data-search-preview-section="conversation">Conversation</button> <button type="button" data-search-preview-section="conversation">Conversation</button>
<button type="button" data-search-preview-section="changes" hidden>Changes</button>
<button type="button" data-search-preview-section="reply">Reply</button> <button type="button" data-search-preview-section="reply">Reply</button>
<button type="button" data-search-preview-section="actions">Act</button> <button type="button" data-search-preview-section="actions">Act</button>
</nav> </nav>
@ -901,6 +902,12 @@
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button> <button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button> <button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
</section> </section>
<section id="search-preview-review" class="search-preview-review" aria-labelledby="search-preview-review-title" hidden>
<h2 id="search-preview-review-title">Changes</h2>
<div id="search-preview-review-status" class="small" aria-live="polite"></div>
<div id="search-preview-files"></div>
<button id="retry-search-preview-review" type="button" hidden>Retry changes</button>
</section>
<section class="search-preview-reply" id="search-preview-reply-workspace" aria-labelledby="search-preview-reply-title" hidden> <section class="search-preview-reply" id="search-preview-reply-workspace" aria-labelledby="search-preview-reply-title" hidden>
<h2 id="search-preview-reply-title">Reply</h2> <h2 id="search-preview-reply-title">Reply</h2>
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea> <textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>

View File

@ -98,12 +98,14 @@ function attachMobileSearchPreviewNavigation({document, window}) {
buttons:{ buttons:{
overview:bySection('overview'), overview:bySection('overview'),
conversation:bySection('conversation'), conversation:bySection('conversation'),
changes:bySection('changes'),
reply:bySection('reply'), reply:bySection('reply'),
actions:bySection('actions'), actions:bySection('actions'),
}, },
targets:{ targets:{
overview:document.getElementById('search-preview-overview'), overview:document.getElementById('search-preview-overview'),
conversation:document.getElementById('search-preview-conversation'), conversation:document.getElementById('search-preview-conversation'),
changes:document.getElementById('search-preview-review'),
reply, reply,
actions:document.getElementById('search-preview-actions'), actions:document.getElementById('search-preview-actions'),
}, },

View File

@ -30,6 +30,10 @@
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/conversation?' + query.toString(); '/preview/conversation?' + query.toString();
}; };
root.followingPullReviewPath = item => {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
return 'api/v1/following/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review-data';
};
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') + root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/comments?kind=' + encodeURIComponent(item.kind); '/comments?kind=' + encodeURIComponent(item.kind);
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') + root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
@ -47,6 +51,7 @@
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), { watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'}, method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
}), }),
review:item => fetchJson(root.followingPullReviewPath(item)),
}; };
options.preview = async item => options.load({...item, ...await fetchJson(root.searchPreviewPath(item), { options.preview = async item => options.load({...item, ...await fetchJson(root.searchPreviewPath(item), {
headers:{Accept:'application/json'}, headers:{Accept:'application/json'},
@ -165,9 +170,58 @@
else if (state.status === 'reply-error') status.textContent = else if (state.status === 'reply-error') status.textContent =
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.'; state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
}; };
root.renderSearchPreviewReview = (review, document, escapeHtml) => {
const section = document.querySelector('#search-preview-review');
const status = document.querySelector('#search-preview-review-status');
const files = document.querySelector('#search-preview-files');
const retry = document.querySelector('#retry-search-preview-review');
section.hidden = !review;
retry.hidden = review?.status !== 'error';
files.innerHTML = '';
if (!review) {
status.textContent = '';
return;
}
if (review.status === 'loading') {
status.textContent = 'Loading CI and changed files…';
return;
}
if (review.status === 'error') {
status.textContent = 'Changes unavailable. This revision has not been marked reviewed.';
return;
}
const data = review.data || {};
const changed = Array.isArray(data.files) ? data.files : [];
const ci = ({success:'CI passed', failure:'CI failed', error:'CI failed', pending:'CI pending'})[
data.ci_state
] || 'CI status unavailable';
status.textContent = ci + ' · ' + changed.length + ' changed ' +
(changed.length === 1 ? 'file.' : 'files.');
files.innerHTML = changed.map(file => {
const lines = (Array.isArray(file.diff_lines) ? file.diff_lines : []).map(raw => {
const line = String(raw);
const kind = line.startsWith('@@') ? 'hunk' : line.startsWith('+') ? 'added' :
line.startsWith('-') ? 'removed' : 'context';
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(line) + '</span>';
}).join('');
const diff = file.diff_available
? '<pre class="pull-diff">' + lines +
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
: '<div class="pull-diff-empty">' +
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') + '</div>';
return '<article class="search-preview-file"><strong>' + escapeHtml(file.filename || 'Unknown file') +
'</strong><span class="small">' + escapeHtml(file.status || 'changed') + ' · +' +
Number(file.additions || 0) + ' / ' + Number(file.deletions || 0) + '</span>' + diff + '</article>';
}).join('');
};
root.renderSearchPreviewWorkspaces = (state, detail, preview, document, escapeHtml) => {
root.renderSearchPreviewReply(state, detail, preview, document);
root.renderSearchPreviewReview(state.review, document, escapeHtml);
document.querySelector('[data-search-preview-section="changes"]').hidden = !state.review;
};
} }
})(typeof globalThis !== 'undefined' ? globalThis : this, function () { })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, afterUnwatch, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) { return function createSearchPreview({ fetchJson, fetchConversation, fetchReview, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, afterUnwatch, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
if (Array.isArray(session)) { if (Array.isArray(session)) {
getSession = session[0]; getSession = session[0];
loadMore = () => session[1].loadMore(); loadMore = () => session[1].loadMore();
@ -182,6 +236,7 @@
let replyRequest = null; let replyRequest = null;
let watchRequest = null; let watchRequest = null;
let conversation = null; let conversation = null;
let review = null;
let openedRevision = null; let openedRevision = null;
function sameItem(left, right) { function sameItem(left, right) {
@ -228,6 +283,10 @@
} }
function publish(state) { function publish(state) {
if (conversation && !Object.prototype.hasOwnProperty.call(state, 'conversation')) {
state = {...state, conversation};
}
if (review && !Object.prototype.hasOwnProperty.call(state, 'review')) state = {...state, review};
const position = navigation(state.item || current); const position = navigation(state.item || current);
if (navigationRoot) { if (navigationRoot) {
const bar = navigationRoot.querySelector('.search-preview-navigation'); const bar = navigationRoot.querySelector('.search-preview-navigation');
@ -293,6 +352,37 @@
}); });
} }
function loadReview(detail, requestGeneration) {
if (!(detail?.following === true && detail?.kind === 'pull' && typeof fetchReview === 'function')) {
review = null;
return Promise.resolve(null);
}
review = {status:'loading', data:null};
publish({status:'ready', item:current, detail:current, conversation, review});
return fetchReview(detail).then(data => {
if (requestGeneration === generation) {
review = {status:'ready', data};
publish({status:'ready', item:current, detail:current, conversation, review});
}
return data;
}).catch(error => {
if (requestGeneration === generation) {
review = {status:'error', data:null, error};
publish({status:'ready', item:current, detail:current, conversation, review});
}
return null;
});
}
async function acknowledgeWhenContextReady(requestGeneration) {
const conversationReady = typeof fetchConversation !== 'function' || conversation?.status === 'ready';
const reviewRequired = current?.following === true && current?.kind === 'pull' &&
typeof fetchReview === 'function';
if (conversationReady && (!reviewRequired || review?.status === 'ready')) {
await notifyOpened(current, requestGeneration);
}
}
const api = { const api = {
hasReplyAttachments() { hasReplyAttachments() {
return Boolean(hasAttachments?.()); return Boolean(hasAttachments?.());
@ -303,6 +393,7 @@
const requestGeneration = generation; const requestGeneration = generation;
current = { ...item }; current = { ...item };
conversation = null; conversation = null;
review = null;
openedRevision = null; openedRevision = null;
publish({ status: 'loading', item: current }); publish({ status: 'loading', item: current });
return fetchJson(current).then(async detail => { return fetchJson(current).then(async detail => {
@ -311,8 +402,9 @@
if (typeof fetchConversation === 'function') { if (typeof fetchConversation === 'function') {
const context = loadConversation(current, requestGeneration); const context = loadConversation(current, requestGeneration);
if (current.following === true) { if (current.following === true) {
await context; const reviewContext = loadReview(current, requestGeneration);
if (conversation?.status === 'ready') await notifyOpened(current, requestGeneration); await Promise.all([context, reviewContext]);
await acknowledgeWhenContextReady(requestGeneration);
} else { } else {
await notifyOpened(current, requestGeneration); await notifyOpened(current, requestGeneration);
} }
@ -371,9 +463,14 @@
if (!current) return null; if (!current) return null;
const requestGeneration = generation; const requestGeneration = generation;
const result = await loadConversation(current, requestGeneration); const result = await loadConversation(current, requestGeneration);
if (current?.following === true && conversation?.status === 'ready') { if (current?.following === true) await acknowledgeWhenContextReady(requestGeneration);
await notifyOpened(current, requestGeneration); return result;
} },
async retryReview() {
if (!current) return null;
const requestGeneration = generation;
const result = await loadReview(current, requestGeneration);
await acknowledgeWhenContextReady(requestGeneration);
return result; return result;
}, },
loadOlderConversation() { loadOlderConversation() {
@ -482,6 +579,7 @@
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {})); navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {})); navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation()); navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
navigationRoot.querySelector('#retry-search-preview-review').addEventListener('click', () => api.retryReview());
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation()); navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
const reply = navigationRoot.querySelector('#search-preview-reply'); const reply = navigationRoot.querySelector('#search-preview-reply');
reply?.addEventListener('input', event => { reply?.addEventListener('input', event => {

View File

@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js'); importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v137'; const CACHE = 'stackchain-dashboard-shell-v138';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -2753,6 +2753,45 @@ async def keep_following_revision(
) )
@app.get("/api/v1/following/{owner}/{repo}/pulls/{number}/review-data")
async def following_pull_review_data(
owner: str, repo: str, number: int = PathParam(gt=0)
):
"""Return bounded review context only for a pull in this account's Following list."""
repository = f"{owner}/{repo}"
login = await _confirmed_login()
async def load_review():
snapshot = await asyncio.to_thread(_following_store().get, login)
watched = any(
item.get("kind") == "pull"
and item.get("repository", "").lower() == repository.lower()
and item.get("number") == number
for item in snapshot["items"]
)
if not watched:
raise HTTPException(status_code=404, detail="Watched pull request not found")
return await gitea_proxy.pull_completion_review(repository, number)
try:
result = await asyncio.wait_for(load_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS)
except HTTPException:
raise
except TimeoutError:
return JSONResponse(
{"error": "Loading watched pull request changes timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "Watched pull request changes are temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.get("/api/v1/completed-filed-reviews") @app.get("/api/v1/completed-filed-reviews")
async def get_completed_filed_reviews(response: Response): async def get_completed_filed_reviews(response: Response):
login = await _confirmed_login() login = await _confirmed_login()

View File

@ -58,6 +58,29 @@ def test_following_queue_is_phone_usable_at_narrow_viewport(viewport):
assert page.locator(".following-card").bounding_box()["height"] >= 44 assert page.locator(".following-card").bounding_box()["height"] >= 44
assert page.locator("#close-following").bounding_box()["height"] >= 44 assert page.locator("#close-following").bounding_box()["height"] >= 44
page.evaluate("""() => {
document.querySelector('#following-sheet').close();
document.querySelector('#search-preview').classList.add('open');
renderSearchPreviewReview({status:'ready',data:{ci_state:'success',files:[{
filename:'src/a/very/long/mobile/path/that-must-wrap/review.py',status:'modified',
additions:1,deletions:1,diff_available:true,
diff_lines:['@@ -1 +1 @@','-unsafe long line that scrolls inside the diff only',
'+safe long line that scrolls inside the diff only']
}]}},document,value=>String(value));
document.querySelector('[data-search-preview-section="changes"]').hidden=false;
}""")
changes = page.locator("#search-preview-review")
expect(changes).to_be_visible()
expect(changes).to_contain_text("CI passed · 1 changed file.")
expect(changes).to_contain_text("review.py")
assert page.locator('[data-search-preview-section="changes"]').bounding_box()["height"] >= 44
assert page.locator("#search-preview-review .pull-diff").evaluate(
"node => node.scrollWidth >= node.clientWidth"
) is True
assert page.evaluate(
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
) is False
retire = page.locator("#watch-search-result") retire = page.locator("#watch-search-result")
page.evaluate("""() => { page.evaluate("""() => {
document.querySelector('#following-sheet').close(); document.querySelector('#following-sheet').close();

View File

@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v137" in worker assert "stackchain-dashboard-shell-v138" in worker

View File

@ -103,6 +103,63 @@ async def test_confirmed_pull_watch_round_trips_through_following(monkeypatch, t
] ]
@pytest.mark.anyio
async def test_watched_pull_can_load_read_only_review_context_without_assignment(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"r" * 32)
store.set_watching("timmy", {
"repository": "stackchain/api", "kind": "pull", "number": 84,
"title": "Review watched changes", "state": "open",
"updated_at": "2026-08-24T05:00:00Z",
"url": "https://forge.example/stackchain/api/pulls/84",
}, True)
review = {
"head_sha": "abc123", "ci_state": "success",
"files": [{"filename": "src/api.py", "status": "modified", "patch": "+safe"}],
}
async def user():
return {"login": "timmy"}
async def load_review(repository, number):
assert (repository, number) == ("stackchain/api", 84)
return review
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", load_review)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/following/stackchain/api/pulls/84/review-data"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == review
@pytest.mark.anyio
async def test_unwatched_pull_review_context_is_not_exposed(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"u" * 32)
async def user():
return {"login": "timmy"}
async def unexpected_review(*_args):
raise AssertionError("unwatched pull must be rejected before upstream access")
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", unexpected_review)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/following/stackchain/api/pulls/84/review-data"
)
assert response.status_code == 404
@pytest.mark.anyio @pytest.mark.anyio
async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path): async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1) store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1)

View File

@ -176,6 +176,76 @@ const preview=createSearchPreview({{
} }
def test_following_pull_revision_waits_for_changes_and_ci_context():
preview_module = ROOT / "frontend" / "search-preview.js"
script = f"""
const createSearchPreview=require({json.dumps(str(preview_module))});
let unavailable=true;
const opened=[];
const states=[];
const preview=createSearchPreview({{
fetchJson:async item=>({{...item,title:'Changed pull request'}}),
fetchConversation:async()=>({{comments:[]}}),
fetchReview:async()=>{{if(unavailable)throw new Error('diff offline');return {{ci_state:'success',files:[{{filename:'src/api.py',diff_lines:['+safe'],diff_available:true}}]}};}},
onOpened:async item=>opened.push(item.updated_at),
onState:state=>states.push({{status:state.review?.status,ci:state.review?.data?.ci_state}}),
}});
(async()=>{{
await preview.open({{
repository:'stackchain/api',kind:'pull',number:84,following:true,
reviewed_at:'2026-08-23T03:00:00Z',updated_at:'2026-08-23T04:00:00Z'
}});
const afterFailure=[...opened];
unavailable=false;
await preview.retryReview();
process.stdout.write(JSON.stringify({{afterFailure,opened,last:states.at(-1)}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result == {
"afterFailure": [],
"opened": ["2026-08-23T04:00:00Z"],
"last": {"status": "ready", "ci": "success"},
}
def test_watched_pull_changes_render_bounded_diff_and_ci_state():
preview_module = ROOT / "frontend" / "search-preview.js"
script = f"""
require({json.dumps(str(preview_module))});
const nodes={{
'#search-preview-review':{{hidden:true}},
'#search-preview-review-status':{{textContent:''}},
'#search-preview-files':{{innerHTML:''}},
'#retry-search-preview-review':{{hidden:true}},
}};
const document={{querySelector:selector=>nodes[selector]}};
global.renderSearchPreviewReview({{status:'ready',data:{{ci_state:'success',files:[{{
filename:'src/api.py',status:'modified',additions:1,deletions:0,
diff_available:true,diff_lines:['@@ -1 +1 @@','-unsafe','+safe']
}}]}}}},document,value=>String(value).replaceAll('<','&lt;'));
process.stdout.write(JSON.stringify({{
hidden:nodes['#search-preview-review'].hidden,
status:nodes['#search-preview-review-status'].textContent,
html:nodes['#search-preview-files'].innerHTML,
retry:nodes['#retry-search-preview-review'].hidden,
}}));
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result["hidden"] is False
assert result["status"] == "CI passed · 1 changed file."
assert "src/api.py" in result["html"]
assert "pull-diff-line added" in result["html"]
assert result["retry"] is True
def test_following_typed_identity_prevents_issue_pull_collisions(): def test_following_typed_identity_prevents_issue_pull_collisions():
script = f""" script = f"""
const createFollowing = require({json.dumps(str(MODULE))}); const createFollowing = require({json.dumps(str(MODULE))});
@ -523,7 +593,7 @@ process.stdout.write(JSON.stringify({{
assert ".following-disposition-mode" in css assert ".following-disposition-mode" in css
assert "if (searchPreviewReturnKind === 'following')" in dashboard assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
assert "stackchain-dashboard-shell-v137" in service_worker assert "stackchain-dashboard-shell-v138" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following(): def test_prepare_today_lazily_refreshes_and_directly_reviews_following():

View File

@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/later-sync.js'" in source assert "BASE + 'static/later-sync.js'" in source

View File

@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v137" in worker assert "stackchain-dashboard-shell-v138" in worker

View File

@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v137" in worker assert "stackchain-dashboard-shell-v138" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions(): def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v137" in worker assert "stackchain-dashboard-shell-v138" in worker
assert ".device-setup-panel" in css assert ".device-setup-panel" in css
assert ".device-readiness-card" in css assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css assert "overflow-x:hidden" in css

View File

@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell(): def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text() worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v137" in worker assert "stackchain-dashboard-shell-v138" in worker
assert "BASE + 'static/mobile-insights.js'" in worker assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -414,7 +414,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v137" in service_worker assert "stackchain-dashboard-shell-v138" in service_worker
@pytest.mark.anyio @pytest.mark.anyio

View File

@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
def test_plan_today_controller_is_available_in_the_offline_shell(): def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text() source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -189,7 +189,7 @@ async function dispatchPush(payload) {{
def test_week_unplan_undo_rolls_the_offline_shell(): def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/week-plan.js'" in source assert "BASE + 'static/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
@ -197,20 +197,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
def test_private_today_action_mailbox_rolls_the_offline_shell(): def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell(): def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/week-plan.js'" in source assert "BASE + 'static/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
@ -219,7 +219,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source assert "BASE + 'static/background-issue-sync.js'" in source
@ -228,7 +228,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source assert "BASE + 'static/issue-attachment.js'" in source
@ -236,14 +236,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically(): def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
@ -251,7 +251,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
@ -259,7 +259,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/issue-sheet.js'" in source assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
@ -269,14 +269,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/later-picker.js'" in source assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source assert "BASE + 'static/install-app.js'" in source
@ -285,21 +285,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/update-ownership.js'" in source assert "BASE + 'static/update-ownership.js'" in source
@ -1354,7 +1354,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell(): def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/queue-today.js'" in source assert "BASE + 'static/queue-today.js'" in source

View File

@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell(): def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text() service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v137';" in service_worker assert "const CACHE = 'stackchain-dashboard-shell-v138';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
def test_inflight_today_drain_ships_in_a_new_offline_shell(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v137" in source assert "stackchain-dashboard-shell-v138" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source