mirror of
https://github.com/block/buzz.git
synced 2026-11-06 01:11:33 +00:00
Projects v3: unify sharing, discussions, and issue ownership (#5792)
## Summary Projects v3 makes repository work shareable, discussion-aware, and easier to scan in one coherent workspace. People can copy canonical links, reopen the exact workspace tab, understand issue and pull-request context at a glance, find related channel conversations, and assign or unassign issues across Desktop and CLI. - **Unified workspace** — top-level sections sit above repository controls in one rounded workspace, with navigation positioned close to the page heading. README and Files retain branch selection; every section has a labeled icon header, and Issues and Pull Requests expose creation from a consistent right-aligned action. - **Repository management** — the repository selector is always available, including single-repository projects. Its integrated add flow lets project owners create a repository manually or select an existing repository without a separate toolbar button. - **Readable work-item lists** — issue and pull-request rows use plain-language context instead of opaque metadata. Files, commits, issues, pull requests, channels, and contributors share consistent row density and right-aligned timestamps, while deterministic fallback-avatar colors keep participants distinct on light backgrounds. Inbox pull-request metadata wraps between complete phrases and truncates long channel names instead of compressing copy into narrow columns. - **Reliable entity links** — projects, repositories, issues, pull requests, and commits have canonical `buzz://` links, preview cards, OS deep-link routing, and tab-aware navigation. Reopening the same link re-applies its destination instead of leaving the user on a locally selected tab. - **Related conversations** — repository and work-item views surface channels discussing the current entity, including participants, channel navigation, message context, and an explicit notice when discovery reaches its 500-result cap. - **Reversible issue ownership** — trusted assignment and unassignment events work across Desktop, Tauri, `buzz-sdk`, and `buzz issues`. Assignees appear in project views and the assigned inbox, while authorized users can remove assignments directly from the assignee row. Assignment state is derived chronologically from labeled Nostr notes. Issue authors and repository owners may change any assignee; other users may only assign or unassign themselves. Shared golden fixtures keep entity-link grammar and validation aligned across TypeScript and Rust. The branch also updates `webbrowser` to the patched release for RUSTSEC-2026-0257. ### Related issue N/A. ### Testing - [x] `just ci` — formatting, lint, typechecking, unit tests, and builds passed - [x] Full pre-push suite — organization, branch-skew, Desktop checks, typechecking, and tests passed on the latest push - [x] `cargo test -p buzz-cli` and focused `buzz-sdk` assignment tests passed - [x] Focused Tauri recipient-note and 500-result search-limit tests passed - [x] Desktop entity-link and issue-assignment unit tests passed - [x] Playwright smoke coverage passed for assignment, repeated entity-link navigation, repository create/select flows, section headers and actions, timestamp alignment, timeline icons, sentence-style issue/PR metadata, header spacing, avatar contrast, and Inbox metadata at stacked and side-rail breakpoints - [ ] Manual staging pass: link round-trips, Channels tab, assignment flows, and inbox routing ### Screenshots Pull requests explain who opened the request, where it lives, and which branch it comes from; fallback avatars remain visually distinct.  Issues use the same sentence-style hierarchy while keeping status and recency easy to scan.  The wide Inbox detail keeps author, timestamp, and origin context readable beside its metadata rail.  [View the complete six-state Projects v3 screenshot set](https://github.com/block/buzz/pull/5624#issuecomment-5268039672) and [the compact/wide Inbox comparison](https://github.com/block/buzz/pull/5624#issuecomment-5268614585). --- > Supersedes #5624, whose head commit accumulated permanently-queued required check suites (block-dco-check et al.) that GitHub never dispatched. History flattened into a single signed-off commit on latest main; tree verified byte-identical (`git merge-tree`) to merging the original branch into main. --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com> Co-authored-by: Wintermute <3f1797424fd9ad6653a83665c660517777cd7f8c228c0d5907f49e01537f3ca5@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -6,14 +6,21 @@ import {
|
||||
type Repository,
|
||||
} from "@/features/projects/hooks";
|
||||
import { isUnsupportedProjectKindError } from "@/features/projects/projectCreation";
|
||||
import { buildAddedRepositoryEventTemplatesFromHead } from "@/features/projects/projectRepositoryCreation";
|
||||
import {
|
||||
isDanglingProjectMemberPublish,
|
||||
publishOwnedAgentProjectAnnouncements,
|
||||
} from "@/features/projects/projectOwnerControl";
|
||||
import {
|
||||
buildAddedRepositoryEventTemplatesFromHead,
|
||||
repositoryDtagFromName,
|
||||
} from "@/features/projects/projectRepositoryCreation";
|
||||
import {
|
||||
addRepositoryToProject,
|
||||
eventToRepository,
|
||||
} from "@/features/projects/projectModels";
|
||||
import { publishProjectOwnerAnnouncement } from "@/shared/api/projectGit";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { signRelayEvent } from "@/shared/api/tauri";
|
||||
import { getIdentity } from "@/shared/api/tauriIdentity";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import {
|
||||
KIND_PROJECT_ANNOUNCEMENT,
|
||||
KIND_REPO_ANNOUNCEMENT,
|
||||
@@ -25,27 +32,47 @@ export type AddProjectRepositoryInput = {
|
||||
cloneUrl?: string;
|
||||
description?: string;
|
||||
name: string;
|
||||
ownerControlAgentPubkey?: string;
|
||||
project: Project;
|
||||
webUrl?: string;
|
||||
};
|
||||
|
||||
async function addProjectRepository({
|
||||
project,
|
||||
...input
|
||||
}: AddProjectRepositoryInput): Promise<{
|
||||
type FetchEventsInput = Parameters<(typeof relayClient)["fetchEvents"]>[0];
|
||||
|
||||
/**
|
||||
* Relay/publish seams injected by tests. The regression for the
|
||||
* partial-publish heal must run the mutation itself — guards included — not
|
||||
* just the template builder, so the whole flow takes its I/O through here.
|
||||
*/
|
||||
export type AddProjectRepositoryDeps = {
|
||||
fetchEvents: (filter: FetchEventsInput) => Promise<RelayEvent[]>;
|
||||
publishOwnedAgentAnnouncements: typeof publishOwnedAgentProjectAnnouncements;
|
||||
publishOwnerAnnouncement: typeof publishProjectOwnerAnnouncement;
|
||||
};
|
||||
|
||||
/** Exported for tests — production callers go through the mutation hook. */
|
||||
export async function addProjectRepository(
|
||||
{ ownerControlAgentPubkey, project, ...input }: AddProjectRepositoryInput,
|
||||
deps?: Partial<AddProjectRepositoryDeps>,
|
||||
): Promise<{
|
||||
previousProjectId: string;
|
||||
project: Project;
|
||||
repository: Repository;
|
||||
}> {
|
||||
const identity = await getIdentity();
|
||||
const {
|
||||
fetchEvents = relayClient.fetchEvents.bind(relayClient),
|
||||
publishOwnedAgentAnnouncements = publishOwnedAgentProjectAnnouncements,
|
||||
publishOwnerAnnouncement = publishProjectOwnerAnnouncement,
|
||||
} = deps ?? {};
|
||||
const targetOwner = project.owner.toLowerCase();
|
||||
|
||||
// Fetch the live signed project head immediately before mutating.
|
||||
// This prevents: (a) unknown-tag erasure from the cached UI projection,
|
||||
// (b) concurrent-write clobber when another session or the CLI advanced
|
||||
// the head after we loaded the page.
|
||||
const liveHeads = await relayClient.fetchEvents({
|
||||
const liveHeads = await fetchEvents({
|
||||
kinds: [KIND_PROJECT_ANNOUNCEMENT],
|
||||
authors: [identity.pubkey],
|
||||
authors: [targetOwner],
|
||||
"#d": [project.dtag],
|
||||
limit: 1,
|
||||
});
|
||||
@@ -56,65 +83,138 @@ async function addProjectRepository({
|
||||
);
|
||||
}
|
||||
|
||||
// Dominated-write guard: if the live head is newer than our cached snapshot,
|
||||
// a concurrent session has already advanced the project — our mutation would
|
||||
// overwrite their changes. Surface the conflict rather than silently clobbering.
|
||||
if (liveHead.created_at > project.createdAt) {
|
||||
throw new Error(
|
||||
"This project was updated by another session while you were working. Refresh and try again.",
|
||||
);
|
||||
}
|
||||
// Partial-publish detection must precede the dominated-write guard below:
|
||||
// the project event can land while the repository event fails, leaving the
|
||||
// live head referencing a coordinate with no repository head (a dangling
|
||||
// member) — and a live head strictly newer than the cached snapshot,
|
||||
// because the failed mutation never updated the cache. Probe for a
|
||||
// repository head at the coordinate first so the template builder can
|
||||
// distinguish "raced with another session" (throw) from "resume a partial
|
||||
// publish" (heal by publishing only the missing event).
|
||||
const repositoryDtag = repositoryDtagFromName(input.name.trim());
|
||||
const existingRepoHeads = repositoryDtag
|
||||
? await fetchEvents({
|
||||
kinds: [KIND_REPO_ANNOUNCEMENT],
|
||||
authors: [targetOwner],
|
||||
"#d": [repositoryDtag],
|
||||
limit: 1,
|
||||
})
|
||||
: [];
|
||||
|
||||
const templates = buildAddedRepositoryEventTemplatesFromHead({
|
||||
...input,
|
||||
existingRepositoryAddresses: project.repositoryAddresses,
|
||||
liveHead,
|
||||
ownerPubkey: identity.pubkey,
|
||||
ownerPubkey: targetOwner,
|
||||
repositoryHeadExists: existingRepoHeads.length > 0,
|
||||
});
|
||||
|
||||
// Cross-project d-tag clobber guard: if the owner already has a 30617
|
||||
// at this coordinate — whether as a standalone repo or in another project —
|
||||
// block the write unconditionally unless it is already a member of this
|
||||
// project (in which case the earlier duplicate-address check would have
|
||||
// thrown before we reach here).
|
||||
const existingRepoHeads = await relayClient.fetchEvents({
|
||||
kinds: [KIND_REPO_ANNOUNCEMENT],
|
||||
authors: [identity.pubkey],
|
||||
"#d": [templates.repositoryDtag],
|
||||
limit: 1,
|
||||
});
|
||||
// block the write unconditionally. (A coordinate that is already a member
|
||||
// of this project with a live repository head threw "already contains"
|
||||
// inside the template builder before reaching here.)
|
||||
if (existingRepoHeads.length > 0) {
|
||||
throw new Error(
|
||||
`A repository named "${templates.repositoryDtag}" already exists (as a standalone repository or in another project). Choose a different name to avoid overwriting it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const [projectEvent, repositoryEvent] = await Promise.all([
|
||||
signRelayEvent({
|
||||
...templates.project,
|
||||
createdAt: Math.max(
|
||||
Math.floor(Date.now() / 1_000),
|
||||
liveHead.created_at + 1,
|
||||
),
|
||||
}),
|
||||
signRelayEvent(templates.repository),
|
||||
]);
|
||||
|
||||
try {
|
||||
// Confirm grouping support before publishing the repository so older
|
||||
// relays cannot leave a new standalone repository behind.
|
||||
await relayClient.publishEvent(
|
||||
projectEvent,
|
||||
"Timed out updating the project.",
|
||||
"Failed to update the project.",
|
||||
// Dominated-write guard: if the live head is newer than our cached snapshot,
|
||||
// a concurrent session has already advanced the project — our mutation would
|
||||
// overwrite their changes. Surface the conflict rather than silently
|
||||
// clobbering. Resume mode publishes no project head, so a moved head cannot
|
||||
// be clobbered and must not block the heal.
|
||||
if (!templates.resume && liveHead.created_at > project.createdAt) {
|
||||
throw new Error(
|
||||
"This project was updated by another session while you were working. Refresh and try again.",
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnsupportedProjectKindError(error)) {
|
||||
}
|
||||
|
||||
const projectCreatedAt = Math.max(
|
||||
Math.floor(Date.now() / 1_000),
|
||||
liveHead.created_at + 1,
|
||||
);
|
||||
if (ownerControlAgentPubkey) {
|
||||
// Resume mode: the live project head already references the coordinate,
|
||||
// so republishing it would only advance created_at for nothing — send
|
||||
// just the missing repository event.
|
||||
let events: RelayEvent[];
|
||||
try {
|
||||
events = await publishOwnedAgentAnnouncements(
|
||||
ownerControlAgentPubkey,
|
||||
templates.resume
|
||||
? [{ ...templates.repository, createdAt: projectCreatedAt }]
|
||||
: [
|
||||
{ ...templates.project, createdAt: projectCreatedAt },
|
||||
{ ...templates.repository, createdAt: projectCreatedAt },
|
||||
],
|
||||
);
|
||||
} catch (error) {
|
||||
// Sequential publish failed partway: the project head landed but the
|
||||
// repository event did not. Consume the partial-success metadata and
|
||||
// resume immediately with just the missing event, instead of
|
||||
// surfacing a dangling member for the user to retry.
|
||||
if (!isDanglingProjectMemberPublish(error)) {
|
||||
throw error;
|
||||
}
|
||||
const repositoryEvents = await publishOwnedAgentAnnouncements(
|
||||
ownerControlAgentPubkey,
|
||||
[{ ...templates.repository, createdAt: projectCreatedAt }],
|
||||
);
|
||||
events = [...error.publishedEvents, ...repositoryEvents];
|
||||
}
|
||||
const projectEvent = templates.resume
|
||||
? liveHead
|
||||
: events.find((event) => event.kind === KIND_PROJECT_ANNOUNCEMENT);
|
||||
const repositoryEvent = events.find(
|
||||
(event) => event.kind === KIND_REPO_ANNOUNCEMENT,
|
||||
);
|
||||
const repository = repositoryEvent
|
||||
? eventToRepository(repositoryEvent, getCachedRelayOrigin())
|
||||
: null;
|
||||
if (!projectEvent || !repository) {
|
||||
throw new Error(
|
||||
`This relay does not support multi-repository projects (event kind ${KIND_PROJECT_ANNOUNCEMENT}).`,
|
||||
"The owner agent updated the project but its response was incomplete. Refresh and try again.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
return {
|
||||
previousProjectId: project.id,
|
||||
project: addRepositoryToProject(
|
||||
project,
|
||||
repository,
|
||||
projectEvent.created_at,
|
||||
),
|
||||
repository,
|
||||
};
|
||||
}
|
||||
|
||||
let projectEvent: RelayEvent;
|
||||
if (templates.resume) {
|
||||
// The live head already references the coordinate — publishing the
|
||||
// missing repository event below is the entire heal.
|
||||
projectEvent = liveHead;
|
||||
} else {
|
||||
try {
|
||||
// Confirm grouping support before publishing the repository so older
|
||||
// relays cannot leave a new standalone repository behind.
|
||||
const publication = await publishOwnerAnnouncement({
|
||||
...templates.project,
|
||||
createdAt: projectCreatedAt,
|
||||
targetOwner,
|
||||
});
|
||||
projectEvent = publication.event;
|
||||
if (publication.publicationError) {
|
||||
throw new Error(publication.publicationError);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isUnsupportedProjectKindError(error)) {
|
||||
throw new Error(
|
||||
`This relay does not support multi-repository projects (event kind ${KIND_PROJECT_ANNOUNCEMENT}).`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// If the repository publish fails, the project event is already live with a
|
||||
@@ -130,15 +230,21 @@ async function addProjectRepository({
|
||||
// This keeps the mutation idempotent: re-submitting the same signed event is
|
||||
// safe because event ids are deterministic hashes of the content+pubkey+timestamp.
|
||||
let repository: Repository | null = null;
|
||||
const publishRepository = async (): Promise<void> => {
|
||||
await relayClient.publishEvent(
|
||||
repositoryEvent,
|
||||
"Timed out creating the repository.",
|
||||
"Failed to create the repository.",
|
||||
);
|
||||
let repositoryEvent: RelayEvent | null = null;
|
||||
const publishRepository = async (): Promise<RelayEvent> => {
|
||||
const publication = await publishOwnerAnnouncement({
|
||||
...templates.repository,
|
||||
createdAt: projectCreatedAt,
|
||||
targetOwner,
|
||||
});
|
||||
repositoryEvent = publication.event;
|
||||
if (publication.publicationError) {
|
||||
throw new Error(publication.publicationError);
|
||||
}
|
||||
return publication.event;
|
||||
};
|
||||
try {
|
||||
await publishRepository();
|
||||
repositoryEvent = await publishRepository();
|
||||
repository = eventToRepository(repositoryEvent, getCachedRelayOrigin());
|
||||
if (!repository) {
|
||||
throw new Error("The repository was created but could not be read.");
|
||||
@@ -147,23 +253,25 @@ async function addProjectRepository({
|
||||
// Query by event id: if the relay already has this event, the ACK was
|
||||
// merely lost — the write succeeded and we can proceed normally.
|
||||
let alreadyStored = false;
|
||||
try {
|
||||
const stored = await relayClient.fetchEvents({
|
||||
ids: [repositoryEvent.id],
|
||||
kinds: [KIND_REPO_ANNOUNCEMENT],
|
||||
limit: 1,
|
||||
});
|
||||
alreadyStored = stored.length > 0;
|
||||
} catch {
|
||||
// Ignore — if the query itself fails we fall through to the retry.
|
||||
if (repositoryEvent) {
|
||||
try {
|
||||
const stored = await fetchEvents({
|
||||
ids: [repositoryEvent.id],
|
||||
kinds: [KIND_REPO_ANNOUNCEMENT],
|
||||
limit: 1,
|
||||
});
|
||||
alreadyStored = stored.length > 0;
|
||||
} catch {
|
||||
// Ignore — if the query itself fails we fall through to the retry.
|
||||
}
|
||||
}
|
||||
if (alreadyStored) {
|
||||
if (alreadyStored && repositoryEvent) {
|
||||
repository = eventToRepository(repositoryEvent, getCachedRelayOrigin());
|
||||
} else {
|
||||
// The relay rejected or never received the event. Retry once with the
|
||||
// same signed event (safe — deterministic id).
|
||||
try {
|
||||
await publishRepository();
|
||||
repositoryEvent = await publishRepository();
|
||||
repository = eventToRepository(repositoryEvent, getCachedRelayOrigin());
|
||||
} catch (retryError) {
|
||||
const message =
|
||||
@@ -173,13 +281,16 @@ async function addProjectRepository({
|
||||
// The project already lists the coordinate. Surface a partial-write
|
||||
// error with the event id so the user (or a future repair pass) can
|
||||
// query the relay directly and re-submit the signed event if needed.
|
||||
const eventLabel = repositoryEvent
|
||||
? `event ${repositoryEvent.id.slice(0, 8)}…`
|
||||
: "an event that could not be signed";
|
||||
throw new Error(
|
||||
`The project was updated but the repository could not be created (event ${repositoryEvent.id.slice(0, 8)}…): ${message} ` +
|
||||
`The project was updated but the repository could not be created (${eventLabel}): ${message} ` +
|
||||
`The add-repository operation is idempotent — re-run Add repository with the same name to resume: the recovery query will find the stored event or retry the publish.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!repository) {
|
||||
if (!repository && repositoryEvent) {
|
||||
repository = eventToRepository(repositoryEvent, getCachedRelayOrigin());
|
||||
}
|
||||
if (!repository) {
|
||||
@@ -201,7 +312,8 @@ async function addProjectRepository({
|
||||
export function useAddProjectRepositoryMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: addProjectRepository,
|
||||
mutationFn: (input: AddProjectRepositoryInput) =>
|
||||
addProjectRepository(input),
|
||||
onSuccess: ({ previousProjectId, project }) => {
|
||||
if (previousProjectId !== project.id) {
|
||||
queryClient.removeQueries({
|
||||
|
||||
Reference in New Issue
Block a user