From d5611cf274631a08bccc6ddc3b8e29ee1384a577 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:09:45 +0000 Subject: [PATCH] fix(mobile): report task pages and review state accurately (#36115) * fix(mobile): report task pages and review state accurately * test(mobile): record task page error provenance * test(mobile): record task page errors in reply matrices --- ...provider-load-github.countworkitems-1.json | 105 +++++------ ....provider-load-github.listworkitems-1.json | 175 +++++++++--------- ...asks.provider-load-linear.listteams-1.json | 125 ++++++------- ...x-tasks.provider-load-linear.status-1.json | 125 ++++++------- ...tasks.provider-load-settings.update-1.json | 125 ++++++------- .../goldens/tk-provider-load.json | 85 ++++----- mobile/src/i18n/locales/en.json | 15 ++ .../tasks/mobile-hosted-check-status.test.ts | 24 +++ .../src/tasks/mobile-hosted-check-status.ts | 84 +++++++-- .../mobile-task-request-cancellation.test.tsx | 106 +++++++++-- .../tasks/mobile-tasks-hosted-review.test.ts | 29 +++ .../src/tasks/mobile-tasks-hosted-review.ts | 26 +-- .../tasks/mobile-tasks-provider-item-list.tsx | 11 +- ...use-mobile-tasks-provider-load-actions.tsx | 8 +- ...ile-tasks-task-pagination-actions.test.tsx | 57 +++++- ...e-mobile-tasks-task-pagination-actions.tsx | 72 ++++++- .../task-page-github-landing-refresh-run.tsx | 2 +- .../use-task-page-github-list-projection.ts | 2 +- .../use-task-page-github-search-pagination.ts | 6 +- .../task-page-work-item-pagination.test.ts | 160 ---------------- .../task-page-work-item-pagination.ts | 65 ------- .../github/work-item-aggregate-actions.ts | 18 +- src/shared/github/work-item-page-errors.ts | 43 +++++ src/shared/work-item-empty-page.test.ts | 163 ++++++++++++++++ src/shared/work-item-empty-page.ts | 65 +++++++ 25 files changed, 1009 insertions(+), 687 deletions(-) create mode 100644 mobile/src/tasks/mobile-tasks-hosted-review.test.ts create mode 100644 src/shared/github/work-item-page-errors.ts create mode 100644 src/shared/work-item-empty-page.test.ts create mode 100644 src/shared/work-item-empty-page.ts diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json index 4f4c1cc2c21..b339448cc16 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json @@ -228,46 +228,6 @@ } ] }, - "49c5fd241816": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "failedCount": 0, - "items": [ - { - "key": "github:repo-1:issue:9", - "provider": "github", - "source": { - "author": { - "$rpc": "null" - }, - "id": "issue:9", - "labels": [], - "number": 9, - "repoId": "repo-1", - "repoName": "Repo", - "state": "open", - "title": "An issue", - "type": "issue", - "updatedAt": "2020-01-01T00:00:00.000Z", - "url": "" - }, - "status": "Open", - "subtitle": "Repo #9", - "title": "An issue", - "updatedAt": "2020-01-01T00:00:00.000Z" - } - ], - "sourceErrors": [], - "sourceFallbacks": [], - "sourcesByRepoId": { - "repo-1": { - "issues": "upstream" - } - } - } - }, "50982acf6b28": { "name": "github.countWorkItems#1", "ordinal": 14, @@ -650,6 +610,47 @@ } } }, + "d0a974f0c6a1": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "errorTypes": [], + "failedCount": 0, + "items": [ + { + "key": "github:repo-1:issue:9", + "provider": "github", + "source": { + "author": { + "$rpc": "null" + }, + "id": "issue:9", + "labels": [], + "number": 9, + "repoId": "repo-1", + "repoName": "Repo", + "state": "open", + "title": "An issue", + "type": "issue", + "updatedAt": "2020-01-01T00:00:00.000Z", + "url": "" + }, + "status": "Open", + "subtitle": "Repo #9", + "title": "An issue", + "updatedAt": "2020-01-01T00:00:00.000Z" + } + ], + "sourceErrors": [], + "sourceFallbacks": [], + "sourcesByRepoId": { + "repo-1": { + "issues": "upstream" + } + } + } + }, "de7bfe07e04b": { "name": "github.countWorkItems#1", "ordinal": 14, @@ -748,7 +749,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -781,7 +782,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -815,7 +816,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "1c97db0775ed" }, "state": "67b5ebc67646", @@ -849,7 +850,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "1c97db0775ed" }, "state": "67b5ebc67646", @@ -883,7 +884,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "1c97db0775ed" }, "state": "67b5ebc67646", @@ -917,7 +918,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "1c97db0775ed" }, "state": "67b5ebc67646", @@ -951,7 +952,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "1c97db0775ed" }, "state": "67b5ebc67646", @@ -985,7 +986,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "1c97db0775ed" }, "state": "67b5ebc67646", @@ -1019,7 +1020,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "1c97db0775ed" }, "state": "67b5ebc67646", @@ -1053,7 +1054,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "1c97db0775ed" }, "state": "67b5ebc67646", @@ -1087,7 +1088,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "1c97db0775ed" }, "state": "67b5ebc67646", @@ -1121,7 +1122,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "1c97db0775ed" }, "state": "67b5ebc67646", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json index ef12de80712..3e28c5b0e80 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json @@ -198,46 +198,6 @@ } ] }, - "49c5fd241816": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "failedCount": 0, - "items": [ - { - "key": "github:repo-1:issue:9", - "provider": "github", - "source": { - "author": { - "$rpc": "null" - }, - "id": "issue:9", - "labels": [], - "number": 9, - "repoId": "repo-1", - "repoName": "Repo", - "state": "open", - "title": "An issue", - "type": "issue", - "updatedAt": "2020-01-01T00:00:00.000Z", - "url": "" - }, - "status": "Open", - "subtitle": "Repo #9", - "title": "An issue", - "updatedAt": "2020-01-01T00:00:00.000Z" - } - ], - "sourceErrors": [], - "sourceFallbacks": [], - "sourcesByRepoId": { - "repo-1": { - "issues": "upstream" - } - } - } - }, "5e1882223b41": { "name": "linear.listTeams#1", "ordinal": 7, @@ -342,18 +302,6 @@ } } }, - "737f20de5cc0": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "failedCount": 1, - "items": [], - "sourceErrors": [], - "sourceFallbacks": [], - "sourcesByRepoId": {} - } - }, "823a0f3cec86": { "name": "github.listWorkItems#1", "ordinal": 13, @@ -369,6 +317,19 @@ "ordinal": 9, "value": ["team-1"] }, + "925a40556fcf": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "errorTypes": [], + "failedCount": 1, + "items": [], + "sourceErrors": [], + "sourceFallbacks": [], + "sourcesByRepoId": {} + } + }, "93695401e4f8": { "name": "linear.listTeams#1", "ordinal": 6, @@ -451,6 +412,19 @@ } } }, + "a9e32280eff6": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "errorTypes": [], + "failedCount": 0, + "items": [], + "sourceErrors": [], + "sourceFallbacks": [], + "sourcesByRepoId": {} + } + }, "adcbf91f89c3": { "status": "fulfilled", "startedAt": 0, @@ -566,6 +540,47 @@ } } }, + "d0a974f0c6a1": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "errorTypes": [], + "failedCount": 0, + "items": [ + { + "key": "github:repo-1:issue:9", + "provider": "github", + "source": { + "author": { + "$rpc": "null" + }, + "id": "issue:9", + "labels": [], + "number": 9, + "repoId": "repo-1", + "repoName": "Repo", + "state": "open", + "title": "An issue", + "type": "issue", + "updatedAt": "2020-01-01T00:00:00.000Z", + "url": "" + }, + "status": "Open", + "subtitle": "Repo #9", + "title": "An issue", + "updatedAt": "2020-01-01T00:00:00.000Z" + } + ], + "sourceErrors": [], + "sourceFallbacks": [], + "sourcesByRepoId": { + "repo-1": { + "issues": "upstream" + } + } + } + }, "d34ffc174996": { "name": "github.listWorkItems#1", "ordinal": 12, @@ -672,18 +687,6 @@ } } }, - "e4b031c4e9d5": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "failedCount": 0, - "items": [], - "sourceErrors": [], - "sourceFallbacks": [], - "sourcesByRepoId": {} - } - }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -786,7 +789,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -819,7 +822,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -841,7 +844,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0" + "github-page-2": "925a40556fcf" }, "state": "67b5ebc67646", "effects": [ @@ -874,7 +877,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0", + "github-page-2": "925a40556fcf", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -896,7 +899,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0" + "github-page-2": "925a40556fcf" }, "state": "67b5ebc67646", "effects": [ @@ -929,7 +932,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0", + "github-page-2": "925a40556fcf", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -951,7 +954,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0" + "github-page-2": "925a40556fcf" }, "state": "67b5ebc67646", "effects": [ @@ -984,7 +987,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0", + "github-page-2": "925a40556fcf", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1006,7 +1009,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0" + "github-page-2": "925a40556fcf" }, "state": "67b5ebc67646", "effects": [ @@ -1039,7 +1042,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0", + "github-page-2": "925a40556fcf", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1061,7 +1064,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0" + "github-page-2": "925a40556fcf" }, "state": "67b5ebc67646", "effects": [ @@ -1094,7 +1097,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0", + "github-page-2": "925a40556fcf", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1116,7 +1119,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0" + "github-page-2": "925a40556fcf" }, "state": "67b5ebc67646", "effects": [ @@ -1149,7 +1152,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0", + "github-page-2": "925a40556fcf", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1171,7 +1174,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "e4b031c4e9d5" + "github-page-2": "a9e32280eff6" }, "state": "67b5ebc67646", "effects": [ @@ -1204,7 +1207,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "e4b031c4e9d5", + "github-page-2": "a9e32280eff6", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1226,7 +1229,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0" + "github-page-2": "925a40556fcf" }, "state": "67b5ebc67646", "effects": [ @@ -1259,7 +1262,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0", + "github-page-2": "925a40556fcf", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1281,7 +1284,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0" + "github-page-2": "925a40556fcf" }, "state": "67b5ebc67646", "effects": [ @@ -1314,7 +1317,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "737f20de5cc0", + "github-page-2": "925a40556fcf", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1336,7 +1339,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "e4b031c4e9d5" + "github-page-2": "a9e32280eff6" }, "state": "67b5ebc67646", "effects": [ @@ -1369,7 +1372,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "e4b031c4e9d5", + "github-page-2": "a9e32280eff6", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json index 86efe7703ca..b6f90e25b8d 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json @@ -165,46 +165,6 @@ } ] }, - "49c5fd241816": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "failedCount": 0, - "items": [ - { - "key": "github:repo-1:issue:9", - "provider": "github", - "source": { - "author": { - "$rpc": "null" - }, - "id": "issue:9", - "labels": [], - "number": 9, - "repoId": "repo-1", - "repoName": "Repo", - "state": "open", - "title": "An issue", - "type": "issue", - "updatedAt": "2020-01-01T00:00:00.000Z", - "url": "" - }, - "status": "Open", - "subtitle": "Repo #9", - "title": "An issue", - "updatedAt": "2020-01-01T00:00:00.000Z" - } - ], - "sourceErrors": [], - "sourceFallbacks": [], - "sourcesByRepoId": { - "repo-1": { - "issues": "upstream" - } - } - } - }, "5e1882223b41": { "name": "linear.listTeams#1", "ordinal": 7, @@ -694,6 +654,47 @@ "code": "incompatible_reply" } }, + "d0a974f0c6a1": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "errorTypes": [], + "failedCount": 0, + "items": [ + { + "key": "github:repo-1:issue:9", + "provider": "github", + "source": { + "author": { + "$rpc": "null" + }, + "id": "issue:9", + "labels": [], + "number": 9, + "repoId": "repo-1", + "repoName": "Repo", + "state": "open", + "title": "An issue", + "type": "issue", + "updatedAt": "2020-01-01T00:00:00.000Z", + "url": "" + }, + "status": "Open", + "subtitle": "Repo #9", + "title": "An issue", + "updatedAt": "2020-01-01T00:00:00.000Z" + } + ], + "sourceErrors": [], + "sourceFallbacks": [], + "sourcesByRepoId": { + "repo-1": { + "issues": "upstream" + } + } + } + }, "de1f2f536bd0": { "name": "github.countWorkItems#1", "ordinal": 13, @@ -941,7 +942,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -974,7 +975,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1023,7 +1024,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "cb437d5a4339", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "bd5c2292b89a", "effects": ["f57b6504f168", "470893ebd4a7", "5efa1426d38f"] @@ -1050,7 +1051,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "cb437d5a4339", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "bd5c2292b89a", @@ -1093,7 +1094,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "cb437d5a4339", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "bd5c2292b89a", "effects": ["f57b6504f168", "470893ebd4a7", "5efa1426d38f"] @@ -1120,7 +1121,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "cb437d5a4339", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "bd5c2292b89a", @@ -1163,7 +1164,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "cb437d5a4339", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "bd5c2292b89a", "effects": ["f57b6504f168", "470893ebd4a7", "5efa1426d38f"] @@ -1190,7 +1191,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "cb437d5a4339", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "bd5c2292b89a", @@ -1233,7 +1234,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "cb437d5a4339", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "bd5c2292b89a", "effects": ["f57b6504f168", "470893ebd4a7", "5efa1426d38f"] @@ -1260,7 +1261,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "cb437d5a4339", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "bd5c2292b89a", @@ -1303,7 +1304,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "cb437d5a4339", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "bd5c2292b89a", "effects": ["f57b6504f168", "470893ebd4a7", "5efa1426d38f"] @@ -1330,7 +1331,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "cb437d5a4339", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "bd5c2292b89a", @@ -1373,7 +1374,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "32a7c0ae7918", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "bd5c2292b89a", "effects": ["f57b6504f168", "470893ebd4a7", "5efa1426d38f"] @@ -1400,7 +1401,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "32a7c0ae7918", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "bd5c2292b89a", @@ -1443,7 +1444,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "f3b516f62081", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "bd5c2292b89a", "effects": ["f57b6504f168", "470893ebd4a7", "5efa1426d38f"] @@ -1470,7 +1471,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "f3b516f62081", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "bd5c2292b89a", @@ -1513,7 +1514,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "b948e8307e81", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "bd5c2292b89a", "effects": ["f57b6504f168", "470893ebd4a7", "5efa1426d38f"] @@ -1540,7 +1541,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "b948e8307e81", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "bd5c2292b89a", @@ -1583,7 +1584,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "a947768bc0ed", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "bd5c2292b89a", "effects": ["f57b6504f168", "470893ebd4a7", "5efa1426d38f"] @@ -1610,7 +1611,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "a947768bc0ed", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "bd5c2292b89a", @@ -1653,7 +1654,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "c7584e82c72f", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "bd5c2292b89a", "effects": ["f57b6504f168", "470893ebd4a7", "5efa1426d38f"] @@ -1680,7 +1681,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "c7584e82c72f", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "bd5c2292b89a", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json index 2627fd0afc9..55bd0cc98c4 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json @@ -221,46 +221,6 @@ } ] }, - "49c5fd241816": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "failedCount": 0, - "items": [ - { - "key": "github:repo-1:issue:9", - "provider": "github", - "source": { - "author": { - "$rpc": "null" - }, - "id": "issue:9", - "labels": [], - "number": 9, - "repoId": "repo-1", - "repoName": "Repo", - "state": "open", - "title": "An issue", - "type": "issue", - "updatedAt": "2020-01-01T00:00:00.000Z", - "url": "" - }, - "status": "Open", - "subtitle": "Repo #9", - "title": "An issue", - "updatedAt": "2020-01-01T00:00:00.000Z" - } - ], - "sourceErrors": [], - "sourceFallbacks": [], - "sourcesByRepoId": { - "repo-1": { - "issues": "upstream" - } - } - } - }, "520f24cdfcf1": { "name": "settings.update#1", "ordinal": 3, @@ -841,6 +801,47 @@ "ordinal": 3, "value": false }, + "d0a974f0c6a1": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "errorTypes": [], + "failedCount": 0, + "items": [ + { + "key": "github:repo-1:issue:9", + "provider": "github", + "source": { + "author": { + "$rpc": "null" + }, + "id": "issue:9", + "labels": [], + "number": 9, + "repoId": "repo-1", + "repoName": "Repo", + "state": "open", + "title": "An issue", + "type": "issue", + "updatedAt": "2020-01-01T00:00:00.000Z", + "url": "" + }, + "status": "Open", + "subtitle": "Repo #9", + "title": "An issue", + "updatedAt": "2020-01-01T00:00:00.000Z" + } + ], + "sourceErrors": [], + "sourceFallbacks": [], + "sourcesByRepoId": { + "repo-1": { + "issues": "upstream" + } + } + } + }, "d44ba8747c4c": { "name": "selectedLinearWorkspaceId", "ordinal": 7, @@ -1101,7 +1102,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -1134,7 +1135,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1183,7 +1184,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "db17b820bb60", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "0577812165ed", "effects": [] @@ -1198,7 +1199,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "db17b820bb60", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "0577812165ed", @@ -1241,7 +1242,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "db17b820bb60", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "0577812165ed", "effects": [] @@ -1256,7 +1257,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "db17b820bb60", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "0577812165ed", @@ -1311,7 +1312,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "0577812165ed", "effects": [ @@ -1332,7 +1333,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "0577812165ed", @@ -1393,7 +1394,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "0577812165ed", "effects": [ @@ -1414,7 +1415,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "0577812165ed", @@ -1475,7 +1476,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "0577812165ed", "effects": [ @@ -1496,7 +1497,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "0577812165ed", @@ -1545,7 +1546,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "32a7c0ae7918", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "0577812165ed", "effects": [] @@ -1560,7 +1561,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "32a7c0ae7918", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "0577812165ed", @@ -1603,7 +1604,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "f3b516f62081", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "0577812165ed", "effects": [] @@ -1618,7 +1619,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "f3b516f62081", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "0577812165ed", @@ -1661,7 +1662,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "b948e8307e81", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "0577812165ed", "effects": [] @@ -1676,7 +1677,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "b948e8307e81", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "0577812165ed", @@ -1719,7 +1720,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "a947768bc0ed", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "0577812165ed", "effects": [] @@ -1734,7 +1735,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "a947768bc0ed", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "0577812165ed", @@ -1777,7 +1778,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "c7584e82c72f", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "0577812165ed", "effects": [] @@ -1792,7 +1793,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "c7584e82c72f", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "0577812165ed", diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json index bf011c657d8..c7f65cc304b 100644 --- a/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json @@ -186,46 +186,6 @@ } ] }, - "49c5fd241816": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "failedCount": 0, - "items": [ - { - "key": "github:repo-1:issue:9", - "provider": "github", - "source": { - "author": { - "$rpc": "null" - }, - "id": "issue:9", - "labels": [], - "number": 9, - "repoId": "repo-1", - "repoName": "Repo", - "state": "open", - "title": "An issue", - "type": "issue", - "updatedAt": "2020-01-01T00:00:00.000Z", - "url": "" - }, - "status": "Open", - "subtitle": "Repo #9", - "title": "An issue", - "updatedAt": "2020-01-01T00:00:00.000Z" - } - ], - "sourceErrors": [], - "sourceFallbacks": [], - "sourcesByRepoId": { - "repo-1": { - "issues": "upstream" - } - } - } - }, "534fb96c7e7f": { "name": "settings.update#1", "ordinal": 10, @@ -567,6 +527,47 @@ } } }, + "d0a974f0c6a1": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "errorTypes": [], + "failedCount": 0, + "items": [ + { + "key": "github:repo-1:issue:9", + "provider": "github", + "source": { + "author": { + "$rpc": "null" + }, + "id": "issue:9", + "labels": [], + "number": 9, + "repoId": "repo-1", + "repoName": "Repo", + "state": "open", + "title": "An issue", + "type": "issue", + "updatedAt": "2020-01-01T00:00:00.000Z", + "url": "" + }, + "status": "Open", + "subtitle": "Repo #9", + "title": "An issue", + "updatedAt": "2020-01-01T00:00:00.000Z" + } + ], + "sourceErrors": [], + "sourceFallbacks": [], + "sourcesByRepoId": { + "repo-1": { + "issues": "upstream" + } + } + } + }, "d6a6916c7399": { "name": "settings.update#1", "ordinal": 10, @@ -732,7 +733,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -765,7 +766,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -807,7 +808,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -840,7 +841,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -882,7 +883,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -915,7 +916,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -957,7 +958,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -990,7 +991,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1032,7 +1033,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -1065,7 +1066,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1107,7 +1108,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -1140,7 +1141,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1182,7 +1183,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -1215,7 +1216,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1257,7 +1258,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -1290,7 +1291,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1332,7 +1333,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -1365,7 +1366,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1407,7 +1408,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -1440,7 +1441,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", @@ -1482,7 +1483,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -1515,7 +1516,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", diff --git a/mobile/rpc-foundation/goldens/tk-provider-load.json b/mobile/rpc-foundation/goldens/tk-provider-load.json index a85fe23ff07..968191a3149 100644 --- a/mobile/rpc-foundation/goldens/tk-provider-load.json +++ b/mobile/rpc-foundation/goldens/tk-provider-load.json @@ -85,46 +85,6 @@ } ] }, - "49c5fd241816": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "failedCount": 0, - "items": [ - { - "key": "github:repo-1:issue:9", - "provider": "github", - "source": { - "author": { - "$rpc": "null" - }, - "id": "issue:9", - "labels": [], - "number": 9, - "repoId": "repo-1", - "repoName": "Repo", - "state": "open", - "title": "An issue", - "type": "issue", - "updatedAt": "2020-01-01T00:00:00.000Z", - "url": "" - }, - "status": "Open", - "subtitle": "Repo #9", - "title": "An issue", - "updatedAt": "2020-01-01T00:00:00.000Z" - } - ], - "sourceErrors": [], - "sourceFallbacks": [], - "sourcesByRepoId": { - "repo-1": { - "issues": "upstream" - } - } - } - }, "5e1882223b41": { "name": "linear.listTeams#1", "ordinal": 7, @@ -294,6 +254,47 @@ } } }, + "d0a974f0c6a1": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "errorTypes": [], + "failedCount": 0, + "items": [ + { + "key": "github:repo-1:issue:9", + "provider": "github", + "source": { + "author": { + "$rpc": "null" + }, + "id": "issue:9", + "labels": [], + "number": 9, + "repoId": "repo-1", + "repoName": "Repo", + "state": "open", + "title": "An issue", + "type": "issue", + "updatedAt": "2020-01-01T00:00:00.000Z", + "url": "" + }, + "status": "Open", + "subtitle": "Repo #9", + "title": "An issue", + "updatedAt": "2020-01-01T00:00:00.000Z" + } + ], + "sourceErrors": [], + "sourceFallbacks": [], + "sourcesByRepoId": { + "repo-1": { + "issues": "upstream" + } + } + } + }, "de7bfe07e04b": { "name": "github.countWorkItems#1", "ordinal": 14, @@ -392,7 +393,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816" + "github-page-2": "d0a974f0c6a1" }, "state": "67b5ebc67646", "effects": [ @@ -425,7 +426,7 @@ "mount": "eb79a9b3682a", "linear-context-0": "eb79a9b3682a", "persist-teams-1": "eb79a9b3682a", - "github-page-2": "49c5fd241816", + "github-page-2": "d0a974f0c6a1", "github-count-3": "adcbf91f89c3" }, "state": "67b5ebc67646", diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 1d923043e64..ae946609e76 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -3869,6 +3869,21 @@ }, "worktreeCreateRetry": { "createFailed": "Failed to create workspace" + }, + "hostedMerge": { + "merged": "Merged", + "closed": "Closed", + "conflicts": "Conflicts", + "behind": "Behind", + "blocked": "Blocked", + "checksPending": "Checks pending", + "ableToMerge": "Able to merge", + "unknown": "Unknown" + }, + "pagination": { + "pageUnreachable": "Page {{page}} is beyond what GitHub search can return.", + "loadPageFailed": "Page {{page}} could not be loaded from GitHub.", + "noMoreResults": "No more results on page {{page}}." } }, "terminal": { diff --git a/mobile/src/tasks/mobile-hosted-check-status.test.ts b/mobile/src/tasks/mobile-hosted-check-status.test.ts index f98b71dc575..80a0e33a866 100644 --- a/mobile/src/tasks/mobile-hosted-check-status.test.ts +++ b/mobile/src/tasks/mobile-hosted-check-status.test.ts @@ -2,11 +2,35 @@ import { describe, expect, it } from 'vitest' import type { HostedReviewDecision } from '../../../src/shared/hosted-review' import { getHostedChecksLabel, + getHostedMergeLabel, getHostedReviewLabel, getHostedReviewSignalTone } from './mobile-hosted-check-status' describe('mobile hosted check status', () => { + it.each(['merged', 'closed'] as const)('shows %s before stale merge enrichment', (state) => { + expect(getHostedMergeLabel({ state, mergeable: 'CONFLICTING' })).toBe( + state === 'merged' ? 'Merged' : 'Closed' + ) + expect(getHostedReviewSignalTone({ state, mergeable: 'CONFLICTING' }, 'merge')).toBe('neutral') + }) + + it('does not call pending checks Behind', () => { + const item = { + mergeable: 'MERGEABLE' as const, + checksSummary: { + state: 'pending' as const, + total: 1, + passed: 0, + failed: 0, + pending: 1, + neutral: 0 + } + } + expect(getHostedMergeLabel(item)).toBe('Checks pending') + expect(getHostedReviewSignalTone(item, 'merge')).toBe('warning') + }) + it('renders a hydrated neutral GitLab summary as unresolved checks', () => { const summary = { state: 'neutral' as const, diff --git a/mobile/src/tasks/mobile-hosted-check-status.ts b/mobile/src/tasks/mobile-hosted-check-status.ts index 141e830dec1..79407c414ae 100644 --- a/mobile/src/tasks/mobile-hosted-check-status.ts +++ b/mobile/src/tasks/mobile-hosted-check-status.ts @@ -2,9 +2,12 @@ import type { PRMergeableState, ProviderCheckSummary } from '../../../src/shared/github/pull-request-types' +import { translate } from '../i18n/mobile-i18n' +import { resolveGitHubPRMergeState } from '../../../src/shared/github/pull-request-merge-state' import { getProviderChecksLabel } from '../../../src/shared/provider-check-summary' export type MobileHostedReviewStatus = { + state?: string checksSummary?: ProviderCheckSummary reviewDecision?: string | null reviewRequests?: readonly unknown[] @@ -29,17 +32,47 @@ export function getHostedReviewLabel(item: MobileHostedReviewStatus): string { : 'No reviewers' } -export function getHostedMergeLabel(item: MobileHostedReviewStatus): string { - if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') { - return 'Conflicts' +function hostedMergeState(item: MobileHostedReviewStatus) { + return resolveGitHubPRMergeState({ + state: + item.state === 'merged' || item.state === 'closed' || item.state === 'draft' + ? item.state + : 'open', + mergeable: item.mergeable, + mergeStateStatus: item.mergeStateStatus, + checksSummary: item.checksSummary + }).kind +} + +export function getHostedMergeLabel(item: MobileHostedReviewStatus): string | null { + switch (hostedMergeState(item)) { + case 'merged': + return translate('tasks.hostedMerge.merged', 'Merged') + case 'closed': + return translate('tasks.hostedMerge.closed', 'Closed') + case 'conflicts': + return translate('tasks.hostedMerge.conflicts', 'Conflicts') + case 'behind': + return translate('tasks.hostedMerge.behind', 'Behind') + case 'blocked': + return translate('tasks.hostedMerge.blocked', 'Blocked') + case 'mergeable_checks_pending': + return translate('tasks.hostedMerge.checksPending', 'Checks pending') + case 'mergeable': + case 'mergeable_checks_failed': + case 'mergeable_checks_passed': + return translate('tasks.hostedMerge.ableToMerge', 'Able to merge') + case 'merge_status_unavailable': + case 'checks_passed': + case 'draft': + return null + case 'review_required': + case 'changes_requested': + case 'merge_queue': + case 'merge_queue_auto_merge_on': + case 'checking': + return translate('tasks.hostedMerge.unknown', 'Unknown') } - if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') { - return 'Behind' - } - if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { - return 'Able to merge' - } - return 'Unknown' } export function getHostedChecksLabel(item: { checksSummary?: ProviderCheckSummary }): string { @@ -82,14 +115,27 @@ export function getHostedReviewSignalTone( } return 'neutral' } - if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') { - return 'danger' + switch (hostedMergeState(item)) { + case 'conflicts': + case 'blocked': + return 'danger' + case 'behind': + case 'mergeable_checks_pending': + return 'warning' + case 'mergeable': + case 'mergeable_checks_failed': + case 'mergeable_checks_passed': + return 'success' + case 'merged': + case 'closed': + case 'draft': + case 'review_required': + case 'changes_requested': + case 'merge_queue': + case 'merge_queue_auto_merge_on': + case 'merge_status_unavailable': + case 'checks_passed': + case 'checking': + return 'neutral' } - if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') { - return 'warning' - } - if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { - return 'success' - } - return 'neutral' } diff --git a/mobile/src/tasks/mobile-task-request-cancellation.test.tsx b/mobile/src/tasks/mobile-task-request-cancellation.test.tsx index ef41b73d865..5870751b3de 100644 --- a/mobile/src/tasks/mobile-task-request-cancellation.test.tsx +++ b/mobile/src/tasks/mobile-task-request-cancellation.test.tsx @@ -40,9 +40,9 @@ afterEach(() => { mounted.clear() }) -function reply(request: SentRequest): RpcResponse { +function reply(request: SentRequest, rowCount = 1): RpcResponse { if (request.method === 'github.countWorkItems') { - return { id: 'count', ok: true, result: { count: 100 } } + return { id: 'count', ok: true, result: 100 } } const params = request.args[1] const query = @@ -51,19 +51,17 @@ function reply(request: SentRequest): RpcResponse { id: 'list', ok: true, result: { - items: [ - { - id: 'issue-1', - type: 'issue', - number: 1, - title: query, - state: 'open', - labels: [], - url: 'https://example.com/issues/1', - updatedAt: '2026-10-27T00:00:00Z', - author: null - } - ] + items: Array.from({ length: rowCount }, (_, index) => ({ + id: `issue-${index}`, + type: 'issue', + number: index + 1, + title: query, + state: 'open', + labels: [], + url: 'https://example.com/issues/1', + updatedAt: '2026-10-27T00:00:00Z', + author: null + })) } } } @@ -184,11 +182,11 @@ async function mount(provider: Provider, repoCount = 30, counted = false) { return { request: request! } }, pending, - async answer(requests = pending()) { + async answer(requests = pending(), respond = reply) { await act(async () => { for (const request of requests) { answered.add(request) - request.resolve(reply(request)) + request.resolve(respond(request)) } }) }, @@ -312,3 +310,77 @@ describe('task repository request ownership', () => { expect(fixture.model().setRefreshing).toHaveBeenLastCalledWith(false) }) }) + +describe('GitHub next-page source failures', () => { + for (const [source, type, message] of [ + ['prs', 'rate_limited', 'API rate limit exceeded'], + ['issues', 'permission_denied', 'Resource not accessible'], + ['issues', 'validation_error', 'Invalid query'], + ['prs', 'validation_error', 'Only the first 1000 search results are available'] + ] as const) { + it(`keeps Next retryable for ${source} ${type}`, async () => { + const fixture = await mount('github', 1) + const first = await fixture.load() + await fixture.answer(fixture.pending(), (request) => reply(request, 72)) + await first.request + expect(fixture.model().githubCanLoadUncountedNextPage).toBe(true) + let page: Promise | undefined + await act(async () => { + page = fixture.model().handleGitHubPageChange(1) + }) + await fixture.answer(fixture.pending(), () => ({ + id: 'page', + ok: true, + result: { items: [], errors: { [source]: { type, message } } } + })) + await page + expect(fixture.model().setError).toHaveBeenLastCalledWith( + 'Page 2 could not be loaded from GitHub.' + ) + expect(fixture.model().githubCanLoadUncountedNextPage).toBe(true) + expect(fixture.model().githubCountedTotalPages).toBeNull() + expect(fixture.model().githubPages).toHaveLength(1) + }) + } + + it('keeps a proven issue-search window limit through a late count, resetting on reload', async () => { + const fixture = await mount('github', 1, true) + const first = await fixture.load() + await fixture.answer(fixture.pending(), (request) => reply(request, 72)) + await first.request + const count = fixture.pending() + expect(starts(count, 'github.countWorkItems')).toHaveLength(1) + let page: Promise | undefined + await act(async () => { + page = fixture.model().handleGitHubPageChange(1) + }) + await fixture.answer( + fixture.pending().filter((request) => !count.includes(request)), + () => ({ + id: 'page', + ok: true, + result: { + items: [], + errors: { + issues: { + type: 'validation_error', + message: 'Only the first 1000 search results are available' + } + } + } + }) + ) + await page + expect(fixture.model().setError).toHaveBeenLastCalledWith( + 'Page 2 is beyond what GitHub search can return.' + ) + expect(fixture.model().githubCanLoadUncountedNextPage).toBe(false) + await fixture.answer(count) + expect(fixture.model().githubCountedTotalPages).toBe(3) + expect(fixture.model().githubTotalPages).toBe(1) + const fresh = await fixture.load() + await fixture.drain() + await fresh.request + expect(fixture.model().githubTotalPages).toBe(3) + }) +}) diff --git a/mobile/src/tasks/mobile-tasks-hosted-review.test.ts b/mobile/src/tasks/mobile-tasks-hosted-review.test.ts new file mode 100644 index 00000000000..7b087344df4 --- /dev/null +++ b/mobile/src/tasks/mobile-tasks-hosted-review.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' +import { getGitHubMergeLabel } from './mobile-tasks-hosted-review' +import type { GitHubWorkItem } from './mobile-tasks-provider-detail-types' + +vi.mock('./mobile-tasks-item-mapping', () => ({ projectRowType: vi.fn() })) + +const item: GitHubWorkItem = { + id: 'pr:30140', + type: 'pr', + number: 30140, + title: 'Page GitHub tasks by number', + state: 'merged', + url: 'https://github.com/stablyai/orca/pull/30140', + labels: [], + updatedAt: '2026-10-16T00:00:00Z', + author: null, + repoId: 'orca', + repoName: 'orca' +} + +describe('mobile Tasks GitHub merge label', () => { + it.each(['merged', 'closed'] as const)('uses %s state without merge enrichment', (state) => { + expect(getGitHubMergeLabel({ ...item, state })).toBe(state === 'merged' ? 'Merged' : 'Closed') + }) + + it('hides the merge label for open list rows without merge enrichment', () => { + expect(getGitHubMergeLabel({ ...item, state: 'open' })).toBeNull() + }) +}) diff --git a/mobile/src/tasks/mobile-tasks-hosted-review.ts b/mobile/src/tasks/mobile-tasks-hosted-review.ts index 9a7966442b3..dbc47832fba 100644 --- a/mobile/src/tasks/mobile-tasks-hosted-review.ts +++ b/mobile/src/tasks/mobile-tasks-hosted-review.ts @@ -1,3 +1,4 @@ +import { getHostedMergeLabel } from './mobile-hosted-check-status' import { projectRowType } from './mobile-tasks-item-mapping' import type { HostedReviewItem, @@ -131,29 +132,8 @@ export function hostedBranchSummary(item: TaskItem): { head: string; base: strin return null } -export function getGitHubMergeLabel(item: GitHubWorkItem): string { - if (item.mergeable === undefined && item.mergeStateStatus === undefined) { - return 'Merge' - } - if (item.state === 'merged') { - return 'Merged' - } - if (item.state === 'closed') { - return 'Closed' - } - if (item.mergeable === 'CONFLICTING') { - return 'Conflicts' - } - if (item.mergeStateStatus === 'BEHIND') { - return 'Behind' - } - if (item.mergeStateStatus === 'BLOCKED') { - return 'Blocked' - } - if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { - return 'Able to merge' - } - return 'Unknown' +export function getGitHubMergeLabel(item: GitHubWorkItem): string | null { + return getHostedMergeLabel(item) } export function getHostedReviewMergeMethodLabel(method: HostedReviewMergeMethod): string { diff --git a/mobile/src/tasks/mobile-tasks-provider-item-list.tsx b/mobile/src/tasks/mobile-tasks-provider-item-list.tsx index 7be7ccff4ef..1b8df9c0e1b 100644 --- a/mobile/src/tasks/mobile-tasks-provider-item-list.tsx +++ b/mobile/src/tasks/mobile-tasks-provider-item-list.tsx @@ -178,6 +178,11 @@ export function renderMobileTasksProviderItemList( const isGitLabMr = item.provider === 'gitlab' && item.source.type === 'mr' const githubPrDelta = isGitHubPr ? formatGitHubPRDelta(item.source) : null const branchSummary = hostedBranchSummary(item) + const mergeLabel = isGitHubPr + ? getGitHubMergeLabel(item.source) + : isGitLabMr + ? getHostedMergeLabel(item.source) + : null return ( [styles.taskRow, pressed && styles.taskRowPressed]} @@ -256,7 +261,7 @@ export function renderMobileTasksProviderItemList( {getHostedChecksLabel(item.source)} - {isGitHubPr || isGitLabMr ? ( + {mergeLabel ? ( - {isGitHubPr - ? getGitHubMergeLabel(item.source) - : getHostedMergeLabel(item.source)} + {mergeLabel} ) : null} diff --git a/mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx b/mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx index fc154e1a02b..d92df16b365 100644 --- a/mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx @@ -1,3 +1,5 @@ +import type { ClassifiedError } from '../../../src/shared/classified-error' +import { getWorkItemPageErrorTypes } from '../../../src/shared/github/work-item-page-errors' import type { RuntimeHydrationModel } from './use-mobile-tasks-runtime-hydration' import { readJiraConnection, reconcileJiraConnection } from './jira-mobile-connection' import { @@ -138,6 +140,7 @@ export function useMobileTasksProviderLoadActions> failedCount: number + errorTypes: ClassifiedError['type'][] sourcesByRepoId: Record sourceErrors: GitHubIssueSourceError[] sourceFallbacks: GitHubIssueSourceFallback[] @@ -161,8 +164,9 @@ export function useMobileTasksProviderLoadActions> sources?: GitHubRepoSources errors?: { issues?: { message: string } } @@ -171,6 +175,7 @@ export function useMobileTasksProviderLoadActions createGitHubTask(repo, item)), sources: envelope.sources, + errorTypes: getWorkItemPageErrorTypes(decoded.errors), sourceError: extractGitHubIssueSourceError(repo, envelope), sourceFallback: extractGitHubIssueSourceFallback(repo, envelope), repoId: repo.id @@ -213,6 +218,7 @@ export function useMobileTasksProviderLoadActions result.items) .sort((a, b) => b.source.number - a.source.number), failedCount: results.filter((result) => result.error).length, + errorTypes: results.flatMap((result) => result.errorTypes ?? []), sourcesByRepoId, sourceErrors, sourceFallbacks diff --git a/mobile/src/tasks/use-mobile-tasks-task-pagination-actions.test.tsx b/mobile/src/tasks/use-mobile-tasks-task-pagination-actions.test.tsx index 0ee485528e8..383abda8201 100644 --- a/mobile/src/tasks/use-mobile-tasks-task-pagination-actions.test.tsx +++ b/mobile/src/tasks/use-mobile-tasks-task-pagination-actions.test.tsx @@ -1,4 +1,4 @@ -import { createElement } from 'react' +import { createElement, useState } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -7,6 +7,7 @@ vi.mock('./mobile-tasks-dependencies', async () => { return { useCallback: react.useCallback, useMemo: react.useMemo, + useState: react.useState, ...(await import('../../../src/shared/work-items')) } }) @@ -43,6 +44,7 @@ function buildModel( taskUiReady: true, setError: vi.fn(), setGithubCurrentPage: vi.fn(), + setGithubCountedTotalPages: vi.fn(), setGithubLoadingTargetPage: vi.fn(), setGithubPages: vi.fn(), setGithubPaginationLoading: vi.fn(), @@ -53,15 +55,19 @@ function buildModel( let renderer: ReactTestRenderer | null = null let changePage: ((page: number) => Promise) | null = null let canLoadUncountedNextPage = false +let totalPages = 0 function mount(model: ReturnType): void { function Harness(): null { + const [countedTotalPages, setCountedTotalPages] = useState(model.githubCountedTotalPages) + model.setGithubCountedTotalPages.mockImplementation(setCountedTotalPages) const actions = useMobileTasksTaskPaginationActions( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture carries every member the page handler reads. - model as unknown as TaskListLoadingModel + { ...model, githubCountedTotalPages: countedTotalPages } as unknown as TaskListLoadingModel ) changePage = actions.handleGitHubPageChange canLoadUncountedNextPage = actions.githubCanLoadUncountedNextPage + totalPages = actions.githubTotalPages return null } act(() => { @@ -79,7 +85,8 @@ describe('GitHub task page change', () => { it('asks for the target page by number and shows it', async () => { const model = buildModel(async () => ({ items: oldSelectionPage, - failedCount: 0 + failedCount: 0, + errorTypes: [] })) mount(model) await act(async () => { @@ -110,7 +117,7 @@ describe('GitHub task page change', () => { }) model.loadGenerationRef.current += 1 await act(async () => { - land({ items: oldSelectionPage, failedCount: 0 }) + land({ items: oldSelectionPage, failedCount: 0, errorTypes: [] }) await pending }) expect(model.setGithubPages).not.toHaveBeenCalled() @@ -118,6 +125,46 @@ describe('GitHub task page change', () => { expect(model.setGithubPaginationLoading).toHaveBeenLastCalledWith(false) }) + it('explains a clean empty next page and withdraws speculative Next', async () => { + const model = buildModel(async () => ({ items: [], failedCount: 0, errorTypes: [] }), null) + model.githubPages = [ + Array.from({ length: 72 }, (_, index) => ({ + key: `github:repo-1:issue:${index}`, + source: { repoId: 'repo-1' } + })) + ] + mount(model) + expect(canLoadUncountedNextPage).toBe(true) + await act(async () => { + await changePage?.(1) + }) + expect(model.setError).toHaveBeenCalledWith('No more results on page 2.') + expect(canLoadUncountedNextPage).toBe(false) + expect(model.setItems).not.toHaveBeenCalled() + }) + + it('explains an all-repo failure and keeps the page retryable', async () => { + const model = buildModel(async () => ({ items: [], failedCount: 1, errorTypes: [] })) + mount(model) + await act(async () => { + await changePage?.(1) + }) + expect(model.setError).toHaveBeenCalledWith('Page 2 could not be loaded from GitHub.') + expect(model.setGithubCountedTotalPages).not.toHaveBeenCalled() + expect(model.setItems).not.toHaveBeenCalled() + }) + + it('keeps a real count when an empty page is returned', async () => { + const model = buildModel(async () => ({ items: [], failedCount: 0, errorTypes: [] }), 3) + mount(model) + await act(async () => { + await changePage?.(1) + }) + expect(model.setError).toHaveBeenCalledWith('No more results on page 2.') + expect(totalPages).toBe(3) + expect(model.setItems).not.toHaveBeenCalled() + }) + describe('without a count, as in the default all-repos view', () => { function rows(repoId: string, count: number): PageRow[] { return Array.from({ length: count }, (_, index) => ({ @@ -127,7 +174,7 @@ describe('GitHub task page change', () => { } function lastPageOffersNext(page: PageRow[]): boolean { - const model = buildModel(async () => ({ items: [], failedCount: 0 }), null) + const model = buildModel(async () => ({ items: [], failedCount: 0, errorTypes: [] }), null) model.githubPages = [page] model.selectedHostedRepos = [{ id: 'repo-a' }, { id: 'repo-b' }] mount(model) diff --git a/mobile/src/tasks/use-mobile-tasks-task-pagination-actions.tsx b/mobile/src/tasks/use-mobile-tasks-task-pagination-actions.tsx index d24506a4aff..0926643931b 100644 --- a/mobile/src/tasks/use-mobile-tasks-task-pagination-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-task-pagination-actions.tsx @@ -1,10 +1,18 @@ +import { + applyEmptyPageClamp, + applyWindowPageLimit, + deriveAdvertisedTotalPages, + resolveEmptyPageOutcome +} from '../../../src/shared/work-item-empty-page' +import { translate } from '../i18n/mobile-i18n' import type { TaskListLoadingModel } from './use-mobile-tasks-task-list-loading' import { CROSS_REPO_DISPLAY_LIMIT, PER_REPO_FETCH_LIMIT, getTaskPagePerRepoLimit, useCallback, - useMemo + useMemo, + useState } from './mobile-tasks-dependencies' import { type TaskItem, buildPartialRepositoryNotice } from './mobile-tasks-legacy-foundation' import { linearAccountConnect } from './mobile-task-list-operations' @@ -42,6 +50,7 @@ export function useMobileTasksTaskPaginationActions(model: TaskListLoadingModel) selectedHostedRepos, setError, setGithubCurrentPage, + setGithubCountedTotalPages, setGithubLoadingTargetPage, setGithubPages, setGithubPaginationLoading, @@ -109,10 +118,15 @@ export function useMobileTasksTaskPaginationActions(model: TaskListLoadingModel) [loadTasks] ) - // Zero count uses the desktop’s failed-count fallback. - const githubTotalPages = githubCountedTotalPages - ? Math.max(githubPages.length, githubCountedTotalPages) - : githubPages.length + const [windowLimit, setWindowLimit] = useState<{ generation: number; pages: number } | null>(null) + const provenPageLimit = + windowLimit?.generation === loadGenerationRef.current ? windowLimit.pages : null + const githubTotalPages = deriveAdvertisedTotalPages({ + loadedPages: githubPages.length, + countedTotalPages: githubCountedTotalPages, + fallbackTotalPages: githubPages.length, + provenPageLimit + }) const githubPerRepoLimit = getTaskPagePerRepoLimit( selectedHostedRepos.length, PER_REPO_FETCH_LIMIT, @@ -120,7 +134,9 @@ export function useMobileTasksTaskPaginationActions(model: TaskListLoadingModel) ) // One full per-repo page may have more; all-repo mode skips counts and relies on this to reach page two. const githubCanLoadUncountedNextPage = - !githubCountedTotalPages && hasRepoWithFullPage(githubPages.at(-1) ?? [], githubPerRepoLimit) + !githubCountedTotalPages && + (provenPageLimit === null || githubPages.length < provenPageLimit) && + hasRepoWithFullPage(githubPages.at(-1) ?? [], githubPerRepoLimit) const githubCanShowPagination = githubTotalPages > 1 || (githubPages.length > 0 && githubCanLoadUncountedNextPage) const githubPagePickerPages = useMemo(() => { @@ -165,11 +181,52 @@ export function useMobileTasksTaskPaginationActions(model: TaskListLoadingModel) const generation = loadGenerationRef.current const isCurrent = () => loadGenerationRef.current === generation && clientRef.current === client + setError('') setGithubPaginationLoading(true) setGithubLoadingTargetPage(targetPage) try { const page = await fetchGitHubItemsPage(client, selectedHostedRepos, targetPage, isCurrent) - if (!isCurrent() || page.items.length === 0) { + if (!isCurrent()) { + return + } + if (page.items.length === 0) { + const emptyPage = { + target: targetPage, + failedCount: page.failedCount, + errorTypes: page.errorTypes + } + const { reason } = resolveEmptyPageOutcome({ ...emptyPage, countedTotalPages: null }) + if (reason === 'window-unreachable') { + setError( + translate( + 'tasks.pagination.pageUnreachable', + 'Page {{page}} is beyond what GitHub search can return.', + { page: targetPage + 1 } + ) + ) + setWindowLimit((previous) => ({ + generation, + pages: applyWindowPageLimit( + previous?.generation === generation ? previous.pages : null, + targetPage + ) + })) + } else if (reason === 'load-failed') { + setError( + translate( + 'tasks.pagination.loadPageFailed', + 'Page {{page}} could not be loaded from GitHub.', + { page: targetPage + 1 } + ) + ) + } else { + setError( + translate('tasks.pagination.noMoreResults', 'No more results on page {{page}}.', { + page: targetPage + 1 + }) + ) + setGithubCountedTotalPages((previous) => applyEmptyPageClamp(previous, emptyPage)) + } return } if (page.failedCount > 0) { @@ -195,6 +252,7 @@ export function useMobileTasksTaskPaginationActions(model: TaskListLoadingModel) githubPaginationLoading, loadGenerationRef, selectedHostedRepos, + setGithubCountedTotalPages, tasksSupported ] ) diff --git a/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.tsx b/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.tsx index 508804dbf17..ef417564494 100644 --- a/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.tsx +++ b/src/renderer/src/components/task-page/github/task-page-github-landing-refresh-run.tsx @@ -15,7 +15,7 @@ import { } from '@/components/task-page/github/task-page-github-work-item-mutation-pages' import { clearTaskPageGitHubAuthorityThroughGeneration } from '@/components/task-page/github/task-page-github-work-item-authority-refresh' import { getOrCreateQuietRevalidateState } from '@/components/task-page/github/task-page-github-work-item-quiet-state' -import { resolveEmptyPageOutcome } from '@/components/task-page/task-page-work-item-pagination' +import { resolveEmptyPageOutcome } from '../../../../../shared/work-item-empty-page' import { deriveTaskPageGitHubWorkItemsFetchOptions, shouldReplaceTaskPageItemsAfterRefresh, diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-list-projection.ts b/src/renderer/src/components/task-page/github/use-task-page-github-list-projection.ts index 8a98410dfd4..0a7e0d212bb 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-list-projection.ts +++ b/src/renderer/src/components/task-page/github/use-task-page-github-list-projection.ts @@ -4,7 +4,7 @@ import { useCallback, useMemo, useEffect } from 'react' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' import { taskPageGitHubItemKey } from '@/components/task-page/github/task-page-github-work-item-mutation-keys' import { deriveTaskPagePRCheckSummary } from '../task-page-pr-check-summary' -import { deriveAdvertisedTotalPages } from '@/components/task-page/task-page-work-item-pagination' +import { deriveAdvertisedTotalPages } from '../../../../../shared/work-item-empty-page' import { GITHUB_PR_TASK_GRID_CLASS, GITHUB_TASK_GRID_CLASS, diff --git a/src/renderer/src/components/task-page/github/use-task-page-github-search-pagination.ts b/src/renderer/src/components/task-page/github/use-task-page-github-search-pagination.ts index 940dd852912..67eff53090c 100644 --- a/src/renderer/src/components/task-page/github/use-task-page-github-search-pagination.ts +++ b/src/renderer/src/components/task-page/github/use-task-page-github-search-pagination.ts @@ -4,9 +4,9 @@ import { stripRepoQualifiers } from '../../../../../shared/task-query' import { taskPageToGitHubApiPage } from '../../../../../shared/work-items' import { resolveEmptyPageOutcome, - applyWindowPageLimit, - applyEmptyPageClamp -} from '@/components/task-page/task-page-work-item-pagination' + applyEmptyPageClamp, + applyWindowPageLimit +} from '../../../../../shared/work-item-empty-page' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { overlayPendingOnTaskPagePages } from '@/components/task-page/github/task-page-github-work-item-mutation-pages' diff --git a/src/renderer/src/components/task-page/task-page-work-item-pagination.test.ts b/src/renderer/src/components/task-page/task-page-work-item-pagination.test.ts index 7bd8bae8b8b..e98aebbb114 100644 --- a/src/renderer/src/components/task-page/task-page-work-item-pagination.test.ts +++ b/src/renderer/src/components/task-page/task-page-work-item-pagination.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' import { accumulateWorkItemPages, - applyEmptyPageClamp, - applyWindowPageLimit, buildSelectedReposKey, - deriveAdvertisedTotalPages, - resolveEmptyPageOutcome, workItemIdentity } from './task-page-work-item-pagination' @@ -44,162 +40,6 @@ describe('workItemIdentity', () => { }) }) -describe('resolveEmptyPageOutcome', () => { - const base = { failedCount: 0, errorTypes: [] as const, countedTotalPages: null } - - it('clamps and reports unreachable when the search window 422 is present', () => { - expect( - resolveEmptyPageOutcome({ ...base, target: 33, errorTypes: ['validation_error'] }) - ).toEqual({ reason: 'window-unreachable', clampTotalPagesTo: 33 }) - expect( - resolveEmptyPageOutcome({ - ...base, - target: 33, - errorTypes: ['validation_error'], - countedTotalPages: 39 - }) - ).toEqual({ reason: 'window-unreachable', clampTotalPagesTo: 33 }) - }) - - it('resolves as load-failed when a window 422 coincides with a sibling failure', () => { - // A sibling failure says nothing about healthy repos’ remaining pages and must outrank a window clamp. - expect( - resolveEmptyPageOutcome({ - ...base, - target: 5, - failedCount: 1, - errorTypes: ['validation_error'] - }) - ).toEqual({ reason: 'load-failed', clampTotalPagesTo: null }) - expect( - resolveEmptyPageOutcome({ - ...base, - target: 5, - errorTypes: ['validation_error', 'permission_denied'] - }) - ).toEqual({ reason: 'load-failed', clampTotalPagesTo: null }) - }) - - it('reports a failure without clamping for transient or unclassified errors', () => { - expect(resolveEmptyPageOutcome({ ...base, target: 5, failedCount: 2 })).toEqual({ - reason: 'load-failed', - clampTotalPagesTo: null - }) - for (const type of ['permission_denied', 'not_found', 'rate_limited', 'unknown'] as const) { - expect(resolveEmptyPageOutcome({ ...base, target: 5, errorTypes: [type] })).toEqual({ - reason: 'load-failed', - clampTotalPagesTo: null - }) - } - }) - - it('withdraws the speculative page on clean empty only while the count is unknown', () => { - expect(resolveEmptyPageOutcome({ ...base, target: 2 })).toEqual({ - reason: 'end-of-data', - clampTotalPagesTo: 2 - }) - expect(resolveEmptyPageOutcome({ ...base, target: 2, countedTotalPages: 0 })).toEqual({ - reason: 'end-of-data', - clampTotalPagesTo: 2 - }) - // PR failures can become clean-empty results; shrinking a real count would silently hide healthy pages. - expect(resolveEmptyPageOutcome({ ...base, target: 2, countedTotalPages: 34 })).toEqual({ - reason: 'end-of-data', - clampTotalPagesTo: null - }) - expect(resolveEmptyPageOutcome({ ...base, target: 0 })).toEqual({ - reason: 'end-of-data', - clampTotalPagesTo: 1 - }) - }) -}) - -describe('applyEmptyPageClamp', () => { - const clean = { failedCount: 0, errorTypes: [] as const } - - it('keeps a real count that resolved between click and response', () => { - // The count can resolve during fetch; clamp against committed state rather than the click-time closure. - expect(applyEmptyPageClamp(33, { ...clean, target: 2 })).toBe(33) - }) - - it('withdraws the speculative page while the count is unknown or failed', () => { - expect(applyEmptyPageClamp(null, { ...clean, target: 2 })).toBe(2) - expect(applyEmptyPageClamp(0, { ...clean, target: 2 })).toBe(2) - }) - - it('never touches the count for window or failure outcomes', () => { - expect( - applyEmptyPageClamp(39, { target: 33, failedCount: 0, errorTypes: ['validation_error'] }) - ).toBe(39) - expect(applyEmptyPageClamp(33, { target: 5, failedCount: 2, errorTypes: [] })).toBe(33) - expect(applyEmptyPageClamp(null, { target: 5, failedCount: 2, errorTypes: [] })).toBe(null) - }) -}) - -describe('applyWindowPageLimit', () => { - it('sets, only lowers, and never goes below one page', () => { - expect(applyWindowPageLimit(null, 33)).toBe(33) - expect(applyWindowPageLimit(33, 20)).toBe(20) - expect(applyWindowPageLimit(20, 33)).toBe(20) - expect(applyWindowPageLimit(null, 0)).toBe(1) - }) -}) - -describe('deriveAdvertisedTotalPages', () => { - it('is order-independent: a count landing after a speculative withdrawal wins', () => { - // A late real count must overwrite speculative end-of-data withdrawal. - expect( - deriveAdvertisedTotalPages({ - loadedPages: 1, - countedTotalPages: 39, - fallbackTotalPages: 2, - provenPageLimit: null - }) - ).toBe(39) - }) - - it('caps a real count with the proven window limit', () => { - expect( - deriveAdvertisedTotalPages({ - loadedPages: 3, - countedTotalPages: 39, - fallbackTotalPages: 4, - provenPageLimit: 33 - }) - ).toBe(33) - }) - - it('uses the fallback while the count is unknown/failed, still window-capped', () => { - expect( - deriveAdvertisedTotalPages({ - loadedPages: 1, - countedTotalPages: null, - fallbackTotalPages: 2, - provenPageLimit: null - }) - ).toBe(2) - expect( - deriveAdvertisedTotalPages({ - loadedPages: 1, - countedTotalPages: 0, - fallbackTotalPages: 5, - provenPageLimit: 3 - }) - ).toBe(3) - }) - - it('never advertises fewer pages than are loaded', () => { - expect( - deriveAdvertisedTotalPages({ - loadedPages: 5, - countedTotalPages: 2, - fallbackTotalPages: 2, - provenPageLimit: 2 - }) - ).toBe(5) - }) -}) - describe('buildSelectedReposKey', () => { const contextFor = (r: { id: string }) => ({ provider: 'github', repoId: r.id }) diff --git a/src/renderer/src/components/task-page/task-page-work-item-pagination.ts b/src/renderer/src/components/task-page/task-page-work-item-pagination.ts index b5f14c78e25..e9774f5247b 100644 --- a/src/renderer/src/components/task-page/task-page-work-item-pagination.ts +++ b/src/renderer/src/components/task-page/task-page-work-item-pagination.ts @@ -1,4 +1,3 @@ -import type { ClassifiedError } from '../../../../shared/classified-error' import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' /** Inclusive updatedAt cursors preserve timestamp ties; dedup boundary rows and backfill full display pages, flushing partials on exhaustion/stall. */ @@ -8,70 +7,6 @@ export function workItemIdentity(item: Pick): s return `${item.repoId}:${item.id}` } -export type EmptyPageOutcome = { - reason: 'window-unreachable' | 'load-failed' | 'end-of-data' - /** Count clamp or null; window limits belong in applyWindowPageLimit, never the count slot. */ - clampTotalPagesTo: number | null -} - -/** Only all-window 422 errors prove a cap; transient sibling failures win, and clean-empty results may withdraw speculative counts only. */ -export function resolveEmptyPageOutcome(args: { - target: number - failedCount: number - errorTypes: readonly ClassifiedError['type'][] - countedTotalPages: number | null -}): EmptyPageOutcome { - const clamp = Math.max(1, Math.floor(args.target)) - // A sibling non-window error may hide healthy pages, so it must block the clamp. - const onlyWindowErrors = - args.errorTypes.length > 0 && args.errorTypes.every((type) => type === 'validation_error') - if (onlyWindowErrors && args.failedCount === 0) { - return { reason: 'window-unreachable', clampTotalPagesTo: clamp } - } - if (args.failedCount > 0 || args.errorTypes.length > 0) { - return { reason: 'load-failed', clampTotalPagesTo: null } - } - const countUnknown = args.countedTotalPages === null || args.countedTotalPages === 0 - return { reason: 'end-of-data', clampTotalPagesTo: countUnknown ? clamp : null } -} - -/** Clamp speculative counts against the latest committed state; store proven 422 limits separately so late real counts can replace speculation. */ -export function applyEmptyPageClamp( - previous: number | null, - args: { - target: number - failedCount: number - errorTypes: readonly ClassifiedError['type'][] - } -): number | null { - const outcome = resolveEmptyPageOutcome({ ...args, countedTotalPages: previous }) - if (outcome.reason !== 'end-of-data' || outcome.clampTotalPagesTo === null) { - return previous - } - return outcome.clampTotalPagesTo -} - -/** Proven window 422 limit: set once, only ever lowered, reset per generation. */ -export function applyWindowPageLimit(previous: number | null, target: number): number { - const clamp = Math.max(1, Math.floor(target)) - return previous === null ? clamp : Math.min(previous, clamp) -} - -/** Cap count/fallback by the proven window limit, retaining loaded pages; separate state makes late count updates order-independent. */ -export function deriveAdvertisedTotalPages(args: { - loadedPages: number - countedTotalPages: number | null - fallbackTotalPages: number - provenPageLimit: number | null -}): number { - const uncapped = - args.countedTotalPages && args.countedTotalPages > 0 - ? Math.max(args.loadedPages, args.countedTotalPages) - : args.fallbackTotalPages - const capped = args.provenPageLimit === null ? uncapped : Math.min(uncapped, args.provenPageLimit) - return Math.max(args.loadedPages, capped) -} - /** Key selection by request fields and resolved timestamp-free source context so fresh repo arrays cannot reset pagination mid-click. */ export function buildSelectedReposKey< T extends { diff --git a/src/renderer/src/store/github/work-item-aggregate-actions.ts b/src/renderer/src/store/github/work-item-aggregate-actions.ts index 51eea95cb16..fe0993a01f1 100644 --- a/src/renderer/src/store/github/work-item-aggregate-actions.ts +++ b/src/renderer/src/store/github/work-item-aggregate-actions.ts @@ -1,3 +1,4 @@ +import { getWorkItemPageErrorTypes } from '../../../../shared/github/work-item-page-errors' import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { GitHubSlice } from './slice-types' @@ -10,10 +11,7 @@ import { PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' -import { - GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN, - isGitHubWorkItemsQueryTooLarge -} from '../slices/github-work-items-query-bounds' +import { isGitHubWorkItemsQueryTooLarge } from '../slices/github-work-items-query-bounds' import { workItemsCacheKey, workItemsInflightRequestKey } from './cache-identity' import { isFresh, WORK_ITEMS_CACHE_TTL } from './cache-policy' import { @@ -132,25 +130,15 @@ export const createWorkItemAggregateActions = ( ...(options?.noCache ? { noCache: true } : {}), ...(options?.wakeWslDistro ? { wakeWslDistro: true } : {}) }) + errorTypes.push(...getWorkItemPageErrorTypes(envelope.errors)) // Page-N errors bypass initial-fetch banners; log them to expose silent truncation (design §6). if (envelope.errors?.issues) { - const { type, message } = envelope.errors.issues - // Only 1000-result-window 422 implies unreachable clamp; classify other validation errors as failures. - errorTypes.push( - type === 'validation_error' && - !GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN.test(message) - ? 'unknown' - : type - ) console.warn( `[workItems] next page ${r.repoId} issues-side partial failure:`, envelope.errors.issues ) } if (envelope.errors?.prs) { - // Window 422 applies only to issues; PR validation failures never imply unreachable. - const { type } = envelope.errors.prs - errorTypes.push(type === 'validation_error' ? 'unknown' : type) console.warn( `[workItems] next page ${r.repoId} prs-side partial failure:`, envelope.errors.prs diff --git a/src/shared/github/work-item-page-errors.ts b/src/shared/github/work-item-page-errors.ts new file mode 100644 index 00000000000..19fba08569c --- /dev/null +++ b/src/shared/github/work-item-page-errors.ts @@ -0,0 +1,43 @@ +import type { ClassifiedError } from '../classified-error' +import { GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN } from './work-items-query-bounds' + +export function getWorkItemPageErrorTypes(errors: unknown): ClassifiedError['type'][] { + if (errors === undefined || errors === null) { + return [] + } + if (typeof errors !== 'object') { + return ['unknown'] + } + const sources = [ + ['issues', 'issues' in errors ? errors.issues : undefined], + ['prs', 'prs' in errors ? errors.prs : undefined] + ] as const + return sources.flatMap(([source, error]): ClassifiedError['type'][] => { + if (error === undefined || error === null) { + return [] + } + if (typeof error !== 'object' || !('type' in error)) { + return ['unknown'] + } + switch (error.type) { + case 'validation_error': + // Only issue search's 1000-result window proves a page limit. + return [ + source === 'issues' && + 'message' in error && + typeof error.message === 'string' && + GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN.test(error.message) + ? 'validation_error' + : 'unknown' + ] + case 'permission_denied': + case 'not_found': + case 'issues_disabled': + case 'rate_limited': + case 'network_error': + return [error.type] + default: + return ['unknown'] + } + }) +} diff --git a/src/shared/work-item-empty-page.test.ts b/src/shared/work-item-empty-page.test.ts new file mode 100644 index 00000000000..92d15bd97f6 --- /dev/null +++ b/src/shared/work-item-empty-page.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' +import { + applyEmptyPageClamp, + resolveEmptyPageOutcome, + applyWindowPageLimit, + deriveAdvertisedTotalPages +} from './work-item-empty-page' + +describe('resolveEmptyPageOutcome', () => { + const base = { failedCount: 0, errorTypes: [] as const, countedTotalPages: null } + + it('clamps and reports unreachable when the search window 422 is present', () => { + expect( + resolveEmptyPageOutcome({ ...base, target: 33, errorTypes: ['validation_error'] }) + ).toEqual({ reason: 'window-unreachable', clampTotalPagesTo: 33 }) + expect( + resolveEmptyPageOutcome({ + ...base, + target: 33, + errorTypes: ['validation_error'], + countedTotalPages: 39 + }) + ).toEqual({ reason: 'window-unreachable', clampTotalPagesTo: 33 }) + }) + + it('resolves as load-failed when a window 422 coincides with a sibling failure', () => { + // A sibling failure says nothing about healthy repos’ remaining pages and must outrank a window clamp. + expect( + resolveEmptyPageOutcome({ + ...base, + target: 5, + failedCount: 1, + errorTypes: ['validation_error'] + }) + ).toEqual({ reason: 'load-failed', clampTotalPagesTo: null }) + expect( + resolveEmptyPageOutcome({ + ...base, + target: 5, + errorTypes: ['validation_error', 'permission_denied'] + }) + ).toEqual({ reason: 'load-failed', clampTotalPagesTo: null }) + }) + + it('reports a failure without clamping for transient or unclassified errors', () => { + expect(resolveEmptyPageOutcome({ ...base, target: 5, failedCount: 2 })).toEqual({ + reason: 'load-failed', + clampTotalPagesTo: null + }) + for (const type of ['permission_denied', 'not_found', 'rate_limited', 'unknown'] as const) { + expect(resolveEmptyPageOutcome({ ...base, target: 5, errorTypes: [type] })).toEqual({ + reason: 'load-failed', + clampTotalPagesTo: null + }) + } + }) + + it('withdraws the speculative page on clean empty only while the count is unknown', () => { + expect(resolveEmptyPageOutcome({ ...base, target: 2 })).toEqual({ + reason: 'end-of-data', + clampTotalPagesTo: 2 + }) + expect(resolveEmptyPageOutcome({ ...base, target: 2, countedTotalPages: 0 })).toEqual({ + reason: 'end-of-data', + clampTotalPagesTo: 2 + }) + // PR failures can become clean-empty results; shrinking a real count would silently hide healthy pages. + expect(resolveEmptyPageOutcome({ ...base, target: 2, countedTotalPages: 34 })).toEqual({ + reason: 'end-of-data', + clampTotalPagesTo: null + }) + expect(resolveEmptyPageOutcome({ ...base, target: 0 })).toEqual({ + reason: 'end-of-data', + clampTotalPagesTo: 1 + }) + }) +}) + +describe('applyEmptyPageClamp', () => { + const clean = { failedCount: 0, errorTypes: [] as const } + + it('keeps a real count that resolved between click and response', () => { + // The count can resolve during fetch; clamp against committed state rather than the click-time closure. + expect(applyEmptyPageClamp(33, { ...clean, target: 2 })).toBe(33) + }) + + it('withdraws the speculative page while the count is unknown or failed', () => { + expect(applyEmptyPageClamp(null, { ...clean, target: 2 })).toBe(2) + expect(applyEmptyPageClamp(0, { ...clean, target: 2 })).toBe(2) + }) + + it('never touches the count for window or failure outcomes', () => { + expect( + applyEmptyPageClamp(39, { target: 33, failedCount: 0, errorTypes: ['validation_error'] }) + ).toBe(39) + expect(applyEmptyPageClamp(33, { target: 5, failedCount: 2, errorTypes: [] })).toBe(33) + expect(applyEmptyPageClamp(null, { target: 5, failedCount: 2, errorTypes: [] })).toBe(null) + }) +}) + +describe('applyWindowPageLimit', () => { + it('sets, only lowers, and never goes below one page', () => { + expect(applyWindowPageLimit(null, 33)).toBe(33) + expect(applyWindowPageLimit(33, 20)).toBe(20) + expect(applyWindowPageLimit(20, 33)).toBe(20) + expect(applyWindowPageLimit(null, 0)).toBe(1) + }) +}) + +describe('deriveAdvertisedTotalPages', () => { + it('is order-independent: a count landing after a speculative withdrawal wins', () => { + // A late real count must overwrite speculative end-of-data withdrawal. + expect( + deriveAdvertisedTotalPages({ + loadedPages: 1, + countedTotalPages: 39, + fallbackTotalPages: 2, + provenPageLimit: null + }) + ).toBe(39) + }) + + it('caps a real count with the proven window limit', () => { + expect( + deriveAdvertisedTotalPages({ + loadedPages: 3, + countedTotalPages: 39, + fallbackTotalPages: 4, + provenPageLimit: 33 + }) + ).toBe(33) + }) + + it('uses the fallback while the count is unknown/failed, still window-capped', () => { + expect( + deriveAdvertisedTotalPages({ + loadedPages: 1, + countedTotalPages: null, + fallbackTotalPages: 2, + provenPageLimit: null + }) + ).toBe(2) + expect( + deriveAdvertisedTotalPages({ + loadedPages: 1, + countedTotalPages: 0, + fallbackTotalPages: 5, + provenPageLimit: 3 + }) + ).toBe(3) + }) + + it('never advertises fewer pages than are loaded', () => { + expect( + deriveAdvertisedTotalPages({ + loadedPages: 5, + countedTotalPages: 2, + fallbackTotalPages: 2, + provenPageLimit: 2 + }) + ).toBe(5) + }) +}) diff --git a/src/shared/work-item-empty-page.ts b/src/shared/work-item-empty-page.ts new file mode 100644 index 00000000000..55b3d3cbf6b --- /dev/null +++ b/src/shared/work-item-empty-page.ts @@ -0,0 +1,65 @@ +import type { ClassifiedError } from './classified-error' + +export type EmptyPageOutcome = { + reason: 'window-unreachable' | 'load-failed' | 'end-of-data' + /** Count clamp or null; window limits belong in applyWindowPageLimit, never the count slot. */ + clampTotalPagesTo: number | null +} + +/** Only all-window 422 errors prove a cap; transient sibling failures win, and clean-empty results may withdraw speculative counts only. */ +export function resolveEmptyPageOutcome(args: { + target: number + failedCount: number + errorTypes: readonly ClassifiedError['type'][] + countedTotalPages: number | null +}): EmptyPageOutcome { + const clamp = Math.max(1, Math.floor(args.target)) + // A sibling non-window error may hide healthy pages, so it must block the clamp. + const onlyWindowErrors = + args.errorTypes.length > 0 && args.errorTypes.every((type) => type === 'validation_error') + if (onlyWindowErrors && args.failedCount === 0) { + return { reason: 'window-unreachable', clampTotalPagesTo: clamp } + } + if (args.failedCount > 0 || args.errorTypes.length > 0) { + return { reason: 'load-failed', clampTotalPagesTo: null } + } + const countUnknown = args.countedTotalPages === null || args.countedTotalPages === 0 + return { reason: 'end-of-data', clampTotalPagesTo: countUnknown ? clamp : null } +} + +/** Clamp speculative counts against the latest committed state; store proven 422 limits separately so late real counts can replace speculation. */ +export function applyEmptyPageClamp( + previous: number | null, + args: { + target: number + failedCount: number + errorTypes: readonly ClassifiedError['type'][] + } +): number | null { + const outcome = resolveEmptyPageOutcome({ ...args, countedTotalPages: previous }) + if (outcome.reason !== 'end-of-data' || outcome.clampTotalPagesTo === null) { + return previous + } + return outcome.clampTotalPagesTo +} + +/** Proven window 422 limit: set once, only ever lowered, reset per generation. */ +export function applyWindowPageLimit(previous: number | null, target: number): number { + const clamp = Math.max(1, Math.floor(target)) + return previous === null ? clamp : Math.min(previous, clamp) +} + +/** Cap count/fallback by the proven window limit, retaining loaded pages; separate state makes late count updates order-independent. */ +export function deriveAdvertisedTotalPages(args: { + loadedPages: number + countedTotalPages: number | null + fallbackTotalPages: number + provenPageLimit: number | null +}): number { + const uncapped = + args.countedTotalPages && args.countedTotalPages > 0 + ? Math.max(args.loadedPages, args.countedTotalPages) + : args.fallbackTotalPages + const capped = args.provenPageLimit === null ? uncapped : Math.min(uncapped, args.provenPageLimit) + return Math.max(args.loadedPages, capped) +}