From 937df5d20b3669b13e2ab19360a2c918b93fd570 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 7 Aug 2026 11:07:56 +0000 Subject: [PATCH] feat: edit assigned issue content from mobile (#189) --- frontend/index.html | 99 +++++++++++++++++++++++++++- frontend/issue-sheet.js | 34 ++++++++++ frontend/my-work.js | 10 +++ src/gitea_proxy.py | 48 ++++++++++++++ src/main.py | 56 ++++++++++++++++ tests/test_issue_api.py | 138 ++++++++++++++++++++++++++++++++++++++++ tests/test_my_work.py | 95 +++++++++++++++++++++++++++ 7 files changed, 479 insertions(+), 1 deletion(-) diff --git a/frontend/index.html b/frontend/index.html index 166279c..031fe88 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -131,6 +131,13 @@ textarea { resize: vertical; min-height: 120px; } .issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } .issue-sheet-header button { min-height:44px; } .issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; } +#edit-issue-content { min-height:44px; width:100%; } +.issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; } +.issue-edit-form label { display:grid; gap:6px; min-width:0; } +.issue-edit-form input, .issue-edit-form textarea { box-sizing:border-box; width:100%; max-width:100%; } +.issue-edit-form textarea { min-height:132px; resize:vertical; } +.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px; } +.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; } .issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; } .issue-comment-composer { display:grid; gap:8px; margin-top:16px; } .issue-comment-composer button { min-height:44px; width:100%; } @@ -346,7 +353,7 @@ textarea { resize: vertical; min-height: 120px; }
Choose an issue.
- +
Labels @@ -355,6 +362,20 @@ textarea { resize: vertical; min-height: 120px; }

+ +

Recent discussion

@@ -589,6 +610,7 @@ textarea { resize: vertical; min-height: 120px; } let selectedUpdate = null; let updateTrigger = null; let selectedIssue = null; + let selectedIssueDetail = null; let issueTrigger = null; let selectedPull = null; let pullTrigger = null; @@ -1051,6 +1073,7 @@ textarea { resize: vertical; min-height: 120px; } async function openIssueSheet(item, trigger) { if (!item) return; selectedIssue = item; + selectedIssueDetail = null; issueTrigger = trigger; qs('#issue-sheet').classList.add('open'); qs('#issue-sheet-key').textContent = item.key || ''; @@ -1068,11 +1091,15 @@ textarea { resize: vertical; min-height: 120px; } qs('#retry-issue-load').hidden = true; qs('#open-issue-gitea').href = item.url || '#'; qs('#send-issue-comment').disabled = false; + qs('#edit-issue-content').disabled = true; + qs('#issue-edit-form').hidden = true; + qs('#issue-edit-status').textContent = ''; qs('#close-issue').disabled = false; qs('#close-issue-sheet').focus(); try { const detail = await issueController.load(item); if (selectedIssue !== item) return; + selectedIssueDetail = detail; qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue'; qs('#issue-sheet-body').textContent = detail.body || 'No description provided.'; qs('#issue-labels').innerHTML = (detail.labels || []).map(label => @@ -1085,6 +1112,7 @@ textarea { resize: vertical; min-height: 120px; } detail.comments.map(renderIssueComment).join('') : '
No comments yet.
'; qs('#open-issue-gitea').href = detail.url || item.url || '#'; qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open'); + qs('#edit-issue-content').disabled = false; } catch (error) { if (selectedIssue !== item) return; qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.'; @@ -1096,6 +1124,7 @@ textarea { resize: vertical; min-height: 120px; } function closeIssueSheet() { qs('#issue-sheet').classList.remove('open'); selectedIssue = null; + selectedIssueDetail = null; if (issueTrigger?.isConnected) issueTrigger.focus(); } @@ -1730,6 +1759,74 @@ textarea { resize: vertical; min-height: 120px; } qs('#retry-issue-load').addEventListener('click', () => { if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger); }); + qs('#edit-issue-content').addEventListener('click', () => { + if (!selectedIssue || !selectedIssueDetail?.updated_at) return; + const draft = issueController.loadEditDraft(selectedIssue) || { + title: selectedIssueDetail.title || '', + body: selectedIssueDetail.body || '', + expectedUpdatedAt: selectedIssueDetail.updated_at, + }; + qs('#issue-edit-title').value = draft.title; + qs('#issue-edit-body').value = draft.body; + qs('#issue-edit-form').hidden = false; + qs('#issue-edit-status').textContent = 'Edit the issue, then save.'; + qs('#issue-edit-title').focus(); + }); + ['#issue-edit-title', '#issue-edit-body'].forEach(selector => + qs(selector).addEventListener('input', () => { + if (!selectedIssue || !selectedIssueDetail?.updated_at) return; + issueController.saveEditDraft(selectedIssue, { + title: qs('#issue-edit-title').value, + body: qs('#issue-edit-body').value, + expectedUpdatedAt: issueController.loadEditDraft(selectedIssue)?.expectedUpdatedAt || selectedIssueDetail.updated_at, + }); + }) + ); + qs('#cancel-issue-content').addEventListener('click', () => { + qs('#issue-edit-form').hidden = true; + qs('#edit-issue-content').focus(); + }); + qs('#issue-edit-form').addEventListener('submit', async event => { + event.preventDefault(); + if (!selectedIssue || !selectedIssueDetail?.updated_at || !lastContextSnapshot) return; + const title = qs('#issue-edit-title').value.trim(); + const body = qs('#issue-edit-body').value.trim(); + if (!title) { + qs('#issue-edit-status').textContent = 'Add a title before saving.'; + qs('#issue-edit-title').focus(); + return; + } + const editing = selectedIssue; + const savedDraft = issueController.loadEditDraft(editing); + const draft = { + title, + body, + expectedUpdatedAt: savedDraft?.expectedUpdatedAt || selectedIssueDetail.updated_at, + }; + const button = qs('#save-issue-content'); + button.disabled = true; + qs('#issue-edit-status').textContent = 'Saving issue…'; + try { + const confirmed = await issueController.updateContent(editing, draft); + lastContextSnapshot = buildMyWork.replaceIssueContent( + lastContextSnapshot, editing.repository, editing.number, confirmed + ); + selectedIssue = { ...editing, ...confirmed, key: editing.key }; + selectedIssueDetail = { ...selectedIssueDetail, ...confirmed }; + qs('#issue-sheet-title').textContent = confirmed.title; + qs('#issue-sheet-body').textContent = confirmed.body || 'No description provided.'; + paintMyWork(lastContextSnapshot); + qs('#issue-edit-form').hidden = true; + qs('#issue-sheet-status').textContent = 'Issue saved.'; + qs('#edit-issue-content').focus(); + } catch (error) { + qs('#issue-edit-status').textContent = error.message + ' Your draft is safe; reload latest or open in Gitea.'; + qs('#retry-issue-load').hidden = false; + qs('#issue-edit-title').focus(); + } finally { + button.disabled = false; + } + }); qs('#issue-comment').addEventListener('input', event => { if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value); }); diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index 8e1ad4d..1ffb397 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -3,11 +3,13 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global let closeRequest = null; let releaseRequest = null; let labelRequest = null; + let editRequest = null; const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') + '/issues/' + encodeURIComponent(item.number); const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number; const operationKey = item => draftKey(item) + ':operation'; const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number; + const editDraftKey = item => 'stackchain.issue-content.v1:' + item.repository + '#' + item.number; return { load(item) { @@ -31,6 +33,38 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global } catch (_error) { /* The textarea remains the fallback. */ } }, + loadEditDraft(item) { + try { + const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null'); + return value && typeof value.title === 'string' && typeof value.body === 'string' && + typeof value.expectedUpdatedAt === 'string' ? value : null; + } catch (_error) { return null; } + }, + saveEditDraft(item, draft) { + try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); } + catch (_error) { /* The edit fields remain the fallback. */ } + }, + updateContent(item, draft) { + if (editRequest) return editRequest; + this.saveEditDraft(item, draft); + editRequest = fetchJson(issuePath(item) + '/content', { + method: 'PATCH', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: draft.title, + body: draft.body, + expected_updated_at: draft.expectedUpdatedAt, + }), + }).then(result => { + if (result?.number !== item.number || result?.title !== draft.title || result?.body !== draft.body) { + throw new Error('Issue update was not confirmed.'); + } + try { storage?.removeItem(editDraftKey(item)); } + catch (_error) { /* Confirmed upstream content is authoritative. */ } + return result; + }).finally(() => { editRequest = null; }); + return editRequest; + }, loadLabelDraft(item) { try { const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]'); diff --git a/frontend/my-work.js b/frontend/my-work.js index 76702c2..c89f286 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -349,6 +349,15 @@ function replaceIssueLabels(data, repository, number, labels) { }; } +function replaceIssueContent(data, repository, number, content) { + return { + ...data, + issues: (data.issues || []).map(item => + item.repository === repository && item.number === number ? { ...item, ...content } : item + ), + }; +} + function removeIssue(data, repository, number) { return { ...data, @@ -381,6 +390,7 @@ function countMyWork(items) { if (typeof module !== 'undefined' && module.exports) { buildMyWork.filterMyWork = filterMyWork; buildMyWork.replaceIssueLabels = replaceIssueLabels; + buildMyWork.replaceIssueContent = replaceIssueContent; buildMyWork.removeIssue = removeIssue; buildMyWork.summarizeMyWork = summarizeMyWork; buildMyWork.countMyWork = countMyWork; diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 688f392..7065960 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -42,6 +42,10 @@ class IssueNotAvailableError(ValueError): """Raised before assignment when an issue is no longer open and unassigned.""" +class IssueEditConflictError(ValueError): + """Raised when an issue changed after the editor loaded it.""" + + def _auth() -> dict[str, str]: headers: dict[str, str] = {"Accept": "application/json"} if GITEA_TOKEN: @@ -751,6 +755,7 @@ async def issue_detail(repository: str, number: int) -> dict: "title": issue.get("title", "") if isinstance(issue.get("title"), str) else "", "state": issue.get("state", "") if isinstance(issue.get("state"), str) else "", "body": issue.get("body", "") if isinstance(issue.get("body"), str) else "", + "updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "", "url": _safe_web_url(issue.get("html_url")), "labels": [ label["name"] for label in labels @@ -764,6 +769,49 @@ async def issue_detail(repository: str, number: int) -> dict: } +async def update_assigned_issue( + repository: str, + number: int, + title: str, + body: str, + expected_updated_at: str, +) -> dict: + path = f"repos/{repository}/issues/{number}" + login, issue = await _current_login_and_target(path) + if ( + issue.get("state") != "open" + or isinstance(issue.get("pull_request"), dict) + or not _login_in_users(login, issue.get("assignees")) + ): + raise IssueNotAvailableError("assigned issue not found") + if issue.get("updated_at") != expected_updated_at: + raise IssueEditConflictError("issue changed upstream") + + response = await _get_client().patch( + f"/api/v1/{path}", + headers=_auth(), + json={"title": title, "body": body}, + ) + response.raise_for_status() + confirmed = response.json() + if ( + not isinstance(confirmed, dict) + or confirmed.get("number") != number + or confirmed.get("title") != title + or confirmed.get("body", "") != body + ): + raise ValueError("Gitea did not confirm the issue content update") + return { + "repository": repository, + "number": number, + "title": title, + "body": body, + "state": confirmed.get("state", "open"), + "updated_at": confirmed.get("updated_at", ""), + "url": _safe_web_url(confirmed.get("html_url")), + } + + async def _current_login_and_target(path: str) -> tuple[str, dict]: user, target = await asyncio.gather(current_user(), fetch(path)) login = user.get("login") if isinstance(user, dict) else None diff --git a/src/main.py b/src/main.py index c897300..c03d487 100644 --- a/src/main.py +++ b/src/main.py @@ -155,6 +155,25 @@ class IssueCreation(BaseModel): return value.strip() +class IssueContentUpdate(BaseModel): + title: str = Field(min_length=1, max_length=255) + body: str = Field(default="", max_length=10_000) + expected_updated_at: str = Field(min_length=1, max_length=64) + + @field_validator("title") + @classmethod + def strip_title(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("title must not be blank") + return value + + @field_validator("body") + @classmethod + def strip_body(cls, value: str) -> str: + return value.strip() + + class IssueLabelUpdate(BaseModel): label_ids: list[PositiveInt] = Field(max_length=20) @@ -1070,6 +1089,43 @@ async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(g ) +@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content") +async def update_assigned_issue_content( + update: IssueContentUpdate, + owner: str, + repo: str, + number: int = PathParam(gt=0), +): + repository = f"{owner}/{repo}" + try: + result = await asyncio.wait_for( + gitea_proxy.update_assigned_issue( + repository, + number, + update.title, + update.body, + update.expected_updated_at, + ), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except gitea_proxy.IssueEditConflictError: + return JSONResponse( + {"error": "This issue changed in Gitea. Your draft is safe; reload the latest issue before saving."}, + status_code=409, + ) + except gitea_proxy.IssueNotAvailableError: + raise HTTPException(status_code=404, detail="Assigned issue not found") + except HTTPException: + raise + except Exception: + return JSONResponse( + {"error": "The issue could not be updated. Your draft is safe; please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result) + + @app.get("/api/v1/repos/{owner}/{repo}/labels") async def repository_labels(owner: str, repo: str): repository = f"{owner}/{repo}" diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index dd8a3e0..ef44dce 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -6,6 +6,142 @@ import pytest from src import gitea_proxy, main +@pytest.mark.anyio +async def test_edit_assigned_issue_updates_title_and_body_at_expected_revision(monkeypatch): + calls = [] + + async def update(repository, number, title, body, expected_updated_at): + calls.append((repository, number, title, body, expected_updated_at)) + return { + "repository": repository, + "number": number, + "title": title, + "body": body, + "state": "open", + "updated_at": "2026-08-07T10:01:00Z", + } + + monkeypatch.setattr( + main.gitea_proxy, "update_assigned_issue", update, raising=False + ) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.patch( + "/api/v1/repos/stackchain/api/issues/17/content", + json={ + "title": " Clarified scope ", + "body": " Updated acceptance criteria ", + "expected_updated_at": "2026-08-07T10:00:00Z", + }, + ) + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json()["title"] == "Clarified scope" + assert calls == [( + "stackchain/api", 17, "Clarified scope", "Updated acceptance criteria", + "2026-08-07T10:00:00Z", + )] + + +@pytest.mark.anyio +async def test_gitea_edit_issue_rejects_stale_revision_without_patch(): + requests = [] + + async def handler(request): + requests.append(request) + if request.url.path == "/api/v1/user": + return httpx.Response(200, json={"login": "timmy"}) + return httpx.Response(200, json={ + "number": 17, + "title": "Changed upstream", + "body": "Newer body", + "state": "open", + "updated_at": "2026-08-07T10:02:00Z", + "assignees": [{"login": "timmy"}], + "pull_request": None, + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + with pytest.raises(gitea_proxy.IssueEditConflictError): + await gitea_proxy.update_assigned_issue( + "stackchain/api", 17, "My draft", "Draft body", + "2026-08-07T10:00:00Z", + ) + 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"), + ] + + +@pytest.mark.anyio +async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content(): + 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={ + "number": 17, "title": "Old", "body": "Old body", "state": "open", + "updated_at": "2026-08-07T10:00:00Z", + "assignees": [{"login": "timmy"}], "pull_request": None, + }) + return httpx.Response(200, json={ + "number": 17, "title": "Clarified", "body": "New body", "state": "open", + "updated_at": "2026-08-07T10:01:00Z", + "html_url": "https://forge.example/stackchain/api/issues/17", + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.update_assigned_issue( + "stackchain/api", 17, "Clarified", "New body", "2026-08-07T10:00:00Z" + ) + 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"), + ("PATCH", "/api/v1/repos/stackchain/api/issues/17"), + ] + assert requests[2].content == b'{"title":"Clarified","body":"New body"}' + assert result == { + "repository": "stackchain/api", "number": 17, "title": "Clarified", + "body": "New body", "state": "open", "updated_at": "2026-08-07T10:01:00Z", + "url": "https://forge.example/stackchain/api/issues/17", + } + + +@pytest.mark.anyio +async def test_edit_assigned_issue_reports_revision_conflict_without_mutation(monkeypatch): + async def update(*_args): + raise gitea_proxy.IssueEditConflictError("changed") + + monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue", update) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.patch( + "/api/v1/repos/stackchain/api/issues/17/content", + json={ + "title": "My draft", + "body": "Still safe", + "expected_updated_at": "2026-08-07T10:00:00Z", + }, + ) + + assert response.status_code == 409 + assert response.json() == { + "error": "This issue changed in Gitea. Your draft is safe; reload the latest issue before saving." + } + + @pytest.fixture(autouse=True) def clear_issue_creation_operations(): main._issue_creation_operations.clear() @@ -749,6 +885,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments "title": "Fix mobile flow", "state": "open", "body": "Full issue context", + "updated_at": "2026-08-07T09:59:00Z", "html_url": "https://forge.example/stackchain/api/issues/7", "labels": [{"name": "P1"}], "assignees": [{"login": "timmy"}], @@ -771,6 +908,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments "title": "Fix mobile flow", "state": "open", "body": "Full issue context", + "updated_at": "2026-08-07T09:59:00Z", "url": "https://forge.example/stackchain/api/issues/7", "labels": ["P1"], "assignees": ["timmy"], diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 9dfeb2c..e05fc26 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -110,6 +110,34 @@ process.stdout.write(JSON.stringify({{ } +def test_confirmed_issue_content_updates_my_work_without_mutating_snapshot(): + payload = { + "issues": [{"number": 17, "repository": "stackchain/api", "title": "Old", "body": "Old body"}], + "pull_requests": [], + } + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const original = {json.dumps(payload)}; +const updated = buildMyWork.replaceIssueContent( + original, 'stackchain/api', 17, + {{title:'Clarified',body:'New body',updated_at:'2026-08-07T10:01:00Z'}} +); +process.stdout.write(JSON.stringify({{updated:updated.issues[0],original:original.issues[0]}})); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + assert json.loads(result.stdout) == { + "updated": { + "number": 17, "repository": "stackchain/api", "title": "Clarified", + "body": "New body", "updated_at": "2026-08-07T10:01:00Z", + }, + "original": { + "number": 17, "repository": "stackchain/api", "title": "Old", "body": "Old body", + }, + } + + def test_issue_release_is_single_flight_and_requires_confirmed_unassignment(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); @@ -155,6 +183,57 @@ controller.release({{repository:'stackchain/api', number:17}}, 'timmy') assert result.stdout == "Issue release was not confirmed." +def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +const values = new Map(); +const storage = {{ + getItem:key => values.get(key) || null, + setItem:(key,value) => values.set(key,value), + removeItem:key => values.delete(key), +}}; +let calls = []; +let finish; +const controller = createIssueSheet({{ + storage, + fetchJson:(url, options) => {{ + calls.push({{url, options}}); + return new Promise(resolve => {{ finish = resolve; }}); + }}, +}}); +const item = {{repository:'stackchain/api', number:17}}; +const draft = {{title:'Clarified scope', body:'Updated body', expectedUpdatedAt:'2026-08-07T10:00:00Z'}}; +controller.saveEditDraft(item, draft); +const first = controller.updateContent(item, draft); +const duplicate = controller.updateContent(item, draft); +const during = controller.loadEditDraft(item); +finish({{repository:'stackchain/api',number:17,title:'Clarified scope',body:'Updated body',updated_at:'2026-08-07T10:01:00Z'}}); +Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{ + calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})), + same:first === duplicate, during, after:controller.loadEditDraft(item), results +}}))); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + assert output["calls"] == [{ + "url": "api/v1/repos/stackchain/api/issues/17/content", + "method": "PATCH", + "body": { + "title": "Clarified scope", "body": "Updated body", + "expected_updated_at": "2026-08-07T10:00:00Z", + }, + }] + assert output["same"] is True + assert output["during"] == { + "title": "Clarified scope", "body": "Updated body", + "expectedUpdatedAt": "2026-08-07T10:00:00Z", + } + assert output["after"] is None + assert output["results"][0]["updated_at"] == "2026-08-07T10:01:00Z" + + def test_my_work_reviews_filter_and_summary_are_actionable(): items = [ {"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True}, @@ -415,6 +494,22 @@ async def test_mobile_my_work_exposes_truthful_work_pagination_control(): assert '.load-more-work { min-height:44px;' in html +@pytest.mark.anyio +async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor(): + html = await dashboard() + + assert 'id="edit-issue-content"' in html + assert 'id="issue-edit-form"' in html + assert 'id="issue-edit-title" type="text" maxlength="255"' in html + assert 'id="issue-edit-body" maxlength="10000"' in html + assert 'id="save-issue-content"' in html + assert 'id="cancel-issue-content"' in html + assert 'id="retry-issue-load" type="button" hidden>Reload latest issue' in html + assert 'id="issue-edit-status" class="small" aria-live="assertive"' in html + assert '.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px;' in html + assert 'buildMyWork.replaceIssueContent(' in html + + @pytest.mark.anyio async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe(): html = await dashboard()