Browse Source

refactor 5

Nostr-Signature: 47651ed0aee8072f356fbac30b6168f2c985bcca392f9ed7d7c38d9670d90f16 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 2ca5d04d4a619dc3e02962249f7c650e3a561315897b329f4493e87148c5dd89fbcb6694515a72d0d17e64c9930e57bd7761e27b353275bb1ada9449330f4e1c
main
Silberengel 2 weeks ago
parent
commit
9dc48a9dc9
  1. 1
      nostr/commit-signatures.jsonl
  2. 1272
      src/routes/repos/[npub]/[repo]/+page.svelte
  3. 577
      src/routes/repos/[npub]/[repo]/services/discussion-operations.ts
  4. 198
      src/routes/repos/[npub]/[repo]/services/issue-operations.ts
  5. 201
      src/routes/repos/[npub]/[repo]/services/patch-operations.ts
  6. 117
      src/routes/repos/[npub]/[repo]/services/pr-operations.ts
  7. 365
      src/routes/repos/[npub]/[repo]/services/repo-operations.ts

1
nostr/commit-signatures.jsonl

@ -97,3 +97,4 @@ @@ -97,3 +97,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772107667,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix build"]],"content":"Signed commit: fix build","id":"2a8db19aff5126547a397f7daf9121f711a3d61efcced642b496687d9afc48dc","sig":"7e0558fac1764e185b3f52450f5a34805b04342bdb0821b4d459b1627d057d7e2af397b3263a8831e9be2e615556ef09094bce808c22f6049261273004da74bc"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772108817,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix build"]],"content":"Signed commit: fix build","id":"a37754536125d75a5c55f6af3b5521f89839e797ad1bffb69e3d313939cb7b65","sig":"6bcca1a025e4478ae330d3664dd2b9cff55f4bec82065ab2afb5bfb92031f7dde3264657dd892fe844396990117048b19247b0ef7423139f89d4cbf46b47f828"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772109639,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor the refactor"]],"content":"Signed commit: refactor the refactor","id":"62aafbdadfd37b20f1b16742a297e2b17d59dd3d6930e64e75d0d1b6a2f04bd6","sig":"050eaca1703b73443b51fd160932a2edfa04fc0a5efd3b5bb0a1e4c8b944caa60d444b2148c07b74f4ff4a589984fa524a7109a2a89c3eddf6c937b23b18c69b"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772110337,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 4"]],"content":"Signed commit: refactor 4","id":"d330d1a096e5f3951e8b2a66160a23c5ac28aa94313ecd0948c7e50baa60bdbb","sig":"febf4088cca3f7223f55ab300ed7fdb7b333c03d2534b05721dfaf9d9284f4599b385ba54379890fa6b846aed02d656a5e45429a6dd571dddbb997be6d8159b2"}

1272
src/routes/repos/[npub]/[repo]/+page.svelte

File diff suppressed because it is too large Load Diff

577
src/routes/repos/[npub]/[repo]/services/discussion-operations.ts

@ -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;
}
}

198
src/routes/repos/[npub]/[repo]/services/issue-operations.ts

@ -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 };
}
}

201
src/routes/repos/[npub]/[repo]/services/patch-operations.ts

@ -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;
}
}

117
src/routes/repos/[npub]/[repo]/services/pr-operations.ts

@ -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;
}
}

365
src/routes/repos/[npub]/[repo]/services/repo-operations.ts

@ -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…
Cancel
Save