diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 38e626d..f7f6a9c 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -149,7 +149,7 @@ let launchFilterResolved = false; try { const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY); - if (['all', 'today', 'agenda', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) { + if (['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) { selectedWorkFilter = savedFilter; savedWorkFilter = savedFilter; launchFilterResolved = true; @@ -933,7 +933,7 @@ load: fetchWorkPage, onItems: (stream, items) => { if (!lastContextSnapshot) return; - if (stream === 'issue') lastContextSnapshot.issues = items; + if (stream === 'issue' || stream === 'filed') lastContextSnapshot.issues = items; else lastContextSnapshot.pull_requests = items; paintMyWork(lastContextSnapshot); }, @@ -2592,10 +2592,11 @@ if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review']; if (selectedWorkFilter === 'agenda') return ['issue']; if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review']; + if (selectedWorkFilter === 'filed') return ['filed']; if (selectedWorkFilter === 'issue') return ['issue']; if (selectedWorkFilter === 'pull') return ['pull']; if (selectedWorkFilter === 'review') return ['review']; - if (selectedWorkFilter === 'all') return ['issue', 'pull', 'review']; + if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review']; return []; } @@ -3966,6 +3967,7 @@ root:qs('#issue-filing-receipt'), heading:qs('#issue-filing-receipt-heading'), key:qs('#issue-filing-receipt-key'), title:qs('#issue-filing-receipt-title'), ownership:qs('#issue-filing-receipt-ownership'), openLink:qs('#issue-filing-receipt-open'), + filedButton:qs('#issue-filing-receipt-filed'), shareButton:qs('#issue-filing-receipt-share'), relatedButton:qs('#issue-filing-receipt-related'), fileAnotherButton:qs('#issue-filing-receipt-another'), doneButton:qs('#issue-filing-receipt-done'), status:qs('#issue-filing-receipt-status'), @@ -3976,6 +3978,7 @@ qs('#new-issue').click(); }, onFileAnother:() => qs('#new-issue').click(), + onViewFiled:issue => { selectMobileQueue('filed'); openRoutedWork({...issue, kind:'issue'}); }, }); function closeCreateIssueSheet(navigate = true, preserveDraft = true) { if (navigate && taskOverlayHistory.current() === 'new') { @@ -4000,6 +4003,7 @@ function applyOutboxResult(result, openCreated = false, startCreated = false) { if (result.lease_skipped) { refreshMyWorkView(); return; } (result.confirmed || []).forEach(confirmed => { + confirmed.work_reasons = ['created_by_me']; if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []); }); if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot); @@ -6622,7 +6626,7 @@ if (!stream || !lastContextSnapshot) return; const button = qs('#load-more-work'); button.disabled = true; - const existing = stream === 'issue' ? + const existing = stream === 'issue' || stream === 'filed' ? (lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []); try { const loaded = await workPager.loadMore(stream, existing); diff --git a/frontend/index.html b/frontend/index.html index 72afa89..812935d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -135,6 +135,7 @@ + @@ -433,6 +434,7 @@

+ Open in Gitea diff --git a/frontend/issue-filing-receipt.js b/frontend/issue-filing-receipt.js index 80d4451..3e8ac97 100644 --- a/frontend/issue-filing-receipt.js +++ b/frontend/issue-filing-receipt.js @@ -6,10 +6,11 @@ 'use strict'; function createIssueFilingReceipt({ - root, heading, key, title, ownership, openLink, shareButton, + root, heading, key, title, ownership, openLink, shareButton, filedButton, relatedButton, fileAnotherButton, doneButton, status, navigator = {}, clipboard = navigator.clipboard, onFileRelated = function () {}, onFileAnother = function () {}, + onViewFiled = function () {}, }) { let active = null; let relatedPlan = null; @@ -64,6 +65,12 @@ } shareButton.addEventListener('click', share); + filedButton?.addEventListener('click', () => { + if (!active) return; + const issue = active; + close(); + onViewFiled(issue); + }); root.addEventListener('keydown', event => { if (event.key !== 'Escape' || root.hidden) return; event.preventDefault(); diff --git a/frontend/my-work.js b/frontend/my-work.js index e89c5b2..f90f3bc 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -70,17 +70,19 @@ function buildMyWork(data, now = new Date()) { ); const assigned = (item.assignees || []).includes(login); const isReview = (item.work_reasons || []).includes('review_requested'); + const isFiled = (item.work_reasons || []).includes('created_by_me'); const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null; const normalized = { ...item, key: (item.repository || 'unknown') + '#' + item.number, is_review: isReview, + is_filed: isFiled, is_assigned: assigned, has_update: false, ...(due ? { due_label: due.label } : {}), reason: priorityLabel ? priorityLabel + ' priority' : (due && due.priority < 4 ? due.label : - (isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work'))), + (isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work')))), _priority: priorityLabel ? 0 : (due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))), }; @@ -651,6 +653,7 @@ function createNotificationReplier({ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') { let filtered = items; if (selectedFilter === 'attention') filtered = items.filter(needsAttention); + else if (selectedFilter === 'filed') filtered = items.filter((item) => item.is_filed); else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review); else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update); else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter); @@ -962,6 +965,7 @@ function countMyWork(items) { return { all: items.length, attention: items.filter(needsAttention).length, + filed: items.filter((item) => item.is_filed).length, issue: items.filter((item) => item.kind === 'issue').length, pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length, review: items.filter((item) => item.is_review).length, diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 7c2814e..6fa447a 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -367,6 +367,7 @@ async def repository_access(repository: str) -> dict | None: WORK_SEARCHES = { "issue": ("assigned=true", "issues", None), + "filed": ("created=true", "issues", "created_by_me"), "pull": ("assigned=true", "pulls", "assigned_to_me"), "review": ("review_requested=true", "pulls", "review_requested"), } @@ -738,8 +739,20 @@ def _page_metadata(result: dict) -> dict: async def issues() -> WorkItems: - result = await work_page("issue") - return WorkItems(result["items"], {"issue": _page_metadata(result)}) + assigned, filed = await asyncio.gather(work_page("issue"), work_page("filed")) + merged: dict[int, dict] = {} + for result in (assigned, filed): + for issue in result["items"]: + identity = issue.get("id") + if identity not in merged: + merged[identity] = {**issue, "work_reasons": []} + for reason in issue.get("work_reasons", []): + if reason not in merged[identity]["work_reasons"]: + merged[identity]["work_reasons"].append(reason) + return WorkItems( + list(merged.values()), + {"issue": _page_metadata(assigned), "filed": _page_metadata(filed)}, + ) def _safe_web_url(value: Any) -> str: diff --git a/src/main.py b/src/main.py index 0f38d5a..0cce2a0 100644 --- a/src/main.py +++ b/src/main.py @@ -1021,6 +1021,7 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict: id=i["id"], number=i["number"], title=i["title"], state=i["state"], labels=[label.get("name", "") for label in (i.get("labels") or []) if isinstance(label, dict)], assignees=[assignee.get("login", "") for assignee in (i.get("assignees") or []) if isinstance(assignee, dict)], + work_reasons=[reason for reason in (i.get("work_reasons") or []) if reason == "created_by_me"], repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "", updated_at=i.get("updated_at") or "", due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None, @@ -1060,13 +1061,14 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict: def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]: - if stream == "issue": + if stream in {"issue", "filed"}: return [ Issue( id=item["id"], number=item["number"], title=item["title"], state=item["state"], labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)], assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)], + work_reasons=[reason for reason in (item.get("work_reasons") or []) if reason == "created_by_me"], repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "", updated_at=item.get("updated_at") or "", due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None, @@ -3080,7 +3082,7 @@ async def resolve_work_route( @app.get("/api/v1/work/{stream}") async def paged_work( - stream: Literal["issue", "pull", "review"], + stream: Literal["issue", "filed", "pull", "review"], page: int = Query(ge=2, le=100), ) -> JSONResponse: try: diff --git a/src/models.py b/src/models.py index 7eb0cbc..7126fa1 100644 --- a/src/models.py +++ b/src/models.py @@ -29,6 +29,7 @@ class Issue(BaseModel): state: str labels: list[str] = [] assignees: list[str] = [] + work_reasons: list[str] = [] repository: str = "" updated_at: str = "" due_date: str | None = None diff --git a/tests/test_gitea_work_search.py b/tests/test_gitea_work_search.py index c25d9df..489f742 100644 --- a/tests/test_gitea_work_search.py +++ b/tests/test_gitea_work_search.py @@ -66,6 +66,64 @@ async def test_work_page_preserves_total_and_reason_without_loading_other_pages( assert result["items"][0]["work_reasons"] == ["review_requested"] +@pytest.mark.anyio +async def test_filed_work_page_loads_open_authored_issues_with_a_distinct_reason(): + requests = [] + + def upstream(request): + requests.append(str(request.url)) + return httpx.Response(200, headers={"X-Total-Count": "61"}, json=[{ + "id": 870, "number": 870, "title": "Delegated filing", "state": "open", + "repository": {"full_name": "stackchain/dashboard"}, + "html_url": "https://forge.example/stackchain/dashboard/issues/870", + }]) + + gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) + try: + result = await gitea_proxy.work_page("filed", page=2) + finally: + await gitea_proxy.stop_client() + + assert requests == [ + "http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&created=true&type=issues&limit=50&page=2" + ] + assert result == { + "stream": "filed", "page": 2, "total": 61, "has_more": False, + "items": [{ + "id": 870, "number": 870, "title": "Delegated filing", "state": "open", + "repository": {"full_name": "stackchain/dashboard"}, + "html_url": "https://forge.example/stackchain/dashboard/issues/870", + "work_reasons": ["created_by_me"], + }], + } + + +@pytest.mark.anyio +async def test_issue_context_merges_self_assigned_and_filed_streams_without_duplicates(monkeypatch): + async def page(stream, page=1, limit=50): + del page, limit + common = { + "id": 7, "number": 7, "title": "My own filing", "state": "open", + "repository": {"full_name": "stackchain/api"}, "html_url": "https://forge.example/issues/7", + } + if stream == "issue": + return {"items": [{**common, "assignees": [{"login": "timmy"}]}], "page": 1, "total": 1, "has_more": False} + return {"items": [{**common, "work_reasons": ["created_by_me"]}, { + **common, "id": 8, "number": 8, "title": "Delegated", + "work_reasons": ["created_by_me"], + }], "page": 1, "total": 2, "has_more": False} + + monkeypatch.setattr(gitea_proxy, "work_page", page) + result = await gitea_proxy.issues() + + assert [item["number"] for item in result] == [7, 8] + assert result[0]["work_reasons"] == ["created_by_me"] + assert result.pagination == { + "issue": {"page": 1, "total": 1, "has_more": False}, + "filed": {"page": 1, "total": 2, "has_more": False}, + } + + @pytest.mark.anyio async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_priority(): requests = [] @@ -568,7 +626,7 @@ async def test_confirmed_claim_is_removed_from_retained_available_snapshot(monke async def test_initial_work_collections_expose_independent_pagination(monkeypatch): async def fake_page(stream, page=1, limit=50): assert page == 1 - totals = {"issue": 84, "pull": 61, "review": 73} + totals = {"issue": 84, "filed": 62, "pull": 61, "review": 73} return { "items": [], "page": 1, "total": totals[stream], "has_more": True, "stream": stream, @@ -580,7 +638,8 @@ async def test_initial_work_collections_expose_independent_pagination(monkeypatc pulls = await gitea_proxy.pull_requests() assert assigned_issues.pagination == { - "issue": {"page": 1, "total": 84, "has_more": True} + "issue": {"page": 1, "total": 84, "has_more": True}, + "filed": {"page": 1, "total": 62, "has_more": True}, } assert pulls.pagination == { "pull": {"page": 1, "total": 61, "has_more": True}, @@ -602,6 +661,7 @@ async def test_work_collections_include_supported_review_request_search(monkeypa assert await gitea_proxy.pull_requests() == [] assert requested_streams == [ ("issue", 1, 50), + ("filed", 1, 50), ("pull", 1, 50), ("review", 1, 50), ] diff --git a/tests/test_issue_filing_receipt.py b/tests/test_issue_filing_receipt.py index 2e4d12e..58ca764 100644 --- a/tests/test_issue_filing_receipt.py +++ b/tests/test_issue_filing_receipt.py @@ -89,6 +89,24 @@ process.stdout.write(JSON.stringify({{plans,hidden:elements.root.hidden,restored }], "hidden": True, "restored": 1} +def test_view_in_filed_closes_receipt_and_routes_the_exact_confirmed_issue(): + script = f""" +const createReceipt = require({json.dumps(str(RECEIPT))}); +const element = () => ({{hidden:true,focusCount:0,focus(){{this.focusCount++;}},addEventListener(name, fn){{this[name]=fn;}}}}); +const elements = {{root:element(),heading:element(),key:element(),title:element(),ownership:element(),openLink:element(),shareButton:element(),filedButton:element(),fileAnotherButton:element(),doneButton:element(),status:element()}}; +const filed=[]; +const controller=createReceipt({{...elements,onViewFiled:issue=>filed.push(issue)}}); +controller.show({{repository:'stackchain/dashboard',number:870,title:'Delegated',assignees:['alex'],url:'https://forge.example/issues/870'}},elements.doneButton); +elements.filedButton.click(); +process.stdout.write(JSON.stringify({{filed,hidden:elements.root.hidden,restored:elements.doneButton.focusCount}})); +""" + output = run_node(script) + assert output == { + "filed": [{"repository": "stackchain/dashboard", "number": 870, "title": "Delegated", "assignees": ["alex"], "url": "https://forge.example/issues/870"}], + "hidden": True, "restored": 1, + } + + def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt(): index = INDEX.read_text() css = CSS.read_text() @@ -97,6 +115,7 @@ def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt(): assert 'id="issue-filing-receipt" role="dialog" aria-modal="true"' in index assert 'id="issue-filing-receipt-heading"' in index assert 'id="issue-filing-receipt-open"' in index + assert 'id="issue-filing-receipt-filed"' in index assert 'id="issue-filing-receipt-share"' in index assert 'id="issue-filing-receipt-related"' in index assert 'id="issue-filing-receipt-another"' in index @@ -107,6 +126,9 @@ def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt(): assert '.issue-filing-receipt-actions' in css assert 'min-height:44px' in css assert 'createIssueFilingReceipt({' in dashboard + assert "filedButton:qs('#issue-filing-receipt-filed')" in dashboard + assert 'onViewFiled:issue =>' 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 assert "issueCapture.saveDraft(plan)" in dashboard diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 7f92b2d..a598f68 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -3767,7 +3767,7 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)}) ) assert json.loads(result.stdout) == { - "all": 3, "attention": 1, "issue": 1, "pull": 1, "review": 1, "update": 0 + "all": 3, "attention": 1, "filed": 0, "issue": 1, "pull": 1, "review": 1, "update": 0 } @@ -4927,7 +4927,7 @@ process.stdout.write(JSON.stringify({{ assert output["updates"][1]["kind"] == "update" assert output["updates"][1]["update_reason"] == "" assert output["counts"] == { - "all": 2, "attention": 2, "issue": 1, "pull": 0, "review": 0, "update": 2 + "all": 2, "attention": 2, "filed": 0, "issue": 1, "pull": 0, "review": 0, "update": 2 } assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned" @@ -6896,7 +6896,7 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session(): assert 'data-work-count="review"' in html assert 'data-work-count="update"' in html assert 'data-work-count="later"' in html - assert "['all', 'today', 'agenda', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html + assert "['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html assert 'data-work-count="draft"' in html assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html @@ -7647,3 +7647,28 @@ first.submit(item, payload).catch(() => {{ """ result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) assert json.loads(result.stdout) == {"calls": ["review-op-185", "review-op-185"], "keys": []} + +def test_filed_queue_contains_authored_issues_and_deduplicates_self_assigned_filings(): + payload = { + "user": {"login": "timmy"}, + "issues": [ + {"id": 1, "repository": "stackchain/api", "number": 1, "title": "Delegated", "assignees": ["alex"], "work_reasons": ["created_by_me"]}, + {"id": 2, "repository": "stackchain/api", "number": 2, "title": "Mine", "assignees": ["timmy"], "work_reasons": ["created_by_me"]}, + {"id": 3, "repository": "stackchain/api", "number": 3, "title": "Assigned only", "assignees": ["timmy"]}, + ], + "pull_requests": [], + } + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const items = buildMyWork({json.dumps(payload)}); +process.stdout.write(JSON.stringify({{ + filed: buildMyWork.filterMyWork(items, 'filed').map(item => item.number).sort((a,b) => a-b), + count: buildMyWork.countMyWork(items).filed, + identities: items.map(item => item.key).sort(), +}})); +""" + result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + assert json.loads(result.stdout) == { + "filed": [1, 2], "count": 2, + "identities": ["stackchain/api#1", "stackchain/api#2", "stackchain/api#3"], + } diff --git a/tests/test_work_pages.py b/tests/test_work_pages.py index 2d14cbd..32c044f 100644 --- a/tests/test_work_pages.py +++ b/tests/test_work_pages.py @@ -41,6 +41,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable "items": [{ "id": 51, "number": 51, "title": "Older issue", "state": "open", "labels": [], "assignees": ["timmy"], "repository": "stackchain/api", + "work_reasons": [], "updated_at": "2026-08-07T10:00:00Z", "due_date": "2026-08-09T23:59:59Z", "milestone": {"id": 9, "title": "August RC"}, @@ -66,6 +67,29 @@ async def test_work_page_endpoint_rejects_unknown_stream_before_upstream_io(monk assert called is False +@pytest.mark.anyio +async def test_filed_work_page_endpoint_preserves_authored_reason(monkeypatch): + async def page_loader(stream, page): + return { + "stream": stream, "page": page, "total": 1, "has_more": False, + "items": [{ + "id": 870, "number": 870, "title": "Delegated", "state": "open", + "labels": [], "assignees": [{"login": "alex"}], + "work_reasons": ["created_by_me"], + "repository": {"full_name": "stackchain/dashboard"}, + "html_url": "https://forge.example/issues/870", + }], + } + + monkeypatch.setattr(main.gitea_proxy, "work_page", page_loader) + 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/filed?page=2") + + assert response.status_code == 200 + assert response.json()["items"][0]["work_reasons"] == ["created_by_me"] + + @pytest.mark.anyio async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_data(): transport = httpx.ASGITransport(app=main.app)