mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
## Summary - adopt the finalized NIP-MP project model so one project can enumerate and switch between multiple NIP-34 repositories - add project and repository navigation, activity summaries, existing-repository attachment, and repository access-channel management - preserve privacy-safe activation provenance for agent-authored patches, pull requests, issues, and associated commits ## Test plan - [x] Run desktop typecheck and unit tests - [x] Run focused NIP-MP, repository access, and provenance tests - [x] Run Rust formatting and desktop lint checks - [x] Run the complete pre-push suite after merging current `main` - [ ] Manually verify project creation, repository attachment, switching, and access repair on staging - [ ] Manually verify public-channel and private-agent origin labels on newly created Git activity Related: [#4695](https://github.com/block/buzz/pull/4695) --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
149 lines
4.6 KiB
TypeScript
149 lines
4.6 KiB
TypeScript
import * as React from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
|
import {
|
|
fetchProjects,
|
|
type Project,
|
|
projectsQueryKey,
|
|
} from "@/features/projects/hooks";
|
|
import {
|
|
buildInitialProjectEventTemplates,
|
|
isUnsupportedProjectKindError,
|
|
} from "@/features/projects/projectCreation";
|
|
import { buildProjectReadModels } from "@/features/projects/projectModels";
|
|
import { relayClient } from "@/shared/api/relayClient";
|
|
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
|
|
import { signRelayEvent } from "@/shared/api/tauri";
|
|
import { getIdentity } from "@/shared/api/tauriIdentity";
|
|
|
|
export type CreateProjectInput = {
|
|
accessChannelId: string;
|
|
name: string;
|
|
description?: string;
|
|
cloneUrl?: string;
|
|
webUrl?: string;
|
|
};
|
|
|
|
export type CreateProjectResult = {
|
|
project: Project;
|
|
compatibilityWarning?: string;
|
|
};
|
|
|
|
/** Publishes a project announcement and its initial NIP-34 repository. */
|
|
async function createProject(
|
|
input: CreateProjectInput,
|
|
resumableProjectIds: Set<string>,
|
|
): Promise<CreateProjectResult> {
|
|
const identity = await getIdentity();
|
|
const templates = buildInitialProjectEventTemplates({
|
|
...input,
|
|
ownerPubkey: identity.pubkey,
|
|
});
|
|
const existing = await fetchProjects();
|
|
const ownerPubkey = identity.pubkey.toLowerCase();
|
|
const existingProject = existing.find(
|
|
(project) =>
|
|
project.owner.toLowerCase() === ownerPubkey &&
|
|
project.dtag === templates.dtag,
|
|
);
|
|
const projectId = `${ownerPubkey}:${templates.dtag}`;
|
|
const canResume = resumableProjectIds.has(projectId);
|
|
if (existingProject && !canResume) {
|
|
throw new Error(`You already have a project named "${templates.dtag}".`);
|
|
}
|
|
if (existingProject && !existingProject.legacy) {
|
|
if (
|
|
existingProject.repositories.some(
|
|
(repository) => repository.repoAddress === templates.repositoryAddress,
|
|
)
|
|
) {
|
|
resumableProjectIds.delete(projectId);
|
|
return { project: existingProject };
|
|
}
|
|
throw new Error(`You already have a project named "${templates.dtag}".`);
|
|
}
|
|
|
|
resumableProjectIds.add(projectId);
|
|
const projectEvent = await signRelayEvent(templates.project);
|
|
|
|
let repositoryEvent = null;
|
|
if (!existingProject) {
|
|
repositoryEvent = await signRelayEvent(templates.repository);
|
|
await relayClient.publishEvent(
|
|
repositoryEvent,
|
|
"Timed out creating the initial repository.",
|
|
"Failed to create the initial repository.",
|
|
);
|
|
}
|
|
|
|
try {
|
|
await relayClient.publishEvent(
|
|
projectEvent,
|
|
"Timed out creating project.",
|
|
"Failed to create project.",
|
|
);
|
|
} catch (error) {
|
|
if (!isUnsupportedProjectKindError(error)) throw error;
|
|
|
|
const [legacyProject] = existingProject?.legacy
|
|
? [existingProject]
|
|
: buildProjectReadModels({
|
|
projectEvents: [],
|
|
repositoryEvents: repositoryEvent ? [repositoryEvent] : [],
|
|
relayOrigin: getCachedRelayOrigin(),
|
|
});
|
|
if (!legacyProject) throw error;
|
|
|
|
resumableProjectIds.delete(projectId);
|
|
return {
|
|
project: legacyProject,
|
|
compatibilityWarning:
|
|
"The repository was created, but this relay does not support multi-repository projects yet. It will appear as a standalone project.",
|
|
};
|
|
}
|
|
|
|
const [project] = repositoryEvent
|
|
? buildProjectReadModels({
|
|
projectEvents: [projectEvent],
|
|
repositoryEvents: [repositoryEvent],
|
|
relayOrigin: getCachedRelayOrigin(),
|
|
})
|
|
: (await fetchProjects()).filter(
|
|
(candidate) =>
|
|
candidate.owner.toLowerCase() === ownerPubkey &&
|
|
candidate.dtag === templates.dtag &&
|
|
!candidate.legacy,
|
|
);
|
|
if (!project) {
|
|
throw new Error("The project was created but could not be read.");
|
|
}
|
|
resumableProjectIds.delete(projectId);
|
|
return { project };
|
|
}
|
|
|
|
/** Mutation that creates a project and inserts it into the projects cache. */
|
|
export function useCreateProjectMutation() {
|
|
const queryClient = useQueryClient();
|
|
const resumableProjectIdsRef = React.useRef(new Set<string>());
|
|
|
|
return useMutation({
|
|
mutationFn: (input: CreateProjectInput) =>
|
|
createProject(input, resumableProjectIdsRef.current),
|
|
onSuccess: ({ project }) => {
|
|
queryClient.setQueryData<Project[]>(projectsQueryKey, (current = []) => [
|
|
project,
|
|
...current.filter(
|
|
(candidate) =>
|
|
candidate.id !== project.id &&
|
|
!(
|
|
candidate.legacy &&
|
|
candidate.owner === project.owner &&
|
|
candidate.dtag === project.dtag
|
|
),
|
|
),
|
|
]);
|
|
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
|
},
|
|
});
|
|
}
|