diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index a8bce1081..e20468832 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -21,6 +21,7 @@ pub mod pairing; mod personas; mod prevent_sleep; mod profile; +mod project_git; mod relay_members; mod relay_reconnect; mod social; @@ -49,6 +50,7 @@ pub use pairing::*; pub use personas::*; pub use prevent_sleep::*; pub use profile::*; +pub use project_git::*; pub use relay_members::*; pub use relay_reconnect::*; pub use social::*; diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs new file mode 100644 index 000000000..bd11a751d --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git.rs @@ -0,0 +1,282 @@ +use std::process::Command; + +use nostr::ToBech32; +use serde::Serialize; +use tauri::State; +use url::Url; + +use crate::{app_state::AppState, managed_agents::resolve_command}; + +#[derive(Serialize)] +pub struct ProjectRepoCommitInfo { + pub hash: String, + pub short_hash: String, + pub author_name: String, + pub author_email: String, + pub timestamp: i64, + pub subject: String, +} + +#[derive(Serialize)] +pub struct ProjectRepoFileInfo { + pub path: String, + pub kind: String, + pub size: Option, + pub preview_content: Option, +} + +#[derive(Serialize)] +pub struct ProjectRepoSnapshotInfo { + pub latest_commit: Option, + pub files: Vec, +} + +struct GitAuthConfig { + git_path: std::path::PathBuf, + credential_helper: Option, + nsec: String, +} + +fn run_git( + args: &[&str], + cwd: Option<&std::path::Path>, + auth: &GitAuthConfig, +) -> Result { + let mut command = Command::new(&auth.git_path); + command.args(args); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + configure_git_auth(&mut command, auth); + + let output = command + .output() + .map_err(|error| format!("failed to run git: {error}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!("git exited with status {}", output.status) + } else { + stderr + }); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn configure_git_auth(command: &mut Command, auth: &GitAuthConfig) { + command.env("GIT_TERMINAL_PROMPT", "0"); + command.env("GIT_CONFIG_NOSYSTEM", "1"); + command.env("GIT_CONFIG_GLOBAL", "/dev/null"); + + // Clear inherited/global helpers first. Otherwise Git may authenticate with + // git-credential-nostr successfully, then call a system helper such as + // git-credential-osxkeychain to `store` the ephemeral NIP-98 credential; + // that helper can fail and make an otherwise-successful read look broken. + command.env("GIT_CONFIG_COUNT", "1"); + command.env("GIT_CONFIG_KEY_0", "credential.helper"); + command.env("GIT_CONFIG_VALUE_0", ""); + + let Some(cred_helper) = &auth.credential_helper else { + return; + }; + + command.env("NOSTR_PRIVATE_KEY", &auth.nsec); + command.env("GIT_CONFIG_COUNT", "3"); + command.env("GIT_CONFIG_KEY_1", "credential.helper"); + command.env("GIT_CONFIG_VALUE_1", cred_helper.display().to_string()); + command.env("GIT_CONFIG_KEY_2", "credential.useHttpPath"); + command.env("GIT_CONFIG_VALUE_2", "true"); +} + +fn build_git_auth_config(state: &AppState) -> Result { + let git_path = resolve_command("git").ok_or_else(|| "git was not found on PATH".to_string())?; + let credential_helper = resolve_command("git-credential-nostr"); + let nsec = { + let keys = state.keys.lock().map_err(|error| error.to_string())?; + keys.secret_key() + .to_bech32() + .map_err(|error| format!("encode identity key: {error}"))? + }; + + Ok(GitAuthConfig { + git_path, + credential_helper, + nsec, + }) +} + +fn validate_clone_url(clone_url: &str) -> Result<(), String> { + let parsed = Url::parse(clone_url).map_err(|error| format!("invalid clone URL: {error}"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err("clone URL must be http or https".into()); + } + if !parsed.path().contains("/git/") { + return Err("clone URL must point at a Buzz git repository".into()); + } + Ok(()) +} + +fn parse_latest_commit(output: &str) -> Option { + let line = output.lines().next()?; + let mut parts = line.split('\0'); + let hash = parts.next()?.to_string(); + let short_hash = parts.next()?.to_string(); + let author_name = parts.next()?.to_string(); + let author_email = parts.next()?.to_string(); + let timestamp = parts.next()?.parse::().ok()?; + let subject = parts.next().unwrap_or_default().to_string(); + + Some(ProjectRepoCommitInfo { + hash, + short_hash, + author_name, + author_email, + timestamp, + subject, + }) +} + +fn read_preview_content( + repo_dir: &std::path::Path, + path: &str, + size: Option, +) -> Option { + const MAX_PREVIEW_BYTES: u64 = 64 * 1024; + if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) { + return None; + } + + let full_path = repo_dir.join(path); + let normalized = full_path.canonicalize().ok()?; + let repo_root = repo_dir.canonicalize().ok()?; + if !normalized.starts_with(repo_root) { + return None; + } + + let bytes = std::fs::read(normalized).ok()?; + if bytes.contains(&0) { + return None; + } + String::from_utf8(bytes).ok() +} + +fn parse_ls_tree(repo_dir: &std::path::Path, output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let (meta, path) = line.split_once('\t')?; + let mut parts = meta.split_whitespace(); + let _mode = parts.next()?; + let kind = parts.next()?.to_string(); + let _object = parts.next()?; + let size = parts.next().and_then(|value| value.parse::().ok()); + let preview_content = if kind == "blob" { + read_preview_content(repo_dir, path, size) + } else { + None + }; + Some(ProjectRepoFileInfo { + path: path.to_string(), + kind, + size, + preview_content, + }) + }) + .take(250) + .collect() +} + +fn snapshot_from_repo(repo_dir: &std::path::Path, auth: &GitAuthConfig) -> ProjectRepoSnapshotInfo { + let latest_commit = run_git( + &["log", "-1", "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%s"], + Some(repo_dir), + auth, + ) + .ok() + .and_then(|output| parse_latest_commit(&output)); + let files = if latest_commit.is_some() { + run_git(&["ls-tree", "-r", "--long", "HEAD"], Some(repo_dir), auth) + .map(|output| parse_ls_tree(repo_dir, &output)) + .unwrap_or_default() + } else { + Vec::new() + }; + + ProjectRepoSnapshotInfo { + latest_commit, + files, + } +} + +fn current_checkout_snapshot(auth: &GitAuthConfig) -> Option { + if !cfg!(debug_assertions) { + return None; + } + + let cwd = std::env::current_dir().ok()?; + let root = run_git(&["rev-parse", "--show-toplevel"], Some(&cwd), auth).ok()?; + let root = std::path::PathBuf::from(root.trim()); + Some(snapshot_from_repo(&root, auth)) +} + +#[tauri::command] +pub async fn get_project_repo_snapshot( + clone_url: String, + default_branch: Option, + state: State<'_, AppState>, +) -> Result { + validate_clone_url(&clone_url)?; + let auth = build_git_auth_config(&state)?; + let branch = default_branch + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + tauri::async_runtime::spawn_blocking(move || { + let temp_dir = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?; + let repo_dir = temp_dir.path().join("repo"); + let repo_path = repo_dir + .to_str() + .ok_or_else(|| "temporary repository path is not UTF-8".to_string())?; + + let mut clone_args = vec!["clone", "--depth=1", "--filter=blob:none"]; + if let Some(ref branch) = branch { + clone_args.push("--branch"); + clone_args.push(branch.as_str()); + } + clone_args.push(clone_url.as_str()); + clone_args.push(repo_path); + + if run_git(&clone_args, None, &auth).is_err() && branch.is_some() { + // A newly-announced Buzz git repo can be empty even when the project + // metadata says its default branch is `main`. In that state, + // `git clone --branch main` fails because the ref does not exist yet. + // Retry without the branch selector so we can still render a clean + // "no commits/files yet" state instead of an error card. + run_git( + &[ + "clone", + "--depth=1", + "--filter=blob:none", + clone_url.as_str(), + repo_path, + ], + None, + &auth, + )?; + } + + let snapshot = snapshot_from_repo(&repo_dir, &auth); + if snapshot.latest_commit.is_none() && snapshot.files.is_empty() { + if let Some(local_snapshot) = current_checkout_snapshot(&auth) { + return Ok(local_snapshot); + } + } + + Ok(snapshot) + }) + .await + .map_err(|error| format!("repo snapshot task failed: {error}"))? +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 266733abe..2fe797e37 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -687,6 +687,7 @@ pub fn run() { get_user_profile, get_users_batch, get_user_notes, + get_project_repo_snapshot, search_users, get_presence, get_default_relay_url, diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index 5913b8116..819c46019 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -1,8 +1,32 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import * as React from "react"; import { relayClient } from "@/shared/api/relayClient"; -import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds"; -import type { RelayEvent } from "@/shared/api/types"; +import { getIdentity, signRelayEvent } from "@/shared/api/tauri"; +import { getProjectRepoSnapshot } 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, +} from "@/shared/constants/kinds"; +import type { + ProjectRepoFile, + ProjectRepoSnapshot, + RelayEvent, +} from "@/shared/api/types"; +import { summarizeProjectActivityEvents } from "./projectActivity.mjs"; +import type { ProjectIssue } from "./projectIssues.mjs"; +import { projectIssueEventsToIssues } from "./projectIssues.mjs"; + +const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1"; export type Project = { id: string; @@ -14,8 +38,29 @@ export type Project = { 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; + activityCount: number; + updatedAt: number; + participantPubkeys: string[]; +}; + +export type { ProjectRepoFile, ProjectRepoSnapshot }; + function getTag(event: RelayEvent, name: string): string | undefined { return event.tags.find((t) => t[0] === name)?.[1]; } @@ -29,13 +74,61 @@ function getCloneUrls(event: RelayEvent): string[] { return tag ? tag.slice(1) : []; } +function projectCoordinate(project: Pick): 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 hideProjectCard(project: Project): void { + if (typeof window === "undefined") { + return; + } + + const hidden = new Set(readHiddenProjectCards()); + hidden.add(projectCoordinate(project)); + window.localStorage.setItem( + HIDDEN_PROJECT_CARDS_KEY, + JSON.stringify([...hidden]), + ); +} + +function isHiddenLocally(project: Project): boolean { + return readHiddenProjectCards().includes(projectCoordinate(project)); +} + +function isDeletedByA(project: Project, deletionEvents: RelayEvent[]): boolean { + const coordinate = projectCoordinate(project); + return deletionEvents.some((event) => + 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 contributors = getAllTags(event, "p"); + const setupUsers = getAllTags(event, "auth"); + const contributors = [...new Set([...getAllTags(event, "p"), ...setupUsers])]; + const projectChannelId = + getTag(event, "h") ?? getTag(event, "project-channel") ?? null; return { id: `${event.pubkey}:${d}`, @@ -47,6 +140,10 @@ function eventToProject(event: RelayEvent): Project { 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 }), }; } @@ -67,13 +164,23 @@ function dedup(events: RelayEvent[]): RelayEvent[] { } async function fetchProjects(): Promise { - const events = await relayClient.fetchEvents({ - kinds: [KIND_REPO_ANNOUNCEMENT], - limit: 200, - }); + 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); } @@ -85,7 +192,135 @@ async function fetchProject(projectId: string): Promise { }); const deduped = dedup(events); - return deduped.length > 0 ? eventToProject(deduped[0]) : null; + const project = deduped.length > 0 ? eventToProject(deduped[0]) : null; + if (!project) { + return null; + } + + const deletionEvents = await relayClient.fetchEvents({ + kinds: [KIND_DELETION], + "#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 { + 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 { + const [issueEvents, statusEvents] = 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, + }), + ]); + + return projectIssueEventsToIssues(issueEvents, statusEvents); +} + +async function fetchProjectRepoSnapshot( + project: Project, +): Promise { + const cloneUrl = project.cloneUrls[0]; + if (!cloneUrl) return null; + + return getProjectRepoSnapshot({ + cloneUrl, + defaultBranch: project.defaultBranch, + }); +} + +async function fetchProjectActivitySummaries( + projects: Project[], +): Promise> { + 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 { + const identity = await getIdentity(); + if (identity.pubkey.toLowerCase() !== project.owner.toLowerCase()) { + hideProjectCard(project); + return; + } + + 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; @@ -105,3 +340,74 @@ export function useProjectQuery(projectId: string) { 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, +) { + return useQuery({ + enabled: Boolean(project?.cloneUrls[0]), + queryKey: ["project", project?.id ?? "none", "repo-snapshot"], + queryFn: () => { + if (!project) throw new Error("No project selected."); + return fetchProjectRepoSnapshot(project); + }, + staleTime: 30_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 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(projectsQueryKey, (current = []) => + current.filter((item) => item.id !== project.id), + ); + queryClient.setQueryData(["project", project.dtag], null); + void queryClient.invalidateQueries({ queryKey: projectsQueryKey }); + void queryClient.invalidateQueries({ + queryKey: ["project", project.dtag], + }); + }, + }); +} diff --git a/desktop/src/features/projects/projectActivity.d.mts b/desktop/src/features/projects/projectActivity.d.mts new file mode 100644 index 000000000..7277e7bf7 --- /dev/null +++ b/desktop/src/features/projects/projectActivity.d.mts @@ -0,0 +1,7 @@ +import type { ProjectActivitySummary, Project } from "./hooks"; +import type { RelayEvent } from "@/shared/api/types"; + +export function summarizeProjectActivityEvents( + events: RelayEvent[], + projects: Project[], +): Record; diff --git a/desktop/src/features/projects/projectActivity.mjs b/desktop/src/features/projects/projectActivity.mjs new file mode 100644 index 000000000..0695461d9 --- /dev/null +++ b/desktop/src/features/projects/projectActivity.mjs @@ -0,0 +1,75 @@ +function getAllTags(event, name) { + return event.tags.filter((tag) => tag[0] === name).map((tag) => tag[1]); +} + +function getTag(event, name) { + return event.tags.find((tag) => tag[0] === name)?.[1]; +} + +function normalizePubkey(pubkey) { + return /^[a-fA-F0-9]{64}$/.test(pubkey) ? pubkey.toLowerCase() : null; +} + +function ensureSummary(summaryByRepoAddress, repoAddress) { + const existing = summaryByRepoAddress.get(repoAddress); + if (existing) return existing; + + const summary = { + repoAddress, + issueCount: 0, + activityCount: 0, + updatedAt: 0, + participantPubkeys: [], + }; + summaryByRepoAddress.set(repoAddress, summary); + return summary; +} + +export function summarizeProjectActivityEvents(events, projects) { + const repoAddresses = new Set(projects.map((project) => project.repoAddress)); + const summaryByRepoAddress = new Map(); + const participantsByRepoAddress = new Map(); + + for (const project of projects) { + ensureSummary(summaryByRepoAddress, project.repoAddress); + participantsByRepoAddress.set(project.repoAddress, new Set()); + } + + for (const event of events) { + const repoAddress = getTag(event, "a"); + if (!repoAddress || !repoAddresses.has(repoAddress)) { + continue; + } + + const summary = ensureSummary(summaryByRepoAddress, repoAddress); + const participants = + participantsByRepoAddress.get(repoAddress) ?? new Set(); + participantsByRepoAddress.set(repoAddress, participants); + + summary.activityCount += 1; + summary.updatedAt = Math.max(summary.updatedAt, event.created_at); + + if (event.kind === 1621) { + summary.issueCount += 1; + } + + const author = normalizePubkey(event.pubkey); + if (author) { + participants.add(author); + } + + for (const pubkey of getAllTags(event, "p")) { + const normalized = normalizePubkey(pubkey); + if (normalized) { + participants.add(normalized); + } + } + } + + for (const [repoAddress, participants] of participantsByRepoAddress) { + const summary = ensureSummary(summaryByRepoAddress, repoAddress); + summary.participantPubkeys = [...participants].sort(); + } + + return Object.fromEntries(summaryByRepoAddress); +} diff --git a/desktop/src/features/projects/projectEvents.d.mts b/desktop/src/features/projects/projectEvents.d.mts new file mode 100644 index 000000000..2cd0dc313 --- /dev/null +++ b/desktop/src/features/projects/projectEvents.d.mts @@ -0,0 +1,47 @@ +import type { RelayEvent } from "@/shared/api/types"; + +export type ProjectEventTag = string[]; + +export type ProjectFromEvent = { + 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 function isValidRepoId(repoId: string): boolean; +export function normalizeProjectSlug(input: string): string; +export function getTag(event: RelayEvent, name: string): string | undefined; +export function getAllTags(event: RelayEvent, name: string): string[]; +export function getCloneUrls(event: RelayEvent): string[]; +export function buildProjectCloneUrl(input: { + relayHttpUrl: string; + owner: string; + repoId: string; +}): string; +export function buildRepoAnnouncementTags(input: { + repoId: string; + name?: string | null; + description?: string | null; + cloneUrls?: string[]; + webUrl?: string | null; + relays?: string[]; + projectChannelId?: string | null; + status?: string; + defaultBranch?: string; + contributors?: string[]; +}): ProjectEventTag[]; +export function eventToProject(event: RelayEvent): ProjectFromEvent; +export function dedupProjectEvents(events: RelayEvent[]): RelayEvent[]; +export function projectEventsToProjects( + events: RelayEvent[], +): ProjectFromEvent[]; diff --git a/desktop/src/features/projects/projectEvents.mjs b/desktop/src/features/projects/projectEvents.mjs new file mode 100644 index 000000000..0bbcc259e --- /dev/null +++ b/desktop/src/features/projects/projectEvents.mjs @@ -0,0 +1,191 @@ +const REPO_ID_PATTERN = /^[A-Za-z0-9._-]+$/; + +export function isValidRepoId(repoId) { + return ( + typeof repoId === "string" && + repoId.length > 0 && + repoId.length <= 64 && + !repoId.startsWith(".") && + !repoId.includes("..") && + REPO_ID_PATTERN.test(repoId) + ); +} + +export function normalizeProjectSlug(input) { + const slug = String(input ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[.-]+/, "") + .replace(/[.-]+$/, ""); + + return slug.slice(0, 64); +} + +function assertRepoId(repoId) { + if (!isValidRepoId(repoId)) { + throw new Error( + "Repository ID must be 1-64 letters, numbers, dots, underscores, or dashes, with no leading dot or double dots.", + ); + } +} + +function assertLength(name, value, max) { + if (value && value.length > max) { + throw new Error(`${name} must be ${max} characters or fewer.`); + } +} + +function assertUrl(name, value, allowedPrefixes, max) { + if (!value) return; + if (!allowedPrefixes.some((prefix) => value.startsWith(prefix))) { + throw new Error(`${name} must start with ${allowedPrefixes.join(" or ")}.`); + } + assertLength(name, value, max); +} + +export function getTag(event, name) { + return event.tags.find((tag) => tag[0] === name)?.[1]; +} + +export function getAllTags(event, name) { + return event.tags.filter((tag) => tag[0] === name).map((tag) => tag[1]); +} + +export function getCloneUrls(event) { + const tag = event.tags.find((candidate) => candidate[0] === "clone"); + return tag ? tag.slice(1) : []; +} + +export function buildProjectCloneUrl({ relayHttpUrl, owner, repoId }) { + assertRepoId(repoId); + if (!/^[a-fA-F0-9]{64}$/.test(owner)) { + throw new Error("Owner pubkey must be 64 hex characters."); + } + + return `${relayHttpUrl.replace(/\/+$/, "")}/git/${owner.toLowerCase()}/${repoId}`; +} + +export function buildRepoAnnouncementTags({ + repoId, + name, + description, + cloneUrls = [], + webUrl = null, + relays = [], + projectChannelId = null, + status = "active", + defaultBranch = "main", + contributors = [], +}) { + assertRepoId(repoId); + assertLength("Project name", name, 128); + assertLength("Description", description, 1024); + assertLength("Status", status, 32); + assertLength("Default branch", defaultBranch, 128); + + if (cloneUrls.length > 5) { + throw new Error("Projects can publish at most 5 clone URLs."); + } + for (const url of cloneUrls) { + assertUrl("Clone URL", url, ["http://", "https://"], 512); + } + assertUrl("Web URL", webUrl, ["http://", "https://"], 512); + + if (relays.length > 10) { + throw new Error("Projects can publish at most 10 relay URLs."); + } + for (const relay of relays) { + assertUrl("Relay URL", relay, ["ws://", "wss://"], 256); + } + + const tags = [["d", repoId]]; + if (name) tags.push(["name", name]); + if (description) tags.push(["description", description]); + if (cloneUrls.length > 0) tags.push(["clone", ...cloneUrls]); + if (webUrl) tags.push(["web", webUrl]); + if (relays.length > 0) tags.push(["relays", ...relays]); + if (projectChannelId) tags.push(["h", projectChannelId]); + if (status) tags.push(["status", status]); + if (defaultBranch) tags.push(["default-branch", defaultBranch]); + + for (const contributor of contributors) { + if (/^[a-fA-F0-9]{64}$/.test(contributor)) { + tags.push(["p", contributor.toLowerCase()]); + } + } + + return tags; +} + +export function eventToProject(event) { + const dtag = getTag(event, "d") ?? event.id; + const name = getTag(event, "name") || dtag; + const description = getTag(event, "description") || event.content || ""; + const cloneUrls = getCloneUrls(event); + const webUrl = getTag(event, "web") ?? null; + const projectChannelId = + getTag(event, "h") ?? getTag(event, "project-channel") ?? null; + const contributors = getAllTags(event, "p"); + + return { + id: `${event.pubkey}:${dtag}`, + dtag, + 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: `30617:${event.pubkey}:${dtag}`, + }; +} + +export function withCanonicalProjectCloneUrl(project, relayHttpUrl) { + try { + return { + ...project, + cloneUrls: [ + buildProjectCloneUrl({ + relayHttpUrl, + owner: project.owner, + repoId: project.dtag, + }), + ], + }; + } catch { + return { + ...project, + cloneUrls: project.cloneUrls.filter( + (url) => !/github\.com/i.test(String(url)), + ), + }; + } +} + +export function dedupProjectEvents(events) { + const best = new Map(); + + for (const event of events) { + const dtag = getTag(event, "d") ?? ""; + const key = `${event.pubkey}:${event.kind}:${dtag}`; + const previous = best.get(key); + + if (!previous || event.created_at > previous.created_at) { + best.set(key, event); + } + } + + return [...best.values()]; +} + +export function projectEventsToProjects(events) { + return dedupProjectEvents(events) + .map(eventToProject) + .sort((left, right) => right.createdAt - left.createdAt); +} diff --git a/desktop/src/features/projects/projectIssues.d.mts b/desktop/src/features/projects/projectIssues.d.mts new file mode 100644 index 000000000..99f7b8429 --- /dev/null +++ b/desktop/src/features/projects/projectIssues.d.mts @@ -0,0 +1,54 @@ +import type { RelayEvent } from "@/shared/api/types"; + +export type ProjectIssueStatus = + | "Triage" + | "Backlog" + | "In Progress" + | "In Review" + | "Done" + | "Closed"; + +export type ProjectIssue = { + id: string; + title: string; + content: string; + author: string; + createdAt: number; + repoAddress: string | null; + labels: string[]; + recipients: string[]; + status: ProjectIssueStatus; + statusEventId: string | null; + updatedAt: number; +}; + +export const PROJECT_ISSUE_STATUS: { + TRIAGE: "Triage"; + BACKLOG: "Backlog"; + IN_PROGRESS: "In Progress"; + IN_REVIEW: "In Review"; + DONE: "Done"; + CLOSED: "Closed"; +}; + +export function getTag(event: RelayEvent, name: string): string | undefined; +export function getAllTags(event: RelayEvent, name: string): string[]; +export function eventToProjectIssue( + issue: RelayEvent, + statusEvents?: RelayEvent[], +): ProjectIssue; +export function projectIssueEventsToIssues( + issueEvents: RelayEvent[], + statusEvents?: RelayEvent[], +): ProjectIssue[]; +export function buildGitIssueTags(input: { + repoAddress: string; + repoOwner: string; + title: string; + labels?: string[]; +}): string[][]; +export function buildGitStatusTags(input: { + issueId: string; + repoAddress?: string | null; + repoOwner?: string | null; +}): string[][]; diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs new file mode 100644 index 000000000..69a26d282 --- /dev/null +++ b/desktop/src/features/projects/projectIssues.mjs @@ -0,0 +1,114 @@ +export const PROJECT_ISSUE_STATUS = { + TRIAGE: "Triage", + BACKLOG: "Backlog", + IN_PROGRESS: "In Progress", + IN_REVIEW: "In Review", + DONE: "Done", + CLOSED: "Closed", +}; + +export function getTag(event, name) { + return event.tags.find((tag) => tag[0] === name)?.[1]; +} + +export function getAllTags(event, name) { + return event.tags.filter((tag) => tag[0] === name).map((tag) => tag[1]); +} + +function latestStatusForIssue(issueId, statusEvents) { + return statusEvents + .filter((event) => + event.tags.some((tag) => tag[0] === "e" && tag[1] === issueId), + ) + .sort((left, right) => right.created_at - left.created_at)[0]; +} + +function statusFromEvent(issue, statusEvent) { + if (statusEvent?.kind === 1631) return PROJECT_ISSUE_STATUS.DONE; + if (statusEvent?.kind === 1632) return PROJECT_ISSUE_STATUS.CLOSED; + if (statusEvent?.kind === 1633) return PROJECT_ISSUE_STATUS.TRIAGE; + + const labels = getAllTags(issue, "t").map((label) => label.toLowerCase()); + if (labels.includes("in-review") || labels.includes("review")) { + return PROJECT_ISSUE_STATUS.IN_REVIEW; + } + if (labels.includes("in-progress") || labels.includes("active")) { + return PROJECT_ISSUE_STATUS.IN_PROGRESS; + } + if (labels.includes("triage")) return PROJECT_ISSUE_STATUS.TRIAGE; + return PROJECT_ISSUE_STATUS.BACKLOG; +} + +export function eventToProjectIssue(issue, statusEvents = []) { + const latestStatus = latestStatusForIssue(issue.id, statusEvents); + const title = + getTag(issue, "subject") || + issue.content.split("\n")[0] || + "Untitled issue"; + + return { + id: issue.id, + title, + content: issue.content, + author: issue.pubkey, + createdAt: issue.created_at, + repoAddress: getTag(issue, "a") ?? null, + labels: getAllTags(issue, "t"), + recipients: getAllTags(issue, "p"), + status: statusFromEvent(issue, latestStatus), + statusEventId: latestStatus?.id ?? null, + updatedAt: latestStatus?.created_at ?? issue.created_at, + }; +} + +export function projectIssueEventsToIssues(issueEvents, statusEvents = []) { + return [...issueEvents] + .map((issue) => eventToProjectIssue(issue, statusEvents)) + .sort((left, right) => right.updatedAt - left.updatedAt); +} + +export function buildGitIssueTags({ + repoAddress, + repoOwner, + title, + labels = [], +}) { + if (!repoAddress.startsWith("30617:")) { + throw new Error("Issue repo address must reference a kind:30617 repo."); + } + if (!/^[a-fA-F0-9]{64}$/.test(repoOwner)) { + throw new Error("Repo owner must be 64 hex characters."); + } + const subject = title.trim(); + if (!subject) { + throw new Error("Issue title is required."); + } + if (subject.length > 256) { + throw new Error("Issue title must be 256 characters or fewer."); + } + + const tags = [ + ["a", repoAddress], + ["p", repoOwner.toLowerCase()], + ["subject", subject], + ]; + + for (const label of labels) { + const trimmed = label.trim(); + if (trimmed) tags.push(["t", trimmed]); + } + + return tags; +} + +export function buildGitStatusTags({ issueId, repoAddress, repoOwner }) { + if (!/^[a-fA-F0-9]{64}$/.test(issueId)) { + throw new Error("Issue ID must be 64 hex characters."); + } + const tags = [["e", issueId, "", "root"]]; + if (repoAddress) tags.push(["a", repoAddress]); + if (repoOwner && /^[a-fA-F0-9]{64}$/.test(repoOwner)) { + tags.push(["p", repoOwner.toLowerCase()]); + } + return tags; +} diff --git a/desktop/src/shared/api/projectGit.ts b/desktop/src/shared/api/projectGit.ts new file mode 100644 index 000000000..0e8bc6444 --- /dev/null +++ b/desktop/src/shared/api/projectGit.ts @@ -0,0 +1,60 @@ +import type { ProjectRepoSnapshot } from "@/shared/api/types"; +import { invokeTauri } from "@/shared/api/tauri"; + +type RawProjectRepoCommit = { + hash: string; + short_hash: string; + author_name: string; + author_email: string; + timestamp: number; + subject: string; +}; + +type RawProjectRepoFile = { + path: string; + kind: string; + size: number | null; + preview_content: string | null; +}; + +type RawProjectRepoSnapshot = { + latest_commit: RawProjectRepoCommit | null; + files: RawProjectRepoFile[]; +}; + +function fromRawProjectRepoSnapshot( + snapshot: RawProjectRepoSnapshot, +): ProjectRepoSnapshot { + return { + latestCommit: snapshot.latest_commit + ? { + hash: snapshot.latest_commit.hash, + shortHash: snapshot.latest_commit.short_hash, + authorName: snapshot.latest_commit.author_name, + authorEmail: snapshot.latest_commit.author_email, + timestamp: snapshot.latest_commit.timestamp, + subject: snapshot.latest_commit.subject, + } + : null, + files: snapshot.files.map((file) => ({ + path: file.path, + kind: file.kind, + size: file.size, + previewContent: file.preview_content, + })), + }; +} + +export async function getProjectRepoSnapshot(input: { + cloneUrl: string; + defaultBranch?: string | null; +}): Promise { + const snapshot = await invokeTauri( + "get_project_repo_snapshot", + { + cloneUrl: input.cloneUrl, + defaultBranch: input.defaultBranch ?? null, + }, + ); + return fromRawProjectRepoSnapshot(snapshot); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 1ca74eca3..30e7ef7d8 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -158,6 +158,27 @@ export type UserStatus = { export type UserStatusLookup = Record; +export type ProjectRepoCommit = { + hash: string; + shortHash: string; + authorName: string; + authorEmail: string; + timestamp: number; + subject: string; +}; + +export type ProjectRepoFile = { + path: string; + kind: string; + size: number | null; + previewContent: string | null; +}; + +export type ProjectRepoSnapshot = { + latestCommit: ProjectRepoCommit | null; + files: ProjectRepoFile[]; +}; + export type RelayEvent = { id: string; /** Local-only render identity for optimistic events that are later acknowledged. */ diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 3cb325ff3..be78e0630 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -35,6 +35,15 @@ export const KIND_MESH_CONNECT_REQUEST = 24621; export const KIND_MESH_CALL_ME_NOW = 24622; export const KIND_EVENT_REMINDER = 30300; export const KIND_REPO_ANNOUNCEMENT = 30617; +export const KIND_REPO_STATE = 30618; +export const KIND_GIT_PATCH = 1617; +export const KIND_GIT_PULL_REQUEST = 1618; +export const KIND_GIT_PR_UPDATE = 1619; +export const KIND_GIT_ISSUE = 1621; +export const KIND_GIT_STATUS_OPEN = 1630; +export const KIND_GIT_STATUS_MERGED = 1631; +export const KIND_GIT_STATUS_CLOSED = 1632; +export const KIND_GIT_STATUS_DRAFT = 1633; // NIP-DV: relay-signed per-viewer DM visibility snapshot (d=viewer pubkey, // h-tags = currently-hidden DM channel ids). export const KIND_DM_VISIBILITY = 30622;