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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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