feat: attach screenshots to issue comments (#467)
All checks were successful
CI / lint (pull_request) Successful in 53s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-10 08:55:13 +00:00
parent 3f9dd49766
commit eefb760169
13 changed files with 702 additions and 8 deletions

View File

@ -19,6 +19,9 @@ threads, create and self-assign issues, discover, claim, and release issue assig
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues, list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews. requests, merge assigned pull requests, and submit pull-request reviews.
Assigned-issue comments can include one PNG, JPEG, or WebP screenshot up to 2 MB.
The screenshot uploads before the comment is posted; validation or upload failures keep
both the typed comment and removable preview available for retry.
Pull-request replies and mobile My Work issue and PR comments use Gitea's Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API. In issue, pull-request, and unread-update conversations, typing issue-comment API. In issue, pull-request, and unread-update conversations, typing
at least two characters after `@` offers repository-scoped teammate suggestions; at least two characters after `@` offers repository-scoped teammate suggestions;

View File

@ -255,6 +255,14 @@ textarea { resize: vertical; min-height: 120px; }
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; } .issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; } .issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
.issue-comment-composer button { min-height:44px; width:100%; } .issue-comment-composer button { min-height:44px; width:100%; }
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.issue-attachment-controls { display:flex; max-width:100%; }
.issue-attachment-trigger { min-height:44px; display:inline-flex; align-items:center; justify-content:center; padding:8px 12px; border:1px solid #60a5fa; border-radius:10px; color:#dbeafe; font-weight:700; cursor:pointer; }
.issue-attachment-preview { display:grid; grid-template-columns:64px minmax(0,1fr); gap:8px 12px; align-items:center; max-width:100%; overflow:hidden; padding:10px; border:1px solid #315781; border-radius:10px; background:#101f34; }
.issue-attachment-preview[hidden] { display:none; }
.issue-attachment-preview img { grid-row:span 2; width:64px; height:64px; object-fit:cover; border-radius:8px; }
.issue-attachment-preview .small { min-width:0; overflow-wrap:anywhere; }
.issue-attachment-preview button { min-height:44px; width:auto; justify-self:start; }
.mention-options { display:grid; max-width:100%; max-height:220px; overflow:auto; border:1px solid #315781; border-radius:10px; background:#101f34; box-shadow:0 10px 28px rgba(0,0,0,.35); } .mention-options { display:grid; max-width:100%; max-height:220px; overflow:auto; border:1px solid #315781; border-radius:10px; background:#101f34; box-shadow:0 10px 28px rgba(0,0,0,.35); }
.mention-options[hidden] { display:none; } .mention-options[hidden] { display:none; }
.mention-option { min-height:44px; max-width:100%; overflow:hidden; padding:9px 12px; border:0; border-bottom:1px solid #203a5c; border-radius:0; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:#101f34; color:#dbeafe; } .mention-option { min-height:44px; max-width:100%; overflow:hidden; padding:9px 12px; border:0; border-bottom:1px solid #203a5c; border-radius:0; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:#101f34; color:#dbeafe; }

View File

@ -272,6 +272,37 @@
mobile: window.matchMedia('(max-width: 600px)').matches, mobile: window.matchMedia('(max-width: 600px)').matches,
}); });
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const issueAttachmentController = issueAttachment.mount({
input: qs('#issue-attachment'),
preview: qs('#issue-attachment-preview'),
image: qs('#issue-attachment-image'),
meta: qs('#issue-attachment-meta'),
remove: qs('#remove-issue-attachment'),
status: qs('#issue-comment-status'),
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
readDataUrl: file => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('The screenshot could not be read. Choose it again.'));
reader.readAsDataURL(file);
}),
upload: payload => {
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
return fetchReviewJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(payload.number) + '/attachments',
{
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: payload.filename,
content_type: payload.content_type,
data: payload.data,
}),
},
);
},
});
const planningLoader = createIssueSheet.createPlanningLoader({ const planningLoader = createIssueSheet.createPlanningLoader({
loadLabels: item => issueController.loadLabels(item), loadLabels: item => issueController.loadLabels(item),
loadMilestones: item => issueController.loadMilestones(item), loadMilestones: item => issueController.loadMilestones(item),
@ -2143,6 +2174,7 @@
return; return;
} }
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel')); mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
issueAttachmentController.clear();
qs('#issue-sheet').classList.remove('open'); qs('#issue-sheet').classList.remove('open');
selectedIssue = null; selectedIssue = null;
selectedIssueOffline = false; selectedIssueOffline = false;
@ -3729,7 +3761,7 @@
const textarea = qs('#' + kind + '-comment'); const textarea = qs('#' + kind + '-comment');
const status = qs('#' + kind + '-comment-status'); const status = qs('#' + kind + '-comment-status');
const body = textarea.value.trim(); const body = textarea.value.trim();
if (!body) { if (!body && (kind !== 'issue' || !issueAttachmentController.state())) {
status.textContent = 'Write a comment before posting.'; status.textContent = 'Write a comment before posting.';
textarea.focus(); textarea.focus();
return; return;
@ -3741,17 +3773,21 @@
item.repository + '#' + item.number + ':operation'); item.repository + '#' + item.number + ':operation');
postButton.disabled = true; postButton.disabled = true;
nextButton.disabled = true; nextButton.disabled = true;
status.textContent = 'Posting comment and opening next…'; status.textContent = kind === 'issue' && issueAttachmentController.state() ?
'Uploading screenshot before opening next…' : 'Posting comment and opening next…';
try { try {
const result = await controller.submit(item, body, operationId); const preparedBody = kind === 'issue' ?
await issueAttachmentController.prepareComment(item, body) : body;
const result = await controller.submit(item, preparedBody, operationId);
const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item; const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item;
if (!stillOpen) return; if (!stillOpen) return;
if (kind === 'issue') issueAttachmentController.clear();
if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.'; if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.';
else if (result.delivery === 'posted') status.textContent = 'Comment posted.'; else if (result.delivery === 'posted') status.textContent = 'Comment posted.';
else if (result.background) status.textContent = 'Queued for sync when the connection returns.'; else if (result.background) status.textContent = 'Queued for sync when the connection returns.';
else status.textContent = 'Saved for next launch; background delivery unavailable.'; else status.textContent = 'Saved for next launch; background delivery unavailable.';
} catch (error) { } catch (error) {
status.textContent = error.message + ' Your draft and Today position are safe; retry.'; status.textContent = error.message + ' Your draft, screenshot, and Today position are safe; retry.';
textarea.focus(); textarea.focus();
} finally { } finally {
postButton.disabled = false; postButton.disabled = false;
@ -3763,28 +3799,40 @@
qs('#send-issue-comment').addEventListener('click', async () => { qs('#send-issue-comment').addEventListener('click', async () => {
if (!selectedIssue) return; if (!selectedIssue) return;
const body = qs('#issue-comment').value.trim(); const body = qs('#issue-comment').value.trim();
if (!body) { if (!body && !issueAttachmentController.state()) {
qs('#issue-comment-status').textContent = 'Write a comment before posting.'; qs('#issue-comment-status').textContent = 'Write a comment before posting.';
qs('#issue-comment').focus(); qs('#issue-comment').focus();
return; return;
} }
const button = qs('#send-issue-comment'); const button = qs('#send-issue-comment');
button.disabled = true; button.disabled = true;
qs('#issue-comment-status').textContent = 'Posting comment…'; qs('#issue-comment-status').textContent = issueAttachmentController.state() ?
'Uploading screenshot…' : 'Posting comment…';
let preparedBody;
try { try {
const comment = await issueController.comment(selectedIssue, body); preparedBody = await issueAttachmentController.prepareComment(selectedIssue, body);
} catch (error) {
qs('#issue-comment-status').textContent = error.message + ' Your comment and screenshot are safe; retry.';
qs('#issue-comment').focus();
button.disabled = false;
return;
}
try {
const comment = await issueController.comment(selectedIssue, preparedBody);
if (issueConversation) renderIssueConversation(issueConversation.append(comment)); if (issueConversation) renderIssueConversation(issueConversation.append(comment));
qs('#issue-comment').value = ''; qs('#issue-comment').value = '';
issueAttachmentController.clear();
qs('#issue-comment-status').textContent = 'Comment posted.'; qs('#issue-comment-status').textContent = 'Comment posted.';
} catch (error) { } catch (error) {
if (canQueueMessage(error)) { if (canQueueMessage(error)) {
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation'); const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
qs('#issue-comment-status').textContent = 'Saving for background delivery…'; qs('#issue-comment-status').textContent = 'Saving for background delivery…';
const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository, const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository,
number:selectedIssue.number, body, operationId }); number:selectedIssue.number, body:preparedBody, operationId });
refreshMyWorkView(); refreshMyWorkView();
if (admission.background) { if (admission.background) {
qs('#issue-comment').value = ''; qs('#issue-comment').value = '';
issueAttachmentController.clear();
qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.'; qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.';
} else { } else {
qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.'; qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.';

View File

@ -297,6 +297,15 @@
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea> <textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<div class="mention-options" id="issue-comment-mentions" role="listbox" aria-label="Teammates" hidden></div> <div class="mention-options" id="issue-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="issue-comment-mention-status" aria-live="polite"></div> <div class="mention-status small" id="issue-comment-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<input class="visually-hidden" id="issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="issue-attachment">Attach screenshot</label>
</div>
<div class="issue-attachment-preview" id="issue-attachment-preview" hidden>
<img id="issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="issue-attachment-meta"></span>
<button id="remove-issue-attachment" type="button">Remove screenshot</button>
</div>
<div class="comment-actions"> <div class="comment-actions">
<button id="send-issue-comment" type="button">Post comment</button> <button id="send-issue-comment" type="button">Post comment</button>
<button id="send-issue-comment-next" type="button" hidden>Comment &amp; next</button> <button id="send-issue-comment-next" type="button" hidden>Comment &amp; next</button>
@ -660,6 +669,7 @@
<script src="static/later-picker.js"></script> <script src="static/later-picker.js"></script>
<script src="static/pick-work.js"></script> <script src="static/pick-work.js"></script>
<script src="static/conversation.js"></script> <script src="static/conversation.js"></script>
<script src="static/issue-attachment.js"></script>
<script src="static/issue-sheet.js"></script> <script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script> <script src="static/create-issue-sheet.js"></script>
<script src="static/create-and-start.js"></script> <script src="static/create-and-start.js"></script>

View File

@ -0,0 +1,107 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueAttachment = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function create(options) {
const readDataUrl = options.readDataUrl;
const upload = options.upload;
let selected = null;
let confirmed = null;
function select(file) {
if (!file || !IMAGE_TYPES.has(file.type)) {
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
}
if (!Number.isFinite(file.size) || file.size <= 0 || file.size > MAX_BYTES) {
throw new Error('Choose a screenshot that is 2 MB or smaller.');
}
selected = file;
confirmed = null;
return state();
}
function clear() {
selected = null;
confirmed = null;
}
function state() {
return selected ? {
name: selected.name,
size: selected.size,
uploaded: Boolean(confirmed),
} : null;
}
async function prepareComment(item, body) {
const text = String(body || '').trim();
if (!selected) return text;
if (!confirmed) {
const dataUrl = await readDataUrl(selected);
const marker = ';base64,';
const markerAt = String(dataUrl).indexOf(marker);
if (markerAt < 0) throw new Error('The screenshot could not be read. Choose it again.');
confirmed = await upload({
repository: item.repository,
number: item.number,
filename: selected.name,
content_type: selected.type,
data: String(dataUrl).slice(markerAt + marker.length),
});
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
confirmed = null;
throw new Error('The server did not confirm the screenshot upload.');
}
}
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
}
return { select, clear, state, prepareComment };
}
function mount(options) {
const controller = create(options);
const clearSelection = controller.clear;
let previewUrl = '';
function clearPreview() {
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = '';
options.image.src = '';
options.preview.hidden = true;
options.input.value = '';
clearSelection();
}
options.input.addEventListener('change', event => {
const file = event.target.files && event.target.files[0];
try {
controller.select(file);
} catch (error) {
options.status.textContent = error.message;
options.input.value = '';
return;
}
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = options.createObjectURL(file);
options.image.src = previewUrl;
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = 'Screenshot ready to upload with this comment.';
});
options.remove.addEventListener('click', () => {
clearPreview();
options.status.textContent = 'Screenshot removed. Your comment is unchanged.';
});
return Object.assign(controller, { clear: clearPreview });
}
return { create, mount, MAX_BYTES };
});

View File

@ -42,6 +42,7 @@ const SHELL = [
BASE + 'static/later-picker.js', BASE + 'static/later-picker.js',
BASE + 'static/pick-work.js', BASE + 'static/pick-work.js',
BASE + 'static/conversation.js', BASE + 'static/conversation.js',
BASE + 'static/issue-attachment.js',
BASE + 'static/issue-sheet.js', BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js', BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-and-start.js', BASE + 'static/create-and-start.js',

View File

@ -878,6 +878,33 @@ async def comment_on_issue(repository: str, number: int, body: str) -> dict:
return _normalize_issue_comment(comment) return _normalize_issue_comment(comment)
async def upload_assigned_issue_attachment(
repository: str,
number: int,
filename: str,
content_type: str,
content: bytes,
) -> dict:
if not await is_assigned_issue(repository, number):
raise IssueNotAvailableError("Assigned issue not found")
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 repo_labels(repository: str) -> list[dict]: async def repo_labels(repository: str) -> list[dict]:
response = await _get_client().get( response = await _get_client().get(
f"/api/v1/repos/{repository}/labels", f"/api/v1/repos/{repository}/labels",

View File

@ -1,4 +1,6 @@
import asyncio import asyncio
import base64
import binascii
import hmac import hmac
import math import math
import os import os
@ -310,6 +312,46 @@ class IssueComment(BaseModel):
return value return value
class IssueAttachment(BaseModel):
filename: str = Field(min_length=1, max_length=255)
content_type: Literal["image/png", "image/jpeg", "image/webp"]
data: str = Field(min_length=1, max_length=2_800_000)
@model_validator(mode="after")
def validate_filename(self):
if (
"/" in self.filename
or "\\" in self.filename
or any(ord(character) < 32 for character in self.filename)
):
raise ValueError("attachment filename must be a plain file name")
extension = self.filename.rsplit(".", 1)[-1].lower() if "." in self.filename else ""
expected = {
"image/png": {"png"},
"image/jpeg": {"jpg", "jpeg"},
"image/webp": {"webp"},
}
if extension not in expected[self.content_type]:
raise ValueError("attachment filename must match the selected image type")
return self
def content(self) -> bytes:
try:
content = base64.b64decode(self.data, validate=True)
except (ValueError, binascii.Error) as exc:
raise ValueError("attachment data must be valid base64") from exc
if len(content) > 2 * 1024 * 1024:
raise ValueError("screenshot must be 2 MB or smaller")
signatures = {
"image/png": content.startswith(b"\x89PNG\r\n\x1a\n"),
"image/jpeg": content.startswith(b"\xff\xd8\xff"),
"image/webp": content.startswith(b"RIFF") and content[8:12] == b"WEBP",
}
if not signatures[self.content_type]:
raise ValueError("file contents do not match the selected image type")
return content
class IssueCreation(BaseModel): class IssueCreation(BaseModel):
title: str = Field(min_length=1, max_length=255) title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000) body: str = Field(default="", max_length=10_000)
@ -2728,6 +2770,48 @@ async def comment_on_assigned_issue(
return JSONResponse(result, status_code=201) return JSONResponse(result, status_code=201)
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201)
async def attach_to_assigned_issue(
attachment: IssueAttachment,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
try:
content = attachment.content()
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
try:
result = await asyncio.wait_for(
gitea_proxy.upload_assigned_issue_attachment(
repository,
number,
attachment.filename,
attachment.content_type,
content,
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError as exc:
raise HTTPException(status_code=404, detail="Assigned issue not found") from exc
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The screenshot could not be uploaded. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
safe_name = (
result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
.replace("\r", " ").replace("\n", " ")
)
safe_url = result["url"].replace("<", "%3C").replace(">", "%3E")
result["markdown"] = f"![{safe_name}](<{safe_url}>)"
return JSONResponse(result, status_code=201)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/close") @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/close")
async def close_assigned_issue( async def close_assigned_issue(
request: Request, request: Request,

View File

@ -5,6 +5,7 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send
SESSION_BODY_LIMIT = 16 * 1024 SESSION_BODY_LIMIT = 16 * 1024
API_MUTATION_BODY_LIMIT = 64 * 1024 API_MUTATION_BODY_LIMIT = 64 * 1024
ISSUE_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"}) MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
@ -13,6 +14,13 @@ def request_body_limit(method: str, path: str) -> int | None:
normalized_method = method.upper() normalized_method = method.upper()
if normalized_method == "POST" and path == "/api/v1/session": if normalized_method == "POST" and path == "/api/v1/session":
return SESSION_BODY_LIMIT return SESSION_BODY_LIMIT
if (
normalized_method == "POST"
and path.startswith("/api/v1/repos/")
and "/issues/" in path
and path.endswith("/attachments")
):
return ISSUE_ATTACHMENT_BODY_LIMIT
if normalized_method in MUTATION_METHODS and path.startswith("/api/v1/"): if normalized_method in MUTATION_METHODS and path.startswith("/api/v1/"):
return API_MUTATION_BODY_LIMIT return API_MUTATION_BODY_LIMIT
return None return None

View File

@ -0,0 +1,134 @@
import json
import re
import subprocess
from pathlib import Path
ATTACHMENT = Path(__file__).parents[1] / "frontend" / "issue-attachment.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
README = Path(__file__).parents[1] / "README.md"
def run_node(script: str) -> str:
return subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
def test_mobile_attachment_prepares_comment_and_reuses_confirmed_upload():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const calls = [];
const file = {{name:'checkout.png', type:'image/png', size:8}};
const controller = attachment.create({{
readDataUrl: async selected => {{ calls.push('read:' + selected.name); return 'data:image/png;base64,iVBORw0KGgo='; }},
upload: async payload => {{ calls.push(payload); return {{markdown:'![checkout.png](https://forge.example/a.png)'}}; }},
}});
controller.select(file);
(async()=>{{
const first = await controller.prepareComment({{repository:'stackchain/api', number:17}}, 'Layout breaks');
const second = await controller.prepareComment({{repository:'stackchain/api', number:17}}, 'Layout breaks');
process.stdout.write(JSON.stringify({{first,second,calls,state:controller.state()}}));
}})().catch(error=>{{ console.error(error); process.exit(1); }});
"""
output = json.loads(run_node(script))
assert output["first"] == (
"Layout breaks\n\n![checkout.png](https://forge.example/a.png)"
)
assert output["second"] == output["first"]
assert output["calls"] == [
"read:checkout.png",
{
"repository": "stackchain/api",
"number": 17,
"filename": "checkout.png",
"content_type": "image/png",
"data": "iVBORw0KGgo=",
},
]
assert output["state"] == {
"name": "checkout.png", "size": 8, "uploaded": True
}
def test_issue_composer_renders_thumb_reachable_screenshot_preview():
html = INDEX.read_text()
css = CSS.read_text()
assert 'id="issue-attachment"' in html
assert 'type="file"' in html
assert 'accept="image/png,image/jpeg,image/webp"' in html
assert 'for="issue-attachment"' in html
assert 'id="issue-attachment-preview"' in html
assert 'id="remove-issue-attachment"' in html
assert '.issue-attachment-trigger' in css
assert '.issue-attachment-preview' in css
assert 'min-height:44px' in css
def test_attachment_view_keeps_invalid_draft_and_removes_preview():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
class Element {{
constructor() {{ this.listeners={{}}; this.hidden=true; this.value=''; this.files=[]; this.textContent=''; this.src=''; }}
addEventListener(type, fn) {{ this.listeners[type]=fn; }}
dispatch(type) {{ return this.listeners[type]({{target:this}}); }}
}}
const input=new Element(), preview=new Element(), image=new Element(), meta=new Element(), remove=new Element(), status=new Element();
const revoked=[];
const controller=attachment.mount({{
input,preview,image,meta,remove,status,
createObjectURL:()=> 'blob:preview', revokeObjectURL:url=>revoked.push(url),
readDataUrl:async()=>'', upload:async()=>{{}},
}});
input.files=[{{name:'payload.svg',type:'image/svg+xml',size:20}}]; input.dispatch('change');
const invalid={{message:status.textContent,hidden:preview.hidden}};
input.files=[{{name:'screen.png',type:'image/png',size:2048}}]; input.dispatch('change');
const selected={{src:image.src,meta:meta.textContent,hidden:preview.hidden,state:controller.state()}};
remove.dispatch('click');
process.stdout.write(JSON.stringify({{invalid,selected,removed:{{hidden:preview.hidden,src:image.src,revoked,state:controller.state()}}}}));
"""
output = json.loads(run_node(script))
assert output["invalid"] == {
"message": "Choose a PNG, JPEG, or WebP screenshot.", "hidden": True
}
assert output["selected"]["src"] == "blob:preview"
assert output["selected"]["hidden"] is False
assert "screen.png" in output["selected"]["meta"]
assert output["removed"] == {
"hidden": True, "src": "", "revoked": ["blob:preview"], "state": None
}
def test_issue_comment_actions_upload_before_posting_and_clear_after_acceptance():
source = DASHBOARD.read_text()
assert "issueAttachment.mount({" in source
assert "'/attachments'" in source
assert "issueAttachmentController.prepareComment(item, body)" in source
assert "issueAttachmentController.prepareComment(selectedIssue, body)" in source
assert source.count("issueAttachmentController.clear();") >= 2
def test_closing_issue_sheet_cannot_carry_a_screenshot_to_another_issue():
source = DASHBOARD.read_text()
close_body = re.search(
r"function closeIssueSheet\(navigate = true\) \{(?P<body>.*?)\n \}",
source,
re.DOTALL,
).group("body")
assert "issueAttachmentController.clear();" in close_body
def test_attachment_runtime_is_available_in_the_offline_app_shell():
assert "static/issue-attachment.js" in SERVICE_WORKER.read_text()
def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
readme = README.read_text()
assert "PNG, JPEG, or WebP" in readme
assert "2 MB" in readme
assert "uploads before the comment is posted" in readme

View File

@ -0,0 +1,256 @@
import base64
import httpx
import pytest
from src import gitea_proxy, main
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"mobile screenshot"
@pytest.mark.anyio
async def test_attachment_endpoint_uploads_valid_screenshot_to_assigned_issue(monkeypatch):
calls = []
async def upload(repository, number, filename, content_type, content):
calls.append((repository, number, filename, content_type, content))
return {
"name": "checkout.png",
"url": "https://forge.example/attachments/checkout.png",
"size": len(content),
}
monkeypatch.setattr(
main.gitea_proxy, "upload_assigned_issue_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/repos/stackchain/api/issues/17/attachments",
json={
"filename": "checkout.png",
"content_type": "image/png",
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
},
)
assert response.status_code == 201
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"name": "checkout.png",
"url": "https://forge.example/attachments/checkout.png",
"size": len(PNG_BYTES),
"markdown": "![checkout.png](<https://forge.example/attachments/checkout.png>)",
}
assert calls == [
("stackchain/api", 17, "checkout.png", "image/png", PNG_BYTES)
]
@pytest.mark.anyio
async def test_attachment_endpoint_admits_a_normal_phone_screenshot(monkeypatch):
screenshot = b"\x89PNG\r\n\x1a\n" + (b"x" * (100 * 1024))
async def upload(_repository, _number, filename, _content_type, content):
return {
"name": filename,
"url": "https://forge.example/attachments/screen.png",
"size": len(content),
}
monkeypatch.setattr(
main.gitea_proxy, "upload_assigned_issue_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/repos/stackchain/api/issues/17/attachments",
json={
"filename": "screen.png",
"content_type": "image/png",
"data": base64.b64encode(screenshot).decode("ascii"),
},
)
assert response.status_code == 201
assert response.json()["size"] == len(screenshot)
@pytest.mark.anyio
async def test_attachment_endpoint_rejects_spoofed_image_before_upstream(monkeypatch):
called = False
async def upload(*_args):
nonlocal called
called = True
monkeypatch.setattr(
main.gitea_proxy, "upload_assigned_issue_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/repos/stackchain/api/issues/17/attachments",
json={
"filename": "not-really.png",
"content_type": "image/png",
"data": base64.b64encode(b"<script>alert(1)</script>").decode("ascii"),
},
)
assert response.status_code == 422
assert response.headers["cache-control"] == "no-store"
assert called is False
@pytest.mark.anyio
async def test_attachment_endpoint_rejects_image_over_two_megabytes_before_upstream(monkeypatch):
called = False
async def upload(*_args):
nonlocal called
called = True
return {"name": "large.png", "url": "https://forge.example/a", "size": 1}
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
oversized = b"\x89PNG\r\n\x1a\n" + b"x" * (2 * 1024 * 1024 - 7)
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/17/attachments",
json={
"filename": "large.png",
"content_type": "image/png",
"data": base64.b64encode(oversized).decode("ascii"),
},
)
assert len(oversized) == 2 * 1024 * 1024 + 1
assert response.status_code == 422
assert called is False
@pytest.mark.anyio
async def test_gitea_attachment_revalidates_assignment_and_sends_multipart():
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if request.method == "GET":
return httpx.Response(200, json={
"state": "open", "pull_request": None,
"assignees": [{"login": "timmy"}],
})
return httpx.Response(201, json={
"id": 9,
"name": "checkout.png",
"size": len(PNG_BYTES),
"browser_download_url": "https://forge.example/attachments/checkout.png",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.upload_assigned_issue_attachment(
"stackchain/api", 17, "checkout.png", "image/png", PNG_BYTES
)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("POST", "/api/v1/repos/stackchain/api/issues/17/assets"),
]
upload = requests[-1]
assert upload.url.params["name"] == "checkout.png"
assert "multipart/form-data" in upload.headers["content-type"]
assert b'name="attachment"; filename="checkout.png"' in upload.content
assert PNG_BYTES in upload.content
assert result == {
"name": "checkout.png",
"url": "https://forge.example/attachments/checkout.png",
"size": len(PNG_BYTES),
}
@pytest.mark.anyio
async def test_attachment_markdown_escapes_untrusted_confirmed_filename(monkeypatch):
async def upload(*_args):
return {
"name": "screen](not-an-image).png",
"url": "https://forge.example/attachments/a.png",
"size": len(PNG_BYTES),
}
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_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/17/attachments",
json={
"filename": "screen.png",
"content_type": "image/png",
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
},
)
assert response.status_code == 201
assert response.json()["markdown"] == (
"![screen\\](not-an-image).png](<https://forge.example/attachments/a.png>)"
)
@pytest.mark.anyio
async def test_attachment_endpoint_hides_an_issue_that_is_no_longer_assigned(monkeypatch):
async def upload(*_args):
raise gitea_proxy.IssueNotAvailableError("not assigned")
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_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/17/attachments",
json={
"filename": "screen.png",
"content_type": "image/png",
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
},
)
assert response.status_code == 404
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_attachment_endpoint_rejects_unsafe_or_mismatched_filename(monkeypatch):
called = False
async def upload(*_args):
nonlocal called
called = True
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
traversal = await client.post(
"/api/v1/repos/stackchain/api/issues/17/attachments",
json={
"filename": "../payload.png",
"content_type": "image/png",
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
},
)
mismatch = await client.post(
"/api/v1/repos/stackchain/api/issues/17/attachments",
json={
"filename": "payload.html",
"content_type": "image/png",
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
},
)
assert [traversal.status_code, mismatch.status_code] == [422, 422]
assert called is False

View File

@ -112,6 +112,13 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
== 64 * 1024 == 64 * 1024
) )
assert main.request_body_limit("GET", "/api/v1/context") is None assert main.request_body_limit("GET", "/api/v1/context") is None
assert (
main.request_body_limit(
"POST", "/api/v1/repos/stackchain/project/issues/17/attachments"
)
== 3 * 1024 * 1024
)
assert main.request_body_limit("POST", "/unrelated/attachments") is None
assert main.request_body_limit("POST", "/unrelated") is None assert main.request_body_limit("POST", "/unrelated") is None

View File

@ -437,6 +437,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/later-picker.js", "/dashboard/static/later-picker.js",
"/dashboard/static/pick-work.js", "/dashboard/static/pick-work.js",
"/dashboard/static/conversation.js", "/dashboard/static/conversation.js",
"/dashboard/static/issue-attachment.js",
"/dashboard/static/issue-sheet.js", "/dashboard/static/issue-sheet.js",
"/dashboard/static/create-issue-sheet.js", "/dashboard/static/create-issue-sheet.js",
"/dashboard/static/create-and-start.js", "/dashboard/static/create-and-start.js",