Attach screenshots to unread update replies #528
|
|
@ -75,7 +75,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
|
||||
...(['issue-comment', 'pull-comment'].includes(message.kind) && message.attachment ? {
|
||||
...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? {
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
contentType: String(message.attachment.contentType || ''),
|
||||
|
|
@ -99,6 +99,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
|
||||
async function enqueueDurably(message) {
|
||||
const item = enqueue(message, false);
|
||||
if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
|
||||
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
|
||||
write(read().filter(candidate => candidate.id !== item.id), false);
|
||||
throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
|
||||
}
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -437,10 +437,32 @@ function createBackgroundIssueSync({
|
|||
async function deliverReplyRead(item) {
|
||||
let current = item;
|
||||
if (!current.replyConfirmed) {
|
||||
let attachmentMarkdown = current.attachmentMarkdown;
|
||||
if (current.attachment && !attachmentMarkdown) {
|
||||
const uploaded = await requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
|
||||
body:attachmentMultipart(current.attachment),
|
||||
});
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
|
||||
current = { ...current, attachmentMarkdown };
|
||||
}
|
||||
const text = String(current.body || '').trim();
|
||||
const replyBody = attachmentMarkdown ?
|
||||
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text;
|
||||
const options = authoredRequest('', { ...current, body:replyBody }).options;
|
||||
if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
|
||||
await requestStage(
|
||||
current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
|
||||
authoredRequest('', current).options,
|
||||
options,
|
||||
);
|
||||
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
|
||||
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
|
||||
|
|
@ -548,10 +570,40 @@ function createBackgroundIssueSync({
|
|||
);
|
||||
}
|
||||
|
||||
async function deliverUpdateScreenshotReply(item) {
|
||||
let current = item;
|
||||
let attachmentMarkdown = current.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
|
||||
body:attachmentMultipart(current.attachment),
|
||||
});
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
|
||||
current = { ...current, attachmentMarkdown };
|
||||
}
|
||||
const text = String(current.body || '').trim();
|
||||
return requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Content-Type':'application/json',
|
||||
'Idempotency-Key':stageOperationId(current.operationId, 'reply') },
|
||||
body:JSON.stringify({ body:text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
|
||||
});
|
||||
}
|
||||
|
||||
async function deliver(item) {
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
|
||||
item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
|
||||
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
|
||||
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
||||
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
||||
|
|
|
|||
|
|
@ -255,6 +255,12 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.update-reply button { min-height:44px; width:100%; }
|
||||
.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.update-reply-actions button { min-height:44px; width:100%; }
|
||||
.update-reply .issue-attachment-preview { width:100%; min-width:0; }
|
||||
@media (max-width:390px) {
|
||||
.update-reply-actions { grid-template-columns:1fr; }
|
||||
.update-reply .issue-attachment-preview { grid-template-columns:56px minmax(0,1fr); padding:8px; }
|
||||
.update-reply .issue-attachment-preview img { width:56px; height:56px; }
|
||||
}
|
||||
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.update-ownership-actions button { min-width:0; width:100%; }
|
||||
|
|
|
|||
|
|
@ -324,6 +324,23 @@
|
|||
);
|
||||
},
|
||||
});
|
||||
const updateReplyAttachmentController = issueAttachment.mount({
|
||||
input: qs('#update-reply-attachment'),
|
||||
preview: qs('#update-reply-attachment-preview'),
|
||||
image: qs('#update-reply-attachment-image'),
|
||||
meta: qs('#update-reply-attachment-meta'),
|
||||
remove: qs('#remove-update-reply-attachment'),
|
||||
status: qs('#update-reply-status'),
|
||||
readyMessage: 'Screenshot ready to send with this reply.',
|
||||
removedMessage: 'Screenshot removed. Your reply is unchanged.',
|
||||
createObjectURL: file => URL.createObjectURL(file),
|
||||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||||
upload: payload => fetchReviewJson(
|
||||
'api/v1/notifications/' + encodeURIComponent(payload.notificationId) + '/attachments',
|
||||
{ method:'POST', headers:{ Accept:'application/json', 'Idempotency-Key':payload.operation_id },
|
||||
body:issueAttachment.multipart(payload) },
|
||||
),
|
||||
});
|
||||
const createIssueAttachmentController = issueAttachment.mount({
|
||||
input: qs('#create-issue-attachment'),
|
||||
preview: qs('#create-issue-attachment-preview'),
|
||||
|
|
@ -635,6 +652,9 @@
|
|||
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
|
||||
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
|
||||
onOpen: item => {
|
||||
if (selectedUpdate && selectedUpdate.notification_id !== item.notification_id) {
|
||||
updateReplyAttachmentController.clear();
|
||||
}
|
||||
selectedUpdate = item;
|
||||
updateMentions.dismiss();
|
||||
qs('#update-sheet').classList.add('open');
|
||||
|
|
@ -1093,6 +1113,7 @@
|
|||
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
|
||||
markRead: markNotificationRead,
|
||||
queue: message => authoredOutbox.enqueueDurably(message),
|
||||
deliver: item => authoredOutbox.retry(item.id, activeFlushLogin),
|
||||
canQueue: canQueueMessage,
|
||||
accept: item => {
|
||||
notificationReplier.saveDraft(item, '');
|
||||
|
|
@ -3080,6 +3101,7 @@
|
|||
return;
|
||||
}
|
||||
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
|
||||
updateReplyAttachmentController.clear();
|
||||
qs('#update-sheet').classList.remove('open');
|
||||
selectedUpdate = null;
|
||||
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
|
||||
|
|
@ -4448,21 +4470,26 @@
|
|||
qs('#send-update-reply').addEventListener('click', async () => {
|
||||
if (!selectedUpdate) return;
|
||||
const body = qs('#update-reply').value.trim();
|
||||
if (!body) {
|
||||
qs('#update-reply-status').textContent = 'Write a reply before sending.';
|
||||
const attachment = await updateReplyAttachmentController.serialize();
|
||||
if (!body && !attachment) {
|
||||
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
|
||||
qs('#update-reply').focus();
|
||||
return;
|
||||
}
|
||||
qs('#send-update-reply').disabled = true;
|
||||
const result = await notificationReplier.submit(selectedUpdate, body);
|
||||
updateReplyAttachmentController.setBusy(true);
|
||||
const result = await notificationReplier.submit(selectedUpdate, body, attachment);
|
||||
qs('#send-update-reply').disabled = false;
|
||||
updateReplyAttachmentController.setBusy(false);
|
||||
if (result?.queued) {
|
||||
qs('#update-reply').value = '';
|
||||
updateReplyAttachmentController.clear();
|
||||
refreshMyWorkView();
|
||||
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
|
||||
} else if (result) {
|
||||
notificationReader.appendReply(result);
|
||||
qs('#update-reply').value = '';
|
||||
updateReplyAttachmentController.clear();
|
||||
qs('#mark-update-read-next').focus();
|
||||
} else {
|
||||
qs('#update-reply').focus();
|
||||
|
|
@ -4472,8 +4499,9 @@
|
|||
if (!selectedUpdate) return;
|
||||
const item = selectedUpdate;
|
||||
const body = qs('#update-reply').value.trim();
|
||||
if (!body) {
|
||||
qs('#update-reply-status').textContent = 'Write a reply before sending.';
|
||||
const attachment = await updateReplyAttachmentController.serialize();
|
||||
if (!body && !attachment) {
|
||||
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
|
||||
qs('#update-reply').focus();
|
||||
return;
|
||||
}
|
||||
|
|
@ -4481,10 +4509,12 @@
|
|||
const sendButton = qs('#send-update-reply');
|
||||
button.disabled = true;
|
||||
sendButton.disabled = true;
|
||||
updateReplyAttachmentController.setBusy(true);
|
||||
qs('#update-reply-status').textContent = 'Replying, then marking read…';
|
||||
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
|
||||
try {
|
||||
const result = await updateReplyReadNext.submit(item, body, operationId);
|
||||
const result = await updateReplyReadNext.submit(item, body, operationId, attachment);
|
||||
if (result?.accepted) updateReplyAttachmentController.clear();
|
||||
if (result?.accepted) qs('#my-work-action-status').textContent =
|
||||
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
|
||||
'Reply and read acknowledgement queued for sync.';
|
||||
|
|
@ -4494,6 +4524,7 @@
|
|||
} finally {
|
||||
button.disabled = false;
|
||||
sendButton.disabled = false;
|
||||
updateReplyAttachmentController.setBusy(false);
|
||||
}
|
||||
});
|
||||
qs('#mark-update-read-next').addEventListener('click', async () => {
|
||||
|
|
|
|||
|
|
@ -516,6 +516,15 @@
|
|||
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
|
||||
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
|
||||
<div class="issue-attachment-controls">
|
||||
<input class="visually-hidden" id="update-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<label class="issue-attachment-trigger" for="update-reply-attachment">Attach screenshot</label>
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="update-reply-attachment-preview" hidden>
|
||||
<img id="update-reply-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="update-reply-attachment-meta"></span>
|
||||
<button id="remove-update-reply-attachment" type="button">Remove screenshot</button>
|
||||
</div>
|
||||
<div class="update-reply-actions">
|
||||
<button id="send-update-reply" type="button">Send reply</button>
|
||||
<button id="send-update-reply-read-next" type="button">Reply, mark read & next</button>
|
||||
|
|
|
|||
|
|
@ -404,7 +404,7 @@ function createNotificationReplier({
|
|||
}
|
||||
catch (_error) { /* Keep the editable textarea as the fallback. */ }
|
||||
},
|
||||
async submit(item, body) {
|
||||
async submit(item, body, attachment = null) {
|
||||
if (pending) return false;
|
||||
pending = true;
|
||||
this.saveDraft(item, body);
|
||||
|
|
@ -415,6 +415,23 @@ function createNotificationReplier({
|
|||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
onStatus('Sending reply…');
|
||||
try {
|
||||
if (attachment) {
|
||||
onStatus('Saving screenshot for durable delivery…');
|
||||
const admission = await authoredOutbox.enqueueDurably({
|
||||
kind:'update-reply', notificationId:item.notification_id, body, operationId, attachment,
|
||||
});
|
||||
const delivery = await authoredOutbox.retry(admission.item.id, admission.item.ownerLogin);
|
||||
if (!delivery.confirmed?.length) {
|
||||
const remaining = delivery.remaining?.find(candidate => candidate.id === admission.item.id);
|
||||
if (remaining?.status === 'attention') return false;
|
||||
onStatus('Queued for sync when the connection returns.');
|
||||
return { queued:true };
|
||||
}
|
||||
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
|
||||
catch (_error) { /* Confirmed delivery is authoritative. */ }
|
||||
onStatus('Reply posted. You can mark this update read when ready.');
|
||||
return delivery.confirmed[0];
|
||||
}
|
||||
const result = await post(item.notification_id, body, operationId);
|
||||
try { storage.removeItem(keyFor(item)); }
|
||||
catch (_error) { /* The posted reply is still authoritative. */ }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v86';
|
||||
const CACHE = 'stackchain-dashboard-shell-v87';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
function createUpdateReplyReadNext({
|
||||
post, markRead, queue, canQueue, accept = () => undefined, next = () => undefined,
|
||||
post, markRead, queue, canQueue, deliver, accept = () => undefined, next = () => undefined,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
|
||||
|
|
@ -22,11 +22,25 @@ function createUpdateReplyReadNext({
|
|||
};
|
||||
}
|
||||
|
||||
function submit(item, body, operationId) {
|
||||
function submit(item, body, operationId, attachment = null) {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
let replyConfirmed = false;
|
||||
try {
|
||||
if (attachment) {
|
||||
const admission = await queue({
|
||||
kind:'update-reply-read', notificationId:item.notification_id, body,
|
||||
operationId, replyConfirmed:false, attachment,
|
||||
});
|
||||
if (!admission?.item) throw new Error('Reply and screenshot were not saved for delivery.');
|
||||
const outcome = deliver ? await deliver(admission.item) : null;
|
||||
const remaining = outcome?.remaining?.find(candidate => candidate.id === admission.item.id);
|
||||
if (remaining?.status === 'attention') {
|
||||
throw new Error(remaining.error || 'Reply and screenshot need attention.');
|
||||
}
|
||||
accept(item);
|
||||
return { accepted:true, delivery:outcome?.confirmed?.length ? 'posted' : 'queued', next:await next(item) };
|
||||
}
|
||||
try {
|
||||
await post(item, body, operationId);
|
||||
replyConfirmed = true;
|
||||
|
|
|
|||
|
|
@ -892,6 +892,43 @@ async def reply_to_notification(thread_id: int, body: str) -> dict:
|
|||
return _normalize_issue_comment(comment)
|
||||
|
||||
|
||||
async def upload_notification_attachment(
|
||||
thread_id: int, filename: str, content_type: str, content: bytes
|
||||
) -> dict:
|
||||
"""Upload to the exact issue or pull identified by a trusted notification."""
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
raise ValueError("Gitea notification thread response was not an object")
|
||||
repository = thread.get("repository")
|
||||
subject = thread.get("subject")
|
||||
if not isinstance(repository, dict) or not isinstance(subject, dict):
|
||||
raise ValueError("Notification does not identify a conversation")
|
||||
subject_path = _gitea_api_path(subject.get("url"))
|
||||
match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path)
|
||||
if (
|
||||
not match
|
||||
or match.group(1) != repository.get("full_name")
|
||||
or subject.get("type") not in {"Issue", "Pull"}
|
||||
):
|
||||
raise ValueError("Notification subject is not a supported conversation")
|
||||
# Gitea stores pull-request assets on its shared issue asset endpoint.
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{match.group(1)}/issues/{match.group(3)}/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 close_issue(repository: str, number: int) -> dict:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
|
|
|
|||
54
src/main.py
54
src/main.py
|
|
@ -2991,6 +2991,60 @@ async def reply_to_notification(
|
|||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.post("/api/v1/notifications/{thread_id}/attachments", status_code=201)
|
||||
async def attach_to_notification(
|
||||
request: Request,
|
||||
thread_id: int = PathParam(gt=0),
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
if request.headers.get("content-type", "").lower().startswith("multipart/form-data"):
|
||||
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())
|
||||
else:
|
||||
attachment = IssueAttachment.model_validate(await request.json())
|
||||
filename = attachment.filename
|
||||
content_type = attachment.content_type
|
||||
content = attachment.content()
|
||||
except (ValueError, ValidationError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
async def upload_attachment():
|
||||
result = await gitea_proxy.upload_notification_attachment(
|
||||
thread_id, 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=("notification-attachment", thread_id, filename, content_type,
|
||||
hashlib.sha256(content).hexdigest()),
|
||||
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The screenshot could not be uploaded. Your draft is safe; please retry."},
|
||||
status_code=503, headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/claim")
|
||||
async def claim_available_issue(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ def request_body_limit(method: str, path: str) -> int | None:
|
|||
return SESSION_BODY_LIMIT
|
||||
if (
|
||||
normalized_method == "POST"
|
||||
and path.startswith("/api/v1/repos/")
|
||||
and ("/issues/" in path or "/pulls/" in path)
|
||||
and (
|
||||
(path.startswith("/api/v1/repos/") and ("/issues/" in path or "/pulls/" in path))
|
||||
or path.startswith("/api/v1/notifications/")
|
||||
)
|
||||
and path.endswith("/attachments")
|
||||
):
|
||||
return ISSUE_ATTACHMENT_BODY_LIMIT
|
||||
|
|
|
|||
|
|
@ -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 button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v86" in worker
|
||||
assert "stackchain-dashboard-shell-v87" in worker
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
|||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v86';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v87';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
|||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
|
|
|
|||
|
|
@ -137,4 +137,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 pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v86" in worker
|
||||
assert "stackchain-dashboard-shell-v87" in worker
|
||||
|
|
|
|||
|
|
@ -41,7 +41,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]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v86" in worker
|
||||
assert "stackchain-dashboard-shell-v87" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
|
|
|||
|
|
@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
|||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ async function dispatchNotificationClick(route) {{
|
|||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
|
@ -134,14 +134,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
|||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
|||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -157,14 +157,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
|||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
|
@ -173,21 +173,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
|||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
|
@ -397,7 +397,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():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v86';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v87';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
|||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "stackchain-dashboard-shell-v87" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
178
tests/test_update_reply_attachments.py
Normal file
178
tests/test_update_reply_attachments.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
OUTBOX = ROOT / "frontend" / "authored-outbox.js"
|
||||
SYNC = ROOT / "frontend" / "background-issue-sync.js"
|
||||
PNG = b"\x89PNG\r\n\x1a\nmobile-update"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_idempotency():
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
yield
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_attachment_endpoint_resolves_exact_pull_server_side(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def upload(thread_id, filename, content_type, content):
|
||||
calls.append((thread_id, filename, content_type, content))
|
||||
return {"name": filename, "url": "https://forge.example/a/proof.webp", "size": len(content)}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_notification_attachment", upload, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/notifications/527/attachments",
|
||||
files={"file": ("proof.png", PNG, "image/png")},
|
||||
headers={"Idempotency-Key": "reply-527:attachment"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["markdown"] == ""
|
||||
assert calls == [(527, "proof.png", "image/png", PNG)]
|
||||
assert main.request_body_limit("POST", "/api/v1/notifications/527/attachments") == 2 * 1024 * 1024 + 64 * 1024
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_proxy_notification_upload_trusts_only_matching_gitea_subject_path():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"subject": {
|
||||
"type": "Pull",
|
||||
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/web/pulls/31",
|
||||
},
|
||||
})
|
||||
return httpx.Response(201, json={
|
||||
"name": "proof.png", "size": len(PNG),
|
||||
"browser_download_url": "https://forge.example/a/proof.png",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.upload_notification_attachment(527, "proof.png", "image/png", PNG)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/notifications/threads/527"),
|
||||
("POST", "/api/v1/repos/stackchain/web/issues/31/assets"),
|
||||
]
|
||||
assert result["url"] == "https://forge.example/a/proof.png"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_reply_composer_offers_mobile_safe_removable_screenshot_preview():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="update-reply-attachment"' in html
|
||||
assert 'accept="image/png,image/jpeg,image/webp"' in html
|
||||
assert 'id="update-reply-attachment-preview"' in html
|
||||
assert 'id="remove-update-reply-attachment"' in html
|
||||
assert "const updateReplyAttachmentController = issueAttachment.mount({" in html
|
||||
assert "await updateReplyAttachmentController.serialize()" in html
|
||||
assert ".update-reply .issue-attachment-preview { width:100%; min-width:0; }" in html
|
||||
assert "@media (max-width:390px)" in html
|
||||
|
||||
|
||||
def test_authored_outbox_durably_keeps_account_bound_update_screenshot_out_of_localstorage():
|
||||
script = f"""
|
||||
const createOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const mirrored=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
|
||||
reconcile:async items=>mirrored.push(items),requestSync:async()=>{{}},
|
||||
}}}});
|
||||
(async()=>{{const result=await outbox.enqueueDurably({{
|
||||
kind:'update-reply-read',notificationId:527,body:'',operationId:'reply-image',
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',blob:new Blob(['private-bytes'],{{type:'image/png'}})}},
|
||||
}});process.stdout.write(JSON.stringify({{
|
||||
result,local:outbox.list()[0],raw:values.get('stackchain.authored-outbox.v1'),
|
||||
durable:{{ownerLogin:mirrored[0][0].ownerLogin,text:await mirrored[0][0].attachment.blob.text()}},
|
||||
}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["local"]["attachment"] == {
|
||||
"filename": "phone.png", "contentType": "image/png", "stored": True
|
||||
}
|
||||
assert output["durable"] == {"ownerLogin": "timmy", "text": "private-bytes"}
|
||||
assert "private-bytes" not in output["raw"]
|
||||
|
||||
|
||||
def test_update_screenshot_is_not_admitted_without_indexeddb_durability():
|
||||
script = f"""
|
||||
const createOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createOutbox({{storage,getOwnerLogin:()=>'timmy'}});
|
||||
(async()=>{{let error='';try{{await outbox.enqueueDurably({{
|
||||
kind:'update-reply',notificationId:527,body:'proof',operationId:'no-db',
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',data:'private-bytes'}},
|
||||
}});}}catch(caught){{error=caught.message;}}
|
||||
process.stdout.write(JSON.stringify({{error,items:outbox.list(),raw:values.get('stackchain.authored-outbox.v1')}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["error"] == "Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry."
|
||||
assert output["items"] == []
|
||||
assert "private-bytes" not in output["raw"]
|
||||
|
||||
|
||||
def test_background_update_reply_screenshot_checkpoints_upload_then_reply_then_read():
|
||||
script = f"""
|
||||
const createSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'reply-image',operationId:'reply-image',ownerLogin:'timmy',status:'queued',
|
||||
kind:'update-reply-read',notificationId:527,body:'',replyConfirmed:false,
|
||||
attachment:{{filename:'phone.webp',contentType:'image/webp',blob:new Blob(['pixels'],{{type:'image/webp'}})}}}};
|
||||
const calls=[];let replyAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},
|
||||
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body instanceof FormData?'multipart':options.body?JSON.parse(options.body):null}});
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
if(url.endsWith('/reply') && replyAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
|
||||
return url.endsWith('/reply')?{{id:8}}:{{status:'read'}};
|
||||
}};
|
||||
(async()=>{{const sync=createSync({{store,fetchJson}});try{{await sync.flush();}}catch(_error){{}}
|
||||
const checkpoint={{attachmentMarkdown:item.attachmentMarkdown,replyConfirmed:item.replyConfirmed}};
|
||||
const result=await sync.flush();process.stdout.write(JSON.stringify({{calls,checkpoint,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["checkpoint"]["attachmentMarkdown"].startswith("![phone.webp]")
|
||||
assert output["checkpoint"]["replyConfirmed"] is False
|
||||
assert [call["url"] for call in output["calls"]] == [
|
||||
"api/v1/notifications/527/attachments",
|
||||
"api/v1/notifications/527/reply",
|
||||
"api/v1/notifications/527/reply",
|
||||
"api/v1/notifications/527/read",
|
||||
]
|
||||
assert [call["key"] for call in output["calls"][:3]] == [
|
||||
"reply-image:attachment", "reply-image:reply", "reply-image:reply"
|
||||
]
|
||||
assert output["calls"][2]["body"] == {"body": ""}
|
||||
assert output["result"]["confirmed"] == [{"status": "read"}]
|
||||
Loading…
Reference in New Issue
Block a user