Merge pull request 'Reply and continue from mobile Search' (#806) from timmy/805-reply-continue-mobile-search into main
This commit is contained in:
commit
e5f37a866c
|
|
@ -550,6 +550,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.search-preview-conversation h2 { margin:0; font-size:1rem; }
|
.search-preview-conversation h2 { margin:0; font-size:1rem; }
|
||||||
.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-conversation button { min-height:44px; width:100%; }
|
.search-preview-conversation button { min-height:44px; width:100%; }
|
||||||
|
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
|
||||||
|
.search-preview-reply[hidden] { display:none; }
|
||||||
|
.search-preview-reply h2 { margin:0; font-size:1rem; }
|
||||||
|
.search-preview-reply textarea { box-sizing:border-box; width:100%; min-height:110px; resize:vertical; scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom)); }
|
||||||
|
.search-preview-reply-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
|
.search-preview-reply-actions button { min-height:44px; }
|
||||||
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
|
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
|
||||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||||
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
|
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
|
||||||
|
|
|
||||||
|
|
@ -4348,6 +4348,7 @@
|
||||||
startButton.hidden = true;
|
startButton.hidden = true;
|
||||||
startButton.disabled = false;
|
startButton.disabled = false;
|
||||||
shareButton.disabled = true;
|
shareButton.disabled = true;
|
||||||
|
renderSearchPreviewReply(state, null, searchPreview, document);
|
||||||
|
|
||||||
if (state.status === 'loading') {
|
if (state.status === 'loading') {
|
||||||
searchPreviewDetail = null;
|
searchPreviewDetail = null;
|
||||||
|
|
@ -4377,6 +4378,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);
|
||||||
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';
|
||||||
|
|
@ -4416,6 +4418,7 @@
|
||||||
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
|
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
|
||||||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
||||||
),
|
),
|
||||||
|
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
|
||||||
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
|
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
|
||||||
session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})],
|
session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})],
|
||||||
onState: renderSearchPreview,
|
onState: renderSearchPreview,
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,15 @@
|
||||||
<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 class="search-preview-reply" aria-labelledby="search-preview-reply-title" hidden>
|
||||||
|
<h2 id="search-preview-reply-title">Reply</h2>
|
||||||
|
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||||
|
<div id="search-preview-reply-status" class="small" aria-live="polite"></div>
|
||||||
|
<div class="search-preview-reply-actions">
|
||||||
|
<button id="send-search-preview-reply" type="button">Send</button>
|
||||||
|
<button id="send-search-preview-reply-next" type="button">Send & next</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div class="search-preview-actions">
|
<div class="search-preview-actions">
|
||||||
<nav class="search-preview-navigation" aria-label="Search result navigation" hidden>
|
<nav class="search-preview-navigation" aria-label="Search result navigation" hidden>
|
||||||
<button id="previous-search-result" type="button">Previous</button>
|
<button id="previous-search-result" type="button">Previous</button>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,16 @@
|
||||||
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.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||||
|
'/comments?kind=' + encodeURIComponent(item.kind);
|
||||||
|
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
|
||||||
|
storage,
|
||||||
|
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||||
|
postReply:(item,body,operationId) => fetchJson(root.searchPreviewReplyPath(item), {
|
||||||
|
method:'POST', headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':operationId},
|
||||||
|
body:JSON.stringify({body}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
|
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
|
||||||
const comments = document.querySelector('#search-preview-comments');
|
const comments = document.querySelector('#search-preview-comments');
|
||||||
const status = document.querySelector('#search-preview-conversation-status');
|
const status = document.querySelector('#search-preview-conversation-status');
|
||||||
|
|
@ -57,9 +67,29 @@
|
||||||
else status.textContent = conversation.comments.length +
|
else status.textContent = conversation.comments.length +
|
||||||
(conversation.comments.length === 1 ? ' message.' : ' messages.');
|
(conversation.comments.length === 1 ? ' message.' : ' messages.');
|
||||||
};
|
};
|
||||||
|
root.renderSearchPreviewReply = (state, detail, preview, document) => {
|
||||||
|
const section = document.querySelector('.search-preview-reply');
|
||||||
|
const input = document.querySelector('#search-preview-reply');
|
||||||
|
const status = document.querySelector('#search-preview-reply-status');
|
||||||
|
const buttons = ['#send-search-preview-reply', '#send-search-preview-reply-next']
|
||||||
|
.map(selector => document.querySelector(selector));
|
||||||
|
section.hidden = detail?.commentable !== true;
|
||||||
|
status.textContent = '';
|
||||||
|
if (section.hidden) {
|
||||||
|
buttons.forEach(button => { button.disabled = true; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.value = preview.replyDraft();
|
||||||
|
const replying = state.status === 'replying';
|
||||||
|
buttons.forEach(button => { button.disabled = replying || !input.value.trim(); });
|
||||||
|
if (replying) status.textContent = 'Sending reply…';
|
||||||
|
else if (state.status === 'replied') status.textContent = 'Reply posted.';
|
||||||
|
else if (state.status === 'reply-error') status.textContent =
|
||||||
|
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
|
||||||
|
};
|
||||||
}
|
}
|
||||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
||||||
if (Array.isArray(session)) {
|
if (Array.isArray(session)) {
|
||||||
getSession = session[0];
|
getSession = session[0];
|
||||||
loadMore = () => session[1].loadMore();
|
loadMore = () => session[1].loadMore();
|
||||||
|
|
@ -71,6 +101,7 @@
|
||||||
let mutationRequest = null;
|
let mutationRequest = null;
|
||||||
let shareRequest = null;
|
let shareRequest = null;
|
||||||
let moveRequest = null;
|
let moveRequest = null;
|
||||||
|
let replyRequest = null;
|
||||||
let conversation = null;
|
let conversation = null;
|
||||||
|
|
||||||
function sameItem(left, right) {
|
function sameItem(left, right) {
|
||||||
|
|
@ -78,6 +109,23 @@
|
||||||
Number(left.number) === Number(right.number);
|
Number(left.number) === Number(right.number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replyKey(item, suffix) {
|
||||||
|
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
|
||||||
|
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stored(key) {
|
||||||
|
try { return storage?.getItem(key) || ''; }
|
||||||
|
catch (_) { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(key, value) {
|
||||||
|
try {
|
||||||
|
if (value) storage?.setItem(key, value);
|
||||||
|
else storage?.removeItem(key);
|
||||||
|
} catch (_) { /* Volatile drafts still remain in the textarea. */ }
|
||||||
|
}
|
||||||
|
|
||||||
function navigation(item) {
|
function navigation(item) {
|
||||||
const session = typeof getSession === 'function' ? getSession() : null;
|
const session = typeof getSession === 'function' ? getSession() : null;
|
||||||
const items = Array.isArray(session?.items) ? session.items : [];
|
const items = Array.isArray(session?.items) ? session.items : [];
|
||||||
|
|
@ -130,10 +178,9 @@
|
||||||
return fetchConversation(detail, page).then(result => {
|
return fetchConversation(detail, page).then(result => {
|
||||||
if (requestGeneration !== generation) return result;
|
if (requestGeneration !== generation) return result;
|
||||||
const incoming = Array.isArray(result?.comments) ? result.comments : [];
|
const incoming = Array.isArray(result?.comments) ? result.comments : [];
|
||||||
const comments = page
|
const liveComments = Array.isArray(conversation?.comments) ? conversation.comments : previousComments;
|
||||||
? [...incoming, ...previousComments].filter((comment, index, all) =>
|
const comments = [...incoming, ...liveComments].filter((comment, index, all) =>
|
||||||
all.findIndex(candidate => candidate?.id === comment?.id) === index)
|
all.findIndex(candidate => candidate?.id === comment?.id) === index);
|
||||||
: incoming;
|
|
||||||
conversation = {
|
conversation = {
|
||||||
status:'ready',
|
status:'ready',
|
||||||
comments,
|
comments,
|
||||||
|
|
@ -222,6 +269,44 @@
|
||||||
if (!current || !conversation?.olderPage) return Promise.resolve(null);
|
if (!current || !conversation?.olderPage) return Promise.resolve(null);
|
||||||
return loadConversation(current, generation, conversation.olderPage);
|
return loadConversation(current, generation, conversation.olderPage);
|
||||||
},
|
},
|
||||||
|
saveReplyDraft(body) {
|
||||||
|
if (!current) return '';
|
||||||
|
const key = replyKey(current, 'draft');
|
||||||
|
const next = String(body || '');
|
||||||
|
if (stored(key) !== next) save(replyKey(current, 'operation'), '');
|
||||||
|
save(key, next);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
replyDraft() {
|
||||||
|
return current ? stored(replyKey(current, 'draft')) : '';
|
||||||
|
},
|
||||||
|
reply({ advance = false } = {}) {
|
||||||
|
if (replyRequest) return replyRequest;
|
||||||
|
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
|
||||||
|
const body = api.replyDraft().trim();
|
||||||
|
if (!body) return Promise.reject(new Error('Write a reply first.'));
|
||||||
|
const item = { ...current };
|
||||||
|
const operationKey = replyKey(item, 'operation');
|
||||||
|
let operationId = stored(operationKey);
|
||||||
|
if (!operationId) {
|
||||||
|
operationId = String(createOperationId?.() || Date.now()).slice(0, 128);
|
||||||
|
save(operationKey, operationId);
|
||||||
|
}
|
||||||
|
publish({ status:'replying', item:current, detail:current, conversation });
|
||||||
|
replyRequest = postReply(item, body, operationId).then(comment => {
|
||||||
|
const comments = [...(conversation?.comments || [])];
|
||||||
|
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
|
||||||
|
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
|
||||||
|
save(replyKey(item, 'draft'), '');
|
||||||
|
save(operationKey, '');
|
||||||
|
publish({ status:'replied', item:current, detail:current, conversation, result:comment });
|
||||||
|
return advance ? api.next().then(() => comment) : comment;
|
||||||
|
}).catch(error => {
|
||||||
|
publish({ status:'reply-error', item:current, detail:current, conversation, error });
|
||||||
|
throw error;
|
||||||
|
}).finally(() => { replyRequest = null; });
|
||||||
|
return replyRequest;
|
||||||
|
},
|
||||||
share(url) {
|
share(url) {
|
||||||
if (shareRequest) return shareRequest;
|
if (shareRequest) return shareRequest;
|
||||||
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
|
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
|
||||||
|
|
@ -248,6 +333,17 @@
|
||||||
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('#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');
|
||||||
|
reply?.addEventListener('input', event => {
|
||||||
|
api.saveReplyDraft(event.target.value);
|
||||||
|
const disabled = !event.target.value.trim();
|
||||||
|
navigationRoot.querySelector('#send-search-preview-reply').disabled = disabled;
|
||||||
|
navigationRoot.querySelector('#send-search-preview-reply-next').disabled = disabled;
|
||||||
|
});
|
||||||
|
reply?.addEventListener('focus', event => event.target.scrollIntoView({ block:'center', behavior:'smooth' }));
|
||||||
|
navigationRoot.querySelector('#send-search-preview-reply')?.addEventListener('click', () => api.reply().catch(() => {}));
|
||||||
|
navigationRoot.querySelector('#send-search-preview-reply-next')?.addEventListener('click', () =>
|
||||||
|
api.reply({ advance:true }).catch(() => {}));
|
||||||
}
|
}
|
||||||
return api;
|
return api;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -586,6 +586,7 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||||
"reopenable": actual_kind == "issue" and state == "closed",
|
"reopenable": actual_kind == "issue" and state == "closed",
|
||||||
"assigned_to_me": bool(login and login in assignee_names),
|
"assigned_to_me": bool(login and login in assignee_names),
|
||||||
"reviewable": reviewable,
|
"reviewable": reviewable,
|
||||||
|
"commentable": bool(login),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
42
src/main.py
42
src/main.py
|
|
@ -2804,6 +2804,48 @@ async def global_search_preview_conversation(
|
||||||
return JSONResponse(conversation)
|
return JSONResponse(conversation)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/api/v1/repos/{owner}/{repo}/issues/{number}/preview/comments",
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def comment_on_global_search_preview(
|
||||||
|
comment: IssueComment,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
number: int = PathParam(gt=0),
|
||||||
|
kind: Literal["issue", "pull"] = Query(),
|
||||||
|
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||||
|
) -> JSONResponse:
|
||||||
|
repository = f"{owner}/{repo}"
|
||||||
|
|
||||||
|
async def post_comment():
|
||||||
|
preview = await gitea_proxy.work_preview(repository, kind, number)
|
||||||
|
if (
|
||||||
|
preview.get("repository") != repository
|
||||||
|
or preview.get("kind") != kind
|
||||||
|
or preview.get("number") != number
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Search result not found")
|
||||||
|
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await _run_idempotent_authored_action(
|
||||||
|
post_comment(),
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
fingerprint=("search-preview-comment", repository, kind, number, comment.body),
|
||||||
|
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "The reply could not be posted. Your draft is safe; please retry."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
return JSONResponse(result, status_code=201)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/work-route")
|
@app.get("/api/v1/work-route")
|
||||||
async def resolve_work_route(
|
async def resolve_work_route(
|
||||||
kind: Literal["issue", "pull", "review", "update"] = Query(),
|
kind: Literal["issue", "pull", "review", "update"] = Query(),
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,23 @@ def test_mobile_search_exposes_accessible_repository_scope_and_forwards_it():
|
||||||
assert '#cmd-search-repository' in css
|
assert '#cmd-search-repository' in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_search_preview_exposes_a_keyboard_safe_reply_and_continue_composer():
|
||||||
|
html = dashboard_bundle_text()
|
||||||
|
css = (FRONTEND / "dashboard.css").read_text()
|
||||||
|
preview = SEARCH_PREVIEW.read_text()
|
||||||
|
|
||||||
|
assert '<section class="search-preview-reply"' in html
|
||||||
|
assert '<textarea id="search-preview-reply"' in html
|
||||||
|
assert '<button id="send-search-preview-reply"' in html
|
||||||
|
assert '<button id="send-search-preview-reply-next"' in html
|
||||||
|
assert "searchPreviewReplyOptions(fetchReviewJson" in html
|
||||||
|
assert "root.searchPreviewReplyPath(item)" in preview
|
||||||
|
assert "api.saveReplyDraft" in preview
|
||||||
|
assert "api.reply({ advance:true })" in preview
|
||||||
|
assert ".search-preview-reply textarea" in css
|
||||||
|
assert "scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom))" in css
|
||||||
|
|
||||||
|
|
||||||
def test_remote_command_search_repository_change_aborts_and_restarts_scoped_search():
|
def test_remote_command_search_repository_change_aborts_and_restarts_scoped_search():
|
||||||
script = f"""
|
script = f"""
|
||||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||||
|
|
@ -327,6 +344,132 @@ process.stdout.write(JSON.stringify({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation():
|
||||||
|
script = f"""
|
||||||
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||||
|
(async () => {{
|
||||||
|
const item = {{repository:'stackchain/api', number:42, kind:'pull'}};
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{
|
||||||
|
getItem:key => values.has(key) ? values.get(key) : null,
|
||||||
|
setItem:(key,value) => values.set(key,value),
|
||||||
|
removeItem:key => values.delete(key),
|
||||||
|
}};
|
||||||
|
const calls = [];
|
||||||
|
const states = [];
|
||||||
|
let attempt = 0;
|
||||||
|
const preview = createSearchPreview({{
|
||||||
|
fetchJson: candidate => Promise.resolve({{...candidate, title:'Review reply'}}),
|
||||||
|
mutate: () => Promise.resolve(),
|
||||||
|
postReply: (candidate, body, operationId) => {{
|
||||||
|
calls.push([candidate.number, body, operationId]);
|
||||||
|
attempt += 1;
|
||||||
|
return attempt === 1 ? Promise.reject(new Error('offline')) : Promise.resolve({{
|
||||||
|
id:91, author:'timmy', body, created_at:'2026-08-14T12:30:00Z'
|
||||||
|
}});
|
||||||
|
}},
|
||||||
|
storage,
|
||||||
|
createOperationId: () => 'reply-operation-42',
|
||||||
|
onState: state => states.push(state),
|
||||||
|
}});
|
||||||
|
await preview.open(item);
|
||||||
|
preview.saveReplyDraft('Ready to merge.');
|
||||||
|
try {{ await preview.reply(); }} catch (_) {{}}
|
||||||
|
const draftAfterFailure = preview.replyDraft();
|
||||||
|
await preview.reply();
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
calls,
|
||||||
|
draftAfterFailure,
|
||||||
|
draftAfterSuccess:preview.replyDraft(),
|
||||||
|
comments:states.filter(state => state.conversation).at(-1).conversation.comments,
|
||||||
|
}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"calls": [
|
||||||
|
[42, "Ready to merge.", "reply-operation-42"],
|
||||||
|
[42, "Ready to merge.", "reply-operation-42"],
|
||||||
|
],
|
||||||
|
"draftAfterFailure": "Ready to merge.",
|
||||||
|
"draftAfterSuccess": "",
|
||||||
|
"comments": [{
|
||||||
|
"id": 91,
|
||||||
|
"author": "timmy",
|
||||||
|
"body": "Ready to merge.",
|
||||||
|
"created_at": "2026-08-14T12:30:00Z",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_preview_reply_and_next_advances_only_after_server_confirmation():
|
||||||
|
script = f"""
|
||||||
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||||
|
(async () => {{
|
||||||
|
const items = [1, 2].map(number => ({{repository:'stackchain/api', number, kind:'issue'}}));
|
||||||
|
const values = new Map();
|
||||||
|
let confirm;
|
||||||
|
const navigated = [];
|
||||||
|
const preview = createSearchPreview({{
|
||||||
|
fetchJson:item => Promise.resolve(item), mutate:() => Promise.resolve(),
|
||||||
|
postReply:() => new Promise(resolve => {{ confirm = resolve; }}),
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||||
|
createOperationId:() => 'reply-next-1',
|
||||||
|
getSession:() => ({{items,more:false}}), loadMore:() => Promise.resolve(),
|
||||||
|
onNavigate:item => navigated.push(item.number), onState:() => {{}},
|
||||||
|
}});
|
||||||
|
await preview.open(items[0]);
|
||||||
|
preview.saveReplyDraft('Done.');
|
||||||
|
const pending = preview.reply({{advance:true}});
|
||||||
|
await Promise.resolve();
|
||||||
|
const before = [...navigated];
|
||||||
|
confirm({{id:7,author:'timmy',body:'Done.'}});
|
||||||
|
await pending;
|
||||||
|
process.stdout.write(JSON.stringify({{before,after:navigated,draft:preview.replyDraft()}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"before": [],
|
||||||
|
"after": [2],
|
||||||
|
"draft": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_preview_keeps_confirmed_reply_when_earlier_conversation_load_finishes_late():
|
||||||
|
script = f"""
|
||||||
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||||
|
(async () => {{
|
||||||
|
const values = new Map();
|
||||||
|
let finishConversation;
|
||||||
|
const states = [];
|
||||||
|
const preview = createSearchPreview({{
|
||||||
|
fetchJson:item => Promise.resolve({{...item,title:'Race'}}),
|
||||||
|
fetchConversation:() => new Promise(resolve => {{ finishConversation = resolve; }}),
|
||||||
|
mutate:() => Promise.resolve(),
|
||||||
|
postReply:(_item,body) => Promise.resolve({{id:9,author:'timmy',body}}),
|
||||||
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||||
|
createOperationId:() => 'reply-race-9', onState:state => states.push(state),
|
||||||
|
}});
|
||||||
|
await preview.open({{repository:'stackchain/api',number:9,kind:'issue'}});
|
||||||
|
preview.saveReplyDraft('Confirmed reply');
|
||||||
|
await preview.reply();
|
||||||
|
finishConversation({{comments:[{{id:3,author:'alex',body:'Earlier'}}],older_page:null}});
|
||||||
|
await Promise.resolve();
|
||||||
|
const comments = states.filter(state => state.conversation?.status === 'ready').at(-1).conversation.comments;
|
||||||
|
process.stdout.write(JSON.stringify(comments.map(comment => comment.id)));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == [3, 9]
|
||||||
|
|
||||||
|
|
||||||
def test_search_preview_next_loads_one_page_at_boundary_then_announces_completion():
|
def test_search_preview_next_loads_one_page_at_boundary_then_announces_completion():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,84 @@ async def test_global_search_preview_conversation_returns_a_bounded_authorized_p
|
||||||
assert requested == [("stackchain/api", 42, None, 20)]
|
assert requested == [("stackchain/api", 42, None, 20)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_global_search_preview_reply_is_idempotent_for_an_authorized_pull(monkeypatch):
|
||||||
|
main._idempotency_ledger.clear()
|
||||||
|
previews = []
|
||||||
|
comments = []
|
||||||
|
|
||||||
|
async def preview(repository, kind, number):
|
||||||
|
previews.append((repository, kind, number))
|
||||||
|
return {
|
||||||
|
"repository": repository,
|
||||||
|
"kind": "pull",
|
||||||
|
"number": number,
|
||||||
|
"title": "Review mobile reply",
|
||||||
|
"state": "open",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def comment(repository, number, body):
|
||||||
|
comments.append((repository, number, body))
|
||||||
|
return {
|
||||||
|
"id": 91,
|
||||||
|
"author": "timmy",
|
||||||
|
"body": body,
|
||||||
|
"created_at": "2026-08-14T12:30:00Z",
|
||||||
|
"url": "https://forge.example/stackchain/api/pulls/42#issuecomment-91",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
headers = {"Idempotency-Key": "search-reply-pull-42"}
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
first = await client.post(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
|
||||||
|
json={"body": "Ready to merge."},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
replay = await client.post(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
|
||||||
|
json={"body": "Ready to merge."},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.status_code == replay.status_code == 201
|
||||||
|
assert replay.json() == first.json()
|
||||||
|
assert previews == [("stackchain/api", "pull", 42)]
|
||||||
|
assert comments == [("stackchain/api", 42, "Ready to merge.")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_global_search_preview_reply_rejects_a_mismatched_target_before_commenting(monkeypatch):
|
||||||
|
main._idempotency_ledger.clear()
|
||||||
|
comments = []
|
||||||
|
|
||||||
|
async def preview(repository, kind, number):
|
||||||
|
return {
|
||||||
|
"repository": repository,
|
||||||
|
"kind": "issue",
|
||||||
|
"number": number,
|
||||||
|
"title": "Different target",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def comment(*args):
|
||||||
|
comments.append(args)
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
|
||||||
|
json={"body": "Do not misroute this."},
|
||||||
|
headers={"Idempotency-Key": "search-reply-mismatch-42"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert comments == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
|
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
|
||||||
requests = []
|
requests = []
|
||||||
|
|
@ -420,6 +498,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
|
||||||
"reopenable": False,
|
"reopenable": False,
|
||||||
"assigned_to_me": False,
|
"assigned_to_me": False,
|
||||||
"reviewable": False,
|
"reviewable": False,
|
||||||
|
"commentable": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user