From f53d2c2b1516388d06981692b71f1417227f6c83 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 25 Feb 2026 07:19:07 +0100 Subject: [PATCH] added status to patches renamed chat-relay to project-relay Nostr-Signature: 3c717ed3935bf95a70a0e9ffbe655728d325f72e8cbeb3d38da37b1b6e1304a2 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 952584bfe718362864fdf117bb4c4b042dbea9fe2307bca2f94a9004394bb6fdb3f4f4acd6714bcfdb32453a9d09d24e2c97f512bc1b06e1ba3cd50556f67b6e --- nostr/commit-signatures.jsonl | 1 + src/lib/components/PRDetail.svelte | 53 ++++--- src/lib/services/nostr/discussions-service.ts | 2 +- src/lib/styles/repo.css | 82 ++++++++++- .../repos/[npub]/[repo]/patches/+server.ts | 120 +++++++++++++++- src/routes/repos/[npub]/[repo]/+page.svelte | 136 ++++++++++++++---- 6 files changed, 335 insertions(+), 59 deletions(-) diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 8960ea6..3d0dc4e 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -77,3 +77,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771968145,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix repo search"]],"content":"Signed commit: fix repo search","id":"e9eff432fe83e0b629e217fe4c00b19858a797127ff0dad28e248e02629d938c","sig":"47c81818e91fa1b2760ceee78da38a82698c9609df0acc7f4dfaae7c6e7025c870cf2a8c34f3ea9c09dc9110be74f4605762a903d722f8bc15d2a0a2fd7edd04"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771970166,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","patch highlights and comments\nupdate prs to match"]],"content":"Signed commit: patch highlights and comments\nupdate prs to match","id":"f85ce49f7314d99b96e3d837d096fa36745f4dd6087123c51a4a9110f23fcbfa","sig":"a323eb2081c46974a2fa09835bde3a65e5242f782ef34a1ec363e9da0784a00ad63c3f9f6cee016612bdab0d4d9a0e54e2a5bdb4424a635c650e317704331825"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771999453,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","load files from HEAD"]],"content":"Signed commit: load files from HEAD","id":"214fc0597e79b465c0c718a2227de942697409002b6cf5c322c9a6d9b36de333","sig":"713a33e751e0582669e9328bca2ac048585534111984bd6ca938270409f7957178d497c92a981719594e927ca7d301e033306c1d1b261395984b91b2d81762e2"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771999938,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","verify button for cloned repos"]],"content":"Signed commit: verify button for cloned repos","id":"4710ea5de6287e00b5da9a6d7cd6568901e3db45a71476b56dc83ec39b8be73d","sig":"7613ca0847af4eb1fd3f52ef0f59c8f6316ba75605085da8eb0a64ced6fe43897d6af26b84d218155ab61ab8e1b42cbc2a686f2eab9572734fb7d911961d3e85"} diff --git a/src/lib/components/PRDetail.svelte b/src/lib/components/PRDetail.svelte index 714fd19..3bbe5e4 100644 --- a/src/lib/components/PRDetail.svelte +++ b/src/lib/components/PRDetail.svelte @@ -384,35 +384,44 @@
-

{pr.subject}

+
+

{pr.subject}

+ {#if isMaintainer && userPubkeyHex} + + {:else} + + {pr.status} + + {/if} +
- - {pr.status} - {#if pr.commitId} Commit: {pr.commitId.slice(0, 7)} {/if} Created {new Date(pr.created_at * 1000).toLocaleString()}
- {#if isMaintainer && userPubkeyHex} + {#if isMaintainer && userPubkeyHex && pr.status === 'open' && pr.commitId}
- {#if pr.status === 'open'} - - - {:else if pr.status === 'closed'} - - {/if} - {#if pr.status !== 'draft'} - - {/if} +
{/if}
diff --git a/src/lib/services/nostr/discussions-service.ts b/src/lib/services/nostr/discussions-service.ts index c5fc158..92e1934 100644 --- a/src/lib/services/nostr/discussions-service.ts +++ b/src/lib/services/nostr/discussions-service.ts @@ -77,7 +77,7 @@ export class DiscussionsService { } /** - * Fetch kind 11 discussion threads from chat relays + * Fetch kind 11 discussion threads from project relays * Threads should reference the repo announcement via an 'a' tag */ async getThreads( diff --git a/src/lib/styles/repo.css b/src/lib/styles/repo.css index a81750c..ecb9aec 100644 --- a/src/lib/styles/repo.css +++ b/src/lib/styles/repo.css @@ -2087,19 +2087,97 @@ span.clone-more { } .issue-status, -.pr-status { +.pr-status, +.patch-status { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; font-weight: 500; + text-transform: capitalize; } .issue-status.open, -.pr-status.open { +.pr-status.open, +.patch-status.open { background: var(--accent); color: var(--accent-text, #ffffff); } +.issue-status.closed, +.pr-status.closed, +.patch-status.closed { + background: var(--bg-secondary); + color: var(--text-secondary); +} + +.issue-status.resolved, +.pr-status.merged, +.patch-status.applied { + background: var(--success-bg, #d4edda); + color: var(--success-text, #155724); +} + +.issue-status.draft, +.pr-status.draft, +.patch-status.draft { + background: var(--bg-tertiary, #e9ecef); + color: var(--text-secondary); +} + +/* Status dropdown */ +.status-dropdown { + padding: 0.375rem 0.75rem; + border-radius: 0.25rem; + font-size: 0.875rem; + font-weight: 500; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + text-transform: capitalize; +} + +.status-dropdown.open { + background: var(--accent); + color: var(--accent-text, #ffffff); + border-color: var(--accent); +} + +.status-dropdown.closed { + background: var(--bg-secondary); + color: var(--text-secondary); +} + +.status-dropdown.resolved, +.status-dropdown.merged, +.status-dropdown.applied { + background: var(--success-bg, #d4edda); + color: var(--success-text, #155724); +} + +.status-dropdown.draft { + background: var(--bg-tertiary, #e9ecef); + color: var(--text-secondary); +} + +/* Detail headers with status */ +.issue-detail-header, +.patch-detail-header, +.pr-header-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.issue-detail-header h3, +.patch-detail-header h3, +.pr-header-top h2 { + margin: 0; + flex: 1; +} + .create-issue-button, .create-pr-button, .create-patch-button, diff --git a/src/routes/api/repos/[npub]/[repo]/patches/+server.ts b/src/routes/api/repos/[npub]/[repo]/patches/+server.ts index 3d183d3..601fed2 100644 --- a/src/routes/api/repos/[npub]/[repo]/patches/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/patches/+server.ts @@ -4,14 +4,15 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { nostrClient } from '$lib/services/service-registry.js'; +import { nostrClient, maintainerService } from '$lib/services/service-registry.js'; import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import { handleValidationError, handleApiError } from '$lib/utils/error-handler.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js'; import logger from '$lib/services/logger.js'; -import { KIND } from '$lib/types/nostr.js'; +import { KIND, type NostrEvent } from '$lib/types/nostr.js'; +import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; function getRepoAddress(repoOwnerPubkey: string, repoId: string): string { return `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repoId}`; @@ -27,9 +28,51 @@ export const GET: RequestHandler = createRepoGetHandler( '#a': [repoAddress], limit: 100 } - ]); + ]) as NostrEvent[]; - return json(patches); + // Fetch status events for each patch + const patchIds = patches.map(p => p.id); + const statusEvents = await nostrClient.fetchEvents([ + { + kinds: [KIND.STATUS_OPEN, KIND.STATUS_APPLIED, KIND.STATUS_CLOSED, KIND.STATUS_DRAFT], + '#e': patchIds, + limit: 1000 + } + ]) as NostrEvent[]; + + // Group status events by patch ID and get the most recent one + const statusMap = new Map(); + for (const status of statusEvents) { + const rootTag = status.tags.find(t => t[0] === 'e' && t[3] === 'root'); + if (rootTag && rootTag[1]) { + const patchId = rootTag[1]; + const existing = statusMap.get(patchId); + if (!existing || status.created_at > existing.created_at) { + statusMap.set(patchId, status); + } + } + } + + // Combine patches with their status + const patchesWithStatus = patches.map(patch => { + const statusEvent = statusMap.get(patch.id); + let status: 'open' | 'applied' | 'closed' | 'draft' = 'open'; + + if (statusEvent) { + if (statusEvent.kind === KIND.STATUS_OPEN) status = 'open'; + else if (statusEvent.kind === KIND.STATUS_APPLIED) status = 'applied'; + else if (statusEvent.kind === KIND.STATUS_CLOSED) status = 'closed'; + else if (statusEvent.kind === KIND.STATUS_DRAFT) status = 'draft'; + } + + return { + ...patch, + status, + statusEvent + }; + }); + + return json(patchesWithStatus); }, { operation: 'getPatches', requireRepoExists: false, requireRepoAccess: false } // Patches are stored in Nostr, don't require local repo ); @@ -68,3 +111,72 @@ export const POST: RequestHandler = withRepoValidation( }, { operation: 'createPatch', requireRepoAccess: false } // Patches can be created by anyone with access ); + +export const PATCH: RequestHandler = withRepoValidation( + async ({ repoContext, requestContext, event }) => { + const body = await event.request.json(); + const { patchId, patchAuthor, status } = body; + + if (!patchId || !patchAuthor || !status) { + throw handleValidationError('Missing required fields: patchId, patchAuthor, status', { operation: 'updatePatchStatus', npub: repoContext.npub, repo: repoContext.repo }); + } + + // Check if user is maintainer or patch author + const isMaintainer = await maintainerService.isMaintainer(requestContext.userPubkeyHex || '', repoContext.repoOwnerPubkey, repoContext.repo); + const isAuthor = requestContext.userPubkeyHex === patchAuthor; + + if (!isMaintainer && !isAuthor && requestContext.userPubkeyHex !== repoContext.repoOwnerPubkey) { + throw handleApiError(new Error('Only repository owners, maintainers, or patch authors can update patch status'), { operation: 'updatePatchStatus', npub: repoContext.npub, repo: repoContext.repo }, 'Unauthorized'); + } + + // Validate status + const validStatuses: ('open' | 'applied' | 'closed' | 'draft')[] = ['open', 'applied', 'closed', 'draft']; + const normalizedStatus = status.toLowerCase() as 'open' | 'applied' | 'closed' | 'draft'; + if (!validStatuses.includes(normalizedStatus)) { + throw handleValidationError(`Invalid status: must be one of ${validStatuses.join(', ')}`, { operation: 'updatePatchStatus', npub: repoContext.npub, repo: repoContext.repo }); + } + + // Determine status kind + let statusKind: number; + switch (normalizedStatus) { + case 'open': + statusKind = KIND.STATUS_OPEN; + break; + case 'applied': + statusKind = KIND.STATUS_APPLIED; + break; + case 'closed': + statusKind = KIND.STATUS_CLOSED; + break; + case 'draft': + statusKind = KIND.STATUS_DRAFT; + break; + } + + const repoAddress = getRepoAddress(repoContext.repoOwnerPubkey, repoContext.repo); + const tags: string[][] = [ + ['e', patchId, '', 'root'], + ['p', repoContext.repoOwnerPubkey], + ['p', patchAuthor], + ['a', repoAddress] + ]; + + const statusEvent = await signEventWithNIP07({ + kind: statusKind, + content: `Patch ${normalizedStatus}`, + tags, + created_at: Math.floor(Date.now() / 1000), + pubkey: '' + }); + + // Publish status event + const result = await nostrClient.publishEvent(statusEvent, DEFAULT_NOSTR_RELAYS); + + if (result.failed.length > 0 && result.success.length === 0) { + throw handleApiError(new Error('Failed to publish status event to all relays'), { operation: 'updatePatchStatus', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish status event'); + } + + return json({ success: true, event: statusEvent }); + }, + { operation: 'updatePatchStatus', requireRepoAccess: false } +); diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 297c63e..73162fd 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -577,7 +577,8 @@ }); // Patches - let patches = $state>([]); + let patches = $state>([]); + let updatingPatchStatus = $state>({}); let loadingPatches = $state(false); let selectedPatch = $state(null); let showCreatePatchDialog = $state(false); @@ -1938,7 +1939,7 @@ } const repoOwnerPubkey = decoded.data as string; - // Fetch repo announcement to get chat-relay tags and announcement ID + // Fetch repo announcement to get project-relay tags and announcement ID const client = new NostrClient(DEFAULT_NOSTR_RELAYS); const events = await client.fetchEvents([ { @@ -1956,7 +1957,7 @@ const announcement = events[0]; const chatRelays = announcement.tags - .filter(t => t[0] === 'chat-relay') + .filter(t => t[0] === 'project-relay') .flatMap(t => t.slice(1)) .filter(url => url && typeof url === 'string') as string[]; @@ -1985,7 +1986,7 @@ ])]; console.log('[Discussions] Using all available relays for threads:', allRelays); - console.log('[Discussions] Chat relays from announcement:', chatRelays); + console.log('[Discussions] Project relays from announcement:', chatRelays); const discussionsService = new DiscussionsService(allRelays); const discussionEntries = await discussionsService.getDiscussions( @@ -2089,9 +2090,9 @@ const announcement = events[0]; const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repo}`; - // Get chat relays from announcement, or use default relays + // Get project relays from announcement, or use default relays const chatRelays = announcement.tags - .filter(t => t[0] === 'chat-relay') + .filter(t => t[0] === 'project-relay') .flatMap(t => t.slice(1)) .filter(url => url && typeof url === 'string') as string[]; @@ -2190,9 +2191,9 @@ const announcement = events[0]; - // Get chat relays from announcement, or use default relays + // Get project relays from announcement, or use default relays const chatRelays = announcement.tags - .filter(t => t[0] === 'chat-relay') + .filter(t => t[0] === 'project-relay') .flatMap(t => t.slice(1)) .filter(url => url && typeof url === 'string') as string[]; @@ -4831,6 +4832,44 @@ } } + async function updatePatchStatus(patchId: string, patchAuthor: string, status: string) { + if (!userPubkey || !userPubkeyHex) { + error = 'Please log in to update patch status'; + return; + } + + updatingPatchStatus[patchId] = true; + error = null; + + try { + const response = await fetch(`/api/repos/${npub}/${repo}/patches`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + ...buildApiHeaders() + }, + body: JSON.stringify({ + patchId, + patchAuthor, + status + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || `Failed to update patch status: ${response.statusText}`); + } + + // Reload patches to get updated status + await loadPatches(); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to update patch status'; + console.error('Error updating patch status:', err); + } finally { + updatingPatchStatus[patchId] = false; + } + } + async function updateIssueStatus(issueId: string, issueAuthor: string, status: 'open' | 'closed' | 'resolved' | 'draft') { if (!userPubkeyHex) { alert('Please connect your NIP-07 extension'); @@ -5037,7 +5076,7 @@ }); if (response.ok) { const data = await response.json(); - patches = data.map((patch: { id: string; tags: string[][]; content: string; pubkey: string; created_at: number; kind?: number }) => { + patches = data.map((patch: { id: string; tags: string[][]; content: string; pubkey: string; created_at: number; kind?: number; status?: string }) => { // 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]; @@ -5069,6 +5108,7 @@ 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, @@ -6067,6 +6107,9 @@ class="patch-item-button" >
+ + {patch.status} + {patch.subject}
@@ -6503,11 +6546,34 @@ {@const issue = issues.find(i => i.id === selectedIssue)} {#if issue}
-

{issue.subject}

+
+

{issue.subject}

+ {#if userPubkeyHex && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived || userPubkeyHex === issue.author)} + + {:else} + + {issue.status} + + {/if} +
- - {issue.status} - #{issue.id.slice(0, 7)} Created {new Date(issue.created_at * 1000).toLocaleString()} @@ -6516,22 +6582,6 @@ {@html issue.content.replace(/\n/g, '
')}
- {#if userPubkeyHex && (isMaintainer || userPubkeyHex === issue.author)} -
- {#if issue.status === 'open'} - - - {:else if issue.status === 'closed' || issue.status === 'resolved'} - - {/if} -
- {/if}

Replies ({issueReplies.length})

@@ -6649,7 +6699,33 @@ {:else if selectedPatch} {#each patches.filter(p => p.id === selectedPatch) as patch}
-

{patch.subject}

+
+

{patch.subject}

+ {#if userPubkey && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived || userPubkeyHex === patch.author)} + + {:else} + + {patch.status} + + {/if} +
#{patch.id.slice(0, 7)} Created {new Date(patch.created_at * 1000).toLocaleString()}