From 0bb76bdc8ba115a4a7562eeaa199d8ec153ad79d Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 05:43:28 +0000 Subject: [PATCH] feat: open delegated filings read-only (Closes #872) --- frontend/dashboard.css | 9 ++++ frontend/dashboard.js | 18 ++++++-- frontend/issue-sheet.js | 6 ++- frontend/work-route.js | 12 +++--- src/frontend_bundle.py | 2 +- src/gitea_proxy.py | 24 ++++++++++- src/main.py | 19 +++++++-- tests/test_issue_api.py | 34 +++++++++++++++ tests/test_issue_filing_receipt.py | 1 + tests/test_my_work.py | 64 +++++++++++++++++++++++++++- tests/test_work_route_resolver.py | 67 ++++++++++++++++++++++++++++++ 11 files changed, 238 insertions(+), 18 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 23a50f4..5effb2a 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -521,6 +521,15 @@ 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-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; } .issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index f7f6a9c..095d85a 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1269,7 +1269,7 @@ closeOpenWorkSheets(); if (item.kind === 'update') notificationReader.open(item, lastMyWork); else if (item.kind === 'review') openReviewSheet(item, reviewTrigger); - else if (item.kind === 'issue') openIssueSheet(item, issueTrigger); + else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger); else if (item.kind === 'pull') openPullSheet(item, pullTrigger); }, onQueue: openWorkQueueRoute, @@ -3294,6 +3294,8 @@ async function openIssueSheet(item, trigger, offlineDetail = null) { if (!item) return; + const readOnly = issueController.readOnly(item); + qs('#issue-sheet').classList.toggle('read-only', readOnly); issueDetailPosition.open(workDetailIdentity('issue', item)); qs('#issue-planning').inert = false; qs('#issue-handoff').inert = false; @@ -3362,9 +3364,12 @@ ).join(' '); qs('#issue-assignees').textContent = (detail.assignees || []).length ? 'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported'; - renderIssueConversation(issueConversation.snapshot()); + 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 = 'Issue ready · ' + (detail.state || 'open'); + qs('#issue-sheet-status').textContent = readOnly ? 'Filed issue ready · read-only' : + 'Issue ready · ' + (detail.state || 'open'); qs('#edit-issue-content').disabled = false; const dueDraft = issueController.loadDueDateDraft(item); qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10); @@ -3978,7 +3983,12 @@ qs('#new-issue').click(); }, onFileAnother:() => qs('#new-issue').click(), - onViewFiled:issue => { selectMobileQueue('filed'); openRoutedWork({...issue, kind:'issue'}); }, + onViewFiled:issue => { + selectMobileQueue('filed'); + openRoutedWork({ + ...issue, kind:'issue', is_filed:true, is_assigned:(issue.assignees || []).includes(confirmedOwnerLogin), + }); + }, }); function closeCreateIssueSheet(navigate = true, preserveDraft = true) { if (navigate && taskOverlayHistory.current() === 'new') { diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index 2a53ed3..83957f1 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -38,8 +38,12 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number; return { + readOnly(item) { + return Boolean(item?.is_filed && !item?.is_assigned); + }, load(item) { - return fetchJson(issuePath(item) + '/detail', { + const access = this.readOnly(item) ? '?access=filed' : ''; + return fetchJson(issuePath(item) + '/detail' + access, { headers: { Accept: 'application/json' }, }); }, diff --git a/frontend/work-route.js b/frontend/work-route.js index f2ee090..05200cb 100644 --- a/frontend/work-route.js +++ b/frontend/work-route.js @@ -6,7 +6,7 @@ 'use strict'; const repositoryPart = /^[A-Za-z0-9_.-]+$/; - const queueFilters = ['today', 'agenda', 'attention', 'update', 'later', 'draft']; + const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft']; function positiveInteger(value) { const number = Number(value); @@ -27,7 +27,7 @@ const notificationId = positiveInteger(parts[3]); return notificationId ? { kind: 'update', notification_id: notificationId } : null; } - if (!['issue', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null; + if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null; if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null; const number = positiveInteger(parts[5]); return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null; @@ -38,11 +38,12 @@ const notificationId = positiveInteger(item.notification_id); return notificationId ? '#/my-work/update/' + notificationId : ''; } - if (!['issue', 'pull', 'review'].includes(item?.kind)) return ''; + const kind = item?.is_filed && !item?.is_assigned ? 'filed' : item?.kind; + if (!['issue', 'filed', 'pull', 'review'].includes(kind)) return ''; const repository = String(item.repository || '').split('/'); const number = positiveInteger(item.number); if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return ''; - return '#/my-work/' + item.kind + '/' + repository.join('/') + '/' + number; + return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number; } function sameRoute(item, route) { @@ -50,7 +51,8 @@ if (route.kind === 'update') { return Number(item.notification_id) === route.notification_id; } - const itemKind = item.is_review ? 'review' : item.kind; + const itemKind = item.is_review ? 'review' : + (item.is_filed && !item.is_assigned ? 'filed' : item.kind); return itemKind === route.kind && item.repository === route.repository && Number(item.number) === route.number; } diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index ad0d2e5..98f9d29 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -30,7 +30,7 @@ FEATURE_SOURCES = { "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "security-center": ("static/security-center.js",), "today-timer": ( - "static/today-completion.js", "static/work-detail-position.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", + "static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", "static/assign-and-start.js", "static/queue-today.js", "static/create-and-start.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 6fa447a..917266f 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -2360,19 +2360,22 @@ async def resolve_work_route( "title": detail.get("title", ""), "url": detail.get("url", ""), } - if repository is None or number is None or kind not in {"issue", "pull", "review"}: + if repository is None or number is None or kind not in {"issue", "filed", "pull", "review"}: raise WorkRouteUnavailableError("Work identity is missing") - target_path = "issues" if kind == "issue" else "pulls" + target_path = "issues" if kind in {"issue", "filed"} else "pulls" login, target = await _current_login_and_target( f"repos/{repository}/{target_path}/{number}" ) assigned = _login_in_users(login, target.get("assignees")) requested = _login_in_users(login, target.get("requested_reviewers")) + author = target.get("user") if isinstance(target.get("user"), dict) else {} + authored = author.get("login") == login eligible = ( target.get("state") == "open" and ( (kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned) + or (kind == "filed" and not isinstance(target.get("pull_request"), dict) and authored) or (kind == "pull" and assigned) or (kind == "review" and requested) ) @@ -2387,6 +2390,11 @@ async def resolve_work_route( "state": "open", "url": _safe_web_url(target.get("html_url")), **({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}), + **({ + "is_filed": True, + "is_assigned": assigned, + "work_reasons": ["created_by_me"], + } if kind == "filed" else {}), } @@ -2401,6 +2409,18 @@ async def is_assigned_issue(repository: str, number: int) -> bool: ) +async def is_authored_issue(repository: str, number: int) -> bool: + login, issue = await _current_login_and_target( + f"repos/{repository}/issues/{number}" + ) + author = issue.get("user") if isinstance(issue.get("user"), dict) else {} + return ( + issue.get("state") == "open" + and not isinstance(issue.get("pull_request"), dict) + and author.get("login") == login + ) + + async def pull_requests() -> WorkItems: assigned, review_requested = await asyncio.gather( work_page("pull"), diff --git a/src/main.py b/src/main.py index 0cce2a0..b8c6d7d 100644 --- a/src/main.py +++ b/src/main.py @@ -3055,7 +3055,7 @@ async def comment_on_global_search_preview( @app.get("/api/v1/work-route") async def resolve_work_route( - kind: Literal["issue", "pull", "review", "update"] = Query(), + kind: Literal["issue", "filed", "pull", "review", "update"] = Query(), repository: str | None = Query(default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"), number: int | None = Query(default=None, gt=0), notification_id: int | None = Query(default=None, gt=0), @@ -4387,13 +4387,24 @@ async def handoff_assigned_issue( @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail") -async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)): +async def assigned_issue_detail( + owner: str, + repo: str, + number: int = PathParam(gt=0), + access: Literal["assigned", "filed"] = Query(default="assigned"), +): repository = f"{owner}/{repo}" async def load_assigned_issue(): - 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_detail(repository, number) + detail = await gitea_proxy.issue_detail(repository, number) + return {**detail, "read_only": True} if access == "filed" else detail try: return await asyncio.wait_for( diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 7a87dd8..27a0f9f 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -2005,6 +2005,40 @@ async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkey } +@pytest.mark.anyio +async def test_issue_detail_endpoint_returns_authored_filing_in_read_only_mode(monkeypatch): + calls = [] + + async def authored(repository, number): + calls.append(("authored", repository, number)) + return (repository, number) == ("stackchain/api", 8) + + async def detail(repository, number): + calls.append(("detail", repository, number)) + return {"repository": repository, "number": number, "title": "Delegated filing"} + + monkeypatch.setattr(main.gitea_proxy, "is_authored_issue", authored, raising=False) + monkeypatch.setattr(main.gitea_proxy, "issue_detail", detail) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/repos/stackchain/api/issues/8/detail?access=filed" + ) + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json() == { + "repository": "stackchain/api", + "number": 8, + "title": "Delegated filing", + "read_only": True, + } + assert calls == [ + ("authored", "stackchain/api", 8), + ("detail", "stackchain/api", 8), + ] + + @pytest.mark.anyio async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no_store(monkeypatch): calls = [] diff --git a/tests/test_issue_filing_receipt.py b/tests/test_issue_filing_receipt.py index 58ca764..9075615 100644 --- a/tests/test_issue_filing_receipt.py +++ b/tests/test_issue_filing_receipt.py @@ -128,6 +128,7 @@ def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt(): assert 'createIssueFilingReceipt({' in dashboard assert "filedButton:qs('#issue-filing-receipt-filed')" in dashboard assert 'onViewFiled:issue =>' in dashboard + assert "is_filed:true, is_assigned:(issue.assignees || []).includes(confirmedOwnerLogin)" in dashboard assert "confirmed.work_reasons = ['created_by_me']" in dashboard assert "relatedButton:qs('#issue-filing-receipt-related')" in dashboard assert 'relatedDraft:issueCapture.buildRelatedDraft(durableDraft)' in dashboard diff --git a/tests/test_my_work.py b/tests/test_my_work.py index a598f68..2d2fa99 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -1033,6 +1033,8 @@ def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments(): const routes = require({json.dumps(str(WORK_ROUTE))}); const inputs = [ {{kind:'issue',repository:'stackchain/api',number:17}}, + {{kind:'filed',repository:'stackchain/api',number:18}}, + {{kind:'issue',repository:'stackchain/api',number:19,is_filed:true,is_assigned:false}}, {{kind:'pull',repository:'stackchain/dashboard',number:42}}, {{kind:'review',repository:'stackchain/dashboard',number:42}}, {{kind:'update',notification_id:913}}, @@ -1055,12 +1057,16 @@ process.stdout.write(JSON.stringify({{ assert json.loads(result.stdout) == { "paths": [ "#/my-work/issue/stackchain/api/17", + "#/my-work/filed/stackchain/api/18", + "#/my-work/filed/stackchain/api/19", "#/my-work/pull/stackchain/dashboard/42", "#/my-work/review/stackchain/dashboard/42", "#/my-work/update/913", ], "parsed": [ {"kind": "issue", "repository": "stackchain/api", "number": 17}, + {"kind": "filed", "repository": "stackchain/api", "number": 18}, + {"kind": "filed", "repository": "stackchain/api", "number": 19}, {"kind": "pull", "repository": "stackchain/dashboard", "number": 42}, {"kind": "review", "repository": "stackchain/dashboard", "number": 42}, {"kind": "update", "notification_id": 913}, @@ -1158,7 +1164,7 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash, parsed:routes.p def test_every_mobile_queue_route_survives_hydration_and_detail_back_navigation(): script = f""" const routes = require({json.dumps(str(WORK_ROUTE))}); -const canonical = ['today', 'agenda', 'attention', 'updates', 'later', 'drafts']; +const canonical = ['today', 'agenda', 'attention', 'filed', 'updates', 'later', 'drafts']; const parsed = canonical.map(name => [name, routes.parse('#/my-work/' + name)]); const listeners = {{}}; const location = {{hash:'#/my-work/agenda'}}; @@ -1194,6 +1200,7 @@ process.stdout.write(JSON.stringify({{parsed, calls, hash:location.hash}})); ["today", {"kind": "queue", "filter": "today"}], ["agenda", {"kind": "queue", "filter": "agenda"}], ["attention", {"kind": "queue", "filter": "attention"}], + ["filed", {"kind": "queue", "filter": "filed"}], ["updates", {"kind": "queue", "filter": "update"}], ["later", {"kind": "queue", "filter": "later"}], ["drafts", {"kind": "queue", "filter": "draft"}], @@ -1320,6 +1327,24 @@ const calls = []; } +@pytest.mark.anyio +async def test_dashboard_opens_delegated_filings_as_read_only_issue_sheets(): + 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-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 + + @pytest.mark.anyio async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share(): html = await dashboard() @@ -1549,6 +1574,43 @@ process.stdout.write(JSON.stringify({{updated:updated.issues[0],original:origina } +def test_issue_sheet_identifies_delegated_filings_as_read_only(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +const controller = createIssueSheet({{fetchJson:async () => ({{}}), storage:null}}); +process.stdout.write(JSON.stringify([ + controller.readOnly({{kind:'filed',is_filed:true,is_assigned:false}}), + controller.readOnly({{kind:'issue',is_filed:true,is_assigned:true}}), + controller.readOnly({{kind:'issue',is_assigned:true}}), +])); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + assert json.loads(result.stdout) == [True, False, False] + + +def test_issue_sheet_loads_filed_items_through_read_only_access(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +const urls = []; +const controller = createIssueSheet({{ + fetchJson: async url => {{ urls.push(url); return {{}}; }}, storage:null, +}}); +Promise.all([ + controller.load({{kind:'issue',repository:'stackchain/api',number:17,is_assigned:true}}), + controller.load({{kind:'filed',repository:'stackchain/api',number:18,is_filed:true,is_assigned:false}}), +]).then(() => process.stdout.write(JSON.stringify(urls))); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + assert json.loads(result.stdout) == [ + "api/v1/repos/stackchain/api/issues/17/detail", + "api/v1/repos/stackchain/api/issues/18/detail?access=filed", + ] + + def test_issue_release_is_single_flight_and_requires_confirmed_unassignment(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); diff --git a/tests/test_work_route_resolver.py b/tests/test_work_route_resolver.py index 0e89e8c..13a8b95 100644 --- a/tests/test_work_route_resolver.py +++ b/tests/test_work_route_resolver.py @@ -39,6 +39,33 @@ async def test_work_route_endpoint_resolves_one_authorized_item(monkeypatch): } +@pytest.mark.anyio +async def test_work_route_endpoint_accepts_filed_issue_identity(monkeypatch): + requested = [] + + async def resolve(kind, repository, number, notification_id): + requested.append((kind, repository, number, notification_id)) + return { + "kind": "filed", + "repository": repository, + "number": number, + "title": "Delegated filing", + "is_filed": True, + "is_assigned": False, + } + + monkeypatch.setattr(main.gitea_proxy, "resolve_work_route", resolve) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/work-route?kind=filed&repository=stackchain/dashboard&number=88" + ) + + assert response.status_code == 200 + assert requested == [("filed", "stackchain/dashboard", 88, None)] + assert response.json()["kind"] == "filed" + + @pytest.mark.anyio async def test_resolve_work_route_returns_only_a_requested_review(): requests = [] @@ -80,6 +107,46 @@ async def test_resolve_work_route_returns_only_a_requested_review(): } +@pytest.mark.anyio +async def test_resolve_work_route_returns_an_open_issue_filed_by_the_current_user(): + requests = [] + + async def upstream(request): + requests.append(request.url.path) + if request.url.path.endswith("/user"): + return httpx.Response(200, json={"login": "timmy"}) + return httpx.Response(200, json={ + "id": 701, + "number": 88, + "title": "Delegated filing", + "state": "open", + "html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/issues/88", + "assignees": [{"login": "alex"}], + "user": {"login": "timmy"}, + }) + + gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) + try: + result = await gitea_proxy.resolve_work_route( + "filed", "stackchain/dashboard", 88, None + ) + finally: + await gitea_proxy.stop_client() + + assert len(requests) == 2 + assert result == { + "kind": "filed", + "repository": "stackchain/dashboard", + "number": 88, + "title": "Delegated filing", + "state": "open", + "url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/issues/88", + "is_filed": True, + "is_assigned": False, + "work_reasons": ["created_by_me"], + } + + @pytest.mark.anyio async def test_resolve_work_route_rejects_a_notification_that_is_already_read(): async def upstream(request):