authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-03 18:40:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 22:40:10-03:00
log7bc161f8692dfb969489f40bf3eb5bd27bc01f10
tree6347c1971ca81f236521532e87ba7672d4207d09
parent2b8bb0c1881583428adbc07e3b641b21067ab41a
signature Signed by PGP key B5690EEEBB952194

fix(web): links to issues no longer strand the pull request viewer (#14242)


12 files changed, 73 insertions(+), 6 deletions(-)

apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts+1
......@@ -117,6 +117,7 @@ export function azureDevOpsProviderFailure(
117117 if (error._tag === "AzureDevOpsCliUnavailableError") return { reason: "missing-tool" };
118118 if (error._tag === "AzureDevOpsCliAuthenticationError") return { reason: "unauthenticated" };
119119 if (error._tag === "AzureDevOpsCliRateLimitError") return { reason: "rate-limited" };
120 if (error._tag === "AzureDevOpsPullRequestNotFoundError") return { reason: "not-found" };
120121 return { reason: "failed" };
121122}
122123
apps/server/src/pullRequest/BitbucketPullRequestProvider.ts+6
......@@ -82,6 +82,12 @@ export function bitbucketProviderFailure(
8282 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
8383 };
8484 }
85 if (
86 (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") &&
87 error.status === 404
88 ) {
89 return { reason: "not-found" };
90 }
8591 return { reason: "failed" };
8692}
8793
apps/server/src/pullRequest/ForgejoPullRequestProvider.ts+3-1
......@@ -89,7 +89,9 @@ export const make = Effect.gen(function* () {
8989 ? "unauthenticated"
9090 : error.reason === "rate-limit"
9191 ? "rate-limited"
92 : "failed",
92 : error.reason === "not-found"
93 ? "not-found"
94 : "failed",
9395 }),
9496 ),
9597 );
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+1
......@@ -115,6 +115,7 @@ export function gitHubProviderFailure(
115115 if (error._tag === "SourceControlRateLimitPausedError") {
116116 return { reason: "rate-limited", retryAt: error.retryAt };
117117 }
118 if (error._tag === "GitHubPullRequestNotFoundError") return { reason: "not-found" };
118119 return { reason: "failed" };
119120}
120121
apps/server/src/pullRequest/GitLabPullRequestProvider.ts+1
......@@ -102,6 +102,7 @@ export function gitLabProviderFailure(
102102 if (error._tag === "GitLabCliUnavailableError") return { reason: "missing-tool" };
103103 if (error._tag === "GitLabCliAuthenticationError") return { reason: "unauthenticated" };
104104 if (error._tag === "GitLabCliRateLimitError") return { reason: "rate-limited" };
105 if (error._tag === "GitLabMergeRequestNotFoundError") return { reason: "not-found" };
105106 return { reason: "failed" };
106107}
107108
apps/server/src/pullRequest/PullRequestProvider.ts+7-1
......@@ -52,7 +52,13 @@ export class PullRequestProviderError extends Schema.TaggedError<PullRequestProv
5252 {
5353 provider: SourceControlProviderKindSchema,
5454 operation: Schema.String,
55 reason: Schema.Literals(["missing-tool", "unauthenticated", "rate-limited", "failed"]),
55 reason: Schema.Literals([
56 "missing-tool",
57 "unauthenticated",
58 "rate-limited",
59 "not-found",
60 "failed",
61 ]),
5662 detail: Schema.String,
5763 retryAt: Schema.optional(Schema.Number),
5864 cause: Schema.optional(Schema.Defect()),
apps/server/src/pullRequest/PullRequestService.test.ts+27
......@@ -4815,6 +4815,33 @@ it.effect("does not let a still-cached detail overwrite a fresher linked summary
48154815 }),
48164816);
48174817
4818it.effect("tells the client when the host has no pull request under that number", () =>
4819 Effect.gen(function* () {
4820 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 121 };
4821 const service = yield* makeService({
4822 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4823 providers: [
4824 fakeProvider("github", {
4825 getChangeRequest: () =>
4826 Effect.fail(
4827 new PullRequestProviderError({
4828 provider: "github",
4829 operation: "getChangeRequest",
4830 reason: "not-found",
4831 detail: "Pull request not found. Check the PR number or URL and try again.",
4832 }),
4833 ),
4834 }),
4835 ],
4836 });
4837
4838 const error = yield* Effect.flip(service.detail(reference));
4839
4840 assert.strictEqual(error._tag, "PullRequestOperationError");
4841 assert.strictEqual(error._tag === "PullRequestOperationError" && error.reason, "not-found");
4842 }),
4843);
4844
48184845it.effect("keeps recent detail on a transient refresh failure but not after invalidation", () =>
48194846 Effect.gen(function* () {
48204847 let failing = false;
apps/server/src/pullRequest/PullRequestService.ts+6-1
......@@ -477,7 +477,12 @@ function toPullRequestError(
477477 return (error) =>
478478 isProviderUnusable(error)
479479 ? toUnavailableError(error)
480 : new PullRequestOperationError({ operation, detail: error.detail, cause: error });
480 : new PullRequestOperationError({
481 operation,
482 detail: error.detail,
483 ...(error.reason === "not-found" ? { reason: "not-found" as const } : {}),
484 cause: error,
485 });
481486}
482487
483488function withRateLimitBackoff(
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+8-1
......@@ -144,6 +144,7 @@ import {
144144 handoffReviewComments,
145145 latestPullRequestReviewOutcomes,
146146 loadingPullRequestCheckoutCommand,
147 isPullRequestNotFound,
147148 isStackedPullRequestBase,
148149 pullRequestActionMenuHasGroup,
149150 pullRequestActionNeedsHostRefresh,
......@@ -2719,7 +2720,13 @@ export function PullRequestDetailPanel({
27192720 >
27202721 {detailQuery.error && !detail ? (
27212722 <PullRequestsUnavailableState
2722 error={detailQuery.error}
2723 {...(isPullRequestNotFound(detailQuery.failure)
2724 ? {
2725 title: `Pull request #${reference.number} not found`,
2726 error:
2727 "It may be an issue rather than a pull request, or this account can't see it.",
2728 }
2729 : { error: detailQuery.error })}
27232730 refreshing={detailQuery.isPending}
27242731 onRetry={refreshDetail}
27252732 {...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})}
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+7
......@@ -2,6 +2,7 @@ import * as Schema from "effect/Schema";
22
33import {
44 PullRequestDetail,
5 PullRequestOperationError,
56 pullRequestHostOf,
67 type PullRequestAction,
78 type PullRequestActor,
......@@ -1161,6 +1162,12 @@ export function buildAddSelectionToAgentHandoff(input: {
11611162 };
11621163}
11631164
1165const isPullRequestOperationError = Schema.is(PullRequestOperationError);
1166
1167export function isPullRequestNotFound(failure: unknown): boolean {
1168 return isPullRequestOperationError(failure) && failure.reason === "not-found";
1169}
1170
11641171/**
11651172 * The internal wrapper every failed operation arrives in: which operation ran, and which tool
11661173 * said no. A reader has no use for either.
apps/web/src/state/query.ts+5-2
......@@ -7,10 +7,11 @@ const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial<never, never>(fals
77 Atom.withLabel("web-environment-query:empty"),
88);
99
10export interface EnvironmentQueryView<A> {
10export interface EnvironmentQueryView<A, E = unknown> {
1111 readonly data: A | null;
1212 readonly dataUpdatedAt: number;
1313 readonly error: string | null;
14 readonly failure: E | null;
1415 readonly isPending: boolean;
1516 readonly isSuccess: boolean;
1617 readonly refresh: () => void;
......@@ -25,7 +26,7 @@ export function formatEnvironmentQueryError(cause: Cause.Cause<unknown>): string
2526
2627export function useEnvironmentQuery<A, E>(
2728 atom: Atom.Atom<AsyncResult.AsyncResult<A, E>> | null,
28): EnvironmentQueryView<A> {
29): EnvironmentQueryView<A, E> {
2930 const selectedAtom = atom ?? EMPTY_ASYNC_RESULT_ATOM;
3031 const result = useAtomValue(selectedAtom);
3132 const refresh = useAtomRefresh(selectedAtom);
......@@ -38,6 +39,8 @@ export function useEnvironmentQuery<A, E>(
3839 ? (Option.getOrNull(result.previousSuccess)?.timestamp ?? 0)
3940 : 0,
4041 error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null,
42 failure:
43 result._tag === "Failure" ? Option.getOrNull(Cause.findErrorOption(result.cause)) : null,
4144 isPending: atom !== null && result.waiting,
4245 isSuccess: result._tag === "Success",
4346 refresh,
packages/contracts/src/pullRequest.ts+1
......@@ -1407,6 +1407,7 @@ export class PullRequestOperationError extends Schema.TaggedError<PullRequestOpe
14071407 {
14081408 operation: Schema.String,
14091409 detail: TrimmedNonEmptyString,
1410 reason: Schema.optional(Schema.Literal("not-found")),
14101411 cause: Schema.optional(Schema.Defect()),
14111412 },
14121413 { httpApiStatus: 502 },