fix(desktop): batch project work item queries (#2160)

This commit is contained in:
thomaspblock
2026-07-20 08:45:48 +02:00
committed by GitHub
parent ac8c526122
commit ba07de3670
10 changed files with 569 additions and 122 deletions
+9 -41
View File
@@ -41,6 +41,7 @@ import type { ProjectIssue } from "./projectIssues.mjs";
import { projectIssueEventsToIssues } from "./projectIssues.mjs";
import type { ProjectPullRequest } from "./projectPullRequests.mjs";
import { projectPullRequestEventsToPullRequests } from "./projectPullRequests.mjs";
import { fetchProjectsWorkItems } from "./projectWorkItems";
export type { ProjectIssue, ProjectPullRequest };
@@ -768,47 +769,12 @@ export function useProjectPullRequestsQuery(
});
}
export function useProjectsIssuesQuery(projects: Project[]) {
/** Loads cross-project issues and pull requests with partial-failure metadata. */
export function useProjectsWorkItemsQuery(projects: Project[]) {
return useQuery({
enabled: projects.length > 0,
queryKey: ["projects", "issues", projects.map((project) => project.id)],
queryFn: async (): Promise<ProjectIssueListItem[]> => {
const results = await Promise.all(
projects.map(async (project) => {
const issues = await fetchProjectIssues(project);
return issues.map((issue) => ({ project, issue }));
}),
);
return results
.flat()
.sort((left, right) => right.issue.updatedAt - left.issue.updatedAt);
},
staleTime: 30_000,
});
}
export function useProjectsPullRequestsQuery(projects: Project[]) {
return useQuery({
enabled: projects.length > 0,
queryKey: [
"projects",
"pull-requests",
projects.map((project) => project.id),
],
queryFn: async (): Promise<ProjectPullRequestListItem[]> => {
const results = await Promise.all(
projects.map(async (project) => {
const pullRequests = await fetchProjectPullRequests(project);
return pullRequests.map((pullRequest) => ({ project, pullRequest }));
}),
);
return results
.flat()
.sort(
(left, right) =>
right.pullRequest.updatedAt - left.pullRequest.updatedAt,
);
},
queryKey: ["projects", "work-items", projects.map((project) => project.id)],
queryFn: () => fetchProjectsWorkItems(projects),
staleTime: 30_000,
});
}
@@ -843,7 +809,9 @@ export function useCreateProjectIssueCommentMutation(
void queryClient.invalidateQueries({
queryKey: ["project", project?.id ?? "none", "issues"],
});
void queryClient.invalidateQueries({ queryKey: ["projects", "issues"] });
void queryClient.invalidateQueries({
queryKey: ["projects", "work-items"],
});
void queryClient.invalidateQueries({
queryKey: ["projects", "activity-summaries"],
});
@@ -882,7 +850,7 @@ export function useCreateProjectPullRequestCommentMutation(
queryKey: ["project", project?.id ?? "none", "pull-requests"],
});
void queryClient.invalidateQueries({
queryKey: ["projects", "pull-requests"],
queryKey: ["projects", "work-items"],
});
void queryClient.invalidateQueries({
queryKey: ["projects", "activity-summaries"],
@@ -47,7 +47,7 @@ export function useCreateProjectIssueMutation(
queryKey: ["project", project?.id ?? "none", "issues"],
}),
queryClient.invalidateQueries({
queryKey: ["projects", "issues"],
queryKey: ["projects", "work-items"],
}),
queryClient.invalidateQueries({
queryKey: ["projects", "activity-summaries"],
@@ -0,0 +1,157 @@
import { relayClient } from "@/shared/api/relayClient";
import type { RelayEvent } from "@/shared/api/types";
import {
KIND_GIT_ISSUE,
KIND_GIT_PR_UPDATE,
KIND_GIT_PULL_REQUEST,
KIND_GIT_STATUS_CLOSED,
KIND_GIT_STATUS_DRAFT,
KIND_GIT_STATUS_MERGED,
KIND_GIT_STATUS_OPEN,
KIND_TEXT_NOTE,
} from "@/shared/constants/kinds";
import {
getTag,
type ProjectIssue,
projectIssueEventsToIssues,
} from "./projectIssues.mjs";
import {
type ProjectPullRequest,
projectPullRequestEventsToPullRequests,
} from "./projectPullRequests.mjs";
type ProjectReference = {
repoAddress: string;
};
/** Optional event groups that can fail without discarding root work items. */
export type ProjectWorkItemSection =
| "comments"
| "pull-request-updates"
| "statuses";
/** Aggregate work items plus any optional event groups that failed to load. */
export type ProjectsWorkItemsResult<TProject extends ProjectReference> = {
issues: {
items: Array<{ project: TProject; issue: ProjectIssue }>;
failedSections: ProjectWorkItemSection[];
};
pullRequests: {
items: Array<{ project: TProject; pullRequest: ProjectPullRequest }>;
failedSections: ProjectWorkItemSection[];
};
};
function groupByRepoAddress(events: RelayEvent[]): Map<string, RelayEvent[]> {
const grouped = new Map<string, RelayEvent[]>();
for (const event of events) {
const repoAddress = getTag(event, "a");
if (!repoAddress) continue;
const projectEvents = grouped.get(repoAddress) ?? [];
projectEvents.push(event);
grouped.set(repoAddress, projectEvents);
}
return grouped;
}
/** Loads aggregate issue and pull-request data with bounded relay fan-out. */
export async function fetchProjectsWorkItems<TProject extends ProjectReference>(
projects: TProject[],
): Promise<ProjectsWorkItemsResult<TProject>> {
const repoAddresses = [
...new Set(projects.map((project) => project.repoAddress)),
];
const [rootResult, updateResult, commentResult, statusResult] =
await Promise.allSettled([
relayClient.fetchEvents({
kinds: [KIND_GIT_ISSUE, KIND_GIT_PULL_REQUEST],
"#a": repoAddresses,
limit: 2_000,
}),
relayClient.fetchEvents({
kinds: [KIND_GIT_PR_UPDATE],
"#a": repoAddresses,
limit: 2_000,
}),
relayClient.fetchEvents({
kinds: [KIND_TEXT_NOTE],
"#a": repoAddresses,
limit: 2_000,
}),
relayClient.fetchEvents({
kinds: [
KIND_GIT_STATUS_OPEN,
KIND_GIT_STATUS_MERGED,
KIND_GIT_STATUS_CLOSED,
KIND_GIT_STATUS_DRAFT,
],
"#a": repoAddresses,
limit: 2_000,
}),
]);
if (rootResult.status === "rejected") {
throw rootResult.reason instanceof Error
? rootResult.reason
: new Error("Could not load project issues and pull requests.");
}
const updateEvents =
updateResult.status === "fulfilled" ? updateResult.value : [];
const commentEvents =
commentResult.status === "fulfilled" ? commentResult.value : [];
const statusEvents =
statusResult.status === "fulfilled" ? statusResult.value : [];
const rootsByRepo = groupByRepoAddress(rootResult.value);
const updatesByRepo = groupByRepoAddress(updateEvents);
const commentsByRepo = groupByRepoAddress(commentEvents);
const statusesByRepo = groupByRepoAddress(statusEvents);
const pullRequests = projects
.flatMap((project) =>
projectPullRequestEventsToPullRequests(
(rootsByRepo.get(project.repoAddress) ?? []).filter(
(event) => event.kind === KIND_GIT_PULL_REQUEST,
),
updatesByRepo.get(project.repoAddress) ?? [],
commentsByRepo.get(project.repoAddress) ?? [],
statusesByRepo.get(project.repoAddress) ?? [],
).map((pullRequest) => ({ project, pullRequest })),
)
.sort(
(left, right) => right.pullRequest.updatedAt - left.pullRequest.updatedAt,
);
const issues = projects
.flatMap((project) =>
projectIssueEventsToIssues(
(rootsByRepo.get(project.repoAddress) ?? []).filter(
(event) => event.kind === KIND_GIT_ISSUE,
),
statusesByRepo.get(project.repoAddress) ?? [],
commentsByRepo.get(project.repoAddress) ?? [],
).map((issue) => ({ project, issue })),
)
.sort((left, right) => right.issue.updatedAt - left.issue.updatedAt);
const sharedFailedSections: ProjectWorkItemSection[] = [];
if (commentResult.status === "rejected") {
sharedFailedSections.push("comments");
}
if (statusResult.status === "rejected") {
sharedFailedSections.push("statuses");
}
const pullRequestFailedSections = [...sharedFailedSections];
if (updateResult.status === "rejected") {
pullRequestFailedSections.unshift("pull-request-updates");
}
return {
issues: {
items: issues,
failedSections: sharedFailedSections,
},
pullRequests: {
items: pullRequests,
failedSections: pullRequestFailedSections,
},
};
}
@@ -160,7 +160,7 @@ export function useProjectPullRequestWriteInvalidation(
queryKey: ["project", project?.id ?? "none", "pull-requests"],
});
void queryClient.invalidateQueries({
queryKey: ["projects", "pull-requests"],
queryKey: ["projects", "work-items"],
});
void queryClient.invalidateQueries({
queryKey: ["projects", "activity-summaries"],
@@ -5,6 +5,7 @@ import type {
ProjectIssue,
ProjectIssueListItem,
} from "@/features/projects/hooks";
import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems";
import {
resolveUserLabel,
type UserProfileLookup,
@@ -15,6 +16,7 @@ import { Card } from "@/shared/ui/card";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon";
import { ProjectListRowMenu } from "./ProjectListRowMenu";
import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice";
import {
PROJECT_LIST_CONTAINER_CLASS,
PROJECT_LIST_ROW_CLASS,
@@ -27,8 +29,12 @@ import {
} from "./projectListRowStyles";
type ProjectsIssuesListProps = {
error: unknown;
failedSections: ProjectWorkItemSection[];
isLoading: boolean;
isRetrying: boolean;
onOpen: (project: Project, issue: ProjectIssue) => void;
onRetry: () => void;
profiles?: UserProfileLookup;
issues: ProjectIssueListItem[];
viewMode: "grid" | "list";
@@ -228,9 +234,13 @@ function IssueListRow({
}
export function ProjectsIssuesList({
error,
failedSections,
isLoading,
isRetrying,
issues,
onOpen,
onRetry,
profiles,
viewMode,
}: ProjectsIssuesListProps) {
@@ -242,19 +252,56 @@ export function ProjectsIssuesList({
);
}
const loadNotice = (
<ProjectsWorkItemsLoadNotice
error={error}
failedSections={failedSections}
isRetrying={isRetrying}
onRetry={onRetry}
subject="issues"
/>
);
if (error && issues.length === 0) {
return loadNotice;
}
if (issues.length === 0) {
return (
<div className="border border-dashed border-border/60 px-4 py-12 text-center text-sm text-muted-foreground">
No issues yet.
<div className="space-y-3">
{loadNotice}
<div className="border border-dashed border-border/60 px-4 py-12 text-center text-sm text-muted-foreground">
No issues yet.
</div>
</div>
);
}
if (viewMode === "grid") {
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="space-y-3">
{loadNotice}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{issues.map(({ project, issue }) => (
<IssueGridCard
issue={issue}
key={issue.id}
onOpen={onOpen}
profiles={profiles}
project={project}
/>
))}
</div>
</div>
);
}
return (
<div className="space-y-3">
{loadNotice}
<div className={PROJECT_LIST_CONTAINER_CLASS}>
{issues.map(({ project, issue }) => (
<IssueGridCard
<IssueListRow
issue={issue}
key={issue.id}
onOpen={onOpen}
@@ -263,20 +310,6 @@ export function ProjectsIssuesList({
/>
))}
</div>
);
}
return (
<div className={PROJECT_LIST_CONTAINER_CLASS}>
{issues.map(({ project, issue }) => (
<IssueListRow
issue={issue}
key={issue.id}
onOpen={onOpen}
profiles={profiles}
project={project}
/>
))}
</div>
);
}
@@ -5,6 +5,7 @@ import type {
ProjectPullRequest,
ProjectPullRequestListItem,
} from "@/features/projects/hooks";
import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems";
import {
resolveUserLabel,
type UserProfileLookup,
@@ -15,6 +16,7 @@ import { Card } from "@/shared/ui/card";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon";
import { ProjectListRowMenu } from "./ProjectListRowMenu";
import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice";
import {
PROJECT_LIST_CONTAINER_CLASS,
PROJECT_LIST_ROW_CLASS,
@@ -47,8 +49,12 @@ function AuthorNameButton({
}
type ProjectsPullRequestsListProps = {
error: unknown;
failedSections: ProjectWorkItemSection[];
isLoading: boolean;
isRetrying: boolean;
onOpen: (project: Project, pullRequest: ProjectPullRequest) => void;
onRetry: () => void;
profiles?: UserProfileLookup;
pullRequests: ProjectPullRequestListItem[];
viewMode: "grid" | "list";
@@ -252,8 +258,12 @@ function PullRequestListRow({
}
export function ProjectsPullRequestsList({
error,
failedSections,
isLoading,
isRetrying,
onOpen,
onRetry,
profiles,
pullRequests,
viewMode,
@@ -266,19 +276,56 @@ export function ProjectsPullRequestsList({
);
}
const loadNotice = (
<ProjectsWorkItemsLoadNotice
error={error}
failedSections={failedSections}
isRetrying={isRetrying}
onRetry={onRetry}
subject="pull requests"
/>
);
if (error && pullRequests.length === 0) {
return loadNotice;
}
if (pullRequests.length === 0) {
return (
<div className="border border-dashed border-border/60 px-4 py-12 text-center text-sm text-muted-foreground">
No pull requests yet.
<div className="space-y-3">
{loadNotice}
<div className="border border-dashed border-border/60 px-4 py-12 text-center text-sm text-muted-foreground">
No pull requests yet.
</div>
</div>
);
}
if (viewMode === "grid") {
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="space-y-3">
{loadNotice}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{pullRequests.map(({ project, pullRequest }) => (
<PullRequestGridCard
key={pullRequest.id}
onOpen={onOpen}
profiles={profiles}
project={project}
pullRequest={pullRequest}
/>
))}
</div>
</div>
);
}
return (
<div className="space-y-3">
{loadNotice}
<div className={PROJECT_LIST_CONTAINER_CLASS}>
{pullRequests.map(({ project, pullRequest }) => (
<PullRequestGridCard
<PullRequestListRow
key={pullRequest.id}
onOpen={onOpen}
profiles={profiles}
@@ -287,20 +334,6 @@ export function ProjectsPullRequestsList({
/>
))}
</div>
);
}
return (
<div className={PROJECT_LIST_CONTAINER_CLASS}>
{pullRequests.map(({ project, pullRequest }) => (
<PullRequestListRow
key={pullRequest.id}
onOpen={onOpen}
profiles={profiles}
project={project}
pullRequest={pullRequest}
/>
))}
</div>
);
}
@@ -10,9 +10,8 @@ import {
useDeleteProjectMutation,
useProjectActivitySummariesQuery,
useProjectLocalRepositoriesQuery,
useProjectsIssuesQuery,
useProjectsPullRequestsQuery,
useProjectsQuery,
useProjectsWorkItemsQuery,
} from "@/features/projects/hooks";
import { useCreateProjectMutation } from "@/features/projects/useCreateProject";
import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots";
@@ -31,6 +30,7 @@ import { ProjectsIssuesList } from "@/features/projects/ui/ProjectsIssuesList";
import { ProjectsOverviewPanel } from "@/features/projects/ui/ProjectsOverviewPanel";
import { ProjectsOverviewRail } from "@/features/projects/ui/ProjectsOverviewRail";
import { ProjectsPullRequestsList } from "@/features/projects/ui/ProjectsPullRequestsList";
import { ProjectsWorkItemsLoadNotice } from "@/features/projects/ui/ProjectsWorkItemsLoadNotice";
import { ProjectsListScopeDropdown } from "@/features/projects/ui/ProjectsListScopeDropdown";
import { PROJECT_LIST_CONTAINER_CLASS } from "@/features/projects/ui/projectListRowStyles";
import {
@@ -142,17 +142,18 @@ export function ProjectsView() {
const projectsQuery = useProjectsQuery();
const identityQuery = useIdentityQuery();
const projects = projectsQuery.data ?? [];
const activitySummariesQuery = useProjectActivitySummariesQuery(projects);
const localRepositoriesQuery = useProjectLocalRepositoriesQuery(
activeCommunity?.reposDir,
);
const projectPullRequestsQuery = useProjectsPullRequestsQuery(projects);
const [filter, setFilter] = React.useState<ProjectsFilter>(() => {
const storedFilter = readStoredFilter();
return storedFilter === "mine" || storedFilter === "local"
? "repositories"
: storedFilter;
});
const activitySummariesQuery = useProjectActivitySummariesQuery(
filter === "prs" || filter === "issues" ? [] : projects,
);
const [repositoryScope, setRepositoryScope] =
React.useState<ProjectsRepositoryScope>(() => readStoredRepositoryScope());
const [pullRequestScope, setPullRequestScope] =
@@ -160,8 +161,8 @@ export function ProjectsView() {
const [issueScope, setIssueScope] = React.useState<ProjectsWorkItemScope>(
() => readStoredIssueScope(),
);
const projectIssuesQuery = useProjectsIssuesQuery(
filter === "issues" || filter === "all" ? projects : [],
const projectsWorkItemsQuery = useProjectsWorkItemsQuery(
filter === "all" || filter === "prs" || filter === "issues" ? projects : [],
);
// One blobless clone per unique repository — only scan while the overview
// header (filter === "all") is actually visible.
@@ -195,15 +196,17 @@ export function ProjectsView() {
activitySummariesQuery.data?.[project.repoAddress],
),
),
...(projectPullRequestsQuery.data?.flatMap(({ pullRequest }) => [
pullRequest.author,
...pullRequest.recipients,
...pullRequest.reviewers,
...pullRequest.approvals.map((approval) => approval.author),
...pullRequest.updates.map((update) => update.author),
...pullRequest.comments.map((comment) => comment.author),
]) ?? []),
...(projectIssuesQuery.data?.flatMap(({ issue }) => [
...(projectsWorkItemsQuery.data?.pullRequests.items.flatMap(
({ pullRequest }) => [
pullRequest.author,
...pullRequest.recipients,
...pullRequest.reviewers,
...pullRequest.approvals.map((approval) => approval.author),
...pullRequest.updates.map((update) => update.author),
...pullRequest.comments.map((comment) => comment.author),
],
) ?? []),
...(projectsWorkItemsQuery.data?.issues.items.flatMap(({ issue }) => [
issue.author,
...issue.recipients,
...issue.comments.map((comment) => comment.author),
@@ -211,12 +214,7 @@ export function ProjectsView() {
].map(normalizePubkey),
),
],
[
activitySummariesQuery.data,
projectIssuesQuery.data,
projectPullRequestsQuery.data,
projects,
],
[activitySummariesQuery.data, projects, projectsWorkItemsQuery.data],
);
const profilesQuery = useUsersBatchQuery(projectPubkeys, {
enabled: projectPubkeys.length > 0,
@@ -337,7 +335,7 @@ export function ProjectsView() {
]);
const visiblePullRequests = React.useMemo(() => {
const pullRequests = projectPullRequestsQuery.data ?? [];
const pullRequests = projectsWorkItemsQuery.data?.pullRequests.items ?? [];
const scopedPullRequests =
pullRequestScope === "mine" && currentPubkey
? pullRequests.filter(
@@ -355,10 +353,10 @@ export function ProjectsView() {
}
return right.pullRequest.updatedAt - left.pullRequest.updatedAt;
});
}, [currentPubkey, projectPullRequestsQuery.data, pullRequestScope, sort]);
}, [currentPubkey, projectsWorkItemsQuery.data, pullRequestScope, sort]);
const visibleIssues = React.useMemo(() => {
const issues = projectIssuesQuery.data ?? [];
const issues = projectsWorkItemsQuery.data?.issues.items ?? [];
const scopedIssues =
issueScope === "mine" && currentPubkey
? issues.filter(
@@ -375,7 +373,7 @@ export function ProjectsView() {
}
return right.issue.updatedAt - left.issue.updatedAt;
});
}, [currentPubkey, issueScope, projectIssuesQuery.data, sort]);
}, [currentPubkey, issueScope, projectsWorkItemsQuery.data, sort]);
// Route by the canonical `owner:dtag` project ID — a bare dtag is
// ambiguous across owners (forks can share the same dtag).
@@ -528,23 +526,38 @@ export function ProjectsView() {
</div>
);
const workItemFailedSections = [
...new Set([
...(projectsWorkItemsQuery.data?.issues.failedSections ?? []),
...(projectsWorkItemsQuery.data?.pullRequests.failedSections ?? []),
]),
];
const activityFeed = (
<ProjectsActivityFeed
isLoading={
repoSnapshotsQuery.isLoading ||
projectPullRequestsQuery.isLoading ||
projectIssuesQuery.isLoading
}
issues={projectIssuesQuery.data ?? []}
onOpenCommit={handleOpenCommit}
onOpenIssue={handleOpenIssue}
onOpenProject={handleOpenProject}
onOpenPullRequest={handleOpenPullRequest}
profiles={profiles}
projects={projects}
pullRequests={projectPullRequestsQuery.data ?? []}
snapshots={repoSnapshotsQuery.data}
/>
<>
<ProjectsWorkItemsLoadNotice
error={projectsWorkItemsQuery.error}
failedSections={workItemFailedSections}
isRetrying={
projectsWorkItemsQuery.isFetching && !projectsWorkItemsQuery.isLoading
}
onRetry={() => void projectsWorkItemsQuery.refetch()}
subject="project activity"
/>
<ProjectsActivityFeed
isLoading={
repoSnapshotsQuery.isLoading || projectsWorkItemsQuery.isLoading
}
issues={projectsWorkItemsQuery.data?.issues.items ?? []}
onOpenCommit={handleOpenCommit}
onOpenIssue={handleOpenIssue}
onOpenProject={handleOpenProject}
onOpenPullRequest={handleOpenPullRequest}
profiles={profiles}
projects={projects}
pullRequests={projectsWorkItemsQuery.data?.pullRequests.items ?? []}
snapshots={repoSnapshotsQuery.data}
/>
</>
);
const createMenu = (
@@ -685,17 +698,36 @@ export function ProjectsView() {
</div>
{filter === "prs" ? (
<ProjectsPullRequestsList
isLoading={projectPullRequestsQuery.isLoading}
error={projectsWorkItemsQuery.error}
failedSections={
projectsWorkItemsQuery.data?.pullRequests
.failedSections ?? []
}
isLoading={projectsWorkItemsQuery.isLoading}
isRetrying={
projectsWorkItemsQuery.isFetching &&
!projectsWorkItemsQuery.isLoading
}
onOpen={handleOpenPullRequest}
onRetry={() => void projectsWorkItemsQuery.refetch()}
profiles={profiles}
pullRequests={visiblePullRequests}
viewMode={viewMode}
/>
) : filter === "issues" ? (
<ProjectsIssuesList
isLoading={projectIssuesQuery.isLoading}
error={projectsWorkItemsQuery.error}
failedSections={
projectsWorkItemsQuery.data?.issues.failedSections ?? []
}
isLoading={projectsWorkItemsQuery.isLoading}
isRetrying={
projectsWorkItemsQuery.isFetching &&
!projectsWorkItemsQuery.isLoading
}
issues={visibleIssues}
onOpen={handleOpenIssue}
onRetry={() => void projectsWorkItemsQuery.refetch()}
profiles={profiles}
viewMode={viewMode}
/>
@@ -0,0 +1,67 @@
import { AlertCircle } from "lucide-react";
import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems";
import { Button } from "@/shared/ui/button";
const SECTION_LABELS: Record<ProjectWorkItemSection, string> = {
comments: "comments",
"pull-request-updates": "pull request updates",
statuses: "statuses",
};
type ProjectsWorkItemsLoadNoticeProps = {
error: unknown;
failedSections: ProjectWorkItemSection[];
isRetrying: boolean;
onRetry: () => void;
subject: "issues" | "project activity" | "pull requests";
};
/** Displays full and partial aggregate work-item failures with a retry action. */
export function ProjectsWorkItemsLoadNotice({
error,
failedSections,
isRetrying,
onRetry,
subject,
}: ProjectsWorkItemsLoadNoticeProps) {
if (!error && failedSections.length === 0) return null;
const detailSubject =
subject === "pull requests"
? "pull request"
: subject === "issues"
? "issue"
: subject;
const title = error
? `Could not load ${subject}.`
: `Some ${detailSubject} details could not be loaded.`;
const description = error
? error instanceof Error
? error.message
: "The relay request failed."
: `Missing ${failedSections
.map((section) => SECTION_LABELS[section])
.join(", ")}. The available results are shown below.`;
return (
<div
className="flex items-start gap-3 border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm"
role="alert"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground">{title}</p>
<p className="mt-0.5 text-muted-foreground">{description}</p>
</div>
<Button
disabled={isRetrying}
onClick={onRetry}
size="sm"
variant="outline"
>
{isRetrying ? "Retrying..." : "Retry"}
</Button>
</div>
);
}
+16
View File
@@ -941,6 +941,10 @@ declare global {
__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__?: number[];
/** Overrides the first mock repository owner for delegated-owner tests. */
__BUZZ_E2E_PROJECT_OWNER_OVERRIDE__?: string;
/** Project history kinds rejected with CLOSED for aggregate-query tests. */
__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__?: number[];
/** Captured aggregate project-history filters for request-count assertions. */
__BUZZ_E2E_PROJECT_QUERY_FILTERS__?: MockFilter[];
__BUZZ_E2E_PROJECT_REPO_SYNC_STATUS__?: {
local_path: string | null;
local_branch: string | null;
@@ -8411,6 +8415,18 @@ function sendToMockSocket(args: {
filter.kinds?.some((kind) => MOCK_PROJECT_KINDS.has(kind)) ||
(filter.kinds?.includes(1) && filter["#a"])
) {
window.__BUZZ_E2E_PROJECT_QUERY_FILTERS__ ??= [];
window.__BUZZ_E2E_PROJECT_QUERY_FILTERS__.push(filter);
const rejectedKinds =
window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ ?? [];
if (filter.kinds?.some((kind) => rejectedKinds.includes(kind))) {
sendWsText(socket.handler, [
"CLOSED",
subId,
"mock project query failure",
]);
return;
}
for (const event of filterMockProjectEvents(filter)) {
sendWsText(socket.handler, ["EVENT", subId, event]);
}
+141
View File
@@ -327,6 +327,147 @@ test("viewer without repository ownership cannot merge", async ({ page }) => {
);
});
test("project pull requests preserve partial results from batched queries", async ({
page,
}) => {
await enableProjectsFeature(page);
await page.addInitScript(() => {
window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ = [1619];
});
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await page
.getByRole("button", { name: "Pull Requests", exact: true })
.click();
await expect(
page.getByRole("button", { name: /^View / }).first(),
).toBeVisible();
await expect(
page.getByText(/Some pull request details could not be loaded/),
).toBeVisible();
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
const workItemFilters = await page.evaluate(
() =>
window.__BUZZ_E2E_PROJECT_QUERY_FILTERS__?.filter(
(filter) => filter.limit === 2_000,
) ?? [],
);
expect(
workItemFilters
.map((filter) => JSON.stringify([...(filter.kinds ?? [])].sort()))
.sort(),
).toEqual(
[[1], [1618, 1621], [1619], [1630, 1631, 1632, 1633]]
.map((kinds) => JSON.stringify(kinds))
.sort(),
);
expect(
workItemFilters.every((filter) => (filter["#a"]?.length ?? 0) > 1),
).toBe(true);
const expectedRepoAddresses = [
`30617:${DEFAULT_MOCK_PUBKEY}:buzz`,
`30617:${TEST_IDENTITIES.alice.pubkey}:relay-tools`,
`30617:${TEST_IDENTITIES.bob.pubkey}:design-system`,
].sort();
for (const filter of workItemFilters) {
expect([...(filter["#a"] ?? [])].sort()).toEqual(expectedRepoAddresses);
}
await page.evaluate(() => {
window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ = [];
});
await page.getByRole("button", { name: "Retry" }).click();
await expect(
page.getByText(/Some pull request details could not be loaded/),
).toHaveCount(0);
});
test("project pull requests report aggregate root query failures", async ({
page,
}) => {
await enableProjectsFeature(page);
await page.addInitScript(() => {
window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ = [1618];
});
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await page
.getByRole("button", { name: "Pull Requests", exact: true })
.click();
await expect(page.getByText("Could not load pull requests.")).toBeVisible();
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
await expect(page.getByText("No pull requests yet.")).toHaveCount(0);
await page.evaluate(() => {
window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ = [];
});
await page.getByRole("button", { name: "Retry" }).click();
await expect(page.getByText("Could not load pull requests.")).toHaveCount(0);
await expect(
page.getByRole("button", { name: /^View / }).first(),
).toBeVisible();
});
test("project issues preserve partial results from aggregate queries", async ({
page,
}) => {
await enableProjectsFeature(page);
await page.addInitScript(() => {
window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ = [1];
});
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await page.getByRole("button", { name: "Issues", exact: true }).click();
await expect(
page.getByRole("button", { name: /^View / }).first(),
).toBeVisible();
await expect(
page.getByText("Some issue details could not be loaded."),
).toBeVisible();
await expect(page.getByText(/Missing comments\./)).toBeVisible();
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
await page.evaluate(() => {
window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ = [];
});
await page.getByRole("button", { name: "Retry" }).click();
await expect(
page.getByText("Some issue details could not be loaded."),
).toHaveCount(0);
});
test("project overview reports aggregate work-item failures", async ({
page,
}) => {
await enableProjectsFeature(page);
await page.addInitScript(() => {
window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ = [1618];
});
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await expect(
page.getByText("Could not load project activity."),
).toBeVisible();
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
await page.evaluate(() => {
window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ = [];
});
await page.getByRole("button", { name: "Retry" }).click();
await expect(page.getByText("Could not load project activity.")).toHaveCount(
0,
);
});
test("project without a checkout offers fetch feedback and dropdown cloning", async ({
page,
}) => {