mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
272 lines
7.6 KiB
TypeScript
272 lines
7.6 KiB
TypeScript
import { useMutation } from "@tanstack/react-query";
|
|
|
|
import {
|
|
mergeProjectPullRequest,
|
|
publishProjectPullRequestMergedStatus,
|
|
} from "@/shared/api/projectGit";
|
|
import { relayClient } from "@/shared/api/relayClient";
|
|
import { signRelayEvent } from "@/shared/api/tauri";
|
|
import { getIdentity } from "@/shared/api/tauriIdentity";
|
|
import {
|
|
KIND_GIT_PR_UPDATE,
|
|
KIND_GIT_PULL_REQUEST,
|
|
} from "@/shared/constants/kinds";
|
|
import { normalizePubkey } from "@/shared/lib/pubkey";
|
|
import type { Project, ProjectPullRequest } from "./hooks";
|
|
import { nextProjectPullRequestStatusCreatedAt } from "./projectPullRequests.mjs";
|
|
import { useProjectPullRequestWriteInvalidation } from "./pullRequestReviews";
|
|
|
|
type CreateProjectPullRequestInput = {
|
|
title: string;
|
|
body: string;
|
|
branch: string;
|
|
targetBranch: string;
|
|
commit: string;
|
|
mergeBase: string | null;
|
|
reviewers: string[];
|
|
};
|
|
|
|
function uniquePubkeys(pubkeys: readonly string[]) {
|
|
return [...new Set(pubkeys.map(normalizePubkey))];
|
|
}
|
|
|
|
export function projectPullRequestTags(
|
|
project: Project,
|
|
input: CreateProjectPullRequestInput,
|
|
): string[][] {
|
|
const tags = [
|
|
["a", project.repoAddress],
|
|
...uniquePubkeys([project.owner, ...input.reviewers]).map((pubkey) => [
|
|
"p",
|
|
pubkey,
|
|
]),
|
|
["subject", input.title],
|
|
["c", input.commit],
|
|
["clone", ...project.cloneUrls],
|
|
["branch-name", input.branch],
|
|
["target-branch", input.targetBranch],
|
|
];
|
|
if (input.mergeBase) tags.push(["merge-base", input.mergeBase]);
|
|
return tags;
|
|
}
|
|
|
|
export function projectPullRequestUpdateTags(
|
|
project: Project,
|
|
pullRequest: ProjectPullRequest,
|
|
commit: string,
|
|
mergeBase: string | null,
|
|
): string[][] {
|
|
const tags = [
|
|
["a", project.repoAddress],
|
|
...uniquePubkeys([project.owner, pullRequest.author]).map((pubkey) => [
|
|
"p",
|
|
pubkey,
|
|
]),
|
|
["E", pullRequest.id],
|
|
["P", normalizePubkey(pullRequest.author)],
|
|
["c", commit],
|
|
[
|
|
"clone",
|
|
...(pullRequest.cloneUrls.length > 0
|
|
? pullRequest.cloneUrls
|
|
: project.cloneUrls),
|
|
],
|
|
];
|
|
if (mergeBase) tags.push(["merge-base", mergeBase]);
|
|
return tags;
|
|
}
|
|
|
|
export function projectPullRequestMergedTags(
|
|
project: Project,
|
|
pullRequest: ProjectPullRequest,
|
|
mergeCommit: string,
|
|
): string[][] {
|
|
return [
|
|
["e", pullRequest.id, "", "root"],
|
|
["a", project.repoAddress],
|
|
...uniquePubkeys([project.owner, pullRequest.author]).map((pubkey) => [
|
|
"p",
|
|
pubkey,
|
|
]),
|
|
["merge-commit", mergeCommit],
|
|
["r", mergeCommit],
|
|
];
|
|
}
|
|
|
|
/** Whether the active identity may publish a trusted update for this PR. */
|
|
export function canPublishProjectPullRequestUpdate(
|
|
viewerPubkey: string,
|
|
project: Project,
|
|
pullRequest: ProjectPullRequest,
|
|
) {
|
|
const viewer = normalizePubkey(viewerPubkey);
|
|
return (
|
|
viewer === normalizePubkey(project.owner) ||
|
|
viewer === normalizePubkey(pullRequest.author)
|
|
);
|
|
}
|
|
|
|
async function publishProjectPullRequest(
|
|
project: Project,
|
|
input: CreateProjectPullRequestInput,
|
|
) {
|
|
const title = input.title.trim();
|
|
if (!title) throw new Error("Pull request title cannot be empty.");
|
|
if (title.length > 256) {
|
|
throw new Error("Pull request title must be 256 characters or fewer.");
|
|
}
|
|
if (project.cloneUrls.length === 0) {
|
|
throw new Error("This project has no clone URL.");
|
|
}
|
|
if (input.branch === input.targetBranch) {
|
|
throw new Error("The base and compare branches must be different.");
|
|
}
|
|
|
|
const event = await signRelayEvent({
|
|
kind: KIND_GIT_PULL_REQUEST,
|
|
content: input.body.trim(),
|
|
tags: projectPullRequestTags(project, { ...input, title }),
|
|
});
|
|
await relayClient.publishEvent(
|
|
event,
|
|
"Timed out creating pull request.",
|
|
"Failed to create pull request.",
|
|
);
|
|
return event.id;
|
|
}
|
|
|
|
export async function publishProjectPullRequestUpdate({
|
|
commit,
|
|
mergeBase,
|
|
project,
|
|
pullRequest,
|
|
}: {
|
|
commit: string;
|
|
mergeBase: string | null;
|
|
project: Project;
|
|
pullRequest: ProjectPullRequest;
|
|
}): Promise<boolean> {
|
|
if (pullRequest.commit?.toLowerCase() === commit.toLowerCase()) return false;
|
|
const identity = await getIdentity();
|
|
if (
|
|
!canPublishProjectPullRequestUpdate(identity.pubkey, project, pullRequest)
|
|
) {
|
|
throw new Error(
|
|
"Only the pull request author or repository owner can publish its update.",
|
|
);
|
|
}
|
|
const event = await signRelayEvent({
|
|
kind: KIND_GIT_PR_UPDATE,
|
|
content: "",
|
|
createdAt: Math.max(
|
|
Math.floor(Date.now() / 1_000),
|
|
...pullRequest.updates.map((update) => update.createdAt + 1),
|
|
),
|
|
tags: projectPullRequestUpdateTags(project, pullRequest, commit, mergeBase),
|
|
});
|
|
await relayClient.publishEvent(
|
|
event,
|
|
"Timed out updating pull request.",
|
|
"The branch was pushed, but the pull request update could not be published.",
|
|
);
|
|
return true;
|
|
}
|
|
|
|
export async function publishProjectPullRequestMerged(
|
|
project: Project,
|
|
statusEvent: string,
|
|
) {
|
|
await publishProjectPullRequestMergedStatus({
|
|
targetOwner: project.owner,
|
|
statusEvent,
|
|
});
|
|
}
|
|
|
|
export function useCreateProjectPullRequestMutation(
|
|
project: Project | null | undefined,
|
|
) {
|
|
const invalidate = useProjectPullRequestWriteInvalidation(project);
|
|
return useMutation({
|
|
mutationFn: (input: CreateProjectPullRequestInput) => {
|
|
if (!project) throw new Error("No project selected.");
|
|
return publishProjectPullRequest(project, input);
|
|
},
|
|
onSuccess: invalidate,
|
|
});
|
|
}
|
|
|
|
export function useUpdateProjectPullRequestMutation(
|
|
project: Project | null | undefined,
|
|
pullRequest: ProjectPullRequest | null,
|
|
) {
|
|
const invalidate = useProjectPullRequestWriteInvalidation(project);
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
commit,
|
|
mergeBase,
|
|
}: {
|
|
commit: string;
|
|
mergeBase: string | null;
|
|
}) => {
|
|
if (!project) throw new Error("No project selected.");
|
|
if (!pullRequest)
|
|
throw new Error("No open pull request for this branch.");
|
|
return publishProjectPullRequestUpdate({
|
|
commit,
|
|
mergeBase,
|
|
project,
|
|
pullRequest,
|
|
});
|
|
},
|
|
onSuccess: invalidate,
|
|
});
|
|
}
|
|
|
|
export function useMergeProjectPullRequestMutation(
|
|
project: Project | null | undefined,
|
|
) {
|
|
const invalidate = useProjectPullRequestWriteInvalidation(project);
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
pullRequest,
|
|
}: {
|
|
pullRequest: ProjectPullRequest;
|
|
}) => {
|
|
if (!project?.cloneUrls[0]) throw new Error("No project selected.");
|
|
if (!pullRequest.branchName || !pullRequest.commit) {
|
|
throw new Error("Pull request branch information is incomplete.");
|
|
}
|
|
const result = await mergeProjectPullRequest({
|
|
targetCloneUrl: project.cloneUrls[0],
|
|
sourceCloneUrl: pullRequest.cloneUrls[0] ?? project.cloneUrls[0],
|
|
targetOwner: project.owner,
|
|
repoAddress: project.repoAddress,
|
|
pullRequestId: pullRequest.id,
|
|
pullRequestAuthor: pullRequest.author,
|
|
statusCreatedAt: nextProjectPullRequestStatusCreatedAt(
|
|
pullRequest,
|
|
Math.floor(Date.now() / 1_000),
|
|
),
|
|
targetBranch: pullRequest.targetBranch ?? project.defaultBranch,
|
|
sourceBranch: pullRequest.branchName,
|
|
expectedCommit: pullRequest.commit,
|
|
});
|
|
return result;
|
|
},
|
|
onSuccess: invalidate,
|
|
});
|
|
}
|
|
|
|
export function usePublishProjectPullRequestMergedMutation(
|
|
project: Project | null | undefined,
|
|
) {
|
|
const invalidate = useProjectPullRequestWriteInvalidation(project);
|
|
return useMutation({
|
|
mutationFn: ({ statusEvent }: { statusEvent: string }) => {
|
|
if (!project) throw new Error("No project selected.");
|
|
return publishProjectPullRequestMerged(project, statusEvent);
|
|
},
|
|
onSuccess: invalidate,
|
|
});
|
|
}
|