mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): batch project work item queries (#2160)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user