From 1b68223798feca80b77966c26cbf2484bc74485e Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 06:58:49 +0000 Subject: [PATCH] feat: reply from mobile Filed (Closes #876) --- frontend/dashboard.css | 5 ++-- frontend/dashboard.js | 16 +++++++++--- frontend/issue-sheet.js | 11 +++++--- src/main.py | 16 ++++++++++-- tests/test_issue_api.py | 48 ++++++++++++++++++++++++++++++++++ tests/test_my_work.py | 58 ++++++++++++++++++++++++++++++++++++++--- 6 files changed, 138 insertions(+), 16 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 5effb2a..35303a5 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -492,6 +492,7 @@ textarea { resize: vertical; min-height: 120px; } .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 textarea { min-height:96px; } .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%; } @@ -521,14 +522,14 @@ textarea { resize: vertical; min-height: 120px; } .issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; } .issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); } .work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; } -#issue-sheet.read-only .issue-comment-composer, +#issue-sheet.read-only .issue-attachment-controls, +#issue-sheet.read-only .issue-attachment-preview, #issue-sheet.read-only #issue-planning, #issue-sheet.read-only #issue-handoff, #issue-sheet.read-only #release-issue, #issue-sheet.read-only #close-issue, #issue-sheet.read-only .detail-defer, #issue-sheet.read-only #issue-blockers, -#issue-sheet.read-only #load-older-issue-comments, #issue-sheet.read-only #retry-issue-comment-actions { display:none; } .issue-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; } .issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index a940ce1..131b6c5 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -3329,6 +3329,7 @@ qs('#load-older-issue-comments').hidden = true; qs('#issue-conversation-status').textContent = 'Loading newest messages…'; qs('#issue-comment').value = issueController.loadDraft(item); + qs('#issue-comment-title').textContent = readOnly ? 'Add follow-up' : 'Add comment'; qs('#issue-comment-status').textContent = ''; qs('#issue-handoff').open = false; qs('#issue-handoff-recipient').innerHTML = ''; @@ -3379,9 +3380,8 @@ 'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported'; if (readOnly) paintIssueConversation(issueConversation.snapshot(), null); else renderIssueConversation(issueConversation.snapshot()); - if (readOnly) qs('#load-older-issue-comments').hidden = true; qs('#open-issue-gitea').href = detail.url || item.url || '#'; - qs('#issue-sheet-status').textContent = readOnly ? 'Filed issue ready · read-only' : + qs('#issue-sheet-status').textContent = readOnly ? 'Filed issue ready · follow-up enabled' : 'Issue ready · ' + (detail.state || 'open'); qs('#edit-issue-content').disabled = false; const dueDraft = issueController.loadDueDateDraft(item); @@ -5470,7 +5470,11 @@ button.disabled = true; qs('#issue-conversation-status').textContent = 'Loading older messages…'; try { - renderIssueConversation(await issueConversation.loadOlder()); + if (issueController.readOnly(selectedIssue)) { + paintIssueConversation(await issueConversation.loadOlder(), null); + } else { + renderIssueConversation(await issueConversation.loadOlder()); + } panel.scrollTop += panel.scrollHeight - previousHeight; } catch (error) { qs('#issue-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.'; @@ -5703,7 +5707,11 @@ } try { const comment = await issueController.comment(selectedIssue, preparedBody); - if (issueConversation) renderIssueConversation(issueConversation.append(comment)); + if (issueConversation) { + const conversation = issueConversation.append(comment); + if (issueController.readOnly(selectedIssue)) paintIssueConversation(conversation, null); + else renderIssueConversation(conversation); + } qs('#issue-comment').value = ''; issueAttachmentController.clear(); qs('#issue-comment-status').textContent = 'Comment posted.'; diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index 83957f1..02cade7 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -48,10 +48,12 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global }); }, conversation(item, initialPage) { + const access = this.readOnly(item) ? '&access=filed' : ''; const pager = createConversationPager({ - loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', { - headers: { Accept: 'application/json' }, - }), + loadPage: page => fetchJson( + issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20' + access, + { headers: { Accept: 'application/json' } }, + ), }); pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 }); return pager; @@ -266,7 +268,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128); storage?.setItem(operationKey(item), operationId); } catch (_error) { operationId = String(createOperationId()).slice(0, 128); } - commentRequest = fetchJson(issuePath(item) + '/comments', { + const access = this.readOnly(item) ? '?access=filed' : ''; + commentRequest = fetchJson(issuePath(item) + '/comments' + access, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId }, body: JSON.stringify({ body }), diff --git a/src/main.py b/src/main.py index b8c6d7d..b075362 100644 --- a/src/main.py +++ b/src/main.py @@ -4852,11 +4852,17 @@ async def assigned_issue_conversation( number: int = PathParam(gt=0), page: int | None = Query(default=None, ge=1, le=100), limit: int = Query(default=20, ge=1, le=50), + access: Literal["assigned", "filed"] = Query(default="assigned"), ): repository = f"{owner}/{repo}" async def load_conversation(): - if not await gitea_proxy.is_assigned_issue(repository, number): + authorized = await ( + gitea_proxy.is_authored_issue(repository, number) + if access == "filed" + else gitea_proxy.is_assigned_issue(repository, number) + ) + if not authorized: raise HTTPException(status_code=404, detail="Assigned issue not found") return await gitea_proxy.issue_conversation_page(repository, number, page, limit) @@ -4881,11 +4887,17 @@ async def comment_on_assigned_issue( repo: str, number: int = PathParam(gt=0), idempotency_key: str | None = Header(default=None, max_length=128), + access: Literal["assigned", "filed"] = Query(default="assigned"), ): repository = f"{owner}/{repo}" async def post_comment(): - if not await gitea_proxy.is_assigned_issue(repository, number): + authorized = await ( + gitea_proxy.is_authored_issue(repository, number) + if access == "filed" + else gitea_proxy.is_assigned_issue(repository, number) + ) + if not authorized: raise HTTPException(status_code=404, detail="Assigned issue not found") return await gitea_proxy.comment_on_issue(repository, number, comment.body) diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 27a0f9f..306ea92 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -2077,6 +2077,54 @@ async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no ] +@pytest.mark.anyio +async def test_filed_issue_author_can_read_and_reply_but_other_issues_stay_hidden(monkeypatch): + calls = [] + + async def authored(repository, number): + calls.append(("authored", repository, number)) + return (repository, number) == ("stackchain/api", 8) + + async def conversation(repository, number, page, limit): + calls.append(("conversation", repository, number, page, limit)) + return {"comments": [{"id": 21}], "page": 2, "older_page": 1, "total": 21} + + async def comment(repository, number, body): + calls.append(("comment", repository, number, body)) + return {"id": 22, "author": "timmy", "body": body} + + monkeypatch.setattr(main.gitea_proxy, "is_authored_issue", authored, raising=False) + monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation) + monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + loaded = await client.get( + "/api/v1/repos/stackchain/api/issues/8/comments?access=filed&page=2&limit=20" + ) + posted = await client.post( + "/api/v1/repos/stackchain/api/issues/8/comments?access=filed", + json={"body": " One clarification "}, + headers={"Idempotency-Key": "filed-followup-876"}, + ) + hidden = await client.post( + "/api/v1/repos/private/secret/issues/8/comments?access=filed", + json={"body": "Do not post"}, + ) + + assert loaded.status_code == 200 + assert loaded.json()["older_page"] == 1 + assert posted.status_code == 201 + assert posted.json() == {"id": 22, "author": "timmy", "body": "One clarification"} + assert hidden.status_code == 404 + assert calls == [ + ("authored", "stackchain/api", 8), + ("conversation", "stackchain/api", 8, 2, 20), + ("authored", "stackchain/api", 8), + ("comment", "stackchain/api", 8, "One clarification"), + ("authored", "private/secret", 8), + ] + + @pytest.mark.anyio async def test_issue_detail_deadline_is_retryable_sanitized_and_cancels_work(monkeypatch): cancelled = asyncio.Event() diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 95431a4..b843607 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -1356,21 +1356,25 @@ const calls = []; @pytest.mark.anyio -async def test_dashboard_opens_delegated_filings_as_read_only_issue_sheets(): +async def test_dashboard_opens_delegated_filings_with_follow_up_only_capabilities(): html = await dashboard() assert "else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);" in html assert "const readOnly = issueController.readOnly(item);" in html assert "qs('#issue-sheet').classList.toggle('read-only', readOnly);" in html - assert "#issue-sheet.read-only .issue-comment-composer" in html + assert "#issue-sheet.read-only .issue-comment-composer" not in html + assert "#issue-sheet.read-only .issue-attachment-controls" in html assert "#issue-sheet.read-only #issue-planning" in html assert "#issue-sheet.read-only #issue-handoff" in html assert "#issue-sheet.read-only #release-issue" in html assert "#issue-sheet.read-only #close-issue" in html assert "#issue-sheet.read-only .detail-defer" in html assert "if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);" in html - assert "qs('#load-older-issue-comments').hidden = true;" in html - assert "readOnly ? 'Filed issue ready · read-only'" in html + assert "if (readOnly) qs('#load-older-issue-comments').hidden = true;" not in html + assert "readOnly ? 'Filed issue ready · follow-up enabled'" in html + assert "paintIssueConversation(await issueConversation.loadOlder(), null)" in html + assert ".issue-comment-composer textarea" in html + assert ".issue-comment-composer button" in html @pytest.mark.anyio @@ -1639,6 +1643,52 @@ Promise.all([ ] +def test_issue_sheet_uses_author_access_for_filed_conversation_and_follow_up(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +const createConversationPager = require({json.dumps(str(ISSUE_SHEET.parent / "conversation.js"))}); +const calls = []; +const controller = createIssueSheet({{ + fetchJson: async (url, options={{}}) => {{ + calls.push({{url, method:options.method || 'GET', key:options.headers?.['Idempotency-Key'] || ''}}); + if (options.method === 'POST') return {{id:22, body:'Clarifying detail'}}; + return {{comments:[], page:1, older_page:null, total:0}}; + }}, + storage:null, + createOperationId:() => 'filed-followup-876', + createConversationPager, +}}); +const filed = {{kind:'filed',repository:'stackchain/api',number:18,is_filed:true,is_assigned:false}}; +const assigned = {{kind:'issue',repository:'stackchain/api',number:17,is_assigned:true}}; +const initial = {{comments:[], page:2, older_page:1, total:0}}; +Promise.all([ + controller.conversation(filed, initial).loadOlder(), + controller.comment(filed, 'Clarifying detail'), + controller.conversation(assigned, initial).loadOlder(), +]).then(() => process.stdout.write(JSON.stringify(calls))); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + assert json.loads(result.stdout) == [ + { + "url": "api/v1/repos/stackchain/api/issues/18/comments?page=1&limit=20&access=filed", + "method": "GET", + "key": "", + }, + { + "url": "api/v1/repos/stackchain/api/issues/18/comments?access=filed", + "method": "POST", + "key": "filed-followup-876", + }, + { + "url": "api/v1/repos/stackchain/api/issues/17/comments?page=1&limit=20", + "method": "GET", + "key": "", + }, + ] + + def test_issue_release_is_single_flight_and_requires_confirmed_unassignment(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); -- 2.43.0