Files
buzz/desktop/src/features/projects/projectWorkItems.ts
thomaspblock 87f8ff82ae feat(desktop): refine repository-aware project workspaces (#6003)
## Summary
- Makes Projects repository-aware across browsing, branch and tag
selection, local and remote source management, work items, commits, and
contextual repository actions.
- Refines project details into consistent single-column reading surfaces
with a resizable, section-aware context pod, while keeping project
conversations available in the attached chat panel.
- Adds persistent sidebar project navigation, direct entity links,
repository discussion channels, contributor identity matching, and
consistent loading and activity presentation.
- Keeps branch-specific controls on code-oriented sections while Tasks,
Reviews, and Channels remain repository-scoped, reducing misleading
context and actions.

Replacement for #5981 with an identical final tree flattened into one
signed-off commit because the required DCO check suite remained stalled.

### Related issue
N/A

### Testing
- [x] Desktop pre-push checks, TypeScript typecheck, and unit tests
- [x] E2E production build with `pnpm build:e2e`
- [x] Focused Playwright smoke coverage for project workspace, task,
review, commit, sidebar, and contextual-panel behavior
- [ ] Attach final before/after screenshots after design review

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Wintermute <165f0c871dd2586bb18b6aa109eeaf57bb2132ff4d27b10120f4368a0f627022@buzz.block.builderlab.xyz>
Co-authored-by: Wintermute <165f0c871dd2586bb18b6aa109eeaf57bb2132ff4d27b10120f4368a0f627022@buzz.block.builderlab.xyz>
2026-08-19 12:50:00 +01:00

243 lines
7.6 KiB
TypeScript

import { relayClient } from "@/shared/api/relayClient";
import type { RelayEvent } from "@/shared/api/types";
import {
fetchAssignmentOperationEvents,
mergeEventsById,
} from "./assignmentOperationFetch";
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 RepositoryReference = {
repoAddress: string;
};
type ProjectReference = {
repositories: RepositoryReference[];
};
type ProjectRepository<TProject extends ProjectReference> =
TProject["repositories"][number];
/** Optional event groups that can fail without discarding root work items. */
export type ProjectWorkItemSection =
| "assignments"
| "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;
repository: ProjectRepository<TProject>;
issue: ProjectIssue;
}>;
failedSections: ProjectWorkItemSection[];
};
pullRequests: {
items: Array<{
project: TProject;
repository: ProjectRepository<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;
}
type FetchEventsInput = Parameters<(typeof relayClient)["fetchEvents"]>[0];
/** Loads aggregate issue and pull-request data with bounded relay fan-out. */
export async function fetchProjectsWorkItems<TProject extends ProjectReference>(
projects: TProject[],
fetchEvents: (
filter: FetchEventsInput,
) => Promise<RelayEvent[]> = relayClient.fetchEvents.bind(relayClient),
): Promise<ProjectsWorkItemsResult<TProject>> {
const repoAddresses = [
...new Set(
projects.flatMap((project) =>
project.repositories.map((repository) => repository.repoAddress),
),
),
];
const rootPromise = fetchEvents({
kinds: [KIND_GIT_ISSUE, KIND_GIT_PULL_REQUEST],
"#a": repoAddresses,
limit: 2_000,
});
const [rootResult, updateResult, commentResult, statusResult, assignResult] =
await Promise.allSettled([
rootPromise,
fetchEvents({
kinds: [KIND_GIT_PR_UPDATE],
"#a": repoAddresses,
limit: 2_000,
}),
fetchEvents({
kinds: [KIND_TEXT_NOTE],
"#a": repoAddresses,
limit: 2_000,
}),
fetchEvents({
kinds: [
KIND_GIT_STATUS_OPEN,
KIND_GIT_STATUS_MERGED,
KIND_GIT_STATUS_CLOSED,
KIND_GIT_STATUS_DRAFT,
],
"#a": repoAddresses,
limit: 2_000,
}),
// Assignment state must reduce over the complete operation history —
// the 2,000-comment window above is shared across every loaded repo
// and can evict older assignment operations. Keyed by issue id (`#e`)
// because that is the only tag constraint the relay applies before its
// SQL LIMIT; see fetchAssignmentOperationEvents.
rootPromise.then((rootEvents) =>
fetchAssignmentOperationEvents(
rootEvents
.filter((event) => event.kind === KIND_GIT_ISSUE)
.map((event) => event.id),
fetchEvents,
),
),
]);
if (rootResult.status === "rejected") {
throw rootResult.reason instanceof Error
? rootResult.reason
: new Error("Could not load project tasks and reviews.");
}
const updateEvents =
updateResult.status === "fulfilled" ? updateResult.value : [];
const commentEvents = mergeEventsById(
commentResult.status === "fulfilled" ? commentResult.value : [],
assignResult.status === "fulfilled" ? assignResult.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) =>
project.repositories.flatMap((repository) =>
projectPullRequestEventsToPullRequests(
(rootsByRepo.get(repository.repoAddress) ?? []).filter(
(event) => event.kind === KIND_GIT_PULL_REQUEST,
),
updatesByRepo.get(repository.repoAddress) ?? [],
commentsByRepo.get(repository.repoAddress) ?? [],
statusesByRepo.get(repository.repoAddress) ?? [],
).map((pullRequest) => ({ project, pullRequest, repository })),
),
)
// Deduplicate by (repoAddress, pull-request id): a repository in N projects
// must produce exactly one aggregate row per pull request (NIP-MP §Multiple
// membership). First occurrence wins; that project's navigation context is
// kept for the row.
.filter(
(() => {
const seen = new Set<string>();
return (item: {
repository: RepositoryReference;
pullRequest: ProjectPullRequest;
}) => {
const key = `${item.repository.repoAddress}:${item.pullRequest.id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
};
})(),
)
.sort(
(left, right) => right.pullRequest.updatedAt - left.pullRequest.updatedAt,
);
const issues = projects
.flatMap((project) =>
project.repositories.flatMap((repository) =>
projectIssueEventsToIssues(
(rootsByRepo.get(repository.repoAddress) ?? []).filter(
(event) => event.kind === KIND_GIT_ISSUE,
),
statusesByRepo.get(repository.repoAddress) ?? [],
commentsByRepo.get(repository.repoAddress) ?? [],
).map((issue) => ({ issue, project, repository })),
),
)
// Deduplicate by (repoAddress, issue id).
.filter(
(() => {
const seen = new Set<string>();
return (item: {
repository: RepositoryReference;
issue: ProjectIssue;
}) => {
const key = `${item.repository.repoAddress}:${item.issue.id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
};
})(),
)
.sort((left, right) => right.issue.updatedAt - left.issue.updatedAt);
const sharedFailedSections: ProjectWorkItemSection[] = [];
if (assignResult.status === "rejected") {
sharedFailedSections.push("assignments");
}
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,
},
};
}