Browse Source
Nostr-Signature: 47651ed0aee8072f356fbac30b6168f2c985bcca392f9ed7d7c38d9670d90f16 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 2ca5d04d4a619dc3e02962249f7c650e3a561315897b329f4493e87148c5dd89fbcb6694515a72d0d17e64c9930e57bd7761e27b353275bb1ada9449330f4e1cmain
7 changed files with 1555 additions and 1190 deletions
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,577 @@
@@ -0,0 +1,577 @@
|
||||
/** |
||||
* Discussion operations service |
||||
* Handles discussion loading, thread creation, and replies |
||||
*/ |
||||
|
||||
import type { RepoState } from '../stores/repo-state.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '$lib/config.js'; |
||||
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
||||
import { isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; |
||||
import { KIND } from '$lib/types/nostr.js'; |
||||
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||
import { buildApiHeaders } from '../utils/api-client.js'; |
||||
|
||||
interface DiscussionOperationsCallbacks { |
||||
loadDiscussions: () => Promise<void>; |
||||
loadNostrLinks: (content: string) => Promise<void>; |
||||
loadDiscussionEvents: (discussions: Array<{ |
||||
type: 'thread' | 'comments' | string; |
||||
id: string; |
||||
title: string; |
||||
content: string; |
||||
author: string; |
||||
createdAt: number; |
||||
kind?: number; |
||||
pubkey?: string; |
||||
comments?: Array<any>; |
||||
}>) => Promise<void>; |
||||
} |
||||
|
||||
/** |
||||
* Load discussions from the repository |
||||
*/ |
||||
export async function loadDiscussions( |
||||
state: RepoState, |
||||
repoOwnerPubkeyDerived: string, |
||||
callbacks: DiscussionOperationsCallbacks |
||||
): Promise<void> { |
||||
if (state.repoNotFound) return; |
||||
state.loading.discussions = true; |
||||
state.error = null; |
||||
try { |
||||
const decoded = nip19.decode(state.npub); |
||||
if (decoded.type !== 'npub') { |
||||
throw new Error('Invalid npub format'); |
||||
} |
||||
const repoOwnerPubkey = decoded.data as string; |
||||
|
||||
// Fetch repo announcement to get project-relay tags and announcement ID
|
||||
const client = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||
const events = await client.fetchEvents([ |
||||
{ |
||||
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||
authors: [repoOwnerPubkeyDerived], |
||||
'#d': [state.repo], |
||||
limit: 1 |
||||
} |
||||
]); |
||||
|
||||
if (events.length === 0) { |
||||
state.discussions = []; |
||||
return; |
||||
} |
||||
|
||||
const announcement = events[0]; |
||||
const chatRelays = announcement.tags |
||||
.filter(t => t[0] === 'project-relay') |
||||
.flatMap(t => t.slice(1)) |
||||
.filter(url => url && typeof url === 'string') as string[]; |
||||
|
||||
// Get default relays
|
||||
const { DiscussionsService } = await import('$lib/services/nostr/discussions-service.js'); |
||||
|
||||
// Get user's relays if available
|
||||
let userRelays: string[] = []; |
||||
// Try to get user pubkey from userStore first, then fallback to state
|
||||
let currentUserPubkey: string | null = null; |
||||
try { |
||||
const { userStore } = await import('$lib/stores/user-store.js'); |
||||
const { get } = await import('svelte/store'); |
||||
currentUserPubkey = get(userStore)?.userPubkey || state.user.pubkey || null; |
||||
} catch { |
||||
currentUserPubkey = state.user.pubkey || null; |
||||
} |
||||
if (currentUserPubkey) { |
||||
try { |
||||
const { outbox } = await getUserRelays(currentUserPubkey, client); |
||||
userRelays = outbox; |
||||
} catch (err) { |
||||
console.warn('Failed to get user relays, using defaults:', err); |
||||
} |
||||
} |
||||
|
||||
// Combine all available relays: default + search + chat + user relays
|
||||
const allRelays = [...new Set([ |
||||
...DEFAULT_NOSTR_RELAYS, |
||||
...DEFAULT_NOSTR_SEARCH_RELAYS, |
||||
...chatRelays, |
||||
...userRelays |
||||
])]; |
||||
|
||||
console.log('[Discussions] Using all available relays for threads:', allRelays); |
||||
console.log('[Discussions] Project relays from announcement:', chatRelays); |
||||
|
||||
const discussionsService = new DiscussionsService(allRelays); |
||||
const discussionEntries = await discussionsService.getDiscussions( |
||||
repoOwnerPubkey, |
||||
state.repo, |
||||
announcement.id, |
||||
announcement.pubkey, |
||||
allRelays, // Use all relays for threads
|
||||
allRelays // Use all relays for comments too
|
||||
); |
||||
|
||||
console.log('[Discussions] Found', discussionEntries.length, 'discussion entries'); |
||||
|
||||
state.discussions = discussionEntries.map(entry => ({ |
||||
type: entry.type, |
||||
id: entry.id, |
||||
title: entry.title, |
||||
content: entry.content, |
||||
author: entry.author, |
||||
createdAt: entry.createdAt, |
||||
kind: entry.kind ?? KIND.THREAD, |
||||
pubkey: entry.pubkey ?? '', |
||||
comments: entry.comments |
||||
})); |
||||
|
||||
// Fetch full events for discussions and comments to get tags for blurbs
|
||||
await callbacks.loadDiscussionEvents(state.discussions); |
||||
|
||||
// Fetch nostr: links from discussion content
|
||||
for (const discussion of state.discussions) { |
||||
if (discussion.content) { |
||||
await callbacks.loadNostrLinks(discussion.content); |
||||
} |
||||
if (discussion.comments) { |
||||
for (const comment of discussion.comments) { |
||||
if (comment.content) { |
||||
await callbacks.loadNostrLinks(comment.content); |
||||
} |
||||
if (comment.replies) { |
||||
for (const reply of comment.replies) { |
||||
if (reply.content) { |
||||
await callbacks.loadNostrLinks(reply.content); |
||||
} |
||||
if (reply.replies) { |
||||
for (const nestedReply of reply.replies) { |
||||
if (nestedReply.content) { |
||||
await callbacks.loadNostrLinks(nestedReply.content); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} catch (err) { |
||||
state.error = err instanceof Error ? err.message : 'Failed to load discussions'; |
||||
console.error('Error loading discussions:', err); |
||||
} finally { |
||||
state.loading.discussions = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Create a discussion thread |
||||
*/ |
||||
export async function createDiscussionThread( |
||||
state: RepoState, |
||||
repoOwnerPubkeyDerived: string, |
||||
callbacks: DiscussionOperationsCallbacks |
||||
): Promise<void> { |
||||
if (!state.user.pubkey || !state.user.pubkeyHex) { |
||||
state.error = 'You must be logged in to create a discussion thread'; |
||||
return; |
||||
} |
||||
|
||||
if (!state.forms.discussion.threadTitle.trim()) { |
||||
state.error = 'Thread title is required'; |
||||
return; |
||||
} |
||||
|
||||
state.creating.thread = true; |
||||
state.error = null; |
||||
|
||||
try { |
||||
const decoded = nip19.decode(state.npub); |
||||
if (decoded.type !== 'npub') { |
||||
throw new Error('Invalid npub format'); |
||||
} |
||||
const repoOwnerPubkey = decoded.data as string; |
||||
|
||||
// Get repo announcement to get the repo address
|
||||
const client = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||
const events = await client.fetchEvents([ |
||||
{ |
||||
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||
authors: [repoOwnerPubkeyDerived], |
||||
'#d': [state.repo], |
||||
limit: 1 |
||||
} |
||||
]); |
||||
|
||||
if (events.length === 0) { |
||||
throw new Error('Repository announcement not found'); |
||||
} |
||||
|
||||
const announcement = events[0]; |
||||
state.metadata.address = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${state.repo}`; |
||||
|
||||
// Get project relays from announcement, or use default relays
|
||||
const chatRelays = announcement.tags |
||||
.filter(t => t[0] === 'project-relay') |
||||
.flatMap(t => t.slice(1)) |
||||
.filter(url => url && typeof url === 'string') as string[]; |
||||
|
||||
// Combine all available relays
|
||||
let allRelays = [...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS, ...chatRelays]; |
||||
if (state.user.pubkey) { |
||||
try { |
||||
const { outbox } = await getUserRelays(state.user.pubkey, client); |
||||
allRelays = [...allRelays, ...outbox]; |
||||
} catch (err) { |
||||
console.warn('Failed to get user relays:', err); |
||||
} |
||||
} |
||||
allRelays = [...new Set(allRelays)]; // Deduplicate
|
||||
|
||||
// Create kind 11 thread event
|
||||
const threadEventTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
||||
kind: KIND.THREAD, |
||||
pubkey: state.user.pubkeyHex, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: [ |
||||
['a', state.metadata.address], |
||||
['title', state.forms.discussion.threadTitle.trim()], |
||||
['t', 'repo'] |
||||
], |
||||
content: state.forms.discussion.threadContent.trim() || '' |
||||
}; |
||||
|
||||
// Sign the event using NIP-07
|
||||
const signedEvent = await signEventWithNIP07(threadEventTemplate); |
||||
|
||||
// Publish to all available relays
|
||||
const publishClient = new NostrClient(allRelays); |
||||
const result = await publishClient.publishEvent(signedEvent, allRelays); |
||||
|
||||
if (result.failed.length > 0 && result.success.length === 0) { |
||||
throw new Error('Failed to publish thread to all relays'); |
||||
} |
||||
|
||||
// Clear form and close dialog
|
||||
state.forms.discussion.threadTitle = ''; |
||||
state.forms.discussion.threadContent = ''; |
||||
state.openDialog = null; |
||||
|
||||
// Reload discussions
|
||||
await callbacks.loadDiscussions(); |
||||
} catch (err) { |
||||
state.error = err instanceof Error ? err.message : 'Failed to create discussion thread'; |
||||
console.error('Error creating discussion thread:', err); |
||||
} finally { |
||||
state.creating.thread = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Create a thread reply |
||||
*/ |
||||
export async function createThreadReply( |
||||
state: RepoState, |
||||
repoOwnerPubkeyDerived: string, |
||||
callbacks: DiscussionOperationsCallbacks |
||||
): Promise<void> { |
||||
if (!state.user.pubkey || !state.user.pubkeyHex) { |
||||
state.error = 'You must be logged in to reply'; |
||||
return; |
||||
} |
||||
|
||||
if (!state.forms.discussion.replyContent.trim()) { |
||||
state.error = 'Reply content is required'; |
||||
return; |
||||
} |
||||
|
||||
if (!state.discussion.replyingToThread && !state.discussion.replyingToComment) { |
||||
state.error = 'Must reply to either a thread or a comment'; |
||||
return; |
||||
} |
||||
|
||||
state.creating.reply = true; |
||||
state.error = null; |
||||
|
||||
try { |
||||
const decoded = nip19.decode(state.npub); |
||||
if (decoded.type !== 'npub') { |
||||
throw new Error('Invalid npub format'); |
||||
} |
||||
const repoOwnerPubkey = decoded.data as string; |
||||
|
||||
// Get repo announcement to get the repo address and relays
|
||||
const client = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||
const events = await client.fetchEvents([ |
||||
{ |
||||
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||
authors: [repoOwnerPubkeyDerived], |
||||
'#d': [state.repo], |
||||
limit: 1 |
||||
} |
||||
]); |
||||
|
||||
if (events.length === 0) { |
||||
throw new Error('Repository announcement not found'); |
||||
} |
||||
|
||||
const announcement = events[0]; |
||||
|
||||
// Get project relays from announcement, or use default relays
|
||||
const chatRelays = announcement.tags |
||||
.filter(t => t[0] === 'project-relay') |
||||
.flatMap(t => t.slice(1)) |
||||
.filter(url => url && typeof url === 'string') as string[]; |
||||
|
||||
// Combine all available relays
|
||||
let allRelays = [...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS, ...chatRelays]; |
||||
if (state.user.pubkey) { |
||||
try { |
||||
const { outbox } = await getUserRelays(state.user.pubkey, client); |
||||
allRelays = [...allRelays, ...outbox]; |
||||
} catch (err) { |
||||
console.warn('Failed to get user relays:', err); |
||||
} |
||||
} |
||||
allRelays = [...new Set(allRelays)]; // Deduplicate
|
||||
|
||||
let rootEventId: string; |
||||
let rootKind: number; |
||||
let rootPubkey: string; |
||||
let parentEventId: string; |
||||
let parentKind: number; |
||||
let parentPubkey: string; |
||||
|
||||
if (state.discussion.replyingToComment) { |
||||
// Replying to a comment - use the comment object we already have
|
||||
const comment = state.discussion.replyingToComment; |
||||
|
||||
// Determine root: if we have a thread, use it as root; otherwise use announcement
|
||||
if (state.discussion.replyingToThread) { |
||||
rootEventId = state.discussion.replyingToThread.id; |
||||
rootKind = state.discussion.replyingToThread.kind ?? KIND.THREAD; |
||||
rootPubkey = state.discussion.replyingToThread.pubkey ?? state.discussion.replyingToThread.author ?? ''; |
||||
} else { |
||||
// Comment is directly on announcement (in "Comments" pseudo-thread)
|
||||
rootEventId = announcement.id; |
||||
rootKind = KIND.REPO_ANNOUNCEMENT; |
||||
rootPubkey = announcement.pubkey; |
||||
} |
||||
|
||||
// Parent is the comment we're replying to
|
||||
parentEventId = comment.id; |
||||
parentKind = comment.kind ?? KIND.COMMENT; |
||||
parentPubkey = comment.pubkey ?? comment.author ?? ''; |
||||
} else if (state.discussion.replyingToThread) { |
||||
// Replying directly to a thread - use the thread object we already have
|
||||
rootEventId = state.discussion.replyingToThread.id; |
||||
rootKind = state.discussion.replyingToThread.kind ?? KIND.THREAD; |
||||
rootPubkey = state.discussion.replyingToThread.pubkey ?? state.discussion.replyingToThread.author ?? ''; |
||||
parentEventId = state.discussion.replyingToThread.id; |
||||
parentKind = state.discussion.replyingToThread.kind ?? KIND.THREAD; |
||||
parentPubkey = state.discussion.replyingToThread.pubkey ?? state.discussion.replyingToThread.author ?? ''; |
||||
} else { |
||||
throw new Error('Must specify thread or comment to reply to'); |
||||
} |
||||
|
||||
// Create kind 1111 comment event
|
||||
const commentEventTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
||||
kind: KIND.COMMENT, |
||||
pubkey: state.user.pubkeyHex, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: [ |
||||
['e', parentEventId, '', 'reply'], // Parent event
|
||||
['k', parentKind.toString()], // Parent kind
|
||||
['p', parentPubkey], // Parent pubkey
|
||||
['E', rootEventId], // Root event
|
||||
['K', rootKind.toString()], // Root kind
|
||||
['P', rootPubkey] // Root pubkey
|
||||
], |
||||
content: state.forms.discussion.replyContent.trim() |
||||
}; |
||||
|
||||
// Sign the event using NIP-07
|
||||
const signedEvent = await signEventWithNIP07(commentEventTemplate); |
||||
|
||||
// Publish to all available relays
|
||||
const publishClient = new NostrClient(allRelays); |
||||
const result = await publishClient.publishEvent(signedEvent, allRelays); |
||||
|
||||
if (result.failed.length > 0 && result.success.length === 0) { |
||||
throw new Error('Failed to publish reply to all relays'); |
||||
} |
||||
|
||||
// Save thread ID before clearing (for expanding after reload)
|
||||
const threadIdToExpand = state.discussion.replyingToThread?.id; |
||||
|
||||
// Clear form and close dialog
|
||||
state.forms.discussion.replyContent = ''; |
||||
state.openDialog = null; |
||||
state.discussion.replyingToThread = null; |
||||
state.discussion.replyingToComment = null; |
||||
|
||||
// Reload discussions to show the new reply
|
||||
await callbacks.loadDiscussions(); |
||||
|
||||
// Expand the thread if we were replying to a thread
|
||||
if (threadIdToExpand) { |
||||
state.ui.expandedThreads.add(threadIdToExpand); |
||||
state.ui.expandedThreads = new Set(state.ui.expandedThreads); // Trigger reactivity
|
||||
} |
||||
} catch (err) { |
||||
state.error = err instanceof Error ? err.message : 'Failed to create reply'; |
||||
console.error('Error creating reply:', err); |
||||
} finally { |
||||
state.creating.reply = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Load documentation from the repository |
||||
*/ |
||||
export async function loadDocumentation( |
||||
state: RepoState, |
||||
repoOwnerPubkeyDerived: string, |
||||
repoIsPrivate: boolean |
||||
): Promise<void> { |
||||
if (state.loading.docs) return; |
||||
// Reset documentation when reloading
|
||||
state.docs.html = null; |
||||
state.docs.content = null; |
||||
state.docs.kind = null; |
||||
|
||||
state.loading.docs = true; |
||||
try { |
||||
// Guard against SSR - $page store can only be accessed in component context
|
||||
if (typeof window === 'undefined') return; |
||||
|
||||
// Check if repo is private and user has access
|
||||
if (repoIsPrivate) { |
||||
// Check access via API
|
||||
const accessResponse = await fetch(`/api/repos/${state.npub}/${state.repo}/access`, { |
||||
headers: buildApiHeaders() |
||||
}); |
||||
if (accessResponse.ok) { |
||||
const accessData = await accessResponse.json(); |
||||
if (!accessData.canView) { |
||||
// User doesn't have access, don't load documentation
|
||||
state.loading.docs = false; |
||||
return; |
||||
} |
||||
} else { |
||||
// Access check failed, don't load documentation
|
||||
state.loading.docs = false; |
||||
return; |
||||
} |
||||
} |
||||
|
||||
const decoded = nip19.decode(state.npub); |
||||
if (decoded.type === 'npub') { |
||||
const repoOwnerPubkey = decoded.data as string; |
||||
const client = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||
|
||||
// First, get the repo announcement to find the documentation tag
|
||||
const announcementEvents = await client.fetchEvents([ |
||||
{ |
||||
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||
authors: [repoOwnerPubkeyDerived], |
||||
'#d': [state.repo], |
||||
limit: 1 |
||||
} |
||||
]); |
||||
|
||||
if (announcementEvents.length === 0) { |
||||
state.loading.docs = false; |
||||
return; |
||||
} |
||||
|
||||
const announcement = announcementEvents[0]; |
||||
|
||||
// Look for documentation tag in the announcement
|
||||
const documentationTag = announcement.tags.find(t => t[0] === 'documentation'); |
||||
|
||||
state.docs.kind = null; |
||||
|
||||
if (documentationTag && documentationTag[1]) { |
||||
// Parse the a-tag format: kind:pubkey:identifier
|
||||
const docAddress = documentationTag[1]; |
||||
const parts = docAddress.split(':'); |
||||
|
||||
if (parts.length >= 3) { |
||||
state.docs.kind = parseInt(parts[0]); |
||||
const docPubkey = parts[1]; |
||||
const docIdentifier = parts.slice(2).join(':'); // In case identifier contains ':'
|
||||
|
||||
// Fetch the documentation event
|
||||
const docEvents = await client.fetchEvents([ |
||||
{ |
||||
kinds: [state.docs.kind], |
||||
authors: [docPubkey], |
||||
'#d': [docIdentifier], |
||||
limit: 1 |
||||
} |
||||
]); |
||||
|
||||
if (docEvents.length > 0) { |
||||
state.docs.content = docEvents[0].content || null; |
||||
} else { |
||||
console.warn('Documentation event not found:', docAddress); |
||||
state.docs.content = null; |
||||
} |
||||
} else { |
||||
console.warn('Invalid documentation tag format:', docAddress); |
||||
state.docs.content = null; |
||||
} |
||||
} else { |
||||
// No documentation tag, try to use announcement content as fallback
|
||||
state.docs.content = announcement.content || null; |
||||
// Announcement is kind 30617, not a doc kind, so keep state.docs.kind as null
|
||||
} |
||||
|
||||
// Render content based on kind: AsciiDoc for 30041 or 30818, Markdown otherwise
|
||||
if (state.docs.content) { |
||||
// Check if we should use AsciiDoc parser (kinds 30041 or 30818)
|
||||
const useAsciiDoc = state.docs.kind === 30041 || state.docs.kind === 30818; |
||||
|
||||
if (useAsciiDoc) { |
||||
// Use AsciiDoc parser
|
||||
const Asciidoctor = (await import('@asciidoctor/core')).default; |
||||
const asciidoctor = Asciidoctor(); |
||||
const converted = asciidoctor.convert(state.docs.content, { |
||||
safe: 'safe', |
||||
attributes: { |
||||
'source-highlighter': 'highlight.js' |
||||
} |
||||
}); |
||||
// Convert to string if it's a Document object
|
||||
state.docs.html = typeof converted === 'string' ? converted : String(converted); |
||||
} else { |
||||
// Use Markdown parser
|
||||
const MarkdownIt = (await import('markdown-it')).default; |
||||
const hljsModule = await import('highlight.js'); |
||||
const hljs = hljsModule.default || hljsModule; |
||||
|
||||
const md = new MarkdownIt({ |
||||
highlight: function (str: string, lang: string): string { |
||||
if (lang && hljs.getLanguage(lang)) { |
||||
try { |
||||
return hljs.highlight(str, { language: lang }).value; |
||||
} catch (__) {} |
||||
} |
||||
return ''; |
||||
} |
||||
}); |
||||
|
||||
state.docs.html = md.render(state.docs.content); |
||||
} |
||||
} |
||||
} |
||||
} catch (err) { |
||||
console.error('Error loading documentation:', err); |
||||
state.docs.content = null; |
||||
state.docs.html = null; |
||||
} finally { |
||||
state.loading.docs = false; |
||||
} |
||||
} |
||||
@ -0,0 +1,198 @@
@@ -0,0 +1,198 @@
|
||||
/** |
||||
* Issue operations service |
||||
* Handles issue loading, creation, status updates, and replies |
||||
*/ |
||||
|
||||
import type { RepoState } from '../stores/repo-state.js'; |
||||
import { apiRequest } from '../utils/api-client.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; |
||||
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
||||
import { KIND } from '$lib/types/nostr.js'; |
||||
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||
|
||||
interface IssueOperationsCallbacks { |
||||
loadIssues: () => Promise<void>; |
||||
loadIssueReplies: (issueId: string) => Promise<void>; |
||||
nostrClient: NostrClient; |
||||
} |
||||
|
||||
/** |
||||
* Load issues from the repository |
||||
*/ |
||||
export async function loadIssues( |
||||
state: RepoState, |
||||
callbacks: IssueOperationsCallbacks |
||||
): Promise<void> { |
||||
state.loading.issues = true; |
||||
state.error = null; |
||||
try { |
||||
const data = await apiRequest<Array<{ |
||||
id: string; |
||||
tags: string[][]; |
||||
content: string; |
||||
status?: string; |
||||
pubkey: string; |
||||
created_at: number; |
||||
kind?: number; |
||||
}>>(`/api/repos/${state.npub}/${state.repo}/issues`); |
||||
|
||||
state.issues = data.map((issue) => ({ |
||||
id: issue.id, |
||||
subject: issue.tags.find((t: string[]) => t[0] === 'subject')?.[1] || 'Untitled', |
||||
content: issue.content, |
||||
status: issue.status || 'open', |
||||
author: issue.pubkey, |
||||
created_at: issue.created_at, |
||||
kind: issue.kind || KIND.ISSUE, |
||||
tags: issue.tags || [] |
||||
})); |
||||
|
||||
// Auto-select first issue if none selected
|
||||
if (state.issues.length > 0 && !state.selected.issue) { |
||||
state.selected.issue = state.issues[0].id; |
||||
callbacks.loadIssueReplies(state.issues[0].id); |
||||
} |
||||
} catch (err) { |
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load issues'; |
||||
console.error('[Issues] Error loading issues:', err); |
||||
state.error = errorMessage; |
||||
} finally { |
||||
state.loading.issues = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Load replies for an issue |
||||
*/ |
||||
export async function loadIssueReplies( |
||||
issueId: string, |
||||
state: RepoState, |
||||
callbacks: IssueOperationsCallbacks |
||||
): Promise<void> { |
||||
state.loading.issueReplies = true; |
||||
try { |
||||
const replies = await callbacks.nostrClient.fetchEvents([ |
||||
{ |
||||
kinds: [KIND.COMMENT], |
||||
'#e': [issueId], |
||||
limit: 100 |
||||
} |
||||
]) as NostrEvent[]; |
||||
|
||||
state.issueReplies = replies.map(reply => ({ |
||||
id: reply.id, |
||||
content: reply.content, |
||||
author: reply.pubkey, |
||||
created_at: reply.created_at, |
||||
tags: reply.tags || [] |
||||
})).sort((a, b) => a.created_at - b.created_at); |
||||
} catch (err) { |
||||
console.error('[Issues] Error loading replies:', err); |
||||
state.issueReplies = []; |
||||
} finally { |
||||
state.loading.issueReplies = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Create a new issue |
||||
*/ |
||||
export async function createIssue( |
||||
state: RepoState, |
||||
callbacks: IssueOperationsCallbacks |
||||
): Promise<void> { |
||||
if (!state.forms.issue.subject.trim() || !state.forms.issue.content.trim()) { |
||||
alert('Please enter a subject and content'); |
||||
return; |
||||
} |
||||
|
||||
if (!state.user.pubkey) { |
||||
alert('Please connect your NIP-07 extension'); |
||||
return; |
||||
} |
||||
|
||||
state.saving = true; |
||||
state.error = null; |
||||
|
||||
try { |
||||
const { IssuesService } = await import('$lib/services/nostr/issues-service.js'); |
||||
|
||||
const decoded = nip19.decode(state.npub); |
||||
if (decoded.type !== 'npub') { |
||||
throw new Error('Invalid npub format'); |
||||
} |
||||
const repoOwnerPubkey = decoded.data as string; |
||||
|
||||
// Get user's relays and combine with defaults
|
||||
const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||
const { outbox } = await getUserRelays(state.user.pubkey, tempClient); |
||||
const combinedRelays = combineRelays(outbox); |
||||
|
||||
const issuesService = new IssuesService(combinedRelays); |
||||
await issuesService.createIssue( |
||||
repoOwnerPubkey, |
||||
state.repo, |
||||
state.forms.issue.subject.trim(), |
||||
state.forms.issue.content.trim(), |
||||
state.forms.issue.labels.filter(l => l.trim()) |
||||
); |
||||
|
||||
state.openDialog = null; |
||||
state.forms.issue.subject = ''; |
||||
state.forms.issue.content = ''; |
||||
state.forms.issue.labels = ['']; |
||||
await callbacks.loadIssues(); |
||||
alert('Issue created successfully!'); |
||||
} catch (err) { |
||||
state.error = err instanceof Error ? err.message : 'Failed to create issue'; |
||||
console.error('Error creating issue:', err); |
||||
} finally { |
||||
state.saving = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Update issue status |
||||
*/ |
||||
export async function updateIssueStatus( |
||||
issueId: string, |
||||
issueAuthor: string, |
||||
status: 'open' | 'closed' | 'resolved' | 'draft', |
||||
state: RepoState, |
||||
callbacks: IssueOperationsCallbacks |
||||
): Promise<void> { |
||||
if (!state.user.pubkeyHex) { |
||||
alert('Please connect your NIP-07 extension'); |
||||
return; |
||||
} |
||||
|
||||
// Check if user is maintainer or issue author
|
||||
const isAuthor = state.user.pubkeyHex === issueAuthor; |
||||
if (!state.maintainers.isMaintainer && !isAuthor) { |
||||
alert('Only repository maintainers or issue authors can update issue status'); |
||||
return; |
||||
} |
||||
|
||||
state.statusUpdates.issue = { ...state.statusUpdates.issue, [issueId]: true }; |
||||
state.error = null; |
||||
|
||||
try { |
||||
await apiRequest(`/api/repos/${state.npub}/${state.repo}/issues`, { |
||||
method: 'PATCH', |
||||
body: JSON.stringify({ |
||||
issueId, |
||||
issueAuthor, |
||||
status |
||||
}) |
||||
} as RequestInit); |
||||
|
||||
await callbacks.loadIssues(); |
||||
} catch (err) { |
||||
state.error = err instanceof Error ? err.message : 'Failed to update issue status'; |
||||
console.error('Error updating issue status:', err); |
||||
} finally { |
||||
state.statusUpdates.issue = { ...state.statusUpdates.issue, [issueId]: false }; |
||||
} |
||||
} |
||||
@ -0,0 +1,201 @@
@@ -0,0 +1,201 @@
|
||||
/** |
||||
* Patch operations service |
||||
* Handles patch loading, creation, and status updates |
||||
*/ |
||||
|
||||
import type { RepoState } from '../stores/repo-state.js'; |
||||
import { apiRequest } from '../utils/api-client.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; |
||||
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
||||
import { isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; |
||||
import { KIND } from '$lib/types/nostr.js'; |
||||
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||
|
||||
interface PatchOperationsCallbacks { |
||||
loadPatches: () => Promise<void>; |
||||
} |
||||
|
||||
/** |
||||
* Load patches from the repository |
||||
*/ |
||||
export async function loadPatches( |
||||
state: RepoState, |
||||
callbacks: PatchOperationsCallbacks |
||||
): Promise<void> { |
||||
if (state.repoNotFound) return; |
||||
state.loading.patches = true; |
||||
state.error = null; |
||||
try { |
||||
const data = await apiRequest<Array<{ |
||||
id: string; |
||||
tags: string[][]; |
||||
content: string; |
||||
pubkey: string; |
||||
created_at: number; |
||||
kind?: number; |
||||
status?: string; |
||||
}>>(`/api/repos/${state.npub}/${state.repo}/patches`); |
||||
|
||||
state.patches = data.map((patch) => { |
||||
// Extract subject/title from various sources
|
||||
let subject = patch.tags.find((t: string[]) => t[0] === 'subject')?.[1]; |
||||
const description = patch.tags.find((t: string[]) => t[0] === 'description')?.[1]; |
||||
const alt = patch.tags.find((t: string[]) => t[0] === 'alt')?.[1]; |
||||
|
||||
// If no subject tag, try description or alt
|
||||
if (!subject) { |
||||
if (description) { |
||||
subject = description.trim(); |
||||
} else if (alt) { |
||||
// Remove "git patch: " prefix if present
|
||||
subject = alt.replace(/^git patch:\s*/i, '').trim(); |
||||
} else { |
||||
// Try to extract from patch content (git patch format)
|
||||
const subjectMatch = patch.content.match(/^Subject:\s*\[PATCH[^\]]*\]\s*(.+)$/m); |
||||
if (subjectMatch) { |
||||
subject = subjectMatch[1].trim(); |
||||
} else { |
||||
// Try simpler Subject: line
|
||||
const simpleSubjectMatch = patch.content.match(/^Subject:\s*(.+)$/m); |
||||
if (simpleSubjectMatch) { |
||||
subject = simpleSubjectMatch[1].trim(); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
return { |
||||
id: patch.id, |
||||
subject: subject || 'Untitled', |
||||
content: patch.content, |
||||
status: patch.status || 'open', |
||||
author: patch.pubkey, |
||||
created_at: patch.created_at, |
||||
kind: patch.kind || KIND.PATCH, |
||||
description: description?.trim(), |
||||
tags: patch.tags || [] |
||||
}; |
||||
}); |
||||
} catch (err) { |
||||
state.error = err instanceof Error ? err.message : 'Failed to load patches'; |
||||
console.error('Error loading patches:', err); |
||||
} finally { |
||||
state.loading.patches = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Create a new patch |
||||
*/ |
||||
export async function createPatch( |
||||
state: RepoState, |
||||
callbacks: PatchOperationsCallbacks |
||||
): Promise<void> { |
||||
if (!state.forms.patch.content.trim()) { |
||||
alert('Please enter patch content'); |
||||
return; |
||||
} |
||||
|
||||
if (!state.user.pubkey || !state.user.pubkeyHex) { |
||||
alert('Please connect your NIP-07 extension'); |
||||
return; |
||||
} |
||||
|
||||
state.creating.patch = true; |
||||
state.error = null; |
||||
|
||||
try { |
||||
const decoded = nip19.decode(state.npub); |
||||
if (decoded.type !== 'npub') { |
||||
throw new Error('Invalid npub format'); |
||||
} |
||||
const repoOwnerPubkey = decoded.data as string; |
||||
state.metadata.address = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${state.repo}`; |
||||
|
||||
// Get user's relays and combine with defaults
|
||||
const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||
const { outbox } = await getUserRelays(state.user.pubkey, tempClient); |
||||
const combinedRelays = combineRelays(outbox); |
||||
|
||||
// Create patch event (kind 1617)
|
||||
const patchEventTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
||||
kind: KIND.PATCH, |
||||
pubkey: state.user.pubkeyHex, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: [ |
||||
['a', state.metadata.address], |
||||
['p', repoOwnerPubkey], |
||||
['t', 'root'] |
||||
], |
||||
content: state.forms.patch.content.trim() |
||||
}; |
||||
|
||||
// Add subject if provided
|
||||
if (state.forms.patch.subject.trim()) { |
||||
patchEventTemplate.tags.push(['subject', state.forms.patch.subject.trim()]); |
||||
} |
||||
|
||||
// Sign the event using NIP-07
|
||||
const signedEvent = await signEventWithNIP07(patchEventTemplate); |
||||
|
||||
// Publish to all available relays
|
||||
const publishClient = new NostrClient(combinedRelays); |
||||
const result = await publishClient.publishEvent(signedEvent, combinedRelays); |
||||
|
||||
if (result.failed.length > 0 && result.success.length === 0) { |
||||
throw new Error('Failed to publish patch to all relays'); |
||||
} |
||||
|
||||
state.openDialog = null; |
||||
state.forms.patch.content = ''; |
||||
state.forms.patch.subject = ''; |
||||
alert('Patch created successfully!'); |
||||
// Reload patches
|
||||
await callbacks.loadPatches(); |
||||
} catch (err) { |
||||
state.error = err instanceof Error ? err.message : 'Failed to create patch'; |
||||
console.error('Error creating patch:', err); |
||||
} finally { |
||||
state.creating.patch = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Update patch status |
||||
*/ |
||||
export async function updatePatchStatus( |
||||
patchId: string, |
||||
patchAuthor: string, |
||||
status: string, |
||||
state: RepoState, |
||||
callbacks: PatchOperationsCallbacks |
||||
): Promise<void> { |
||||
if (!state.user.pubkey || !state.user.pubkeyHex) { |
||||
state.error = 'Please log in to update patch status'; |
||||
return; |
||||
} |
||||
|
||||
state.statusUpdates.patch[patchId] = true; |
||||
state.error = null; |
||||
|
||||
try { |
||||
await apiRequest(`/api/repos/${state.npub}/${state.repo}/patches`, { |
||||
method: 'PATCH', |
||||
body: JSON.stringify({ |
||||
patchId, |
||||
patchAuthor, |
||||
status |
||||
}) |
||||
} as RequestInit); |
||||
|
||||
// Reload patches to get updated status
|
||||
await callbacks.loadPatches(); |
||||
} catch (err) { |
||||
state.error = err instanceof Error ? err.message : 'Failed to update patch status'; |
||||
console.error('Error updating patch status:', err); |
||||
} finally { |
||||
state.statusUpdates.patch[patchId] = false; |
||||
} |
||||
} |
||||
@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
|
||||
/** |
||||
* PR operations service |
||||
* Handles pull request loading and creation |
||||
*/ |
||||
|
||||
import type { RepoState } from '../stores/repo-state.js'; |
||||
import { apiRequest } from '../utils/api-client.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||
import { DEFAULT_NOSTR_RELAYS, combineRelays, getGitUrl } from '$lib/config.js'; |
||||
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
||||
import { KIND } from '$lib/types/nostr.js'; |
||||
|
||||
interface PROperationsCallbacks { |
||||
loadPRs: () => Promise<void>; |
||||
} |
||||
|
||||
/** |
||||
* Load pull requests from the repository |
||||
*/ |
||||
export async function loadPRs( |
||||
state: RepoState, |
||||
callbacks: PROperationsCallbacks |
||||
): Promise<void> { |
||||
state.loading.prs = true; |
||||
state.error = null; |
||||
try { |
||||
const data = await apiRequest<Array<{ |
||||
id: string; |
||||
tags: string[][]; |
||||
content: string; |
||||
status?: string; |
||||
pubkey: string; |
||||
created_at: number; |
||||
commitId?: string; |
||||
kind?: number; |
||||
}>>(`/api/repos/${state.npub}/${state.repo}/prs`); |
||||
|
||||
state.prs = data.map((pr) => ({ |
||||
id: pr.id, |
||||
subject: pr.tags.find((t: string[]) => t[0] === 'subject')?.[1] || 'Untitled', |
||||
content: pr.content, |
||||
status: pr.status || 'open', |
||||
author: pr.pubkey, |
||||
created_at: pr.created_at, |
||||
commitId: pr.tags.find((t: string[]) => t[0] === 'c')?.[1], |
||||
kind: pr.kind || KIND.PULL_REQUEST |
||||
})); |
||||
} catch (err) { |
||||
state.error = err instanceof Error ? err.message : 'Failed to load pull requests'; |
||||
} finally { |
||||
state.loading.prs = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Create a new pull request |
||||
*/ |
||||
export async function createPR( |
||||
state: RepoState, |
||||
callbacks: PROperationsCallbacks |
||||
): Promise<void> { |
||||
if (!state.forms.pr.subject.trim() || !state.forms.pr.content.trim() || !state.forms.pr.commitId.trim()) { |
||||
alert('Please enter a subject, content, and commit ID'); |
||||
return; |
||||
} |
||||
|
||||
if (!state.user.pubkey) { |
||||
alert('Please connect your NIP-07 extension'); |
||||
return; |
||||
} |
||||
|
||||
state.saving = true; |
||||
state.error = null; |
||||
|
||||
try { |
||||
const { PRsService } = await import('$lib/services/nostr/prs-service.js'); |
||||
|
||||
const decoded = nip19.decode(state.npub); |
||||
if (decoded.type !== 'npub') { |
||||
throw new Error('Invalid npub format'); |
||||
} |
||||
const repoOwnerPubkey = decoded.data as string; |
||||
|
||||
// Get user's relays and combine with defaults
|
||||
const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||
const { outbox } = await getUserRelays(state.user.pubkey, tempClient); |
||||
const combinedRelays = combineRelays(outbox); |
||||
|
||||
const cloneUrl = getGitUrl(state.npub, state.repo); |
||||
const prsService = new PRsService(combinedRelays); |
||||
await prsService.createPullRequest( |
||||
repoOwnerPubkey, |
||||
state.repo, |
||||
state.forms.pr.subject.trim(), |
||||
state.forms.pr.content.trim(), |
||||
state.forms.pr.commitId.trim(), |
||||
cloneUrl, |
||||
state.forms.pr.branchName.trim() || undefined, |
||||
state.forms.pr.labels.filter(l => l.trim()) |
||||
); |
||||
|
||||
state.openDialog = null; |
||||
state.forms.pr.subject = ''; |
||||
state.forms.pr.content = ''; |
||||
state.forms.pr.commitId = ''; |
||||
state.forms.pr.branchName = ''; |
||||
state.forms.pr.labels = ['']; |
||||
await callbacks.loadPRs(); |
||||
alert('Pull request created successfully!'); |
||||
} catch (err) { |
||||
state.error = err instanceof Error ? err.message : 'Failed to create pull request'; |
||||
console.error('Error creating PR:', err); |
||||
} finally { |
||||
state.saving = false; |
||||
} |
||||
} |
||||
@ -0,0 +1,365 @@
@@ -0,0 +1,365 @@
|
||||
/** |
||||
* Repo operations service |
||||
* Handles repository-level operations: clone, fork, bookmark, verification, maintainers |
||||
*/ |
||||
|
||||
import type { RepoState } from '../stores/repo-state.js'; |
||||
import { apiRequest, apiPost } from '../utils/api-client.js'; |
||||
import { buildApiHeaders } from '../utils/api-client.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '$lib/config.js'; |
||||
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
||||
import { KIND } from '$lib/types/nostr.js'; |
||||
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||
import { goto } from '$app/navigation'; |
||||
|
||||
interface RepoOperationsCallbacks { |
||||
checkCloneStatus: (force: boolean) => Promise<void>; |
||||
loadBranches: () => Promise<void>; |
||||
loadFiles: (path: string) => Promise<void>; |
||||
loadReadme: () => Promise<void>; |
||||
loadTags: () => Promise<void>; |
||||
loadCommitHistory: () => Promise<void>; |
||||
} |
||||
|
||||
/** |
||||
* Check clone status |
||||
*/ |
||||
export async function checkCloneStatus( |
||||
force: boolean, |
||||
state: RepoState, |
||||
repoCloneUrls: string[] | undefined |
||||
): Promise<void> { |
||||
if (state.clone.checking && !force) return; |
||||
if (!force && state.clone.isCloned !== null) { |
||||
console.log(`[Clone Status] Skipping check - already checked: ${state.clone.isCloned}, force: ${force}`); |
||||
return; |
||||
} |
||||
|
||||
state.clone.checking = true; |
||||
try { |
||||
// Check if repo exists locally by trying to fetch branches
|
||||
// Use skipApiFallback parameter to ensure we only check local repo, not API fallback
|
||||
// 404 = repo not cloned, 403 = repo exists but access denied (cloned), 200 = cloned and accessible
|
||||
const url = `/api/repos/${state.npub}/${state.repo}/branches?skipApiFallback=true`; |
||||
console.log(`[Clone Status] Checking clone status for ${state.npub}/${state.repo}...`); |
||||
const response = await fetch(url, { |
||||
headers: buildApiHeaders() |
||||
}); |
||||
|
||||
// If response is 403, repo exists (cloned) but user doesn't have access
|
||||
// If response is 404, repo doesn't exist (not cloned)
|
||||
// If response is 200, repo exists and is accessible (cloned)
|
||||
const wasCloned = response.status !== 404; |
||||
state.clone.isCloned = wasCloned; |
||||
|
||||
// If repo is not cloned, check if API fallback is available
|
||||
if (!wasCloned) { |
||||
// Try to detect API fallback by checking if we have clone URLs
|
||||
if (repoCloneUrls && repoCloneUrls.length > 0) { |
||||
// We have clone URLs, so API fallback might work - will be detected when loadBranches() runs
|
||||
state.clone.apiFallbackAvailable = null; // Will be set to true if a subsequent request succeeds
|
||||
} else { |
||||
state.clone.apiFallbackAvailable = false; |
||||
} |
||||
} else { |
||||
// Repo is cloned, API fallback not needed
|
||||
state.clone.apiFallbackAvailable = false; |
||||
} |
||||
|
||||
console.log(`[Clone Status] Repo ${wasCloned ? 'is cloned' : 'is not cloned'} (status: ${response.status}), API fallback: ${state.clone.apiFallbackAvailable}`); |
||||
} catch (err) { |
||||
// On error, assume not cloned
|
||||
console.warn('[Clone Status] Error checking clone status:', err); |
||||
state.clone.isCloned = false; |
||||
state.clone.apiFallbackAvailable = false; |
||||
} finally { |
||||
state.clone.checking = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Clone repository |
||||
*/ |
||||
export async function cloneRepository( |
||||
state: RepoState, |
||||
callbacks: RepoOperationsCallbacks |
||||
): Promise<void> { |
||||
if (state.clone.cloning) return; |
||||
|
||||
state.clone.cloning = true; |
||||
try { |
||||
const data = await apiPost<{ alreadyExists?: boolean }>(`/api/repos/${state.npub}/${state.repo}/clone`, {}); |
||||
|
||||
if (data.alreadyExists) { |
||||
alert('Repository already exists locally.'); |
||||
// Force refresh clone status
|
||||
await callbacks.checkCloneStatus(true); |
||||
} else { |
||||
alert('Repository cloned successfully! The repository is now available on this server.'); |
||||
// Force refresh clone status
|
||||
await callbacks.checkCloneStatus(true); |
||||
// Reset API fallback status since repo is now cloned
|
||||
state.clone.apiFallbackAvailable = false; |
||||
// Reload data to use the cloned repo instead of API
|
||||
await Promise.all([ |
||||
callbacks.loadBranches(), |
||||
callbacks.loadFiles(state.files.currentPath), |
||||
callbacks.loadReadme(), |
||||
callbacks.loadTags(), |
||||
callbacks.loadCommitHistory() |
||||
]); |
||||
} |
||||
} catch (err) { |
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to clone repository'; |
||||
alert(`Error: ${errorMessage}`); |
||||
console.error('Error cloning repository:', err); |
||||
} finally { |
||||
state.clone.cloning = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Fork repository |
||||
*/ |
||||
export async function forkRepository( |
||||
state: RepoState |
||||
): Promise<void> { |
||||
if (!state.user.pubkey) { |
||||
alert('Please connect your NIP-07 extension'); |
||||
return; |
||||
} |
||||
|
||||
state.fork.forking = true; |
||||
state.error = null; |
||||
|
||||
try { |
||||
// Security: Truncate npub in logs
|
||||
const truncatedNpub = state.npub.length > 16 ? `${state.npub.slice(0, 12)}...` : state.npub; |
||||
console.log(`[Fork UI] Starting fork of ${truncatedNpub}/${state.repo}...`); |
||||
|
||||
const data = await apiPost<{ |
||||
success?: boolean; |
||||
message?: string; |
||||
fork?: { |
||||
npub: string; |
||||
repo: string; |
||||
publishedTo?: { announcement?: number }; |
||||
announcementId?: string; |
||||
ownershipTransferId?: string; |
||||
}; |
||||
error?: string; |
||||
details?: string; |
||||
eventName?: string; |
||||
}>(`/api/repos/${state.npub}/${state.repo}/fork`, { userPubkey: state.user.pubkey }); |
||||
|
||||
if (data.success !== false && data.fork) { |
||||
const message = data.message || `Repository forked successfully! Published to ${data.fork.publishedTo?.announcement || 0} relay(s).`; |
||||
console.log(`[Fork UI] ✓ ${message}`); |
||||
// Security: Truncate npub in logs
|
||||
const truncatedForkNpub = data.fork.npub.length > 16 ? `${data.fork.npub.slice(0, 12)}...` : data.fork.npub; |
||||
console.log(`[Fork UI] - Fork location: /repos/${truncatedForkNpub}/${data.fork.repo}`); |
||||
console.log(`[Fork UI] - Announcement ID: ${data.fork.announcementId}`); |
||||
console.log(`[Fork UI] - Ownership Transfer ID: ${data.fork.ownershipTransferId}`); |
||||
|
||||
alert(`${message}\n\nRedirecting to your fork...`); |
||||
goto(`/repos/${data.fork.npub}/${data.fork.repo}`); |
||||
} else { |
||||
const errorMessage = data.error || 'Failed to fork repository'; |
||||
const errorDetails = data.details ? `\n\nDetails: ${data.details}` : ''; |
||||
const fullError = `${errorMessage}${errorDetails}`; |
||||
|
||||
console.error(`[Fork UI] ✗ Fork failed: ${errorMessage}`); |
||||
if (data.details) { |
||||
console.error(`[Fork UI] Details: ${data.details}`); |
||||
} |
||||
if (data.eventName) { |
||||
console.error(`[Fork UI] Failed event: ${data.eventName}`); |
||||
} |
||||
|
||||
state.error = fullError; |
||||
alert(`Fork failed!\n\n${fullError}`); |
||||
} |
||||
} catch (err) { |
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fork repository'; |
||||
console.error(`[Fork UI] ✗ Unexpected error: ${errorMessage}`, err); |
||||
state.error = errorMessage; |
||||
alert(`Fork failed!\n\n${errorMessage}`); |
||||
} finally { |
||||
state.fork.forking = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Toggle bookmark |
||||
*/ |
||||
export async function toggleBookmark( |
||||
state: RepoState, |
||||
bookmarksService: any |
||||
): Promise<void> { |
||||
if (!state.user.pubkey || !state.metadata.address || !bookmarksService || state.loading.bookmark) return; |
||||
|
||||
state.loading.bookmark = true; |
||||
try { |
||||
// Get user's relays for publishing
|
||||
const allSearchRelays = [...new Set([...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS])]; |
||||
const fullRelayClient = new NostrClient(allSearchRelays); |
||||
const { outbox, inbox } = await getUserRelays(state.user.pubkey, fullRelayClient); |
||||
const userRelays = combineRelays(outbox.length > 0 ? outbox : inbox, DEFAULT_NOSTR_RELAYS); |
||||
|
||||
let success = false; |
||||
if (state.bookmark.isBookmarked) { |
||||
success = await bookmarksService.removeBookmark(state.user.pubkey, state.metadata.address, userRelays); |
||||
} else { |
||||
success = await bookmarksService.addBookmark(state.user.pubkey, state.metadata.address, userRelays); |
||||
} |
||||
|
||||
if (success) { |
||||
state.bookmark.isBookmarked = !state.bookmark.isBookmarked; |
||||
} else { |
||||
alert(`Failed to ${state.bookmark.isBookmarked ? 'remove' : 'add'} bookmark. Please try again.`); |
||||
} |
||||
} catch (err) { |
||||
console.error('Failed to toggle bookmark:', err); |
||||
alert(`Failed to ${state.bookmark.isBookmarked ? 'remove' : 'add'} bookmark: ${String(err)}`); |
||||
} finally { |
||||
state.loading.bookmark = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Check maintainer status |
||||
*/ |
||||
export async function checkMaintainerStatus( |
||||
state: RepoState |
||||
): Promise<void> { |
||||
if (state.repoNotFound || !state.user.pubkey) { |
||||
state.maintainers.isMaintainer = false; |
||||
return; |
||||
} |
||||
|
||||
state.loading.maintainerStatus = true; |
||||
try { |
||||
const data = await apiRequest<{ isMaintainer?: boolean }>( |
||||
`/api/repos/${state.npub}/${state.repo}/maintainers?userPubkey=${encodeURIComponent(state.user.pubkey)}` |
||||
); |
||||
state.maintainers.isMaintainer = data.isMaintainer || false; |
||||
} catch (err) { |
||||
console.error('Failed to check maintainer status:', err); |
||||
state.maintainers.isMaintainer = false; |
||||
} finally { |
||||
state.loading.maintainerStatus = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Load all maintainers |
||||
*/ |
||||
export async function loadAllMaintainers( |
||||
state: RepoState, |
||||
repoOwnerPubkeyDerived: string | null, |
||||
repoMaintainers: string[] | undefined |
||||
): Promise<void> { |
||||
if (state.repoNotFound || state.loading.maintainers) return; |
||||
|
||||
state.loading.maintainers = true; |
||||
try { |
||||
const data = await apiRequest<{ |
||||
owner?: string; |
||||
maintainers?: string[]; |
||||
}>(`/api/repos/${state.npub}/${state.repo}/maintainers`); |
||||
|
||||
const owner = data.owner; |
||||
const maintainers = data.maintainers || []; |
||||
|
||||
// Create array with all maintainers, marking the owner
|
||||
const allMaintainersList: Array<{ pubkey: string; isOwner: boolean }> = []; |
||||
const seen = new Set<string>(); |
||||
const ownerLower = owner?.toLowerCase(); |
||||
|
||||
// Process all maintainers, marking owner and deduplicating
|
||||
for (const maintainer of maintainers) { |
||||
const maintainerLower = maintainer.toLowerCase(); |
||||
|
||||
// Skip if we've already added this pubkey (case-insensitive check)
|
||||
if (seen.has(maintainerLower)) { |
||||
continue; |
||||
} |
||||
|
||||
// Mark as seen
|
||||
seen.add(maintainerLower); |
||||
|
||||
// Determine if this is the owner
|
||||
const isOwner = ownerLower && maintainerLower === ownerLower; |
||||
|
||||
// Add to list
|
||||
allMaintainersList.push({
|
||||
pubkey: maintainer,
|
||||
isOwner: !!isOwner |
||||
}); |
||||
} |
||||
|
||||
// Sort: owner first, then other maintainers
|
||||
allMaintainersList.sort((a, b) => { |
||||
if (a.isOwner && !b.isOwner) return -1; |
||||
if (!a.isOwner && b.isOwner) return 1; |
||||
return 0; |
||||
}); |
||||
|
||||
// Ensure owner is always included (in case they weren't in maintainers list)
|
||||
if (owner && ownerLower && !seen.has(ownerLower)) { |
||||
allMaintainersList.unshift({ pubkey: owner, isOwner: true }); |
||||
} |
||||
|
||||
state.maintainers.all = allMaintainersList; |
||||
} catch (err) { |
||||
console.error('Failed to load maintainers:', err); |
||||
state.maintainers.loaded = false; // Reset flag on error
|
||||
// Fallback to pageData if available
|
||||
if (repoOwnerPubkeyDerived) { |
||||
state.maintainers.all = [{ pubkey: repoOwnerPubkeyDerived, isOwner: true }]; |
||||
if (repoMaintainers) { |
||||
for (const maintainer of repoMaintainers) { |
||||
if (maintainer.toLowerCase() !== repoOwnerPubkeyDerived.toLowerCase()) { |
||||
state.maintainers.all.push({ pubkey: maintainer, isOwner: false }); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} finally { |
||||
state.loading.maintainers = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Check verification status |
||||
*/ |
||||
export async function checkVerification( |
||||
state: RepoState |
||||
): Promise<void> { |
||||
if (state.repoNotFound) return; |
||||
state.loading.verification = true; |
||||
try { |
||||
const data = await apiRequest<{ |
||||
verified?: boolean; |
||||
error?: string; |
||||
message?: string; |
||||
cloneVerifications?: Array<{ url: string; verified: boolean; ownerPubkey: string | null; error?: string }>; |
||||
}>(`/api/repos/${state.npub}/${state.repo}/verify`); |
||||
|
||||
console.log('[Verification] Response:', data); |
||||
state.verification.status = { |
||||
verified: data.verified ?? false, |
||||
error: data.error, |
||||
message: data.message, |
||||
cloneVerifications: data.cloneVerifications |
||||
}; |
||||
} catch (err) { |
||||
console.error('[Verification] Failed to check verification:', err); |
||||
state.verification.status = { verified: false, error: 'Failed to check verification' }; |
||||
} finally { |
||||
state.loading.verification = false; |
||||
console.log('[Verification] Status after check:', state.verification.status); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue