Add project git activity data plumbing

Co-authored-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-06-25 09:57:50 -04:00
parent 7726d1b9f8
commit d8baf230c1
13 changed files with 1178 additions and 9 deletions
+2
View File
@@ -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::*;
@@ -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<u64>,
pub preview_content: Option<String>,
}
#[derive(Serialize)]
pub struct ProjectRepoSnapshotInfo {
pub latest_commit: Option<ProjectRepoCommitInfo>,
pub files: Vec<ProjectRepoFileInfo>,
}
struct GitAuthConfig {
git_path: std::path::PathBuf,
credential_helper: Option<std::path::PathBuf>,
nsec: String,
}
fn run_git(
args: &[&str],
cwd: Option<&std::path::Path>,
auth: &GitAuthConfig,
) -> Result<String, String> {
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<GitAuthConfig, String> {
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<ProjectRepoCommitInfo> {
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::<i64>().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<u64>,
) -> Option<String> {
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<ProjectRepoFileInfo> {
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::<u64>().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<ProjectRepoSnapshotInfo> {
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<String>,
state: State<'_, AppState>,
) -> Result<ProjectRepoSnapshotInfo, String> {
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}"))?
}
+1
View File
@@ -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,
+315 -9
View File
@@ -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<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 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<Project[]> {
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<Project | null> {
});
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<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] = 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<ProjectRepoSnapshot | null> {
const cloneUrl = project.cloneUrls[0];
if (!cloneUrl) return null;
return getProjectRepoSnapshot({
cloneUrl,
defaultBranch: 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()) {
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<Project[]>(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],
});
},
});
}
@@ -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<string, ProjectActivitySummary>;
@@ -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);
}
@@ -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[];
@@ -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);
}
@@ -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[][];
@@ -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;
}
+60
View File
@@ -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<ProjectRepoSnapshot> {
const snapshot = await invokeTauri<RawProjectRepoSnapshot>(
"get_project_repo_snapshot",
{
cloneUrl: input.cloneUrl,
defaultBranch: input.defaultBranch ?? null,
},
);
return fromRawProjectRepoSnapshot(snapshot);
}
+21
View File
@@ -158,6 +158,27 @@ export type UserStatus = {
export type UserStatusLookup = Record<string, UserStatus | null>;
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. */
+9
View File
@@ -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;