Browse Source

added status to patches

renamed chat-relay to project-relay

Nostr-Signature: 3c717ed3935bf95a70a0e9ffbe655728d325f72e8cbeb3d38da37b1b6e1304a2 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 952584bfe718362864fdf117bb4c4b042dbea9fe2307bca2f94a9004394bb6fdb3f4f4acd6714bcfdb32453a9d09d24e2c97f512bc1b06e1ba3cd50556f67b6e
main
Silberengel 3 weeks ago
parent
commit
f53d2c2b15
  1. 1
      nostr/commit-signatures.jsonl
  2. 43
      src/lib/components/PRDetail.svelte
  3. 2
      src/lib/services/nostr/discussions-service.ts
  4. 82
      src/lib/styles/repo.css
  5. 120
      src/routes/api/repos/[npub]/[repo]/patches/+server.ts
  6. 128
      src/routes/repos/[npub]/[repo]/+page.svelte

1
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":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":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":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"}

43
src/lib/components/PRDetail.svelte

@ -384,35 +384,44 @@
<div class="pr-detail-view"> <div class="pr-detail-view">
<div class="pr-header"> <div class="pr-header">
<div class="pr-header-top">
<h2>{pr.subject}</h2> <h2>{pr.subject}</h2>
<div class="pr-meta"> {#if isMaintainer && userPubkeyHex}
<select
value={pr.status}
onchange={(e) => {
const target = e.target as HTMLSelectElement;
if (target) updatePRStatus(target.value as 'open' | 'merged' | 'closed' | 'draft');
}}
disabled={updatingStatus}
class="status-dropdown"
class:open={pr.status === 'open'}
class:closed={pr.status === 'closed'}
class:merged={pr.status === 'merged'}
class:draft={pr.status === 'draft'}
>
<option value="open">Open</option>
<option value="merged">Merged</option>
<option value="closed">Closed</option>
<option value="draft">Draft</option>
</select>
{:else}
<span class="pr-status" class:open={pr.status === 'open'} class:closed={pr.status === 'closed'} class:merged={pr.status === 'merged'}> <span class="pr-status" class:open={pr.status === 'open'} class:closed={pr.status === 'closed'} class:merged={pr.status === 'merged'}>
{pr.status} {pr.status}
</span> </span>
{/if}
</div>
<div class="pr-meta">
{#if pr.commitId} {#if pr.commitId}
<span>Commit: {pr.commitId.slice(0, 7)}</span> <span>Commit: {pr.commitId.slice(0, 7)}</span>
{/if} {/if}
<span>Created {new Date(pr.created_at * 1000).toLocaleString()}</span> <span>Created {new Date(pr.created_at * 1000).toLocaleString()}</span>
</div> </div>
{#if isMaintainer && userPubkeyHex} {#if isMaintainer && userPubkeyHex && pr.status === 'open' && pr.commitId}
<div class="pr-actions"> <div class="pr-actions">
{#if pr.status === 'open'} <button onclick={() => showMergeDialog = true} disabled={merging} class="action-btn merge-btn">
<button onclick={() => showMergeDialog = true} disabled={merging || !pr.commitId} class="action-btn merge-btn">
{merging ? 'Merging...' : 'Merge'} {merging ? 'Merging...' : 'Merge'}
</button> </button>
<button onclick={() => updatePRStatus('closed')} disabled={updatingStatus} class="action-btn close-btn">
{updatingStatus ? 'Closing...' : 'Close'}
</button>
{:else if pr.status === 'closed'}
<button onclick={() => updatePRStatus('open')} disabled={updatingStatus} class="action-btn reopen-btn">
{updatingStatus ? 'Reopening...' : 'Reopen'}
</button>
{/if}
{#if pr.status !== 'draft'}
<button onclick={() => updatePRStatus('draft')} disabled={updatingStatus} class="action-btn draft-btn">
{updatingStatus ? 'Updating...' : 'Mark as Draft'}
</button>
{/if}
</div> </div>
{/if} {/if}
</div> </div>

2
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 * Threads should reference the repo announcement via an 'a' tag
*/ */
async getThreads( async getThreads(

82
src/lib/styles/repo.css

@ -2087,19 +2087,97 @@ span.clone-more {
} }
.issue-status, .issue-status,
.pr-status { .pr-status,
.patch-status {
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
border-radius: 0.25rem; border-radius: 0.25rem;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
text-transform: capitalize;
} }
.issue-status.open, .issue-status.open,
.pr-status.open { .pr-status.open,
.patch-status.open {
background: var(--accent); background: var(--accent);
color: var(--accent-text, #ffffff); 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-issue-button,
.create-pr-button, .create-pr-button,
.create-patch-button, .create-patch-button,

120
src/routes/api/repos/[npub]/[repo]/patches/+server.ts

@ -4,14 +4,15 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; 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 { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleValidationError, handleApiError } from '$lib/utils/error-handler.js'; import { handleValidationError, handleApiError } from '$lib/utils/error-handler.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js'; import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js';
import logger from '$lib/services/logger.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 { function getRepoAddress(repoOwnerPubkey: string, repoId: string): string {
return `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repoId}`; return `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repoId}`;
@ -27,9 +28,51 @@ export const GET: RequestHandler = createRepoGetHandler(
'#a': [repoAddress], '#a': [repoAddress],
limit: 100 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<string, NostrEvent>();
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 { 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 { 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 }
);

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

@ -577,7 +577,8 @@
}); });
// Patches // Patches
let patches = $state<Array<{ id: string; subject: string; content: string; author: string; created_at: number; kind: number; description?: string; tags?: string[][] }>>([]); let patches = $state<Array<{ id: string; subject: string; content: string; status: string; author: string; created_at: number; kind: number; description?: string; tags?: string[][] }>>([]);
let updatingPatchStatus = $state<Record<string, boolean>>({});
let loadingPatches = $state(false); let loadingPatches = $state(false);
let selectedPatch = $state<string | null>(null); let selectedPatch = $state<string | null>(null);
let showCreatePatchDialog = $state(false); let showCreatePatchDialog = $state(false);
@ -1938,7 +1939,7 @@
} }
const repoOwnerPubkey = decoded.data as string; 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 client = new NostrClient(DEFAULT_NOSTR_RELAYS);
const events = await client.fetchEvents([ const events = await client.fetchEvents([
{ {
@ -1956,7 +1957,7 @@
const announcement = events[0]; const announcement = events[0];
const chatRelays = announcement.tags const chatRelays = announcement.tags
.filter(t => t[0] === 'chat-relay') .filter(t => t[0] === 'project-relay')
.flatMap(t => t.slice(1)) .flatMap(t => t.slice(1))
.filter(url => url && typeof url === 'string') as string[]; .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] 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 discussionsService = new DiscussionsService(allRelays);
const discussionEntries = await discussionsService.getDiscussions( const discussionEntries = await discussionsService.getDiscussions(
@ -2089,9 +2090,9 @@
const announcement = events[0]; const announcement = events[0];
const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repo}`; 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 const chatRelays = announcement.tags
.filter(t => t[0] === 'chat-relay') .filter(t => t[0] === 'project-relay')
.flatMap(t => t.slice(1)) .flatMap(t => t.slice(1))
.filter(url => url && typeof url === 'string') as string[]; .filter(url => url && typeof url === 'string') as string[];
@ -2190,9 +2191,9 @@
const announcement = events[0]; 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 const chatRelays = announcement.tags
.filter(t => t[0] === 'chat-relay') .filter(t => t[0] === 'project-relay')
.flatMap(t => t.slice(1)) .flatMap(t => t.slice(1))
.filter(url => url && typeof url === 'string') as string[]; .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') { async function updateIssueStatus(issueId: string, issueAuthor: string, status: 'open' | 'closed' | 'resolved' | 'draft') {
if (!userPubkeyHex) { if (!userPubkeyHex) {
alert('Please connect your NIP-07 extension'); alert('Please connect your NIP-07 extension');
@ -5037,7 +5076,7 @@
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); 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 // Extract subject/title from various sources
let subject = patch.tags.find((t: string[]) => t[0] === 'subject')?.[1]; let subject = patch.tags.find((t: string[]) => t[0] === 'subject')?.[1];
const description = patch.tags.find((t: string[]) => t[0] === 'description')?.[1]; const description = patch.tags.find((t: string[]) => t[0] === 'description')?.[1];
@ -5069,6 +5108,7 @@
id: patch.id, id: patch.id,
subject: subject || 'Untitled', subject: subject || 'Untitled',
content: patch.content, content: patch.content,
status: patch.status || 'open',
author: patch.pubkey, author: patch.pubkey,
created_at: patch.created_at, created_at: patch.created_at,
kind: patch.kind || KIND.PATCH, kind: patch.kind || KIND.PATCH,
@ -6067,6 +6107,9 @@
class="patch-item-button" class="patch-item-button"
> >
<div class="patch-header"> <div class="patch-header">
<span class="patch-status" class:open={patch.status === 'open'} class:closed={patch.status === 'closed'} class:applied={patch.status === 'applied'} class:draft={patch.status === 'draft'}>
{patch.status}
</span>
<span class="patch-subject">{patch.subject}</span> <span class="patch-subject">{patch.subject}</span>
</div> </div>
<div class="patch-meta"> <div class="patch-meta">
@ -6503,11 +6546,34 @@
{@const issue = issues.find(i => i.id === selectedIssue)} {@const issue = issues.find(i => i.id === selectedIssue)}
{#if issue} {#if issue}
<div class="issue-detail"> <div class="issue-detail">
<div class="issue-detail-header">
<h3>{issue.subject}</h3> <h3>{issue.subject}</h3>
<div class="issue-meta-detail"> {#if userPubkeyHex && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived || userPubkeyHex === issue.author)}
<select
value={issue.status}
onchange={(e) => {
const target = e.target as HTMLSelectElement;
if (target) updateIssueStatus(issue.id, issue.author, target.value as 'open' | 'closed' | 'resolved' | 'draft');
}}
disabled={updatingIssueStatus[issue.id]}
class="status-dropdown"
class:open={issue.status === 'open'}
class:closed={issue.status === 'closed'}
class:resolved={issue.status === 'resolved'}
class:draft={issue.status === 'draft'}
>
<option value="open">Open</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
<option value="draft">Draft</option>
</select>
{:else}
<span class="issue-status" class:open={issue.status === 'open'} class:closed={issue.status === 'closed'} class:resolved={issue.status === 'resolved'}> <span class="issue-status" class:open={issue.status === 'open'} class:closed={issue.status === 'closed'} class:resolved={issue.status === 'resolved'}>
{issue.status} {issue.status}
</span> </span>
{/if}
</div>
<div class="issue-meta-detail">
<span>#{issue.id.slice(0, 7)}</span> <span>#{issue.id.slice(0, 7)}</span>
<span>Created {new Date(issue.created_at * 1000).toLocaleString()}</span> <span>Created {new Date(issue.created_at * 1000).toLocaleString()}</span>
<EventCopyButton eventId={issue.id} kind={issue.kind} pubkey={issue.author} /> <EventCopyButton eventId={issue.id} kind={issue.kind} pubkey={issue.author} />
@ -6516,22 +6582,6 @@
{@html issue.content.replace(/\n/g, '<br>')} {@html issue.content.replace(/\n/g, '<br>')}
</div> </div>
{#if userPubkeyHex && (isMaintainer || userPubkeyHex === issue.author)}
<div class="issue-actions">
{#if issue.status === 'open'}
<button onclick={() => updateIssueStatus(issue.id, issue.author, 'closed')} disabled={updatingIssueStatus[issue.id]} class="issue-action-btn close-btn">
{updatingIssueStatus[issue.id] ? 'Closing...' : 'Close'}
</button>
<button onclick={() => updateIssueStatus(issue.id, issue.author, 'resolved')} disabled={updatingIssueStatus[issue.id]} class="issue-action-btn resolve-btn">
{updatingIssueStatus[issue.id] ? 'Resolving...' : 'Resolve'}
</button>
{:else if issue.status === 'closed' || issue.status === 'resolved'}
<button onclick={() => updateIssueStatus(issue.id, issue.author, 'open')} disabled={updatingIssueStatus[issue.id]} class="issue-action-btn reopen-btn">
{updatingIssueStatus[issue.id] ? 'Reopening...' : 'Reopen'}
</button>
{/if}
</div>
{/if}
<div class="issue-replies"> <div class="issue-replies">
<h4>Replies ({issueReplies.length})</h4> <h4>Replies ({issueReplies.length})</h4>
@ -6649,7 +6699,33 @@
{:else if selectedPatch} {:else if selectedPatch}
{#each patches.filter(p => p.id === selectedPatch) as patch} {#each patches.filter(p => p.id === selectedPatch) as patch}
<div class="patch-detail"> <div class="patch-detail">
<div class="patch-detail-header">
<h3>{patch.subject}</h3> <h3>{patch.subject}</h3>
{#if userPubkey && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived || userPubkeyHex === patch.author)}
<select
value={patch.status}
onchange={(e) => {
const target = e.target as HTMLSelectElement;
if (target) updatePatchStatus(patch.id, patch.author, target.value);
}}
disabled={updatingPatchStatus[patch.id]}
class="status-dropdown"
class:open={patch.status === 'open'}
class:closed={patch.status === 'closed'}
class:applied={patch.status === 'applied'}
class:draft={patch.status === 'draft'}
>
<option value="open">Open</option>
<option value="applied">Applied</option>
<option value="closed">Closed</option>
<option value="draft">Draft</option>
</select>
{:else}
<span class="patch-status" class:open={patch.status === 'open'} class:closed={patch.status === 'closed'} class:applied={patch.status === 'applied'} class:draft={patch.status === 'draft'}>
{patch.status}
</span>
{/if}
</div>
<div class="patch-meta-detail"> <div class="patch-meta-detail">
<span>#{patch.id.slice(0, 7)}</span> <span>#{patch.id.slice(0, 7)}</span>
<span>Created {new Date(patch.created_at * 1000).toLocaleString()}</span> <span>Created {new Date(patch.created_at * 1000).toLocaleString()}</span>

Loading…
Cancel
Save