diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e97ccb0..2b69086 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: pip install -r requirements-e2e.txt python3 -m playwright install --with-deps chromium - name: Exercise packaged mobile work journeys - run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py tests/e2e/test_mobile_pull_reviewer_status_release.py tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q tests/e2e/test_mobile_address_review_feedback_release.py tests/e2e/test_mobile_cancel_pull_review_request_release.py + run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py tests/e2e/test_mobile_pull_reviewer_status_release.py tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q tests/e2e/test_mobile_address_review_feedback_release.py tests/e2e/test_mobile_cancel_pull_review_request_release.py tests/e2e/test_mobile_authored_pull_queue_release.py release-candidate: runs-on: ubuntu-latest diff --git a/frontend/dashboard.js b/frontend/dashboard.js index f8ee785..c0a5183 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -328,7 +328,7 @@ let launchFilterResolved = false; try { const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY); - if (['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) { + if (['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) { selectedWorkFilter = savedFilter; savedWorkFilter = savedFilter; launchFilterResolved = true; @@ -3498,10 +3498,10 @@ 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', 'filed', 'pull', 'review']; + if (['issue', 'pull', 'review', 'authored'].includes(selectedWorkFilter)) { + return [selectedWorkFilter]; + } + if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review', 'authored']; return []; } @@ -3747,7 +3747,7 @@ } function updateWorkPaginationControls() { - const labels = { issue: 'issues', filed: 'filed issues', pull: 'pull requests', review: 'review requests' }; + const labels = { issue: 'issues', filed: 'filed issues', pull: 'pull requests', review: 'review requests', authored: 'pull requests' }; const streams = activeWorkStreams(); const incomplete = streams.filter(stream => workPagination[stream]?.has_more); const summaries = streams.flatMap(stream => { diff --git a/frontend/index.html b/frontend/index.html index 95bbf9c..6c3eb20 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -197,6 +197,7 @@ + @@ -2085,6 +2086,7 @@ + diff --git a/frontend/my-work.js b/frontend/my-work.js index bee668c..88dec86 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -71,6 +71,7 @@ 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 isAuthored = (item.work_reasons || []).includes('authored_by_me'); const isCompleted = isFiled && item.state === 'closed'; const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null; const normalized = { @@ -78,6 +79,7 @@ function buildMyWork(data, now = new Date()) { key: (item.repository || 'unknown') + '#' + item.number, is_review: isReview, is_filed: isFiled, + is_authored: isAuthored, is_completed: isCompleted, is_assigned: assigned, has_update: false, @@ -85,7 +87,8 @@ function buildMyWork(data, now = new Date()) { reason: priorityLabel ? priorityLabel + ' priority' : (due && due.priority < 4 ? due.label : (isReview ? 'Needs your review' : (isCompleted ? 'Completed · review outcome' : - (assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work'))))), + (assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : + (isAuthored ? 'Authored by you' : 'Open work')))))), _priority: priorityLabel ? 0 : (due && due.priority < 4 ? due.priority : (isReview ? 3 : (isCompleted ? 3.5 : (assigned ? 4 : 5)))), }; @@ -657,6 +660,7 @@ 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 === 'authored') filtered = items.filter((item) => item.is_authored); 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); @@ -1080,6 +1084,7 @@ function countMyWork(items) { all: items.length, attention: items.filter(needsAttention).length, filed: items.filter((item) => item.is_filed).length, + authored: items.filter((item) => item.is_authored).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/frontend/work-route.js b/frontend/work-route.js index b6873c8..836dcf7 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', 'filed', 'update', 'later', 'draft', 'following']; + const queueFilters = ['today', 'agenda', 'attention', 'filed', 'authored', 'update', 'later', 'draft', 'following']; const sections = { issue: ['overview', 'conversation', 'reply', 'actions'], filed: ['overview', 'conversation', 'reply', 'actions'], diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 4bed2fb..22c87ea 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -370,6 +370,7 @@ WORK_SEARCHES = { "filed": ("created=true", "issues", "created_by_me"), "pull": ("assigned=true", "pulls", "assigned_to_me"), "review": ("review_requested=true", "pulls", "review_requested"), + "authored": ("created=true", "pulls", "authored_by_me"), } @@ -2768,29 +2769,34 @@ async def is_open_authored_issue(repository: str, number: int) -> bool: async def pull_requests() -> WorkItems: - assigned, review_requested = await asyncio.gather( - work_page("pull"), - work_page("review"), + streams = ("pull", "review", "authored") + outcomes = await asyncio.gather( + *(work_page(stream) for stream in streams), + return_exceptions=True, ) + for outcome in outcomes: + if isinstance(outcome, asyncio.CancelledError): + raise outcome + if all(isinstance(outcome, BaseException) for outcome in outcomes): + raise outcomes[0] + merged: dict[int, dict] = {} - for result in ( - assigned, - review_requested, - ): - for pull in result["items"]: + pagination = {} + for stream, outcome in zip(streams, outcomes, strict=True): + if isinstance(outcome, BaseException): + pagination[stream] = { + "page": 1, "total": 0, "has_more": False, "unavailable": True, + } + continue + pagination[stream] = _page_metadata(outcome) + for pull in outcome["items"]: identity = pull.get("id") if identity not in merged: merged[identity] = {**pull, "work_reasons": []} for reason in pull.get("work_reasons", []): if reason not in merged[identity]["work_reasons"]: merged[identity]["work_reasons"].append(reason) - return WorkItems( - list(merged.values()), - { - "pull": _page_metadata(assigned), - "review": _page_metadata(review_requested), - }, - ) + return WorkItems(list(merged.values()), pagination) async def is_requested_review(repository: str, number: int) -> bool: diff --git a/src/main.py b/src/main.py index 7803775..3254025 100644 --- a/src/main.py +++ b/src/main.py @@ -1329,7 +1329,7 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict: user=p["user"].get("login", "") if isinstance(p.get("user"), dict) else "", labels=[label.get("name", "") for label in (p.get("labels") or []) if isinstance(label, dict)], assignees=[assignee.get("login", "") for assignee in (p.get("assignees") or []) if isinstance(assignee, dict)], - work_reasons=[reason for reason in (p.get("work_reasons") or []) if reason in ("assigned_to_me", "review_requested")], + work_reasons=[reason for reason in (p.get("work_reasons") or []) if reason in ("assigned_to_me", "review_requested", "authored_by_me")], repository=p["repository"].get("full_name", "") if isinstance(p.get("repository"), dict) else "", updated_at=p.get("updated_at") or "", url=p["html_url"], ) @@ -1370,7 +1370,11 @@ def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]: for item in items if all(field in item for field in ("id", "number", "title", "state", "html_url")) ] - reason = "assigned_to_me" if stream == "pull" else "review_requested" + reason = { + "pull": "assigned_to_me", + "review": "review_requested", + "authored": "authored_by_me", + }[stream] return [ PullRequest( id=item["id"], number=item["number"], title=item["title"], @@ -4156,7 +4160,7 @@ async def resolve_work_route( @app.get("/api/v1/work/{stream}") async def paged_work( - stream: Literal["issue", "filed", "pull", "review"], + stream: Literal["issue", "filed", "pull", "review", "authored"], page: int = Query(ge=2, le=100), ) -> JSONResponse: try: diff --git a/tests/e2e/test_mobile_authored_pull_queue_release.py b/tests/e2e/test_mobile_authored_pull_queue_release.py new file mode 100644 index 0000000..508cf0c --- /dev/null +++ b/tests/e2e/test_mobile_authored_pull_queue_release.py @@ -0,0 +1,89 @@ +import os +from pathlib import Path + +import pytest + +if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1": + pytest.skip("packaged mobile My PRs journey runs only in its gated CI job", allow_module_level=True) +pytest.importorskip("playwright.sync_api") +from playwright.sync_api import expect, sync_playwright + + +ROOT = Path(__file__).parents[2] +FRONTEND = ROOT / "frontend" + + +@pytest.mark.parametrize("viewport", [ + {"width": 320, "height": 568}, + {"width": 390, "height": 844}, +]) +def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(viewport): + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport=viewport) + page.set_content((FRONTEND / "index.html").read_text()) + page.add_style_tag(path=FRONTEND / "dashboard.css") + page.add_script_tag(path=FRONTEND / "my-work.js") + page.add_script_tag(path=FRONTEND / "work-route.js") + page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js") + page.evaluate("document.querySelector('#mobile-queue-sheet').showModal()") + + row = page.locator('[data-mobile-queue="authored"]') + expect(row).to_have_count(1) + expect(row).to_contain_text("My PRs") + expect(row).to_contain_text("Pull requests you authored") + assert row.bounding_box()["height"] >= 44 + + result = page.evaluate("""() => { + const work = buildMyWork({ + user:{login:'timmy'}, + pull_requests:[{ + number:41,title:'Ship the authored mobile flow',repository:'stackchain/dashboard', + state:'open',user:'timmy',assignees:[],work_reasons:['authored_by_me'], + url:'https://forge.example/git/stackchain/dashboard/pulls/41' + }] + }); + const authored = filterMyWork(work, 'authored'); + let selected = ''; + let route = ''; + const action = document.createElement('button'); + action.className = 'my-work-card-main'; + action.addEventListener('click', () => { route = createWorkRoute.serialize(authored[0]); }); + const launcher = createMobileQueueLauncher({ + selectFilter:name => { selected = name; }, + firstAction:() => action, + announce:() => {}, + }); + const opened = launcher.open('authored'); + return {selected, route, opened, count:authored.length}; + }""") + assert result == { + "selected": "authored", + "route": "#/my-work/pull/stackchain/dashboard/41", + "opened": "opened", + "count": 1, + } + assert page.evaluate( + "document.documentElement.scrollWidth > document.documentElement.clientWidth" + ) is False + browser.close() + + +def test_empty_authored_queue_announces_the_standard_empty_message(): + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 320, "height": 568}) + page.set_content((FRONTEND / "index.html").read_text()) + page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js") + + message = page.evaluate("""() => { + let announcement = ''; + const launcher = createMobileQueueLauncher({ + selectFilter:() => {}, firstAction:() => null, + announce:value => { announcement = value; }, + }); + launcher.open('authored'); + return announcement; + }""") + assert message == "No work is ready to open." + browser.close() diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index ec6c6b7..732e637 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -93,3 +93,10 @@ def test_browser_job_gates_latest_pull_review_mobile_journeys(): assert "tests/e2e/test_mobile_address_review_feedback_release.py" in browser assert "tests/e2e/test_mobile_cancel_pull_review_request_release.py" in browser + + +def test_browser_job_gates_authored_pull_queue_journey(): + text = WORKFLOW.read_text() + browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")] + + assert "tests/e2e/test_mobile_authored_pull_queue_release.py" in browser diff --git a/tests/test_gitea_work_search.py b/tests/test_gitea_work_search.py index 484235f..331143f 100644 --- a/tests/test_gitea_work_search.py +++ b/tests/test_gitea_work_search.py @@ -100,6 +100,32 @@ async def test_filed_work_page_loads_open_and_closed_authored_issues_with_a_dist } +@pytest.mark.anyio +async def test_authored_pull_page_loads_open_created_pulls_with_a_distinct_reason(): + requests = [] + + def upstream(request): + requests.append(str(request.url)) + return httpx.Response(200, headers={"X-Total-Count": "3"}, json=[{ + "id": 901, "number": 41, "title": "Ship mobile flow", "state": "open", + "user": {"login": "timmy"}, + "repository": {"full_name": "stackchain/dashboard"}, + "html_url": "http://127.0.0.1:3000/stackchain/dashboard/pulls/41", + }]) + + gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) + try: + result = await gitea_proxy.work_page("authored", page=1) + finally: + await gitea_proxy.stop_client() + + assert requests == [ + "http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&created=true&type=pulls&limit=50&page=1" + ] + assert result["total"] == 3 + assert result["items"][0]["work_reasons"] == ["authored_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): @@ -628,7 +654,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, "filed": 62, "pull": 61, "review": 73} + totals = {"issue": 84, "filed": 62, "pull": 61, "review": 73, "authored": 12} return { "items": [], "page": 1, "total": totals[stream], "has_more": True, "stream": stream, @@ -646,6 +672,7 @@ async def test_initial_work_collections_expose_independent_pagination(monkeypatc assert pulls.pagination == { "pull": {"page": 1, "total": 61, "has_more": True}, "review": {"page": 1, "total": 73, "has_more": True}, + "authored": {"page": 1, "total": 12, "has_more": True}, } @@ -666,11 +693,12 @@ async def test_work_collections_include_supported_review_request_search(monkeypa ("filed", 1, 50), ("pull", 1, 50), ("review", 1, 50), + ("authored", 1, 50), ] @pytest.mark.anyio -async def test_pull_requests_merge_assignment_and_review_responsibilities(monkeypatch): +async def test_pull_requests_merge_assignment_review_and_authored_responsibilities(monkeypatch): assigned = { "id": 11, "number": 7, @@ -683,10 +711,24 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey "title": "Review mobile", "repository": {"full_name": "stackchain/mobile"}, } + authored_only = { + "id": 13, + "number": 9, + "title": "Ship authored flow", + "repository": {"full_name": "stackchain/dashboard"}, + } async def fake_page(stream, page=1, limit=50): - items = [assigned] if stream == "pull" else [assigned.copy(), review_only] - reason = "assigned_to_me" if stream == "pull" else "review_requested" + items = { + "pull": [assigned], + "review": [assigned.copy(), review_only], + "authored": [assigned.copy(), authored_only], + }[stream] + reason = { + "pull": "assigned_to_me", + "review": "review_requested", + "authored": "authored_by_me", + }[stream] return { "stream": stream, "items": [{**item, "work_reasons": [reason]} for item in items], @@ -697,9 +739,44 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey pulls = await gitea_proxy.pull_requests() - assert [pull["id"] for pull in pulls] == [11, 12] - assert pulls[0]["work_reasons"] == ["assigned_to_me", "review_requested"] + assert [pull["id"] for pull in pulls] == [11, 12, 13] + assert pulls[0]["work_reasons"] == [ + "assigned_to_me", "review_requested", "authored_by_me", + ] assert pulls[1]["work_reasons"] == ["review_requested"] + assert pulls[2]["work_reasons"] == ["authored_by_me"] + assert pulls.pagination["authored"] == { + "page": 1, "total": 2, "has_more": False, + } + + +@pytest.mark.anyio +async def test_authored_pull_failure_keeps_other_pull_work_and_marks_only_that_stream_unavailable(monkeypatch): + async def fake_page(stream, page=1, limit=50): + if stream == "authored": + raise httpx.ConnectError("authored search unavailable") + item = { + "id": 11 if stream == "pull" else 12, + "number": 7 if stream == "pull" else 8, + "title": "Assigned" if stream == "pull" else "Review", + "repository": {"full_name": "stackchain/dashboard"}, + "work_reasons": ["assigned_to_me" if stream == "pull" else "review_requested"], + } + return { + "stream": stream, "items": [item], "page": page, + "total": 1, "has_more": False, + } + + monkeypatch.setattr(gitea_proxy, "work_page", fake_page) + + pulls = await gitea_proxy.pull_requests() + + assert [pull["id"] for pull in pulls] == [11, 12] + assert pulls.pagination["pull"].get("unavailable", False) is False + assert pulls.pagination["review"].get("unavailable", False) is False + assert pulls.pagination["authored"] == { + "page": 1, "total": 0, "has_more": False, "unavailable": True, + } @pytest.mark.anyio diff --git a/tests/test_my_work.py b/tests/test_my_work.py index ef46665..0803b11 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -100,6 +100,53 @@ process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)}))); assert result[1]["reason"] == "Filed by you" +def test_authored_pull_is_classified_for_the_my_prs_queue(): + payload = { + "user": {"login": "timmy"}, + "pull_requests": [{ + "number": 41, "title": "Ship mobile flow", "repository": "stackchain/dashboard", + "state": "open", "user": "timmy", "assignees": [], + "work_reasons": ["authored_by_me"], "updated_at": "2026-08-24T12:00:00Z", + }], + } + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)}))); +""" + completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert completed.returncode == 0, completed.stderr + result = json.loads(completed.stdout) + assert result[0]["is_authored"] is True + assert result[0]["reason"] == "Authored by you" + assert result[0]["kind"] == "pull" + + +def test_my_prs_filter_and_count_include_only_authored_pulls(): + script = f""" +const buildMyWork = require({json.dumps(str(MY_WORK))}); +const items = buildMyWork({{ + user:{{login:'timmy'}}, + pull_requests:[ + {{number:41,title:'Authored',repository:'stackchain/dashboard',state:'open',user:'timmy', + assignees:[],work_reasons:['authored_by_me']}}, + {{number:42,title:'Review',repository:'stackchain/dashboard',state:'open',user:'alex', + assignees:[],work_reasons:['review_requested']}}, + ], +}}); +process.stdout.write(JSON.stringify({{ + authored:buildMyWork.filterMyWork(items,'authored').map(item=>item.number), + counts:buildMyWork.countMyWork(items), +}})); +""" + completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert completed.returncode == 0, completed.stderr + result = json.loads(completed.stdout) + assert result["authored"] == [41] + assert result["counts"]["authored"] == 1 + + def test_completed_child_resolves_only_a_canonical_reciprocal_parent_task(): script = f""" const sheet = require({json.dumps(str(ISSUE_SHEET))}); @@ -1059,6 +1106,24 @@ async def test_agenda_activation_checks_all_issue_pages_before_showing_empty_sta assert "selectedWorkFilter === 'agenda' && workPagination.issue?.has_more" in html +@pytest.mark.anyio +async def test_my_prs_is_a_thumb_safe_mobile_queue_and_desktop_filter(): + html = await dashboard() + markup = (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text() + css = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.css").read_text() + + assert 'data-work-filter="authored"' in markup + assert 'data-work-count="authored"' in markup + assert 'data-mobile-queue="authored"' in markup + assert 'data-mobile-queue-count="authored"' in markup + assert 'My PRs' in markup + assert ".mobile-queue-list button" in css and "min-height:56px" in css + assert "['all', 'today', 'agenda', 'attention', 'filed', 'authored'" in html + assert "['issue', 'pull', 'review', 'authored'].includes(selectedWorkFilter)" in html + assert "authored: 'pull requests'" in html + + + @pytest.mark.anyio async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search(): html = await dashboard() @@ -4988,7 +5053,8 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)}) ) assert json.loads(result.stdout) == { - "all": 3, "attention": 1, "filed": 0, "issue": 1, "pull": 1, "review": 1, "update": 0 + "all": 3, "attention": 1, "filed": 0, "authored": 0, + "issue": 1, "pull": 1, "review": 1, "update": 0 } @@ -6165,7 +6231,8 @@ process.stdout.write(JSON.stringify({{ assert output["updates"][1]["kind"] == "update" assert output["updates"][1]["update_reason"] == "" assert output["counts"] == { - "all": 2, "attention": 2, "filed": 0, "issue": 1, "pull": 0, "review": 0, "update": 2 + "all": 2, "attention": 2, "filed": 0, "authored": 0, + "issue": 1, "pull": 0, "review": 0, "update": 2 } assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned" @@ -8480,7 +8547,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', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html + assert "['all', 'today', 'agenda', 'attention', 'filed', 'authored', '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 diff --git a/tests/test_work_pages.py b/tests/test_work_pages.py index 59e50a7..9ef537f 100644 --- a/tests/test_work_pages.py +++ b/tests/test_work_pages.py @@ -90,6 +90,47 @@ async def test_filed_work_page_endpoint_preserves_authored_reason(monkeypatch): assert response.json()["items"][0]["work_reasons"] == ["created_by_me"] +@pytest.mark.anyio +async def test_authored_pull_page_endpoint_preserves_authorship_reason(monkeypatch): + async def page_loader(stream, page): + assert (stream, page) == ("authored", 2) + return { + "stream": stream, "page": page, "total": 51, "has_more": False, + "items": [{ + "id": 901, "number": 41, "title": "Ship mobile flow", "state": "open", + "user": {"login": "timmy"}, "labels": [], "assignees": [], + "work_reasons": ["authored_by_me"], + "repository": {"full_name": "stackchain/dashboard"}, + "html_url": "https://forge.example/pulls/41", + }], + } + + 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/authored?page=2") + + assert response.status_code == 200 + assert response.json()["items"][0]["work_reasons"] == ["authored_by_me"] + + +def test_initial_context_preserves_authored_pull_reason(): + payload = main._context_payload( + {"id": 1, "login": "timmy"}, + [], + [], + [{ + "id": 901, "number": 41, "title": "Ship mobile flow", "state": "open", + "user": {"login": "timmy"}, "labels": [], "assignees": [], + "work_reasons": ["authored_by_me"], + "repository": {"full_name": "stackchain/dashboard"}, + "html_url": "https://forge.example/pulls/41", + }], + ) + + assert payload["pull_requests"][0]["work_reasons"] == ["authored_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)