Merge pull request 'Attach photo evidence to mobile Search preview replies' (#956) from timmy/955-search-preview-photo-evidence into main
This commit is contained in:
commit
cab5802547
|
|
@ -75,6 +75,11 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p
|
||||||
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
|
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
|
||||||
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
|
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
|
||||||
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
|
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
|
||||||
|
Commentable mobile Search previews support camera capture and gallery selection for up to five ordered
|
||||||
|
photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators
|
||||||
|
can send photo-only or text-plus-photo replies without leaving their Search pass. Each upload and the final
|
||||||
|
comment use stable idempotency keys: a failed attempt keeps the draft, evidence order, and confirmed upload
|
||||||
|
checkpoints, while **Send & next** advances only after Gitea confirms the comment.
|
||||||
Named mobile Search views preserve the query, type, status, and optional repository scope. They are
|
Named mobile Search views preserve the query, type, status, and optional repository scope. They are
|
||||||
bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another
|
bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another
|
||||||
device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently
|
device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.search-preview-reply-actions button { min-height:44px; }
|
.search-preview-reply-actions button { min-height:44px; }
|
||||||
.search-preview-reply .voice-conversation-controls button,
|
.search-preview-reply .voice-conversation-controls button,
|
||||||
.search-preview-reply .voice-conversation-review-actions button { min-height:44px; }
|
.search-preview-reply .voice-conversation-review-actions button { min-height:44px; }
|
||||||
|
.search-preview-reply .conversation-photo-actions { max-width:100%; }
|
||||||
.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; }
|
||||||
|
|
|
||||||
|
|
@ -494,6 +494,61 @@
|
||||||
window.location.hash = '#/my-work/agenda';
|
window.location.hash = '#/my-work/agenda';
|
||||||
qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.';
|
qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.';
|
||||||
});
|
});
|
||||||
|
let searchReplyAttachmentTarget = null;
|
||||||
|
const updateSearchReplyButtons = state => {
|
||||||
|
const enabled = Boolean(state) || Boolean(qs('#search-preview-reply').value.trim());
|
||||||
|
qs('#send-search-preview-reply').disabled = !enabled;
|
||||||
|
qs('#send-search-preview-reply-next').disabled = !enabled;
|
||||||
|
};
|
||||||
|
const searchReplyAttachmentController = issueAttachment.mount({
|
||||||
|
maxFiles: 5,
|
||||||
|
input: qs('#search-reply-attachment'),
|
||||||
|
inputs: [qs('#take-search-reply-photo'), qs('#search-reply-attachment')],
|
||||||
|
preview: qs('#search-reply-attachment-preview'),
|
||||||
|
image: qs('#search-reply-attachment-image'),
|
||||||
|
meta: qs('#search-reply-attachment-meta'),
|
||||||
|
remove: qs('#remove-search-reply-attachment'),
|
||||||
|
tray: qs('#search-reply-attachment-tray'),
|
||||||
|
earlier: qs('#move-search-reply-attachment-earlier'),
|
||||||
|
later: qs('#move-search-reply-attachment-later'),
|
||||||
|
note: qs('#search-reply-attachment-note'),
|
||||||
|
noteLabel: qs('#search-reply-attachment-note-label'),
|
||||||
|
status: qs('#search-preview-reply-status'),
|
||||||
|
readyMessage: 'Photo ready to send with this Search reply.',
|
||||||
|
removedMessage: 'Photo removed. Your Search reply is unchanged.',
|
||||||
|
onChange: updateSearchReplyButtons,
|
||||||
|
editor: {
|
||||||
|
document,
|
||||||
|
edit: qs('#edit-search-reply-attachment'),
|
||||||
|
dialog: qs('#issue-evidence-editor'),
|
||||||
|
canvas: qs('#issue-evidence-editor-canvas'),
|
||||||
|
exportCanvas: qs('#issue-evidence-editor-export'),
|
||||||
|
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
|
||||||
|
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
|
||||||
|
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
|
||||||
|
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
|
||||||
|
status: qs('#issue-evidence-editor-status'),
|
||||||
|
appliedMessage: 'Edited photo flattened and ready to send.',
|
||||||
|
},
|
||||||
|
createObjectURL: file => URL.createObjectURL(file),
|
||||||
|
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||||||
|
upload: payload => {
|
||||||
|
const target = searchReplyAttachmentTarget;
|
||||||
|
if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) {
|
||||||
|
return Promise.reject(new Error('Search result changed. Reopen it before sending this photo.'));
|
||||||
|
}
|
||||||
|
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
|
||||||
|
return fetchReviewJson(
|
||||||
|
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(payload.number) +
|
||||||
|
'/preview/attachments?kind=' + encodeURIComponent(target.kind),
|
||||||
|
{
|
||||||
|
method:'POST',
|
||||||
|
headers:{Accept:'application/json','Idempotency-Key':payload.operation_id},
|
||||||
|
body:issueAttachment.multipart(payload),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
const issueAttachmentController = issueAttachment.mount({
|
const issueAttachmentController = issueAttachment.mount({
|
||||||
maxFiles: 5,
|
maxFiles: 5,
|
||||||
input: qs('#issue-attachment'),
|
input: qs('#issue-attachment'),
|
||||||
|
|
@ -5034,6 +5089,15 @@
|
||||||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
||||||
),
|
),
|
||||||
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
|
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
|
||||||
|
prepareReply:(item,body) => {
|
||||||
|
searchReplyAttachmentTarget = item;
|
||||||
|
return searchReplyAttachmentController.prepareComment(item, body);
|
||||||
|
},
|
||||||
|
hasAttachments:() => Boolean(searchReplyAttachmentController.state()),
|
||||||
|
clearAttachments:() => {
|
||||||
|
searchReplyAttachmentTarget = null;
|
||||||
|
searchReplyAttachmentController.clear();
|
||||||
|
},
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -562,6 +562,27 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<div class="issue-attachment-controls conversation-photo-actions">
|
||||||
|
<label class="issue-attachment-trigger" for="take-search-reply-photo">Take photo</label>
|
||||||
|
<input class="visually-hidden" id="take-search-reply-photo" type="file" accept="image/*" capture="environment" />
|
||||||
|
<label class="issue-attachment-trigger" for="search-reply-attachment">Choose existing</label>
|
||||||
|
<input class="visually-hidden" id="search-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
|
||||||
|
</div>
|
||||||
|
<div class="issue-attachment-preview conversation-evidence-review" id="search-reply-attachment-preview" hidden>
|
||||||
|
<img id="search-reply-attachment-image" alt="Selected reply photo preview" />
|
||||||
|
<span class="small" id="search-reply-attachment-meta"></span>
|
||||||
|
<div class="issue-evidence-tray" id="search-reply-attachment-tray" role="toolbar" aria-label="Search reply photos" hidden></div>
|
||||||
|
<label class="issue-evidence-note" for="search-reply-attachment-note">
|
||||||
|
<span id="search-reply-attachment-note-label">Photo caption (optional)</span>
|
||||||
|
<textarea id="search-reply-attachment-note" maxlength="240" rows="2" disabled placeholder="What does this photo show?"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
|
||||||
|
<button id="move-search-reply-attachment-earlier" type="button">Move earlier</button>
|
||||||
|
<button id="edit-search-reply-attachment" type="button">Edit photo</button>
|
||||||
|
<button id="remove-search-reply-attachment" type="button">Remove selected</button>
|
||||||
|
<button id="move-search-reply-attachment-later" type="button">Move later</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="search-preview-reply-status" class="small" aria-live="polite"></div>
|
<div id="search-preview-reply-status" class="small" aria-live="polite"></div>
|
||||||
<div class="search-preview-reply-actions">
|
<div class="search-preview-reply-actions">
|
||||||
<button id="send-search-preview-reply" type="button">Send</button>
|
<button id="send-search-preview-reply" type="button">Send</button>
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,7 @@
|
||||||
input.disabled = false;
|
input.disabled = false;
|
||||||
});
|
});
|
||||||
clearSelection();
|
clearSelection();
|
||||||
|
options.onChange?.(controller.state());
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPreview(file, optimized) {
|
function showPreview(file, optimized) {
|
||||||
|
|
@ -311,6 +312,7 @@
|
||||||
options.status.textContent = values.length + ' screenshots ready to file in this order.';
|
options.status.textContent = values.length + ' screenshots ready to file in this order.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
options.onChange?.(controller.state());
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
if (sequence === selectionSequence) {
|
if (sequence === selectionSequence) {
|
||||||
options.status.textContent = error.message;
|
options.status.textContent = error.message;
|
||||||
|
|
@ -343,6 +345,7 @@
|
||||||
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
|
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
|
||||||
}
|
}
|
||||||
options.status.textContent = options.removedMessage || 'Latest screenshot removed. Your text is unchanged.';
|
options.status.textContent = options.removedMessage || 'Latest screenshot removed. Your text is unchanged.';
|
||||||
|
options.onChange?.(controller.state());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,9 @@
|
||||||
}
|
}
|
||||||
input.value = preview.replyDraft();
|
input.value = preview.replyDraft();
|
||||||
const replying = state.status === 'replying';
|
const replying = state.status === 'replying';
|
||||||
buttons.forEach(button => { button.disabled = replying || !input.value.trim(); });
|
buttons.forEach(button => {
|
||||||
|
button.disabled = replying || (!input.value.trim() && !preview.hasReplyAttachments?.());
|
||||||
|
});
|
||||||
if (replying) status.textContent = 'Sending reply…';
|
if (replying) status.textContent = 'Sending reply…';
|
||||||
else if (state.status === 'replied') status.textContent = 'Reply posted.';
|
else if (state.status === 'replied') status.textContent = 'Reply posted.';
|
||||||
else if (state.status === 'reply-error') status.textContent =
|
else if (state.status === 'reply-error') status.textContent =
|
||||||
|
|
@ -89,7 +91,7 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, prepareReply, hasAttachments, clearAttachments, 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();
|
||||||
|
|
@ -201,7 +203,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
|
hasReplyAttachments() {
|
||||||
|
return Boolean(hasAttachments?.());
|
||||||
|
},
|
||||||
open(item) {
|
open(item) {
|
||||||
|
if (current && !sameItem(current, item) && hasAttachments?.()) clearAttachments?.();
|
||||||
generation += 1;
|
generation += 1;
|
||||||
const requestGeneration = generation;
|
const requestGeneration = generation;
|
||||||
current = { ...item };
|
current = { ...item };
|
||||||
|
|
@ -225,6 +231,7 @@
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
|
clearAttachments?.();
|
||||||
generation += 1;
|
generation += 1;
|
||||||
current = null;
|
current = null;
|
||||||
onState({ status: 'closed' });
|
onState({ status: 'closed' });
|
||||||
|
|
@ -284,7 +291,7 @@
|
||||||
if (replyRequest) return replyRequest;
|
if (replyRequest) return replyRequest;
|
||||||
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
|
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
|
||||||
const body = api.replyDraft().trim();
|
const body = api.replyDraft().trim();
|
||||||
if (!body) return Promise.reject(new Error('Write a reply first.'));
|
if (!body && !hasAttachments?.()) return Promise.reject(new Error('Write a reply or add a photo first.'));
|
||||||
const item = { ...current };
|
const item = { ...current };
|
||||||
const operationKey = replyKey(item, 'operation');
|
const operationKey = replyKey(item, 'operation');
|
||||||
let operationId = stored(operationKey);
|
let operationId = stored(operationKey);
|
||||||
|
|
@ -293,12 +300,18 @@
|
||||||
save(operationKey, operationId);
|
save(operationKey, operationId);
|
||||||
}
|
}
|
||||||
publish({ status:'replying', item:current, detail:current, conversation });
|
publish({ status:'replying', item:current, detail:current, conversation });
|
||||||
replyRequest = postReply(item, body, operationId).then(comment => {
|
replyRequest = Promise.resolve(
|
||||||
|
typeof prepareReply === 'function' ? prepareReply(item, body) : body
|
||||||
|
).then(preparedBody => {
|
||||||
|
if (!String(preparedBody || '').trim()) throw new Error('Write a reply or add a photo first.');
|
||||||
|
return postReply(item, preparedBody, operationId);
|
||||||
|
}).then(comment => {
|
||||||
const comments = [...(conversation?.comments || [])];
|
const comments = [...(conversation?.comments || [])];
|
||||||
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
|
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
|
||||||
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
|
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
|
||||||
save(replyKey(item, 'draft'), '');
|
save(replyKey(item, 'draft'), '');
|
||||||
save(operationKey, '');
|
save(operationKey, '');
|
||||||
|
clearAttachments?.();
|
||||||
publish({ status:'replied', item:current, detail:current, conversation, result:comment });
|
publish({ status:'replied', item:current, detail:current, conversation, result:comment });
|
||||||
return advance ? api.next().then(() => comment) : comment;
|
return advance ? api.next().then(() => comment) : comment;
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
|
|
|
||||||
|
|
@ -1364,6 +1364,32 @@ async def upload_assigned_issue_attachment(
|
||||||
return {"name": name, "url": url, "size": size}
|
return {"name": name, "url": url, "size": size}
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_preview_attachment(
|
||||||
|
repository: str,
|
||||||
|
number: int,
|
||||||
|
filename: str,
|
||||||
|
content_type: str,
|
||||||
|
content: bytes,
|
||||||
|
) -> dict:
|
||||||
|
"""Upload evidence after the caller verifies an exact Search preview target."""
|
||||||
|
response = await _get_client().post(
|
||||||
|
f"/api/v1/repos/{repository}/issues/{number}/assets",
|
||||||
|
headers=_auth(),
|
||||||
|
params={"name": filename},
|
||||||
|
files={"attachment": (filename, content, content_type)},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
attachment = response.json()
|
||||||
|
if not isinstance(attachment, dict):
|
||||||
|
raise ValueError("Gitea attachment response was not an object")
|
||||||
|
name = attachment.get("name")
|
||||||
|
url = _safe_web_url(attachment.get("browser_download_url"))
|
||||||
|
size = attachment.get("size")
|
||||||
|
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
|
||||||
|
raise ValueError("Gitea did not confirm the attachment")
|
||||||
|
return {"name": name, "url": url, "size": size}
|
||||||
|
|
||||||
|
|
||||||
async def upload_assigned_pull_attachment(
|
async def upload_assigned_pull_attachment(
|
||||||
repository: str,
|
repository: str,
|
||||||
number: int,
|
number: int,
|
||||||
|
|
|
||||||
65
src/main.py
65
src/main.py
|
|
@ -3136,6 +3136,71 @@ async def comment_on_global_search_preview(
|
||||||
return JSONResponse(result, status_code=201)
|
return JSONResponse(result, status_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/api/v1/repos/{owner}/{repo}/issues/{number}/preview/attachments",
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def attach_to_global_search_preview(
|
||||||
|
request: Request,
|
||||||
|
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}"
|
||||||
|
try:
|
||||||
|
form = await request.form()
|
||||||
|
uploaded = form.get("file")
|
||||||
|
if not isinstance(uploaded, UploadFile):
|
||||||
|
raise ValueError("screenshot file is required")
|
||||||
|
filename = str(uploaded.filename or "")
|
||||||
|
content_type = str(uploaded.content_type or "")
|
||||||
|
content = _validate_binary_attachment(filename, content_type, await uploaded.read())
|
||||||
|
except (ValueError, ValidationError) as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
async def upload_attachment():
|
||||||
|
preview = await gitea_proxy.work_preview(repository, kind, number)
|
||||||
|
if (
|
||||||
|
preview.get("repository") != repository
|
||||||
|
or preview.get("kind") != kind
|
||||||
|
or preview.get("number") != number
|
||||||
|
or preview.get("commentable") is not True
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Search result not found")
|
||||||
|
result = await gitea_proxy.upload_preview_attachment(
|
||||||
|
repository, number, filename, content_type, content
|
||||||
|
)
|
||||||
|
safe_name = (
|
||||||
|
result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
|
||||||
|
.replace("\r", " ").replace("\n", " ")
|
||||||
|
)
|
||||||
|
safe_url = result["url"].replace("<", "%3C").replace(">", "%3E")
|
||||||
|
result["markdown"] = f""
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await _run_idempotent_authored_action(
|
||||||
|
upload_attachment(),
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
fingerprint=(
|
||||||
|
"search-preview-attachment", repository, kind, number, filename,
|
||||||
|
content_type, hashlib.sha256(content).hexdigest(),
|
||||||
|
),
|
||||||
|
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "The photo could not be uploaded. Your reply 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", "filed", "pull", "review", "update"] = Query(),
|
kind: Literal["issue", "filed", "pull", "review", "update"] = Query(),
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,70 @@ async def test_global_search_preview_reply_rejects_a_mismatched_target_before_co
|
||||||
assert comments == []
|
assert comments == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_global_search_preview_attachment_is_idempotent_for_exact_visible_target(monkeypatch):
|
||||||
|
main._idempotency_ledger.clear()
|
||||||
|
previews = []
|
||||||
|
uploads = []
|
||||||
|
|
||||||
|
async def preview(repository, kind, number):
|
||||||
|
previews.append((repository, kind, number))
|
||||||
|
return {"repository": repository, "kind": kind, "number": number, "commentable": True}
|
||||||
|
|
||||||
|
async def upload(repository, number, filename, content_type, content):
|
||||||
|
uploads.append((repository, number, filename, content_type, content))
|
||||||
|
return {"name": filename, "url": "https://forge.example/evidence/photo.png", "size": len(content)}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
headers = {"Idempotency-Key": "search-photo-pull-42"}
|
||||||
|
image = b"\x89PNG\r\n\x1a\nsafe-pixels"
|
||||||
|
files = {"file": ("photo.png", image, "image/png")}
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
first = await client.post(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull",
|
||||||
|
files=files,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
replay = await client.post(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull",
|
||||||
|
files=files,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.status_code == replay.status_code == 201
|
||||||
|
assert replay.json() == first.json()
|
||||||
|
assert first.json()["markdown"] == ""
|
||||||
|
assert previews == [("stackchain/api", "pull", 42)]
|
||||||
|
assert uploads == [("stackchain/api", 42, "photo.png", "image/png", image)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_global_search_preview_attachment_rejects_kind_mismatch_before_upload(monkeypatch):
|
||||||
|
main._idempotency_ledger.clear()
|
||||||
|
uploads = []
|
||||||
|
|
||||||
|
async def preview(repository, kind, number):
|
||||||
|
return {"repository": repository, "kind": "issue", "number": number}
|
||||||
|
|
||||||
|
async def upload(*args):
|
||||||
|
uploads.append(args)
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload)
|
||||||
|
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/attachments?kind=pull",
|
||||||
|
files={"file": ("photo.png", b"\x89PNG\r\n\x1a\nsafe-pixels", "image/png")},
|
||||||
|
headers={"Idempotency-Key": "search-photo-mismatch-42"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert uploads == []
|
||||||
|
|
||||||
|
|
||||||
@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 = []
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,60 @@ def test_mobile_conversation_composers_accept_five_ordered_photos():
|
||||||
assert "maxFiles: 5" in mount.group(1), controller
|
assert "maxFiles: 5" in mount.group(1), controller
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_search_preview_exposes_and_mounts_photo_evidence_controls():
|
||||||
|
html = INDEX.read_text()
|
||||||
|
dashboard = DASHBOARD.read_text()
|
||||||
|
css = CSS.read_text()
|
||||||
|
|
||||||
|
assert 'for="take-search-reply-photo">Take photo</label>' in html
|
||||||
|
assert 'id="take-search-reply-photo" type="file" accept="image/*" capture="environment"' in html
|
||||||
|
assert 'for="search-reply-attachment">Choose existing</label>' in html
|
||||||
|
assert 'id="search-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple' in html
|
||||||
|
mount = re.search(
|
||||||
|
r"const searchReplyAttachmentController = issueAttachment\.mount\(\{(.*?)\n \}\);",
|
||||||
|
dashboard,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert mount
|
||||||
|
assert "maxFiles: 5" in mount.group(1)
|
||||||
|
assert "'/preview/attachments?kind='" in mount.group(1)
|
||||||
|
assert ".search-preview-reply .conversation-photo-actions" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_preview_can_send_photo_only_and_advances_after_confirmed_comment():
|
||||||
|
search_preview = Path(__file__).parents[1] / "frontend" / "search-preview.js"
|
||||||
|
script = f"""
|
||||||
|
const create = require({json.dumps(str(search_preview))});
|
||||||
|
const posted=[]; const prepared=[]; let cleared=0; let navigated=[]; let attached=true;
|
||||||
|
const item={{kind:'issue',repository:'stackchain/api',number:42}};
|
||||||
|
const next={{kind:'pull',repository:'stackchain/web',number:9}};
|
||||||
|
const api=create({{
|
||||||
|
fetchJson:async value=>({{...value,commentable:true}}),
|
||||||
|
postReply:async (target,body,key)=>{{posted.push([target.number,body,key]);return {{id:91,body}};}},
|
||||||
|
prepareReply:async (target,body)=>{{prepared.push([target.number,body]);return '';}},
|
||||||
|
hasAttachments:()=>attached,
|
||||||
|
clearAttachments:()=>{{cleared += 1;attached=false;}},
|
||||||
|
storage:{{getItem:()=>'',setItem:()=>{{}},removeItem:()=>{{}}}},
|
||||||
|
createOperationId:()=> 'reply-photo-42',
|
||||||
|
getSession:()=>({{items:[item,next],more:false}}),
|
||||||
|
onNavigate:value=>navigated.push(value.number),
|
||||||
|
onState:()=>{{}},
|
||||||
|
}});
|
||||||
|
(async()=>{{
|
||||||
|
await api.open(item);
|
||||||
|
await api.reply({{advance:true}});
|
||||||
|
process.stdout.write(JSON.stringify({{posted,prepared,cleared,navigated}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
output = json.loads(run_node(script))
|
||||||
|
assert output == {
|
||||||
|
"posted": [[42, "", "reply-photo-42"]],
|
||||||
|
"prepared": [[42, ""]],
|
||||||
|
"cleared": 1,
|
||||||
|
"navigated": [9],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_conversation_composers_review_reorder_remove_and_caption_every_photo():
|
def test_mobile_conversation_composers_review_reorder_remove_and_caption_every_photo():
|
||||||
html = INDEX.read_text()
|
html = INDEX.read_text()
|
||||||
dashboard = DASHBOARD.read_text()
|
dashboard = DASHBOARD.read_text()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user