Merge pull request 'Attach screenshots to unread update replies' (#528) from timmy/527-update-reply-screenshots into main
This commit is contained in:
commit
3e16dd00d1
|
|
@ -75,7 +75,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
queuedAt: Number(now()),
|
queuedAt: Number(now()),
|
||||||
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
|
...(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: {
|
attachment: {
|
||||||
filename: String(message.attachment.filename || ''),
|
filename: String(message.attachment.filename || ''),
|
||||||
contentType: String(message.attachment.contentType || ''),
|
contentType: String(message.attachment.contentType || ''),
|
||||||
|
|
@ -99,6 +99,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
|
|
||||||
async function enqueueDurably(message) {
|
async function enqueueDurably(message) {
|
||||||
const item = enqueue(message, false);
|
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) {
|
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||||
return { item, background: false, durability: 'foreground-only' };
|
return { item, background: false, durability: 'foreground-only' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -437,10 +437,32 @@ function createBackgroundIssueSync({
|
||||||
async function deliverReplyRead(item) {
|
async function deliverReplyRead(item) {
|
||||||
let current = item;
|
let current = item;
|
||||||
if (!current.replyConfirmed) {
|
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(
|
await requestStage(
|
||||||
current,
|
current,
|
||||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
|
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
|
||||||
authoredRequest('', current).options,
|
options,
|
||||||
);
|
);
|
||||||
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
|
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
|
||||||
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
|
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) {
|
async function deliver(item) {
|
||||||
const request = deliveryRequest(item);
|
const request = deliveryRequest(item);
|
||||||
try {
|
try {
|
||||||
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
|
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) ?
|
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
|
||||||
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
||||||
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
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 button { min-height:44px; width:100%; }
|
||||||
.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
.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-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-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 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||||
.update-ownership-actions button { min-width:0; width:100%; }
|
.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({
|
const createIssueAttachmentController = issueAttachment.mount({
|
||||||
input: qs('#create-issue-attachment'),
|
input: qs('#create-issue-attachment'),
|
||||||
preview: qs('#create-issue-attachment-preview'),
|
preview: qs('#create-issue-attachment-preview'),
|
||||||
|
|
@ -635,6 +652,9 @@
|
||||||
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
|
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
|
||||||
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
|
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
|
||||||
onOpen: item => {
|
onOpen: item => {
|
||||||
|
if (selectedUpdate && selectedUpdate.notification_id !== item.notification_id) {
|
||||||
|
updateReplyAttachmentController.clear();
|
||||||
|
}
|
||||||
selectedUpdate = item;
|
selectedUpdate = item;
|
||||||
updateMentions.dismiss();
|
updateMentions.dismiss();
|
||||||
qs('#update-sheet').classList.add('open');
|
qs('#update-sheet').classList.add('open');
|
||||||
|
|
@ -1093,6 +1113,7 @@
|
||||||
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
|
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
|
||||||
markRead: markNotificationRead,
|
markRead: markNotificationRead,
|
||||||
queue: message => authoredOutbox.enqueueDurably(message),
|
queue: message => authoredOutbox.enqueueDurably(message),
|
||||||
|
deliver: item => authoredOutbox.retry(item.id, activeFlushLogin),
|
||||||
canQueue: canQueueMessage,
|
canQueue: canQueueMessage,
|
||||||
accept: item => {
|
accept: item => {
|
||||||
notificationReplier.saveDraft(item, '');
|
notificationReplier.saveDraft(item, '');
|
||||||
|
|
@ -3080,6 +3101,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
|
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
|
||||||
|
updateReplyAttachmentController.clear();
|
||||||
qs('#update-sheet').classList.remove('open');
|
qs('#update-sheet').classList.remove('open');
|
||||||
selectedUpdate = null;
|
selectedUpdate = null;
|
||||||
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
|
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
|
||||||
|
|
@ -4448,21 +4470,26 @@
|
||||||
qs('#send-update-reply').addEventListener('click', async () => {
|
qs('#send-update-reply').addEventListener('click', async () => {
|
||||||
if (!selectedUpdate) return;
|
if (!selectedUpdate) return;
|
||||||
const body = qs('#update-reply').value.trim();
|
const body = qs('#update-reply').value.trim();
|
||||||
if (!body) {
|
const attachment = await updateReplyAttachmentController.serialize();
|
||||||
qs('#update-reply-status').textContent = 'Write a reply before sending.';
|
if (!body && !attachment) {
|
||||||
|
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
|
||||||
qs('#update-reply').focus();
|
qs('#update-reply').focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
qs('#send-update-reply').disabled = true;
|
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;
|
qs('#send-update-reply').disabled = false;
|
||||||
|
updateReplyAttachmentController.setBusy(false);
|
||||||
if (result?.queued) {
|
if (result?.queued) {
|
||||||
qs('#update-reply').value = '';
|
qs('#update-reply').value = '';
|
||||||
|
updateReplyAttachmentController.clear();
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
|
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
|
||||||
} else if (result) {
|
} else if (result) {
|
||||||
notificationReader.appendReply(result);
|
notificationReader.appendReply(result);
|
||||||
qs('#update-reply').value = '';
|
qs('#update-reply').value = '';
|
||||||
|
updateReplyAttachmentController.clear();
|
||||||
qs('#mark-update-read-next').focus();
|
qs('#mark-update-read-next').focus();
|
||||||
} else {
|
} else {
|
||||||
qs('#update-reply').focus();
|
qs('#update-reply').focus();
|
||||||
|
|
@ -4472,8 +4499,9 @@
|
||||||
if (!selectedUpdate) return;
|
if (!selectedUpdate) return;
|
||||||
const item = selectedUpdate;
|
const item = selectedUpdate;
|
||||||
const body = qs('#update-reply').value.trim();
|
const body = qs('#update-reply').value.trim();
|
||||||
if (!body) {
|
const attachment = await updateReplyAttachmentController.serialize();
|
||||||
qs('#update-reply-status').textContent = 'Write a reply before sending.';
|
if (!body && !attachment) {
|
||||||
|
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
|
||||||
qs('#update-reply').focus();
|
qs('#update-reply').focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -4481,10 +4509,12 @@
|
||||||
const sendButton = qs('#send-update-reply');
|
const sendButton = qs('#send-update-reply');
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
sendButton.disabled = true;
|
sendButton.disabled = true;
|
||||||
|
updateReplyAttachmentController.setBusy(true);
|
||||||
qs('#update-reply-status').textContent = 'Replying, then marking read…';
|
qs('#update-reply-status').textContent = 'Replying, then marking read…';
|
||||||
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
|
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
|
||||||
try {
|
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 =
|
if (result?.accepted) qs('#my-work-action-status').textContent =
|
||||||
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
|
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
|
||||||
'Reply and read acknowledgement queued for sync.';
|
'Reply and read acknowledgement queued for sync.';
|
||||||
|
|
@ -4494,6 +4524,7 @@
|
||||||
} finally {
|
} finally {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
sendButton.disabled = false;
|
sendButton.disabled = false;
|
||||||
|
updateReplyAttachmentController.setBusy(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
qs('#mark-update-read-next').addEventListener('click', async () => {
|
qs('#mark-update-read-next').addEventListener('click', async () => {
|
||||||
|
|
|
||||||
|
|
@ -516,6 +516,15 @@
|
||||||
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
<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-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="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">
|
<div class="update-reply-actions">
|
||||||
<button id="send-update-reply" type="button">Send reply</button>
|
<button id="send-update-reply" type="button">Send reply</button>
|
||||||
<button id="send-update-reply-read-next" type="button">Reply, mark read & next</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. */ }
|
catch (_error) { /* Keep the editable textarea as the fallback. */ }
|
||||||
},
|
},
|
||||||
async submit(item, body) {
|
async submit(item, body, attachment = null) {
|
||||||
if (pending) return false;
|
if (pending) return false;
|
||||||
pending = true;
|
pending = true;
|
||||||
this.saveDraft(item, body);
|
this.saveDraft(item, body);
|
||||||
|
|
@ -415,6 +415,23 @@ function createNotificationReplier({
|
||||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||||
onStatus('Sending reply…');
|
onStatus('Sending reply…');
|
||||||
try {
|
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);
|
const result = await post(item.notification_id, body, operationId);
|
||||||
try { storage.removeItem(keyFor(item)); }
|
try { storage.removeItem(keyFor(item)); }
|
||||||
catch (_error) { /* The posted reply is still authoritative. */ }
|
catch (_error) { /* The posted reply is still authoritative. */ }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
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 OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
function createUpdateReplyReadNext({
|
function createUpdateReplyReadNext({
|
||||||
post, markRead, queue, canQueue, accept = () => undefined, next = () => undefined,
|
post, markRead, queue, canQueue, deliver, accept = () => undefined, next = () => undefined,
|
||||||
}) {
|
}) {
|
||||||
let inFlight = null;
|
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;
|
if (inFlight) return inFlight;
|
||||||
inFlight = (async () => {
|
inFlight = (async () => {
|
||||||
let replyConfirmed = false;
|
let replyConfirmed = false;
|
||||||
try {
|
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 {
|
try {
|
||||||
await post(item, body, operationId);
|
await post(item, body, operationId);
|
||||||
replyConfirmed = true;
|
replyConfirmed = true;
|
||||||
|
|
|
||||||
|
|
@ -892,6 +892,43 @@ async def reply_to_notification(thread_id: int, body: str) -> dict:
|
||||||
return _normalize_issue_comment(comment)
|
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:
|
async def close_issue(repository: str, number: int) -> dict:
|
||||||
response = await _get_client().patch(
|
response = await _get_client().patch(
|
||||||
f"/api/v1/repos/{repository}/issues/{number}",
|
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)
|
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")
|
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/claim")
|
||||||
async def claim_available_issue(
|
async def claim_available_issue(
|
||||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
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
|
return SESSION_BODY_LIMIT
|
||||||
if (
|
if (
|
||||||
normalized_method == "POST"
|
normalized_method == "POST"
|
||||||
and path.startswith("/api/v1/repos/")
|
and (
|
||||||
and ("/issues/" in path or "/pulls/" in path)
|
(path.startswith("/api/v1/repos/") and ("/issues/" in path or "/pulls/" in path))
|
||||||
|
or path.startswith("/api/v1/notifications/")
|
||||||
|
)
|
||||||
and path.endswith("/attachments")
|
and path.endswith("/attachments")
|
||||||
):
|
):
|
||||||
return ISSUE_ATTACHMENT_BODY_LIMIT
|
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 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||||
assert '.update-reply-actions button { min-height:44px;' in html
|
assert '.update-reply-actions button { min-height:44px;' in html
|
||||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
assert "stackchain-dashboard-shell-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 = changed_frontend / "service-worker.js"
|
||||||
worker.write_text(
|
worker.write_text(
|
||||||
worker.read_text().replace(
|
worker.read_text().replace(
|
||||||
"const CACHE = 'stackchain-dashboard-shell-v86';",
|
"const CACHE = 'stackchain-dashboard-shell-v87';",
|
||||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
"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():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v86" in source
|
assert "stackchain-dashboard-shell-v87" in source
|
||||||
assert "BASE + 'static/later-sync.js'" 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 { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-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]))
|
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||||
|
|
||||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v86" in worker
|
assert "stackchain-dashboard-shell-v87" in worker
|
||||||
|
|
||||||
|
|
||||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
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():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
source = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v86" in source
|
assert "stackchain-dashboard-shell-v87" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.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():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
@ -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():
|
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v86" in source
|
assert "stackchain-dashboard-shell-v87" in source
|
||||||
assert "BASE + 'static/today-completion.js'" in source
|
assert "BASE + 'static/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -149,7 +149,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v86" in source
|
assert "stackchain-dashboard-shell-v87" in source
|
||||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -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():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v86" in source
|
assert "stackchain-dashboard-shell-v87" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -173,21 +173,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v86" in source
|
assert "stackchain-dashboard-shell-v87" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v86" in source
|
assert "stackchain-dashboard-shell-v87" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v86" in source
|
assert "stackchain-dashboard-shell-v87" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" 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():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
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():
|
def test_readiness_runtime_is_available_in_offline_shell():
|
||||||
service_worker = SERVICE_WORKER.read_text()
|
service_worker = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "const CACHE = 'stackchain-dashboard-shell-v86';" in service_worker
|
assert "const CACHE = 'stackchain-dashboard-shell-v87';" in service_worker
|
||||||
assert "BASE + 'static/today-readiness.js'" 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():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v86" in source
|
assert "stackchain-dashboard-shell-v87" in source
|
||||||
assert "BASE + 'static/today-sync.js'" 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