authorgravatar for adam.bogucki@piekna.edu.plAdamulek123 <adam.bogucki@piekna.edu.pl> 2026-08-13 22:40:21+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-13 20:40:21+00:00
log92d4a2e996b19239d95e32a3feb05208e0a52306
treeaaff2a754b619f463f9af0d32072be5ff99a06c5
parent2fab18e289bdb2b1b767f05a307729d0a0d1c002
signature Signed by PGP key B5690EEEBB952194

fix(web): scope pull request errors to their environment (#6490)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

5 files changed, 70 insertions(+), 17 deletions(-)

apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx+19-3
......@@ -3,7 +3,7 @@ import { CircleIcon } from "lucide-react";
33import { Children, isValidElement, type ReactElement, type ReactNode } from "react";
44import { describe, expect, it, vi } from "vite-plus/test";
55
6import { PullRequestFiltersMenu } from "./PullRequestListFilters";
6import { PullRequestFiltersMenu, pullRequestProjectKey } from "./PullRequestListFilters";
77
88function findValueChange(
99 node: ReactNode,
......@@ -129,7 +129,7 @@ describe("pull request filters menu", () => {
129129 const radioGroup = findValueChange(view);
130130 expect(radioGroup).toBeDefined();
131131
132 radioGroup?.props.onValueChange(`${environmentId} ${projectId}`);
132 radioGroup?.props.onValueChange(pullRequestProjectKey({ id: projectId, environmentId }));
133133 expect(onProject).not.toHaveBeenCalled();
134134
135135 radioGroup?.props.onValueChange("all");
......@@ -159,7 +159,23 @@ describe("pull request filters menu", () => {
159159 const radioGroup = findValueChange(view);
160160 expect(radioGroup).toBeDefined();
161161
162 radioGroup?.props.onValueChange(`env-2 ${projectId}`);
162 radioGroup?.props.onValueChange(
163 pullRequestProjectKey({ id: projectId, environmentId: "env-2" as EnvironmentId }),
164 );
163165 expect(onProject).toHaveBeenCalledWith(projectId, "env-2");
164166 });
167
168 it("does not collide when environment and project ids contain spaces", () => {
169 expect(
170 pullRequestProjectKey({
171 environmentId: "a b" as EnvironmentId,
172 id: "c" as ProjectId,
173 }),
174 ).not.toBe(
175 pullRequestProjectKey({
176 environmentId: "a" as EnvironmentId,
177 id: "b c" as ProjectId,
178 }),
179 );
180 });
165181});
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+11-9
......@@ -122,10 +122,10 @@ const UNFILTERED_VALUE = "all";
122122 * A project's own radio value, carrying the server along with the id: the id alone is only
123123 * unique within its own server, so two rows sharing one would otherwise both read as checked.
124124 */
125const projectMenuValue = (project: {
125export const pullRequestProjectKey = (project: {
126126 readonly id: ProjectId;
127127 readonly environmentId: EnvironmentId;
128}) => `${project.environmentId} ${project.id}`;
128}) => JSON.stringify([project.environmentId, project.id]);
129129
130130const DRAFT_OPTIONS = [
131131 { value: UNFILTERED_VALUE, label: "All", Icon: LayersIcon },
......@@ -247,7 +247,7 @@ export function PullRequestFiltersMenu({
247247 * the reader is already choosing between projects, rather than as a count above the list
248248 * that says something is missing without saying which.
249249 */
250 unavailable: ReadonlyMap<ProjectId, string>;
250 unavailable: ReadonlyMap<string, string>;
251251 /** The environment comes with the project id, since picking a row picks a specific server's copy of it. */
252252 onProject: (projectId: ProjectId | undefined, environmentId: EnvironmentId | undefined) => void;
253253}) {
......@@ -350,7 +350,7 @@ export function PullRequestFiltersMenu({
350350 value={
351351 projectId === undefined || projectEnvironmentId === undefined
352352 ? ALL_PROJECTS_VALUE
353 : projectMenuValue({ id: projectId, environmentId: projectEnvironmentId })
353 : pullRequestProjectKey({ id: projectId, environmentId: projectEnvironmentId })
354354 }
355355 onValueChange={(next) => {
356356 if (next === ALL_PROJECTS_VALUE) {
......@@ -359,7 +359,7 @@ export function PullRequestFiltersMenu({
359359 }
360360 // The value carries both halves, since the id alone cannot tell two servers' rows
361361 // apart once they share one.
362 const project = projects.find((candidate) => projectMenuValue(candidate) === next);
362 const project = projects.find((candidate) => pullRequestProjectKey(candidate) === next);
363363 if (
364364 project !== undefined &&
365365 (project.id !== projectId || project.environmentId !== projectEnvironmentId)
......@@ -379,14 +379,16 @@ export function PullRequestFiltersMenu({
379379 as a broken menu rather than as a workspace with three unreadable repositories. */}
380380 {projects
381381 .toSorted(
382 (left, right) => Number(unavailable.has(left.id)) - Number(unavailable.has(right.id)),
382 (left, right) =>
383 Number(unavailable.has(pullRequestProjectKey(left))) -
384 Number(unavailable.has(pullRequestProjectKey(right))),
383385 )
384386 .map((project) => {
385 const reason = unavailable.get(project.id);
387 const reason = unavailable.get(pullRequestProjectKey(project));
386388 return (
387389 <MenuRadioItem
388 key={projectMenuValue(project)}
389 value={projectMenuValue(project)}
390 key={pullRequestProjectKey(project)}
391 value={pullRequestProjectKey(project)}
390392 disabled={reason !== undefined}
391393 title={reason}
392394 >
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+15-1
......@@ -1,4 +1,4 @@
1import type { EnvironmentId, PullRequestListEntry } from "@t3tools/contracts";
1import type { EnvironmentId, ProjectId, PullRequestListEntry } from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";
33
44import {
......@@ -710,6 +710,20 @@ describe("merging the environments' own listings", () => {
710710 );
711711 });
712712
713 it("keeps project errors scoped to the environment that reported them", () => {
714 const error = {
715 projectId: "project-1" as ProjectId,
716 projectTitle: "Web",
717 message: "Not signed in",
718 } as const;
719 const merged = mergePullRequestLists([
720 [ENV_1, answer({ errors: [error] })],
721 [ENV_2, answer()],
722 ]);
723
724 expect(merged?.errors).toEqual([{ ...error, environmentId: ENV_1 }]);
725 });
726
713727 it("folds a host reached from two environments into one switcher row", () => {
714728 const merged = mergePullRequestLists([
715729 [ENV_1, answer()],
apps/web/src/components/pullRequest/pullRequestList.logic.ts+14-3
......@@ -3,6 +3,7 @@ import * as Schema from "effect/Schema";
33import {
44 EnvironmentId,
55 PullRequestListEntry,
6 PullRequestListProjectError,
67 PullRequestListResult,
78 resolvePullRequestAuthorFilter,
89} from "@t3tools/contracts";
......@@ -27,6 +28,10 @@ export interface EnvironmentPullRequestStat extends PullRequestDiffStat {
2728 readonly environmentId: EnvironmentId;
2829}
2930
31export interface EnvironmentPullRequestError extends PullRequestListProjectError {
32 readonly environmentId: EnvironmentId;
33}
34
3035export type PullRequestGroupKey = "reviewRequested" | "authored" | "others";
3136
3237export interface PullRequestGroup<Entry extends PullRequestListEntry = PullRequestListEntry> {
......@@ -452,7 +457,7 @@ export interface MergedPullRequestList {
452457 readonly viewers: PullRequestViewers;
453458 readonly providers: PullRequestListResult["providers"];
454459 readonly entries: ReadonlyArray<EnvironmentPullRequestEntry>;
455 readonly errors: PullRequestListResult["errors"];
460 readonly errors: ReadonlyArray<EnvironmentPullRequestError>;
456461 readonly truncated: boolean;
457462 readonly nextCursors: Readonly<Record<string, PullRequestListCursors>>;
458463 /**
......@@ -476,7 +481,7 @@ export function mergePullRequestLists(
476481 const truncatedEnvironments: string[] = [];
477482 const providers = new Map<string, PullRequestListResult["providers"][number]>();
478483 const entries: EnvironmentPullRequestEntry[] = [];
479 const errors: Array<PullRequestListResult["errors"][number]> = [];
484 const errors: EnvironmentPullRequestError[] = [];
480485 const nextCursors: Record<string, PullRequestListCursors> = {};
481486 let truncated = false;
482487 for (const [environmentId, answer] of answers) {
......@@ -498,7 +503,7 @@ export function mergePullRequestLists(
498503 );
499504 }
500505 entries.push(...answer.entries.map((entry) => ({ ...entry, environmentId })));
501 errors.push(...answer.errors);
506 errors.push(...answer.errors.map((error) => ({ ...error, environmentId })));
502507 truncated ||= answer.truncated;
503508 if (answer.truncated) truncatedEnvironments.push(environmentId);
504509 if (Object.keys(answer.nextCursors).length > 0) {
......@@ -559,12 +564,18 @@ const EnvironmentPullRequestEntrySchema = Schema.Struct({
559564 environmentId: EnvironmentId,
560565});
561566
567const EnvironmentPullRequestErrorSchema = Schema.Struct({
568 ...PullRequestListProjectError.fields,
569 environmentId: EnvironmentId,
570});
571
562572const decodeSnapshot = Schema.decodeUnknownOption(
563573 Schema.Struct({
564574 scope: Schema.String,
565575 data: Schema.Struct({
566576 ...PullRequestListResult.fields,
567577 entries: Schema.Array(EnvironmentPullRequestEntrySchema),
578 errors: Schema.Array(EnvironmentPullRequestErrorSchema),
568579 // Per environment here, unlike the wire shape, which is per repository within one.
569580 nextCursors: Schema.Record(Schema.String, PullRequestListResult.fields.nextCursors),
570581 truncatedEnvironments: Schema.Array(Schema.String),
apps/web/src/routes/_chat.pull-requests.tsx+11-1
......@@ -60,6 +60,7 @@ import {
6060 PullRequestFiltersMenu,
6161 PullRequestSearchInput,
6262 pullRequestHostLabel,
63 pullRequestProjectKey,
6364 type PullRequestExpectedHost,
6465 type PullRequestFilterOption,
6566} from "../components/pullRequest/PullRequestListFilters";
......@@ -1250,7 +1251,16 @@ function PullRequestsRouteView() {
12501251
12511252 /** Reported per project rather than as a count, so the reader can see which one it was. */
12521253 const unavailableProjects = useMemo(
1253 () => new Map(listErrors.map((error) => [error.projectId, error.message] as const)),
1254 () =>
1255 new Map(
1256 listErrors.map(
1257 (error) =>
1258 [
1259 pullRequestProjectKey({ id: error.projectId, environmentId: error.environmentId }),
1260 error.message,
1261 ] as const,
1262 ),
1263 ),
12541264 [listErrors],
12551265 );
12561266