mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
980 lines
27 KiB
TypeScript
980 lines
27 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import * as React from "react";
|
|
|
|
import { relayClient } from "@/shared/api/relayClient";
|
|
import { signRelayEvent } from "@/shared/api/tauri";
|
|
import { getIdentity } from "@/shared/api/tauriIdentity";
|
|
import {
|
|
getProjectLocalRepoDiff,
|
|
getProjectRepoDiff,
|
|
getProjectRepoSyncStatus,
|
|
getProjectLocalRepoSnapshot,
|
|
getProjectRepoSnapshot,
|
|
listProjectLocalRepositories,
|
|
pushProjectLocalRepository,
|
|
} from "@/shared/api/projectGit";
|
|
import {
|
|
KIND_DELETION,
|
|
KIND_GIT_ISSUE,
|
|
KIND_GIT_PATCH,
|
|
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_REPO_ANNOUNCEMENT,
|
|
KIND_REPO_STATE,
|
|
KIND_TEXT_NOTE,
|
|
} from "@/shared/constants/kinds";
|
|
import type {
|
|
ProjectLocalRepository,
|
|
ProjectLocalRepoSnapshot,
|
|
ProjectRepoDiff,
|
|
ProjectRepoPushResult,
|
|
ProjectRepoContributor,
|
|
ProjectRepoFile,
|
|
ProjectRepoSnapshot,
|
|
ProjectRepoSyncStatus,
|
|
RelayEvent,
|
|
} from "@/shared/api/types";
|
|
import { summarizeProjectActivityEvents } from "./projectActivity.mjs";
|
|
import type { ProjectIssue } from "./projectIssues.mjs";
|
|
import { projectIssueEventsToIssues } from "./projectIssues.mjs";
|
|
import type { ProjectPullRequest } from "./projectPullRequests.mjs";
|
|
import { projectPullRequestEventsToPullRequests } from "./projectPullRequests.mjs";
|
|
|
|
export type { ProjectIssue, ProjectPullRequest };
|
|
|
|
const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1";
|
|
|
|
export type Project = {
|
|
id: string;
|
|
dtag: string;
|
|
name: string;
|
|
description: string;
|
|
cloneUrls: string[];
|
|
webUrl: string | null;
|
|
owner: string;
|
|
contributors: string[];
|
|
createdAt: number;
|
|
projectChannelId: string | null;
|
|
status: string;
|
|
defaultBranch: string;
|
|
repoAddress: string;
|
|
};
|
|
|
|
export type RepoState = {
|
|
branches: Array<{ name: string; commit: string }>;
|
|
tags: Array<{ name: string; commit: string }>;
|
|
head: string | null;
|
|
updatedAt: number;
|
|
};
|
|
|
|
export type ProjectActivitySummary = {
|
|
repoAddress: string;
|
|
issueCount: number;
|
|
prCount: number;
|
|
commitCount: number;
|
|
activityCount: number;
|
|
updatedAt: number;
|
|
participantPubkeys: string[];
|
|
latestCommit: {
|
|
author: string | null;
|
|
commit: string;
|
|
createdAt: number;
|
|
title: string;
|
|
} | null;
|
|
/** Activity event counts bucketed by local-time day key ("YYYY-MM-DD"). */
|
|
activityByDay: Record<string, number>;
|
|
};
|
|
|
|
export type {
|
|
ProjectLocalRepository,
|
|
ProjectLocalRepoSnapshot,
|
|
ProjectRepoDiff,
|
|
ProjectRepoPushResult,
|
|
ProjectRepoContributor,
|
|
ProjectRepoFile,
|
|
ProjectRepoSnapshot,
|
|
ProjectRepoSyncStatus,
|
|
};
|
|
|
|
export type ProjectPullRequestListItem = {
|
|
project: Project;
|
|
pullRequest: ProjectPullRequest;
|
|
};
|
|
|
|
export type ProjectIssueListItem = {
|
|
project: Project;
|
|
issue: ProjectIssue;
|
|
};
|
|
|
|
function getTag(event: RelayEvent, name: string): string | undefined {
|
|
const value = event.tags.find((t) => t[0] === name)?.[1];
|
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
}
|
|
|
|
function getAllTags(event: RelayEvent, name: string): string[] {
|
|
return event.tags
|
|
.filter((t) => t[0] === name && typeof t[1] === "string" && t[1].length > 0)
|
|
.map((t) => t[1]);
|
|
}
|
|
|
|
function getCloneUrls(event: RelayEvent): string[] {
|
|
const tag = event.tags.find((t) => t[0] === "clone");
|
|
return tag ? tag.slice(1) : [];
|
|
}
|
|
|
|
function projectCoordinate(project: Pick<Project, "owner" | "dtag">): string {
|
|
return `${KIND_REPO_ANNOUNCEMENT}:${project.owner}:${project.dtag}`;
|
|
}
|
|
|
|
function readHiddenProjectCards(): string[] {
|
|
if (typeof window === "undefined") {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(
|
|
window.localStorage.getItem(HIDDEN_PROJECT_CARDS_KEY) ?? "[]",
|
|
);
|
|
return Array.isArray(parsed)
|
|
? parsed.filter((item): item is string => typeof item === "string")
|
|
: [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function isHiddenLocally(project: Project): boolean {
|
|
return readHiddenProjectCards().includes(projectCoordinate(project));
|
|
}
|
|
|
|
function isDeletedByA(project: Project, deletionEvents: RelayEvent[]): boolean {
|
|
const coordinate = projectCoordinate(project);
|
|
// NIP-09: a deletion is only valid when signed by the author of the
|
|
// referenced event — otherwise anyone could hide someone else's project.
|
|
return deletionEvents.some(
|
|
(event) =>
|
|
event.pubkey.toLowerCase() === project.owner.toLowerCase() &&
|
|
event.tags.some((tag) => tag[0] === "a" && tag[1] === coordinate),
|
|
);
|
|
}
|
|
|
|
function eventToProject(event: RelayEvent): Project {
|
|
const d = getTag(event, "d") ?? event.id;
|
|
const name = getTag(event, "name") || d;
|
|
const description = getTag(event, "description") || event.content || "";
|
|
const cloneUrls = getCloneUrls(event);
|
|
const webUrl = getTag(event, "web") ?? null;
|
|
const setupUsers = getAllTags(event, "auth");
|
|
const contributors = [...new Set([...getAllTags(event, "p"), ...setupUsers])];
|
|
// `h`/`project-channel`, `status`, and `default-branch` are NOT part of
|
|
// NIP-34 — they are read-side tolerance for extension tags no code writes
|
|
// today (the write path that emitted them was removed). If a write path is
|
|
// reintroduced it must go through the buzz-sdk repo-announcement builder;
|
|
// the canonical NIP-34 source for the default branch is the kind:30618
|
|
// state event's HEAD ref, not a 30617 tag.
|
|
const projectChannelId =
|
|
getTag(event, "h") ?? getTag(event, "project-channel") ?? null;
|
|
|
|
return {
|
|
id: `${event.pubkey}:${d}`,
|
|
dtag: d,
|
|
name,
|
|
description,
|
|
cloneUrls,
|
|
webUrl,
|
|
owner: event.pubkey,
|
|
contributors,
|
|
createdAt: event.created_at,
|
|
projectChannelId,
|
|
status: getTag(event, "status") ?? "active",
|
|
defaultBranch: getTag(event, "default-branch") ?? "main",
|
|
repoAddress: projectCoordinate({ owner: event.pubkey, dtag: d }),
|
|
};
|
|
}
|
|
|
|
function dedup(events: RelayEvent[]): RelayEvent[] {
|
|
const best = new Map<string, RelayEvent>();
|
|
|
|
for (const e of events) {
|
|
const d = getTag(e, "d") ?? "";
|
|
const key = `${e.pubkey}:${e.kind}:${d}`;
|
|
const prev = best.get(key);
|
|
|
|
if (!prev || e.created_at > prev.created_at) {
|
|
best.set(key, e);
|
|
}
|
|
}
|
|
|
|
return [...best.values()];
|
|
}
|
|
|
|
async function fetchProjects(): Promise<Project[]> {
|
|
const [events, deletionEvents] = await Promise.all([
|
|
relayClient.fetchEvents({
|
|
kinds: [KIND_REPO_ANNOUNCEMENT],
|
|
limit: 200,
|
|
}),
|
|
relayClient.fetchEvents({
|
|
kinds: [KIND_DELETION],
|
|
limit: 500,
|
|
}),
|
|
]);
|
|
|
|
return dedup(events)
|
|
.map(eventToProject)
|
|
.filter(
|
|
(project) =>
|
|
!isHiddenLocally(project) && !isDeletedByA(project, deletionEvents),
|
|
)
|
|
.sort((a, b) => b.createdAt - a.createdAt);
|
|
}
|
|
|
|
/**
|
|
* Splits a project route ID into its owner pubkey and dtag. The canonical
|
|
* form is `<owner-pubkey>:<dtag>` (matching `Project.id`) — NIP-34 repo
|
|
* identity is the full `30617:<owner>:<dtag>` coordinate, and two owners can
|
|
* both publish the same dtag (forks). Bare-dtag IDs from legacy links are
|
|
* still resolved, ambiguously, to whichever owner the relay returns first.
|
|
*/
|
|
function parseProjectRouteId(projectId: string): {
|
|
owner: string | null;
|
|
dtag: string;
|
|
} {
|
|
const owner = projectId.slice(0, 64);
|
|
if (projectId[64] === ":" && /^[0-9a-fA-F]{64}$/.test(owner)) {
|
|
return { owner: owner.toLowerCase(), dtag: projectId.slice(65) };
|
|
}
|
|
return { owner: null, dtag: projectId };
|
|
}
|
|
|
|
async function fetchProject(projectId: string): Promise<Project | null> {
|
|
const { owner, dtag } = parseProjectRouteId(projectId);
|
|
const events = await relayClient.fetchEvents({
|
|
kinds: [KIND_REPO_ANNOUNCEMENT],
|
|
...(owner ? { authors: [owner] } : {}),
|
|
"#d": [dtag],
|
|
limit: 10,
|
|
});
|
|
|
|
const deduped = dedup(events).filter(
|
|
(event) => !owner || event.pubkey.toLowerCase() === owner,
|
|
);
|
|
const project = deduped.length > 0 ? eventToProject(deduped[0]) : null;
|
|
if (!project) {
|
|
return null;
|
|
}
|
|
|
|
const deletionEvents = await relayClient.fetchEvents({
|
|
kinds: [KIND_DELETION],
|
|
authors: [project.owner],
|
|
"#a": [project.repoAddress],
|
|
limit: 10,
|
|
});
|
|
|
|
return isDeletedByA(project, deletionEvents) ? null : project;
|
|
}
|
|
|
|
function eventToRepoState(event: RelayEvent): RepoState {
|
|
const branches: RepoState["branches"] = [];
|
|
const tags: RepoState["tags"] = [];
|
|
let head: string | null = null;
|
|
|
|
for (const tag of event.tags) {
|
|
const [name, value] = tag;
|
|
if (!name || !value) continue;
|
|
|
|
if (name.startsWith("refs/heads/")) {
|
|
branches.push({ name: name.slice("refs/heads/".length), commit: value });
|
|
} else if (name.startsWith("refs/tags/")) {
|
|
tags.push({ name: name.slice("refs/tags/".length), commit: value });
|
|
} else if (name === "HEAD") {
|
|
head = value.replace(/^ref:\s*/, "");
|
|
}
|
|
}
|
|
|
|
return {
|
|
branches,
|
|
tags,
|
|
head,
|
|
updatedAt: event.created_at,
|
|
};
|
|
}
|
|
|
|
async function fetchRepoState(project: Project): Promise<RepoState | null> {
|
|
const events = await relayClient.fetchEvents({
|
|
kinds: [KIND_REPO_STATE],
|
|
authors: [project.owner],
|
|
"#d": [project.dtag],
|
|
limit: 1,
|
|
});
|
|
|
|
return events.length > 0 ? eventToRepoState(events[0]) : null;
|
|
}
|
|
|
|
async function fetchProjectIssues(project: Project): Promise<ProjectIssue[]> {
|
|
const [issueEvents, statusEvents, commentEvents] = await Promise.all([
|
|
relayClient.fetchEvents({
|
|
kinds: [KIND_GIT_ISSUE],
|
|
"#a": [project.repoAddress],
|
|
limit: 200,
|
|
}),
|
|
relayClient.fetchEvents({
|
|
kinds: [
|
|
KIND_GIT_STATUS_OPEN,
|
|
KIND_GIT_STATUS_MERGED,
|
|
KIND_GIT_STATUS_CLOSED,
|
|
KIND_GIT_STATUS_DRAFT,
|
|
],
|
|
"#a": [project.repoAddress],
|
|
limit: 500,
|
|
}),
|
|
relayClient.fetchEvents({
|
|
kinds: [KIND_TEXT_NOTE],
|
|
"#a": [project.repoAddress],
|
|
limit: 500,
|
|
}),
|
|
]);
|
|
|
|
return projectIssueEventsToIssues(issueEvents, statusEvents, commentEvents);
|
|
}
|
|
|
|
async function fetchProjectPullRequests(
|
|
project: Project,
|
|
): Promise<ProjectPullRequest[]> {
|
|
const [pullRequestEvents, updateEvents, commentEvents, statusEvents] =
|
|
await Promise.all([
|
|
relayClient.fetchEvents({
|
|
kinds: [KIND_GIT_PULL_REQUEST],
|
|
"#a": [project.repoAddress],
|
|
limit: 200,
|
|
}),
|
|
relayClient.fetchEvents({
|
|
kinds: [KIND_GIT_PR_UPDATE],
|
|
"#a": [project.repoAddress],
|
|
limit: 500,
|
|
}),
|
|
relayClient.fetchEvents({
|
|
kinds: [KIND_TEXT_NOTE],
|
|
"#a": [project.repoAddress],
|
|
limit: 500,
|
|
}),
|
|
relayClient.fetchEvents({
|
|
kinds: [
|
|
KIND_GIT_STATUS_OPEN,
|
|
KIND_GIT_STATUS_MERGED,
|
|
KIND_GIT_STATUS_CLOSED,
|
|
KIND_GIT_STATUS_DRAFT,
|
|
],
|
|
"#a": [project.repoAddress],
|
|
limit: 500,
|
|
}),
|
|
]);
|
|
|
|
return projectPullRequestEventsToPullRequests(
|
|
pullRequestEvents,
|
|
updateEvents,
|
|
commentEvents,
|
|
statusEvents,
|
|
);
|
|
}
|
|
|
|
// Issue/PR comments are published as kind:1 text notes because the relay
|
|
// does not register NIP-22 kind 1111 (current NIP-34 reply convention).
|
|
// Pulse feeds filter these out via the repo-address `a` tag (see
|
|
// features/pulse/lib/projectComments.ts). If the relay ever allowlists
|
|
// 1111, migrate these to NIP-22 comments and drop that filter.
|
|
async function createProjectPullRequestComment({
|
|
content,
|
|
mediaTags,
|
|
mentionPubkeys = [],
|
|
project,
|
|
pullRequest,
|
|
}: {
|
|
content: string;
|
|
mediaTags?: string[][];
|
|
mentionPubkeys?: string[];
|
|
project: Project;
|
|
pullRequest: ProjectPullRequest;
|
|
}): Promise<void> {
|
|
const body = content.trim();
|
|
if (!body) {
|
|
throw new Error("Comment cannot be empty.");
|
|
}
|
|
|
|
const recipients = new Set([
|
|
project.owner.toLowerCase(),
|
|
pullRequest.author.toLowerCase(),
|
|
...pullRequest.recipients.map((recipient) => recipient.toLowerCase()),
|
|
...mentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
|
|
]);
|
|
const tags = [
|
|
["e", pullRequest.id, "", "root"],
|
|
["a", project.repoAddress],
|
|
...[...recipients].map((recipient) => ["p", recipient]),
|
|
...(mediaTags ?? []),
|
|
];
|
|
|
|
const event = await signRelayEvent({
|
|
kind: KIND_TEXT_NOTE,
|
|
content: body,
|
|
tags,
|
|
});
|
|
|
|
await relayClient.publishEvent(
|
|
event,
|
|
"Timed out posting pull request comment.",
|
|
"Failed to post pull request comment.",
|
|
);
|
|
}
|
|
|
|
async function createProjectIssueComment({
|
|
content,
|
|
mediaTags,
|
|
mentionPubkeys = [],
|
|
issue,
|
|
project,
|
|
}: {
|
|
content: string;
|
|
mediaTags?: string[][];
|
|
mentionPubkeys?: string[];
|
|
issue: ProjectIssue;
|
|
project: Project;
|
|
}): Promise<void> {
|
|
const body = content.trim();
|
|
if (!body) {
|
|
throw new Error("Comment cannot be empty.");
|
|
}
|
|
|
|
const recipients = new Set([
|
|
project.owner.toLowerCase(),
|
|
issue.author.toLowerCase(),
|
|
...issue.recipients.map((recipient) => recipient.toLowerCase()),
|
|
...mentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
|
|
]);
|
|
const tags = [
|
|
["e", issue.id, "", "root"],
|
|
["a", project.repoAddress],
|
|
...[...recipients].map((recipient) => ["p", recipient]),
|
|
...(mediaTags ?? []),
|
|
];
|
|
|
|
const event = await signRelayEvent({
|
|
kind: KIND_TEXT_NOTE,
|
|
content: body,
|
|
tags,
|
|
});
|
|
|
|
await relayClient.publishEvent(
|
|
event,
|
|
"Timed out posting issue comment.",
|
|
"Failed to post issue comment.",
|
|
);
|
|
}
|
|
|
|
async function fetchProjectRepoSnapshot(
|
|
project: Project,
|
|
branchName?: string | null,
|
|
pullRequest?: ProjectPullRequest | null,
|
|
): Promise<ProjectRepoSnapshot | null> {
|
|
const cloneUrl = pullRequest?.cloneUrls[0] ?? project.cloneUrls[0];
|
|
if (!cloneUrl) return null;
|
|
|
|
return getProjectRepoSnapshot({
|
|
cloneUrl,
|
|
defaultBranch: branchName ?? project.defaultBranch,
|
|
baseBranch: project.defaultBranch,
|
|
targetCommit: pullRequest?.commit ?? null,
|
|
targetRef: pullRequest ? `refs/nostr/${pullRequest.id}` : null,
|
|
});
|
|
}
|
|
|
|
async function fetchProjectRepoDiff(
|
|
project: Project,
|
|
branchName?: string | null,
|
|
pullRequest?: ProjectPullRequest | null,
|
|
): Promise<ProjectRepoDiff | null> {
|
|
const cloneUrl = pullRequest?.cloneUrls[0] ?? project.cloneUrls[0];
|
|
if (!cloneUrl) return null;
|
|
|
|
return getProjectRepoDiff({
|
|
cloneUrl,
|
|
defaultBranch: branchName ?? project.defaultBranch,
|
|
baseBranch: project.defaultBranch,
|
|
targetCommit: pullRequest?.commit ?? null,
|
|
targetRef: pullRequest ? `refs/nostr/${pullRequest.id}` : null,
|
|
});
|
|
}
|
|
|
|
async function fetchProjectLocalRepoDiff(
|
|
project: Project,
|
|
reposDir?: string | null,
|
|
branchName?: string | null,
|
|
pullRequest?: ProjectPullRequest | null,
|
|
): Promise<ProjectRepoDiff | null> {
|
|
return getProjectLocalRepoDiff({
|
|
reposDir,
|
|
projectDtag: project.dtag,
|
|
cloneUrl: project.cloneUrls[0] ?? null,
|
|
defaultBranch: branchName ?? project.defaultBranch,
|
|
baseBranch: project.defaultBranch,
|
|
baseCommit:
|
|
pullRequest?.initialCommit &&
|
|
pullRequest.initialCommit !== pullRequest.commit
|
|
? pullRequest.initialCommit
|
|
: null,
|
|
targetCommit: pullRequest?.commit ?? null,
|
|
});
|
|
}
|
|
|
|
async function fetchProjectLocalRepoSnapshot(
|
|
project: Project,
|
|
reposDir?: string | null,
|
|
branchName?: string | null,
|
|
): Promise<ProjectLocalRepoSnapshot | null> {
|
|
return getProjectLocalRepoSnapshot({
|
|
reposDir,
|
|
projectDtag: project.dtag,
|
|
cloneUrl: project.cloneUrls[0] ?? null,
|
|
defaultBranch: branchName ?? project.defaultBranch,
|
|
baseBranch: project.defaultBranch,
|
|
});
|
|
}
|
|
|
|
async function fetchProjectActivitySummaries(
|
|
projects: Project[],
|
|
): Promise<Record<string, ProjectActivitySummary>> {
|
|
if (projects.length === 0) return {};
|
|
|
|
const events = await relayClient.fetchEvents({
|
|
kinds: [
|
|
KIND_GIT_ISSUE,
|
|
KIND_GIT_STATUS_OPEN,
|
|
KIND_GIT_STATUS_MERGED,
|
|
KIND_GIT_STATUS_CLOSED,
|
|
KIND_GIT_STATUS_DRAFT,
|
|
KIND_GIT_PATCH,
|
|
KIND_GIT_PULL_REQUEST,
|
|
KIND_GIT_PR_UPDATE,
|
|
],
|
|
"#a": projects.map((project) => project.repoAddress),
|
|
limit: 1_000,
|
|
});
|
|
|
|
return summarizeProjectActivityEvents(events, projects) as Record<
|
|
string,
|
|
ProjectActivitySummary
|
|
>;
|
|
}
|
|
|
|
async function deleteProject(project: Project): Promise<void> {
|
|
const identity = await getIdentity();
|
|
if (identity.pubkey.toLowerCase() !== project.owner.toLowerCase()) {
|
|
throw new Error("Only branch owners can delete branches.");
|
|
}
|
|
|
|
const event = await signRelayEvent({
|
|
kind: KIND_DELETION,
|
|
content: `Delete project ${project.name}`,
|
|
tags: [["a", project.repoAddress]],
|
|
});
|
|
|
|
await relayClient.publishEvent(
|
|
event,
|
|
"Timed out deleting project.",
|
|
"Failed to delete project.",
|
|
);
|
|
}
|
|
|
|
export const projectsQueryKey = ["projects"] as const;
|
|
|
|
export function useProjectsQuery() {
|
|
return useQuery({
|
|
queryKey: projectsQueryKey,
|
|
queryFn: fetchProjects,
|
|
staleTime: 60_000,
|
|
});
|
|
}
|
|
|
|
export function useProjectQuery(projectId: string) {
|
|
return useQuery({
|
|
queryKey: ["project", projectId],
|
|
queryFn: () => fetchProject(projectId),
|
|
staleTime: 60_000,
|
|
});
|
|
}
|
|
|
|
export function useRepoStateQuery(project: Project | null | undefined) {
|
|
return useQuery({
|
|
enabled: Boolean(project),
|
|
queryKey: ["project", project?.id ?? "none", "repo-state"],
|
|
queryFn: () => {
|
|
if (!project) throw new Error("No project selected.");
|
|
return fetchRepoState(project);
|
|
},
|
|
staleTime: 30_000,
|
|
});
|
|
}
|
|
|
|
export function useProjectRepoSnapshotQuery(
|
|
project: Project | null | undefined,
|
|
branchName?: string | null,
|
|
pullRequest?: ProjectPullRequest | null,
|
|
) {
|
|
const selectedBranch = branchName ?? project?.defaultBranch ?? null;
|
|
|
|
return useQuery({
|
|
enabled: Boolean(project?.cloneUrls[0]),
|
|
queryKey: [
|
|
"project",
|
|
project?.id ?? "none",
|
|
"repo-snapshot",
|
|
selectedBranch ?? "default",
|
|
pullRequest?.id ?? "none",
|
|
pullRequest?.commit ?? "none",
|
|
],
|
|
queryFn: () => {
|
|
if (!project) throw new Error("No project selected.");
|
|
return fetchProjectRepoSnapshot(project, selectedBranch, pullRequest);
|
|
},
|
|
staleTime: 30_000,
|
|
retry: 1,
|
|
});
|
|
}
|
|
|
|
export function useProjectRepoDiffQuery(
|
|
project: Project | null | undefined,
|
|
branchName?: string | null,
|
|
pullRequest?: ProjectPullRequest | null,
|
|
enabled = true,
|
|
) {
|
|
const selectedBranch = branchName ?? project?.defaultBranch ?? null;
|
|
|
|
return useQuery({
|
|
enabled: Boolean(enabled && project?.cloneUrls[0] && pullRequest),
|
|
queryKey: [
|
|
"project",
|
|
project?.id ?? "none",
|
|
"repo-diff",
|
|
selectedBranch ?? "default",
|
|
pullRequest?.id ?? "none",
|
|
pullRequest?.commit ?? "none",
|
|
],
|
|
queryFn: () => {
|
|
if (!project) throw new Error("No project selected.");
|
|
return fetchProjectRepoDiff(project, selectedBranch, pullRequest);
|
|
},
|
|
staleTime: 30_000,
|
|
retry: 1,
|
|
});
|
|
}
|
|
|
|
export function useProjectLocalRepoDiffQuery(
|
|
project: Project | null | undefined,
|
|
reposDir?: string | null,
|
|
branchName?: string | null,
|
|
pullRequest?: ProjectPullRequest | null,
|
|
enabled = true,
|
|
) {
|
|
const selectedBranch = branchName ?? project?.defaultBranch ?? null;
|
|
|
|
return useQuery({
|
|
enabled: Boolean(enabled && project),
|
|
queryKey: [
|
|
"project",
|
|
project?.id ?? "none",
|
|
"local-repo-diff",
|
|
reposDir ?? "default",
|
|
selectedBranch ?? "default",
|
|
pullRequest?.initialCommit ?? "none",
|
|
pullRequest?.commit ?? "none",
|
|
],
|
|
queryFn: () => {
|
|
if (!project) throw new Error("No project selected.");
|
|
return fetchProjectLocalRepoDiff(
|
|
project,
|
|
reposDir,
|
|
selectedBranch,
|
|
pullRequest,
|
|
);
|
|
},
|
|
staleTime: 30_000,
|
|
retry: 1,
|
|
});
|
|
}
|
|
|
|
export function useProjectLocalRepoSnapshotQuery(
|
|
project: Project | null | undefined,
|
|
reposDir?: string | null,
|
|
branchName?: string | null,
|
|
) {
|
|
const selectedBranch = branchName ?? project?.defaultBranch ?? null;
|
|
|
|
return useQuery({
|
|
enabled: Boolean(project),
|
|
queryKey: [
|
|
"project",
|
|
project?.id ?? "none",
|
|
"local-repo-snapshot",
|
|
reposDir ?? "default",
|
|
selectedBranch ?? "default",
|
|
],
|
|
queryFn: () => {
|
|
if (!project) throw new Error("No project selected.");
|
|
return fetchProjectLocalRepoSnapshot(project, reposDir, selectedBranch);
|
|
},
|
|
staleTime: 10_000,
|
|
retry: 1,
|
|
});
|
|
}
|
|
|
|
export function useProjectLocalRepositoriesQuery(reposDir?: string | null) {
|
|
return useQuery({
|
|
queryKey: ["projects", "local-repositories", reposDir ?? "default"],
|
|
queryFn: () => listProjectLocalRepositories({ reposDir }),
|
|
staleTime: 10_000,
|
|
retry: 1,
|
|
});
|
|
}
|
|
|
|
export function useProjectRepoSyncStatusQuery(
|
|
project: Project | null | undefined,
|
|
reposDir?: string | null,
|
|
branchName?: string | null,
|
|
) {
|
|
const selectedBranch = branchName ?? project?.defaultBranch ?? null;
|
|
|
|
return useQuery({
|
|
enabled: Boolean(project?.cloneUrls[0]),
|
|
queryKey: [
|
|
"project",
|
|
project?.id ?? "none",
|
|
"repo-sync-status",
|
|
reposDir ?? "default",
|
|
selectedBranch ?? "default",
|
|
],
|
|
queryFn: () => {
|
|
if (!project?.cloneUrls[0]) throw new Error("No project selected.");
|
|
return getProjectRepoSyncStatus({
|
|
reposDir,
|
|
projectDtag: project.dtag,
|
|
cloneUrl: project.cloneUrls[0],
|
|
defaultBranch: selectedBranch,
|
|
});
|
|
},
|
|
staleTime: 10_000,
|
|
retry: 1,
|
|
});
|
|
}
|
|
|
|
export function useProjectIssuesQuery(project: Project | null | undefined) {
|
|
return useQuery({
|
|
enabled: Boolean(project),
|
|
queryKey: ["project", project?.id ?? "none", "issues"],
|
|
queryFn: () => {
|
|
if (!project) throw new Error("No project selected.");
|
|
return fetchProjectIssues(project);
|
|
},
|
|
staleTime: 30_000,
|
|
});
|
|
}
|
|
|
|
export function useProjectPullRequestsQuery(
|
|
project: Project | null | undefined,
|
|
) {
|
|
return useQuery({
|
|
enabled: Boolean(project),
|
|
queryKey: ["project", project?.id ?? "none", "pull-requests"],
|
|
queryFn: () => {
|
|
if (!project) throw new Error("No project selected.");
|
|
return fetchProjectPullRequests(project);
|
|
},
|
|
staleTime: 30_000,
|
|
});
|
|
}
|
|
|
|
export function useProjectsIssuesQuery(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,
|
|
);
|
|
},
|
|
staleTime: 30_000,
|
|
});
|
|
}
|
|
|
|
export function useCreateProjectIssueCommentMutation(
|
|
project: Project | null | undefined,
|
|
) {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: ({
|
|
content,
|
|
mediaTags,
|
|
mentionPubkeys,
|
|
issue,
|
|
}: {
|
|
content: string;
|
|
mediaTags?: string[][];
|
|
mentionPubkeys?: string[];
|
|
issue: ProjectIssue;
|
|
}) => {
|
|
if (!project) throw new Error("No project selected.");
|
|
return createProjectIssueComment({
|
|
content,
|
|
mediaTags,
|
|
mentionPubkeys,
|
|
issue,
|
|
project,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["project", project?.id ?? "none", "issues"],
|
|
});
|
|
void queryClient.invalidateQueries({ queryKey: ["projects", "issues"] });
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["projects", "activity-summaries"],
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useCreateProjectPullRequestCommentMutation(
|
|
project: Project | null | undefined,
|
|
) {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: ({
|
|
content,
|
|
mediaTags,
|
|
mentionPubkeys,
|
|
pullRequest,
|
|
}: {
|
|
content: string;
|
|
mediaTags?: string[][];
|
|
mentionPubkeys?: string[];
|
|
pullRequest: ProjectPullRequest;
|
|
}) => {
|
|
if (!project) throw new Error("No project selected.");
|
|
return createProjectPullRequestComment({
|
|
content,
|
|
mediaTags,
|
|
mentionPubkeys,
|
|
project,
|
|
pullRequest,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["project", project?.id ?? "none", "pull-requests"],
|
|
});
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["projects", "pull-requests"],
|
|
});
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["projects", "activity-summaries"],
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useProjectActivitySummariesQuery(projects: Project[]) {
|
|
const repoAddresses = React.useMemo(
|
|
() => projects.map((project) => project.repoAddress).sort(),
|
|
[projects],
|
|
);
|
|
|
|
return useQuery({
|
|
enabled: repoAddresses.length > 0,
|
|
queryKey: ["projects", "activity-summaries", repoAddresses],
|
|
queryFn: () => fetchProjectActivitySummaries(projects),
|
|
staleTime: 30_000,
|
|
});
|
|
}
|
|
|
|
export function useDeleteProjectMutation() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: deleteProject,
|
|
onSuccess: (_data, project) => {
|
|
queryClient.setQueryData<Project[]>(projectsQueryKey, (current = []) =>
|
|
current.filter((item) => item.id !== project.id),
|
|
);
|
|
queryClient.setQueryData(["project", project.id], null);
|
|
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["project", project.id],
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function usePushProjectLocalRepositoryMutation(
|
|
project: Project | null | undefined,
|
|
reposDir?: string | null,
|
|
branchName?: string | null,
|
|
) {
|
|
const queryClient = useQueryClient();
|
|
const selectedBranch = branchName ?? project?.defaultBranch ?? null;
|
|
|
|
return useMutation({
|
|
mutationFn: () => {
|
|
if (!project?.cloneUrls[0]) throw new Error("No project selected.");
|
|
return pushProjectLocalRepository({
|
|
reposDir,
|
|
projectDtag: project.dtag,
|
|
cloneUrl: project.cloneUrls[0],
|
|
defaultBranch: selectedBranch,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["project", project?.id ?? "none"],
|
|
});
|
|
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
|
},
|
|
});
|
|
}
|