mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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}"))?
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user