diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 3ac0dfb..493f5d0 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -69,3 +69,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771923236,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","clean up build warning"]],"content":"Signed commit: clean up build warning","id":"297f43968ae4bcfc8b054037b914a728eaec805770ba0c02e33aab3009c1c046","sig":"91177b6f9c4cd0d69455d5e1c109912588f05c2ddbf287d606a9687ec522ba259ed83750dfbb4b77f20e3cb82a266f251983a14405babc28c0d83eb19bf3da70"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771924650,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","pass announcement"]],"content":"Signed commit: pass announcement","id":"57e1440848e4b322a9b10a6dff49973f29c8dd20b85f6cc75fd40d32eb04f0e4","sig":"3866152051a42592e83a1850bf9f3fd49af597f7dcdb523ef39374d528f6c46df6118682cac3202c29ce89a90fec8b4284c68a57101c6c590d8d1a184cac9731"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771949714,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fallback to API if registered clone unavailble"]],"content":"Signed commit: fallback to API if registered clone unavailble","id":"4921a95aea13f6f72329ff8a278a8ff6321776973e8db327d59ea62b90d363cc","sig":"0efffc826cad23849bd311be582a70cb0a42f3958c742470e8488803c5882955184b9241bf77fcf65fa5ea38feef8bc82de4965de1c783adf53ed05e461dc5de"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771952814,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","more work on branches"]],"content":"Signed commit: more work on branches","id":"adaaea7f2065a00cfd04c9de9bf82b1b976ac3d20c32389a8bd8aa7ad0a95677","sig":"71ce678d0a0732beab1f49f8318cbfe3d8b33d45eacf13392fdb9553e8b1f4732c28d8ffc33b50c9736a8324cf7604c223bb71ff4cfd32f41d7f3e81e1591fcc"} diff --git a/src/lib/components/PRDetail.svelte b/src/lib/components/PRDetail.svelte index c9c0b1c..2a38dd9 100644 --- a/src/lib/components/PRDetail.svelte +++ b/src/lib/components/PRDetail.svelte @@ -175,11 +175,12 @@ pr.author, repoOwnerPubkey, repo, - currentFilePath || undefined, - selectedStartLine, - selectedEndLine, + KIND.PULL_REQUEST, // targetKind + currentFilePath || undefined, // filePath + selectedStartLine, // lineStart + selectedEndLine, // lineEnd undefined, // context - highlightComment.trim() || undefined + highlightComment.trim() || undefined // comment ); const signedEvent = await signEventWithNIP07(eventTemplate); @@ -350,15 +351,13 @@ error = null; try { - const response = await fetch(`/api/repos/${npub}/${repo}/prs/merge`, { + const response = await fetch(`/api/repos/${npub}/${repo}/prs/${pr.id}/merge`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - prId: pr.id, - prAuthor: pr.author, - prCommitId: pr.commitId, targetBranch: mergeTargetBranch, - mergeMessage: mergeMessage.trim() || `Merge pull request ${pr.id.slice(0, 7)}` + mergeCommitMessage: mergeMessage.trim() || `Merge pull request ${pr.id.slice(0, 7)}`, + mergeStrategy: 'merge' }) }); diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index a486dc4..3cf5edf 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -46,6 +46,7 @@ export interface Tag { name: string; hash: string; message?: string; + date?: number; // Unix timestamp of the commit the tag points to } export class FileManager { @@ -2268,23 +2269,45 @@ export class FileManager { for (const tagName of tags.all) { try { - // Try to get tag message - const tagInfo = await git.raw(['cat-file', '-p', tagName]); - const messageMatch = tagInfo.match(/^(.+)$/m); + // Get the commit hash the tag points to const hash = await git.raw(['rev-parse', tagName]); + const commitHash = hash.trim(); - tagList.push({ - name: tagName, - hash: hash.trim(), - message: messageMatch ? messageMatch[1] : undefined - }); - } catch { - // Lightweight tag - const hash = await git.raw(['rev-parse', tagName]); - tagList.push({ - name: tagName, - hash: hash.trim() - }); + // Get the commit date (Unix timestamp) + let commitDate: number | undefined; + try { + const dateStr = await git.raw(['log', '-1', '--format=%at', commitHash]); + commitDate = parseInt(dateStr.trim(), 10); + if (isNaN(commitDate)) { + commitDate = undefined; + } + } catch { + // If we can't get the date, continue without it + commitDate = undefined; + } + + // Try to get tag message (for annotated tags) + try { + const tagInfo = await git.raw(['cat-file', '-p', tagName]); + const messageMatch = tagInfo.match(/^(.+)$/m); + + tagList.push({ + name: tagName, + hash: commitHash, + message: messageMatch ? messageMatch[1] : undefined, + date: commitDate + }); + } catch { + // Lightweight tag - no message + tagList.push({ + name: tagName, + hash: commitHash, + date: commitDate + }); + } + } catch (err) { + // If we can't process this tag, skip it + logger.warn({ error: err, tagName }, 'Error processing tag, skipping'); } } diff --git a/src/lib/services/nostr/highlights-service.ts b/src/lib/services/nostr/highlights-service.ts index 6fef87d..9c8993a 100644 --- a/src/lib/services/nostr/highlights-service.ts +++ b/src/lib/services/nostr/highlights-service.ts @@ -57,12 +57,12 @@ export class HighlightsService { /** * Get PR address (a tag format for PR) */ - private getPRAddress(prId: string, prAuthor: string, repoOwnerPubkey: string, repoId: string): string { - return `${KIND.PULL_REQUEST}:${prAuthor}:${repoId}`; + private getTargetAddress(targetId: string, targetAuthor: string, repoOwnerPubkey: string, repoId: string, targetKind: typeof KIND.PULL_REQUEST | typeof KIND.PATCH): string { + return `${targetKind}:${targetAuthor}:${repoId}`; } /** - * Fetch highlights for a pull request + * Fetch highlights for a pull request or patch */ async getHighlightsForPR( prId: string, @@ -70,22 +70,47 @@ export class HighlightsService { repoOwnerPubkey: string, repoId: string ): Promise { - const prAddress = this.getPRAddress(prId, prAuthor, repoOwnerPubkey, repoId); + return this.getHighlightsForTarget(prId, prAuthor, repoOwnerPubkey, repoId, KIND.PULL_REQUEST); + } + + /** + * Fetch highlights for a patch + */ + async getHighlightsForPatch( + patchId: string, + patchAuthor: string, + repoOwnerPubkey: string, + repoId: string + ): Promise { + return this.getHighlightsForTarget(patchId, patchAuthor, repoOwnerPubkey, repoId, KIND.PATCH); + } + + /** + * Fetch highlights for a pull request or patch (generic) + */ + async getHighlightsForTarget( + targetId: string, + targetAuthor: string, + repoOwnerPubkey: string, + repoId: string, + targetKind: typeof KIND.PULL_REQUEST | typeof KIND.PATCH + ): Promise { + const targetAddress = this.getTargetAddress(targetId, targetAuthor, repoOwnerPubkey, repoId, targetKind); - // Fetch highlights that reference this PR + // Fetch highlights that reference this target const highlights = await this.nostrClient.fetchEvents([ { kinds: [KIND.HIGHLIGHT], - '#a': [prAddress], + '#a': [targetAddress], limit: 100 } ]) as Highlight[]; - // Also fetch highlights that reference the PR by event ID + // Also fetch highlights that reference the target by event ID const highlightsByEvent = await this.nostrClient.fetchEvents([ { kinds: [KIND.HIGHLIGHT], - '#e': [prId], + '#e': [targetId], limit: 100 } ]) as Highlight[]; @@ -225,13 +250,14 @@ export class HighlightsService { } /** - * Get comments for a pull request + * Get comments for a pull request or patch */ - async getCommentsForPR(prId: string): Promise { + async getCommentsForTarget(targetId: string, targetKind: typeof KIND.PULL_REQUEST | typeof KIND.PATCH = KIND.PULL_REQUEST): Promise { const comments = await this.nostrClient.fetchEvents([ { kinds: [KIND.COMMENT], - '#e': [prId], // Root event (lowercase e for filter) + '#E': [targetId], // Root event (uppercase E for NIP-22) + '#K': [targetKind.toString()], // Root kind limit: 100 } ]) as NostrEvent[]; @@ -265,10 +291,11 @@ export class HighlightsService { * Create a highlight event template * * @param highlightedContent - The selected code/text content - * @param prId - Pull request event ID - * @param prAuthor - PR author pubkey + * @param targetId - Pull request or patch event ID + * @param targetAuthor - PR/patch author pubkey * @param repoOwnerPubkey - Repository owner pubkey * @param repoId - Repository identifier + * @param targetKind - Kind of target (PULL_REQUEST or PATCH) * @param filePath - Path to the file being highlighted * @param lineStart - Starting line number (optional) * @param lineEnd - Ending line number (optional) @@ -277,23 +304,24 @@ export class HighlightsService { */ createHighlightEvent( highlightedContent: string, - prId: string, - prAuthor: string, + targetId: string, + targetAuthor: string, repoOwnerPubkey: string, repoId: string, + targetKind: typeof KIND.PULL_REQUEST | typeof KIND.PATCH = KIND.PULL_REQUEST, filePath?: string, lineStart?: number, lineEnd?: number, context?: string, comment?: string ): Omit { - const prAddress = `${KIND.PULL_REQUEST}:${prAuthor}:${repoId}`; + const targetAddress = `${targetKind}:${targetAuthor}:${repoId}`; const tags: string[][] = [ - ['a', prAddress], // Reference to PR - ['e', prId], // PR event ID - ['P', prAuthor], // PR author - ['K', KIND.PULL_REQUEST.toString()], // Root kind + ['a', targetAddress], // Reference to PR or patch + ['e', targetId], // PR/patch event ID + ['P', targetAuthor], // PR/patch author + ['K', targetKind.toString()], // Root kind ]; // Add file path and line numbers if provided diff --git a/src/lib/services/nostr/maintainer-service.ts b/src/lib/services/nostr/maintainer-service.ts index 024d251..38e6e02 100644 --- a/src/lib/services/nostr/maintainer-service.ts +++ b/src/lib/services/nostr/maintainer-service.ts @@ -41,12 +41,13 @@ export interface RepoPrivacyInfo { isPrivate: boolean; owner: string; maintainers: string[]; + contributors: string[]; } export class MaintainerService { private nostrClient: NostrClient; private ownershipTransferService: OwnershipTransferService; - private cache: Map = new Map(); + private cache: Map = new Map(); private cacheTTL = 5 * 60 * 1000; // 5 minutes constructor(relays: string[]) { @@ -63,15 +64,15 @@ export class MaintainerService { } /** - * Get maintainers and privacy info for a repository from NIP-34 announcement + * Get maintainers, contributors, and privacy info for a repository from NIP-34 announcement */ - async getMaintainers(repoOwnerPubkey: string, repoId: string): Promise<{ owner: string; maintainers: string[]; isPrivate: boolean }> { + async getMaintainers(repoOwnerPubkey: string, repoId: string): Promise<{ owner: string; maintainers: string[]; contributors: string[]; isPrivate: boolean }> { const cacheKey = `${repoOwnerPubkey}:${repoId}`; const cached = this.cache.get(cacheKey); // Return cached if still valid if (cached && Date.now() - cached.timestamp < this.cacheTTL) { - return { owner: cached.owner, maintainers: cached.maintainers, isPrivate: cached.isPrivate }; + return { owner: cached.owner, maintainers: cached.maintainers, contributors: cached.contributors, isPrivate: cached.isPrivate }; } try { @@ -87,7 +88,7 @@ export class MaintainerService { if (events.length === 0) { // If no announcement found, only the owner is a maintainer, and repo is public by default - const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey], isPrivate: false }; + const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey], contributors: [], isPrivate: false }; this.cache.set(cacheKey, { ...result, timestamp: Date.now() }); return result; } @@ -105,6 +106,7 @@ export class MaintainerService { const currentOwner = await fileManager.getCurrentOwnerFromRepo(npub, repoId) || announcement.pubkey; const maintainers: string[] = [currentOwner]; // Current owner is always a maintainer + const contributors: string[] = []; // Contributors can view but not modify // Extract maintainers from tags // Maintainers tag format: ['maintainers', 'pubkey1', 'pubkey2', 'pubkey3', ...] @@ -137,14 +139,48 @@ export class MaintainerService { } } - const result = { owner: currentOwner, maintainers, isPrivate }; + // Extract contributors from tags + // Contributors tag format: ['contributors', 'pubkey1', 'pubkey2', 'pubkey3', ...] + for (const tag of announcement.tags) { + if (tag[0] === 'contributors') { + // Iterate through all contributors in the tag (skip index 0 which is 'contributors') + for (let i = 1; i < tag.length; i++) { + const contributorValue = tag[i]; + if (!contributorValue || typeof contributorValue !== 'string') { + continue; + } + + // Contributors can be npub or hex pubkey + let pubkey = contributorValue; + try { + // Try to decode if it's an npub + const decoded = nip19.decode(pubkey); + if (decoded.type === 'npub') { + pubkey = decoded.data as string; + } + } catch { + // Assume it's already a hex pubkey + } + + // Add contributor if it's valid and not already in the list (case-insensitive check) + // Also ensure they're not already a maintainer + if (pubkey && + !contributors.some(c => c.toLowerCase() === pubkey.toLowerCase()) && + !maintainers.some(m => m.toLowerCase() === pubkey.toLowerCase())) { + contributors.push(pubkey); + } + } + } + } + + const result = { owner: currentOwner, maintainers, contributors, isPrivate }; this.cache.set(cacheKey, { ...result, timestamp: Date.now() }); return result; } catch (error) { const logger = await getLogger(); logger.error({ error, repoOwnerPubkey, repoId }, 'Error fetching maintainers'); // Fallback: only owner is maintainer, repo is public by default - const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey], isPrivate: false }; + const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey], contributors: [], isPrivate: false }; this.cache.set(cacheKey, { ...result, timestamp: Date.now() }); return result; } @@ -161,10 +197,10 @@ export class MaintainerService { /** * Check if a user can view a repository * Public repos: anyone can view - * Private repos: only owners and maintainers can view + * Private repos: only owners, maintainers, and contributors can view */ async canView(userPubkey: string | null, repoOwnerPubkey: string, repoId: string): Promise { - const { isPrivate, maintainers, owner } = await this.getMaintainers(repoOwnerPubkey, repoId); + const { isPrivate, maintainers, contributors, owner } = await this.getMaintainers(repoOwnerPubkey, repoId); const logger = await getLogger(); logger.debug({ @@ -173,7 +209,8 @@ export class MaintainerService { currentOwner: owner.substring(0, 16) + '...', repoId, userPubkey: userPubkey ? userPubkey.substring(0, 16) + '...' : null, - maintainerCount: maintainers.length + maintainerCount: maintainers.length, + contributorCount: contributors.length }, 'canView check'); // Public repos are viewable by anyone @@ -202,33 +239,38 @@ export class MaintainerService { // Normalize to lowercase for comparison userPubkeyHex = userPubkeyHex.toLowerCase(); const normalizedMaintainers = maintainers.map(m => m.toLowerCase()); + const normalizedContributors = contributors.map(c => c.toLowerCase()); const normalizedOwner = owner.toLowerCase(); logger.debug({ userPubkeyHex: userPubkeyHex.substring(0, 16) + '...', normalizedOwner: normalizedOwner.substring(0, 16) + '...', - maintainers: normalizedMaintainers.map(m => m.substring(0, 16) + '...') + maintainers: normalizedMaintainers.map(m => m.substring(0, 16) + '...'), + contributors: normalizedContributors.map(c => c.substring(0, 16) + '...') }, 'Comparing pubkeys'); - // Check if user is in maintainers list OR is the current owner - const hasAccess = normalizedMaintainers.includes(userPubkeyHex) || userPubkeyHex === normalizedOwner; + // Check if user is in maintainers list, contributors list, OR is the current owner + const hasAccess = normalizedMaintainers.includes(userPubkeyHex) || + normalizedContributors.includes(userPubkeyHex) || + userPubkeyHex === normalizedOwner; if (!hasAccess) { logger.debug({ userPubkeyHex: userPubkeyHex.substring(0, 16) + '...', currentOwner: normalizedOwner.substring(0, 16) + '...', repoId, - maintainers: normalizedMaintainers.map(m => m.substring(0, 16) + '...') - }, 'Access denied: user not in maintainers list and not current owner'); + maintainers: normalizedMaintainers.map(m => m.substring(0, 16) + '...'), + contributors: normalizedContributors.map(c => c.substring(0, 16) + '...') + }, 'Access denied: user not in maintainers/contributors list and not current owner'); } else { logger.debug({ userPubkeyHex: userPubkeyHex.substring(0, 16) + '...', currentOwner: normalizedOwner.substring(0, 16) + '...', repoId - }, 'Access granted: user is maintainer or current owner'); + }, 'Access granted: user is maintainer, contributor, or current owner'); } - // Check if user is owner or maintainer + // Check if user is owner, maintainer, or contributor return hasAccess; } @@ -236,8 +278,26 @@ export class MaintainerService { * Get privacy info for a repository */ async getPrivacyInfo(repoOwnerPubkey: string, repoId: string): Promise { - const { owner, maintainers, isPrivate } = await this.getMaintainers(repoOwnerPubkey, repoId); - return { isPrivate, owner, maintainers }; + const { owner, maintainers, contributors, isPrivate } = await this.getMaintainers(repoOwnerPubkey, repoId); + return { isPrivate, owner, maintainers, contributors }; + } + + /** + * Check if a user is a contributor (can view but not modify) + */ + async isContributor(userPubkey: string, repoOwnerPubkey: string, repoId: string): Promise { + const { contributors } = await this.getMaintainers(repoOwnerPubkey, repoId); + // Convert userPubkey to hex if needed + let userPubkeyHex = userPubkey; + try { + const decoded = nip19.decode(userPubkey); + if (decoded.type === 'npub') { + userPubkeyHex = decoded.data as string; + } + } catch { + // Assume it's already a hex pubkey + } + return contributors.some(c => c.toLowerCase() === userPubkeyHex.toLowerCase()); } /** diff --git a/src/lib/services/nostr/releases-service.ts b/src/lib/services/nostr/releases-service.ts new file mode 100644 index 0000000..2dc9d9c --- /dev/null +++ b/src/lib/services/nostr/releases-service.ts @@ -0,0 +1,181 @@ +/** + * Service for managing Releases (kind 1642) + * Releases are linked to git tags and provide release notes, changelogs, and binary attachments + */ + +import { NostrClient } from './nostr-client.js'; +import { KIND } from '../../types/nostr.js'; +import type { NostrEvent } from '../../types/nostr.js'; +import { signEventWithNIP07 } from './nip07-signer.js'; + +export interface Release extends NostrEvent { + kind: typeof KIND.RELEASE; + tagName: string; + tagHash?: string; + releaseNotes?: string; + isDraft?: boolean; + isPrerelease?: boolean; +} + +export class ReleasesService { + private nostrClient: NostrClient; + private relays: string[]; + + constructor(relays: string[] = []) { + this.relays = relays; + this.nostrClient = new NostrClient(relays); + } + + /** + * Get repository announcement address (a tag format) + */ + private getRepoAddress(repoOwnerPubkey: string, repoId: string): string { + return `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repoId}`; + } + + /** + * Fetch releases for a repository + */ + async getReleases(repoOwnerPubkey: string, repoId: string): Promise { + const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId); + + const releases = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.RELEASE], + '#a': [repoAddress], + limit: 100 + } + ]) as Release[]; + + // Parse release information from tags + return releases.map(release => { + const tagName = release.tags.find(t => t[0] === 'tag')?.[1] || ''; + const tagHash = release.tags.find(t => t[0] === 'r' && t[2] === 'tag')?.[1]; + const isDraft = release.tags.some(t => t[0] === 'draft' && t[1] === 'true'); + const isPrerelease = release.tags.some(t => t[0] === 'prerelease' && t[1] === 'true'); + + return { + ...release, + tagName, + tagHash, + releaseNotes: release.content, + isDraft, + isPrerelease + }; + }); + } + + /** + * Get a specific release by tag name + */ + async getReleaseByTag(repoOwnerPubkey: string, repoId: string, tagName: string): Promise { + const releases = await this.getReleases(repoOwnerPubkey, repoId); + return releases.find(r => r.tagName === tagName) || null; + } + + /** + * Create a new release + */ + async createRelease( + repoOwnerPubkey: string, + repoId: string, + tagName: string, + tagHash: string, + releaseNotes: string, + isDraft: boolean = false, + isPrerelease: boolean = false + ): Promise { + const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId); + + const tags: string[][] = [ + ['a', repoAddress], + ['p', repoOwnerPubkey], + ['tag', tagName], + ['r', tagHash, '', 'tag'] // Reference to the git tag commit + ]; + + if (isDraft) { + tags.push(['draft', 'true']); + } + + if (isPrerelease) { + tags.push(['prerelease', 'true']); + } + + const event = await signEventWithNIP07({ + kind: KIND.RELEASE, + content: releaseNotes, + tags, + created_at: Math.floor(Date.now() / 1000), + pubkey: '' + }); + + const result = await this.nostrClient.publishEvent(event, this.relays); + if (result.failed.length > 0 && result.success.length === 0) { + throw new Error('Failed to publish release to all relays'); + } + + return { + ...event as Release, + tagName, + tagHash, + releaseNotes, + isDraft, + isPrerelease + }; + } + + /** + * Update an existing release (replaceable event) + * Note: Releases are replaceable events, so updating creates a new event that replaces the old one + */ + async updateRelease( + releaseId: string, + repoOwnerPubkey: string, + repoId: string, + tagName: string, + releaseNotes: string, + isDraft: boolean = false, + isPrerelease: boolean = false + ): Promise { + // For replaceable events, we create a new event with the same d-tag + // The d-tag should be the tag name to make it replaceable + const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId); + + const tags: string[][] = [ + ['a', repoAddress], + ['p', repoOwnerPubkey], + ['d', tagName], // d-tag makes it replaceable + ['tag', tagName] + ]; + + if (isDraft) { + tags.push(['draft', 'true']); + } + + if (isPrerelease) { + tags.push(['prerelease', 'true']); + } + + const event = await signEventWithNIP07({ + kind: KIND.RELEASE, + content: releaseNotes, + tags, + created_at: Math.floor(Date.now() / 1000), + pubkey: '' + }); + + const result = await this.nostrClient.publishEvent(event, this.relays); + if (result.failed.length > 0 && result.success.length === 0) { + throw new Error('Failed to publish release update to all relays'); + } + + return { + ...event as Release, + tagName, + releaseNotes, + isDraft, + isPrerelease + }; + } +} diff --git a/src/lib/services/service-registry.ts b/src/lib/services/service-registry.ts index b5490aa..e5813f1 100644 --- a/src/lib/services/service-registry.ts +++ b/src/lib/services/service-registry.ts @@ -14,6 +14,7 @@ import { IssuesService } from './nostr/issues-service.js'; import { ForkCountService } from './nostr/fork-count-service.js'; import { PRsService } from './nostr/prs-service.js'; import { HighlightsService } from './nostr/highlights-service.js'; +import { ReleasesService } from './nostr/releases-service.js'; import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } from '../config.js'; // Get repo root from environment or use default @@ -33,6 +34,7 @@ let _issuesService: IssuesService | null = null; let _forkCountService: ForkCountService | null = null; let _prsService: PRsService | null = null; let _highlightsService: HighlightsService | null = null; +let _releasesService: ReleasesService | null = null; /** * Get singleton FileManager instance @@ -144,6 +146,16 @@ export function getHighlightsService(): HighlightsService { return _highlightsService; } +/** + * Get singleton ReleasesService instance + */ +export function getReleasesService(): ReleasesService { + if (!_releasesService) { + _releasesService = new ReleasesService(DEFAULT_NOSTR_RELAYS); + } + return _releasesService; +} + // Convenience exports for direct access (common pattern) export const fileManager = getFileManager(); export const repoManager = getRepoManager(); @@ -156,3 +168,4 @@ export const issuesService = getIssuesService(); export const forkCountService = getForkCountService(); export const prsService = getPRsService(); export const highlightsService = getHighlightsService(); +export const releasesService = getReleasesService(); \ No newline at end of file diff --git a/src/lib/types/nostr.ts b/src/lib/types/nostr.ts index 8fe9ef7..26e904b 100644 --- a/src/lib/types/nostr.ts +++ b/src/lib/types/nostr.ts @@ -48,6 +48,7 @@ export const KIND = { STATUS_DRAFT: 1633, // NIP-34: Status draft COMMIT_SIGNATURE: 1640, // Custom: Git commit signature event OWNERSHIP_TRANSFER: 1641, // Custom: Repository ownership transfer event (non-replaceable for chain integrity) + RELEASE: 1642, // Custom: Repository release event COMMENT: 1111, // NIP-22: Comment event THREAD: 11, // NIP-7D: Discussion thread BRANCH_PROTECTION: 30620, // Custom: Branch protection rules diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9b9a897..3b36845 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -262,10 +262,20 @@ } // Provide theme context to child components - setContext('theme', { - get theme() { return { value: theme }; }, - toggleTheme - }); + // Guard against SSR issues where setContext might be called outside component initialization + try { + setContext('theme', { + get theme() { return { value: theme }; }, + toggleTheme + }); + } catch (err) { + // Silently ignore setContext errors during SSR or if called outside component initialization + // This can happen during server-side rendering or in certain edge cases + if (typeof window !== 'undefined') { + // Only log in browser to avoid cluttering SSR logs + console.warn('Failed to set theme context:', err); + } + } // Hide nav bar and footer on splash page (root path) const isSplashPage = $derived($page.url.pathname === '/'); diff --git a/src/routes/api/code-search/+server.ts b/src/routes/api/code-search/+server.ts new file mode 100644 index 0000000..f5a6be7 --- /dev/null +++ b/src/routes/api/code-search/+server.ts @@ -0,0 +1,232 @@ +/** + * API endpoint for global code search across all repositories + * Searches file contents across multiple repositories + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { handleValidationError } from '$lib/utils/error-handler.js'; +import { extractRequestContext } from '$lib/utils/api-context.js'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { NostrClient } from '$lib/services/nostr/nostr-client.js'; +import { KIND } from '$lib/types/nostr.js'; +import { eventCache } from '$lib/services/nostr/event-cache.js'; +import { fetchRepoAnnouncementsWithCache } from '$lib/utils/nostr-utils.js'; +import logger from '$lib/services/logger.js'; +import { readdir, stat } from 'fs/promises'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import { simpleGit } from 'simple-git'; + +const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT + ? process.env.GIT_REPO_ROOT + : '/repos'; + +export interface GlobalCodeSearchResult { + repo: string; + npub: string; + file: string; + line: number; + content: string; + branch: string; +} + +export const GET: RequestHandler = async (event) => { + const query = event.url.searchParams.get('q'); + const repoFilter = event.url.searchParams.get('repo'); // Optional: filter by specific repo (npub/repo format) + const limit = parseInt(event.url.searchParams.get('limit') || '100', 10); + + if (!query || query.trim().length < 2) { + throw handleValidationError('Query must be at least 2 characters', { operation: 'globalCodeSearch' }); + } + + const requestContext = extractRequestContext(event); + const results: GlobalCodeSearchResult[] = []; + + try { + // If repo filter is specified, search only that repo + if (repoFilter) { + const [npub, repo] = repoFilter.split('/'); + if (npub && repo) { + const repoPath = join(repoRoot, npub, `${repo}.git`); + if (existsSync(repoPath)) { + const repoResults = await searchInRepo(npub, repo, query, limit); + results.push(...repoResults); + } + } + return json(results); + } + + // Search across all repositories + // First, get list of all repos from filesystem + if (!existsSync(repoRoot)) { + return json([]); + } + + const users = await readdir(repoRoot); + + for (const user of users) { + const userPath = join(repoRoot, user); + const userStat = await stat(userPath); + + if (!userStat.isDirectory()) { + continue; + } + + const repos = await readdir(userPath); + + for (const repo of repos) { + if (!repo.endsWith('.git')) { + continue; + } + + const repoName = repo.replace(/\.git$/, ''); + const repoPath = join(userPath, repo); + const repoStat = await stat(repoPath); + + if (!repoStat.isDirectory()) { + continue; + } + + // Check access for private repos + try { + const { MaintainerService } = await import('$lib/services/nostr/maintainer-service.js'); + const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); + + // Decode npub to hex + const { nip19 } = await import('nostr-tools'); + let repoOwnerPubkey: string; + try { + const decoded = nip19.decode(user); + if (decoded.type === 'npub') { + repoOwnerPubkey = decoded.data as string; + } else { + repoOwnerPubkey = user; // Assume it's already hex + } + } catch { + repoOwnerPubkey = user; // Assume it's already hex + } + + const canView = await maintainerService.canView( + requestContext.userPubkeyHex || null, + repoOwnerPubkey, + repoName + ); + + if (!canView) { + continue; // Skip private repos user can't access + } + } catch (accessErr) { + logger.debug({ error: accessErr, user, repo: repoName }, 'Error checking access, skipping repo'); + continue; + } + + // Search in this repo + try { + const repoResults = await searchInRepo(user, repoName, query, limit - results.length); + results.push(...repoResults); + + if (results.length >= limit) { + break; + } + } catch (searchErr) { + logger.debug({ error: searchErr, user, repo: repoName }, 'Error searching repo, continuing'); + continue; + } + } + + if (results.length >= limit) { + break; + } + } + + return json(results.slice(0, limit)); + } catch (err) { + logger.error({ error: err, query }, 'Error performing global code search'); + throw err; + } +}; + +async function searchInRepo( + npub: string, + repo: string, + query: string, + limit: number +): Promise { + const repoPath = join(repoRoot, npub, `${repo}.git`); + + if (!existsSync(repoPath)) { + return []; + } + + const results: GlobalCodeSearchResult[] = []; + const git = simpleGit(repoPath); + + try { + // Get default branch + let branch = 'HEAD'; + try { + const branches = await git.branchLocal(); + branch = branches.current || 'HEAD'; + } catch { + // Use HEAD if we can't get branch + } + + const searchQuery = query.trim(); + const gitArgs = ['grep', '-n', '-I', '--break', '--heading', searchQuery, branch]; + + try { + const grepOutput = await git.raw(gitArgs); + + if (!grepOutput || !grepOutput.trim()) { + return []; + } + + const lines = grepOutput.split('\n'); + let currentFile = ''; + + for (const line of lines) { + if (!line.trim()) { + continue; + } + + if (!line.includes(':')) { + currentFile = line.trim(); + continue; + } + + const colonIndex = line.indexOf(':'); + if (colonIndex > 0 && currentFile) { + const lineNumber = parseInt(line.substring(0, colonIndex), 10); + const content = line.substring(colonIndex + 1); + + if (!isNaN(lineNumber) && content) { + results.push({ + repo, + npub, + file: currentFile, + line: lineNumber, + content: content.trim(), + branch: branch === 'HEAD' ? 'HEAD' : branch + }); + + if (results.length >= limit) { + break; + } + } + } + } + } catch (grepError: any) { + // git grep returns exit code 1 when no matches found + if (grepError.message && grepError.message.includes('exit code 1')) { + return []; + } + throw grepError; + } + } catch (err) { + logger.debug({ error: err, npub, repo, query }, 'Error searching in repo'); + return []; + } + + return results; +} diff --git a/src/routes/api/repos/[npub]/[repo]/code-search/+server.ts b/src/routes/api/repos/[npub]/[repo]/code-search/+server.ts new file mode 100644 index 0000000..f2f21e9 --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/code-search/+server.ts @@ -0,0 +1,134 @@ +/** + * API endpoint for code search within repositories + * Searches file contents across repositories + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { fileManager, nostrClient } from '$lib/services/service-registry.js'; +import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; +import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; +import { handleValidationError } from '$lib/utils/error-handler.js'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import logger from '$lib/services/logger.js'; +import { simpleGit } from 'simple-git'; + +const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT + ? process.env.GIT_REPO_ROOT + : '/repos'; + +export interface CodeSearchResult { + file: string; + line: number; + content: string; + branch: string; + commit?: string; +} + +export const GET: RequestHandler = createRepoGetHandler( + async (context: RepoRequestContext, event: RequestEvent) => { + const query = event.url.searchParams.get('q'); + const branch = event.url.searchParams.get('branch') || 'HEAD'; + const limit = parseInt(event.url.searchParams.get('limit') || '100', 10); + + if (!query || query.trim().length < 2) { + throw handleValidationError('Query must be at least 2 characters', { operation: 'codeSearch', npub: context.npub, repo: context.repo }); + } + + const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); + + // Check if repo exists + if (!existsSync(repoPath)) { + logger.debug({ npub: context.npub, repo: context.repo, query }, 'Code search requested for non-existent repo'); + return json([]); + } + + try { + const git = simpleGit(repoPath); + const results: CodeSearchResult[] = []; + + // Use git grep to search file contents + // git grep -n -I --break --heading -i "query" branch + // -n: show line numbers + // -I: ignore binary files + // --break: add blank line between matches from different files + // --heading: show filename before matches + // -i: case-insensitive (optional, we'll make it configurable) + + const searchQuery = query.trim(); + const gitArgs = ['grep', '-n', '-I', '--break', '--heading', searchQuery, branch]; + + try { + const grepOutput = await git.raw(gitArgs); + + if (!grepOutput || !grepOutput.trim()) { + return json([]); + } + + // Parse git grep output + // Format: + // filename + // line:content + // line:content + // + // filename2 + // line:content + + const lines = grepOutput.split('\n'); + let currentFile = ''; + + for (const line of lines) { + if (!line.trim()) { + continue; // Skip empty lines + } + + // Check if this is a filename (no colon, or starts with a path) + if (!line.includes(':') || line.startsWith('/') || line.match(/^[a-zA-Z0-9_\-./]+$/)) { + // This might be a filename + // Git grep with --heading shows filename on its own line + // But we need to be careful - it could also be content with a colon + // If it doesn't have a colon and looks like a path, it's a filename + if (!line.includes(':')) { + currentFile = line.trim(); + continue; + } + } + + // Parse line:content format + const colonIndex = line.indexOf(':'); + if (colonIndex > 0 && currentFile) { + const lineNumber = parseInt(line.substring(0, colonIndex), 10); + const content = line.substring(colonIndex + 1); + + if (!isNaN(lineNumber) && content) { + results.push({ + file: currentFile, + line: lineNumber, + content: content.trim(), + branch: branch === 'HEAD' ? 'HEAD' : branch + }); + + if (results.length >= limit) { + break; + } + } + } + } + } catch (grepError: any) { + // git grep returns exit code 1 when no matches found, which is not an error + if (grepError.message && grepError.message.includes('exit code 1')) { + // No matches found, return empty array + return json([]); + } + throw grepError; + } + + return json(results); + } catch (err) { + logger.error({ error: err, npub: context.npub, repo: context.repo, query }, 'Error performing code search'); + throw err; + } + }, + { operation: 'codeSearch', requireRepoExists: false, requireRepoAccess: true } +); diff --git a/src/routes/api/repos/[npub]/[repo]/download/+server.ts b/src/routes/api/repos/[npub]/[repo]/download/+server.ts index 3659c36..a1cb928 100644 --- a/src/routes/api/repos/[npub]/[repo]/download/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/download/+server.ts @@ -27,22 +27,78 @@ const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext, event: RequestEvent) => { const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); + let useTempClone = false; + let tempClonePath: string | null = null; - // If repo doesn't exist, try to fetch it on-demand + // If repo doesn't exist, try to do a temporary clone if (!existsSync(repoPath)) { try { // Fetch repository announcement (case-insensitive) with caching const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); const announcement = findRepoAnnouncement(allEvents, context.repo); - const events = announcement ? [announcement] : []; - if (events.length > 0) { - // Download requires the actual repo files, so we can't use API fetching - // Return helpful error message - throw handleNotFoundError( - 'Repository is not cloned locally. To download this repository, privileged users can clone it using the "Clone to Server" button.', - { operation: 'download', npub: context.npub, repo: context.repo } - ); + if (announcement) { + // Try to do a temporary clone for download + logger.info({ npub: context.npub, repo: context.repo }, 'Repository not cloned locally, attempting temporary clone for download'); + + const tempDir = resolve(join(repoRoot, '..', 'temp-clones')); + await mkdir(tempDir, { recursive: true }); + tempClonePath = join(tempDir, `${context.npub}-${context.repo}-${Date.now()}.git`); + + // Extract clone URLs and prepare remote URLs + const { extractCloneUrls } = await import('$lib/utils/nostr-utils.js'); + const cloneUrls = extractCloneUrls(announcement); + const { RepoUrlParser } = await import('$lib/services/git/repo-url-parser.js'); + const urlParser = new RepoUrlParser(repoRoot, 'gitrepublic.com'); + const remoteUrls = urlParser.prepareRemoteUrls(cloneUrls); + + if (remoteUrls.length > 0) { + const { GitRemoteSync } = await import('$lib/services/git/git-remote-sync.js'); + const remoteSync = new GitRemoteSync(repoRoot, 'gitrepublic.com'); + const gitEnv = remoteSync.getGitEnvForUrl(remoteUrls[0]); + const authenticatedUrl = remoteSync.injectAuthToken(remoteUrls[0]); + + const { GIT_CLONE_TIMEOUT_MS } = await import('$lib/config.js'); + + await new Promise((resolve, reject) => { + const cloneProcess = spawn('git', ['clone', '--bare', authenticatedUrl, tempClonePath!], { + env: gitEnv, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + const timeoutId = setTimeout(() => { + cloneProcess.kill('SIGTERM'); + const forceKillTimeout = setTimeout(() => { + if (!cloneProcess.killed) { + cloneProcess.kill('SIGKILL'); + } + }, 5000); + cloneProcess.on('close', () => { + clearTimeout(forceKillTimeout); + }); + reject(new Error(`Git clone operation timed out after ${GIT_CLONE_TIMEOUT_MS}ms`)); + }, GIT_CLONE_TIMEOUT_MS); + + let stderr = ''; + cloneProcess.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + cloneProcess.on('close', (code) => { + clearTimeout(timeoutId); + if (code === 0) { + logger.info({ npub: context.npub, repo: context.repo, tempPath: tempClonePath }, 'Successfully created temporary clone'); + useTempClone = true; + resolve(); + } else { + reject(new Error(`Git clone failed with code ${code}: ${stderr}`)); + } + }); + cloneProcess.on('error', reject); + }); + } else { + throw new Error('No remote clone URLs available'); + } } else { throw handleNotFoundError( 'Repository announcement not found in Nostr', @@ -50,6 +106,11 @@ export const GET: RequestHandler = createRepoGetHandler( ); } } catch (err) { + // Clean up temp clone if it was created + if (tempClonePath && existsSync(tempClonePath)) { + await rm(tempClonePath, { recursive: true, force: true }).catch(() => {}); + } + // Check if repo was created by another concurrent request if (existsSync(repoPath)) { // Repo exists now, clear cache and continue with normal flow @@ -57,15 +118,18 @@ export const GET: RequestHandler = createRepoGetHandler( } else { // If fetching fails, return 404 throw handleNotFoundError( - 'Repository not found', + err instanceof Error ? err.message : 'Repository not found', { operation: 'download', npub: context.npub, repo: context.repo } ); } } } - // Double-check repo exists (should be true if we got here) - if (!existsSync(repoPath)) { + // Use temp clone path if we created one, otherwise use regular repo path + const sourceRepoPath = useTempClone && tempClonePath ? tempClonePath : repoPath; + + // Double-check source repo exists + if (!existsSync(sourceRepoPath)) { throw handleNotFoundError( 'Repository not found', { operation: 'download', npub: context.npub, repo: context.repo } @@ -77,21 +141,41 @@ export const GET: RequestHandler = createRepoGetHandler( // If ref is a branch name, validate it exists or use default branch if (ref !== 'HEAD' && !ref.startsWith('refs/')) { - // Security: Validate ref to prevent command injection - if (!isValidBranchName(ref)) { - throw error(400, 'Invalid ref format'); - } + // Check if ref is a commit hash (40-character hex string) + const isCommitHash = /^[0-9a-f]{40}$/i.test(ref); - // Validate branch exists or use default - try { - const branches = await fileManager.getBranches(context.npub, context.repo); - if (!branches.includes(ref)) { - // Branch doesn't exist, use default branch - ref = await fileManager.getDefaultBranch(context.npub, context.repo); + if (isCommitHash) { + // Commit hash is valid, use it directly + // Git will validate the commit exists when we try to use it + } else { + // Security: Validate ref to prevent command injection + if (!isValidBranchName(ref)) { + throw error(400, 'Invalid ref format'); + } + + // Check if it's a tag first (tags are also valid refs) + let isTag = false; + try { + const tags = await fileManager.getTags(context.npub, context.repo); + isTag = tags.some(t => t.name === ref); + } catch { + // If we can't get tags, continue with branch check } - } catch { - // If we can't get branches, fall back to HEAD - ref = 'HEAD'; + + if (!isTag) { + // Not a tag, validate branch exists or use default + try { + const branches = await fileManager.getBranches(context.npub, context.repo); + if (!branches.includes(ref)) { + // Branch doesn't exist, use default branch + ref = await fileManager.getDefaultBranch(context.npub, context.repo); + } + } catch { + // If we can't get branches, fall back to HEAD + ref = 'HEAD'; + } + } + // If it's a tag, use it directly (git accepts tag names as refs) } } @@ -130,7 +214,7 @@ export const GET: RequestHandler = createRepoGetHandler( // Clone repository using simple-git (safer than shell commands) const git = simpleGit(); - await git.clone(repoPath, workDir); + await git.clone(sourceRepoPath, workDir); // Checkout specific ref if not HEAD if (ref !== 'HEAD') { @@ -215,6 +299,10 @@ export const GET: RequestHandler = createRepoGetHandler( // Clean up using fs/promises await rm(workDir, { recursive: true, force: true }).catch(() => {}); await rm(archivePath, { force: true }).catch(() => {}); + // Clean up temp clone if we created one + if (useTempClone && tempClonePath && existsSync(tempClonePath)) { + await rm(tempClonePath, { recursive: true, force: true }).catch(() => {}); + } // Return archive return new Response(archiveBuffer, { @@ -228,6 +316,10 @@ export const GET: RequestHandler = createRepoGetHandler( // Clean up on error using fs/promises await rm(workDir, { recursive: true, force: true }).catch(() => {}); await rm(archivePath, { force: true }).catch(() => {}); + // Clean up temp clone if we created one + if (useTempClone && tempClonePath && existsSync(tempClonePath)) { + await rm(tempClonePath, { recursive: true, force: true }).catch(() => {}); + } const sanitizedError = sanitizeError(archiveError); logger.error({ error: sanitizedError, npub: context.npub, repo: context.repo, ref, format }, 'Error creating archive'); throw archiveError; diff --git a/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts b/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts index d475ff3..d704652 100644 --- a/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts @@ -9,6 +9,7 @@ import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { verifyEvent } from 'nostr-tools'; import type { NostrEvent } from '$lib/types/nostr.js'; +import { KIND } from '$lib/types/nostr.js'; import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; @@ -41,7 +42,7 @@ export const GET: RequestHandler = createRepoGetHandler( ); // Also get top-level comments on the PR - const prComments = await highlightsService.getCommentsForPR(prId); + const prComments = await highlightsService.getCommentsForTarget(prId, KIND.PULL_REQUEST); return json({ highlights, diff --git a/src/routes/api/repos/[npub]/[repo]/patches/[patchId]/apply/+server.ts b/src/routes/api/repos/[npub]/[repo]/patches/[patchId]/apply/+server.ts new file mode 100644 index 0000000..493c790 --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/patches/[patchId]/apply/+server.ts @@ -0,0 +1,160 @@ +/** + * API endpoint for applying patches + * Only maintainers and owners can apply patches + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { fileManager, nostrClient } from '$lib/services/service-registry.js'; +import { 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 { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; +import { KIND } from '$lib/types/nostr.js'; +import logger from '$lib/services/logger.js'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import { writeFile, unlink } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join as pathJoin } from 'path'; +import { spawn } from 'child_process'; +import { promisify } from 'util'; + +const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT + ? process.env.GIT_REPO_ROOT + : '/repos'; + +export const POST: RequestHandler = withRepoValidation( + async ({ repoContext, requestContext, event }) => { + const { patchId } = event.params; + const body = await event.request.json(); + const { branch = 'main', commitMessage } = body; + + if (!patchId) { + throw handleValidationError('Missing patchId', { operation: 'applyPatch', npub: repoContext.npub, repo: repoContext.repo }); + } + + // Check if user is maintainer or owner + const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); + const isMaintainer = await maintainerService.isMaintainer(requestContext.userPubkeyHex || '', repoContext.repoOwnerPubkey, repoContext.repo); + + if (!isMaintainer && requestContext.userPubkeyHex !== repoContext.repoOwnerPubkey) { + throw handleApiError(new Error('Only repository owners and maintainers can apply patches'), { operation: 'applyPatch', npub: repoContext.npub, repo: repoContext.repo }, 'Unauthorized'); + } + + const repoPath = join(repoRoot, repoContext.npub, `${repoContext.repo}.git`); + + if (!existsSync(repoPath)) { + throw handleApiError(new Error('Repository not found locally'), { operation: 'applyPatch', npub: repoContext.npub, repo: repoContext.repo }, 'Repository not found'); + } + + try { + // Fetch the patch event + const patchEvents = await nostrClient.fetchEvents([ + { + kinds: [KIND.PATCH], + ids: [patchId], + limit: 1 + } + ]); + + if (patchEvents.length === 0) { + throw handleApiError(new Error('Patch not found'), { operation: 'applyPatch', npub: repoContext.npub, repo: repoContext.repo }, 'Patch not found'); + } + + const patchEvent = patchEvents[0]; + const patchContent = patchEvent.content; + + if (!patchContent || !patchContent.trim()) { + throw handleApiError(new Error('Patch content is empty'), { operation: 'applyPatch', npub: repoContext.npub, repo: repoContext.repo }, 'Invalid patch'); + } + + // Create temporary patch file + const tmpPatchFile = pathJoin(tmpdir(), `patch-${patchId}-${Date.now()}.patch`); + await writeFile(tmpPatchFile, patchContent, 'utf-8'); + + try { + // Apply patch using git apply + const { simpleGit } = await import('simple-git'); + const git = simpleGit(repoPath); + + // Checkout the target branch + await git.checkout(branch); + + // Apply the patch + await new Promise((resolve, reject) => { + const applyProcess = spawn('git', ['apply', '--check', tmpPatchFile], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stderr = ''; + applyProcess.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + applyProcess.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Patch check failed: ${stderr}`)); + } else { + resolve(); + } + }); + + applyProcess.on('error', reject); + }); + + // Actually apply the patch + await new Promise((resolve, reject) => { + const applyProcess = spawn('git', ['apply', tmpPatchFile], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stderr = ''; + applyProcess.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + applyProcess.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Patch apply failed: ${stderr}`)); + } else { + resolve(); + } + }); + + applyProcess.on('error', reject); + }); + + // Stage all changes + await git.add('.'); + + // Commit the changes + const finalCommitMessage = commitMessage || `Apply patch ${patchId.substring(0, 8)}`; + await git.commit(finalCommitMessage); + + // Get the commit hash + const commitHash = await git.revparse(['HEAD']); + + return json({ + success: true, + commitHash: commitHash.trim(), + message: 'Patch applied successfully' + }); + } finally { + // Clean up temporary patch file + try { + await unlink(tmpPatchFile); + } catch (unlinkErr) { + logger.warn({ error: unlinkErr, tmpPatchFile }, 'Failed to delete temporary patch file'); + } + } + } catch (err) { + logger.error({ error: err, npub: repoContext.npub, repo: repoContext.repo, patchId }, 'Error applying patch'); + throw err; + } + }, + { operation: 'applyPatch', requireRepoExists: true, requireRepoAccess: true } +); diff --git a/src/routes/api/repos/[npub]/[repo]/prs/[prId]/merge/+server.ts b/src/routes/api/repos/[npub]/[repo]/prs/[prId]/merge/+server.ts new file mode 100644 index 0000000..3204447 --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/prs/[prId]/merge/+server.ts @@ -0,0 +1,156 @@ +/** + * API endpoint for merging pull requests + * Only maintainers and owners can merge PRs + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { fileManager, nostrClient, prsService } from '$lib/services/service-registry.js'; +import { 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 { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; +import { KIND } from '$lib/types/nostr.js'; +import logger from '$lib/services/logger.js'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import { simpleGit } from 'simple-git'; + +const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT + ? process.env.GIT_REPO_ROOT + : '/repos'; + +export const POST: RequestHandler = withRepoValidation( + async ({ repoContext, requestContext, event }) => { + const { prId } = event.params; + const body = await event.request.json(); + const { targetBranch = 'main', mergeCommitMessage, mergeStrategy = 'merge' } = body; + + if (!prId) { + throw handleValidationError('Missing prId', { operation: 'mergePR', npub: repoContext.npub, repo: repoContext.repo }); + } + + // Check if user is maintainer or owner + const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); + const isMaintainer = await maintainerService.isMaintainer(requestContext.userPubkeyHex || '', repoContext.repoOwnerPubkey, repoContext.repo); + + if (!isMaintainer && requestContext.userPubkeyHex !== repoContext.repoOwnerPubkey) { + throw handleApiError(new Error('Only repository owners and maintainers can merge pull requests'), { operation: 'mergePR', npub: repoContext.npub, repo: repoContext.repo }, 'Unauthorized'); + } + + const repoPath = join(repoRoot, repoContext.npub, `${repoContext.repo}.git`); + + if (!existsSync(repoPath)) { + throw handleApiError(new Error('Repository not found locally'), { operation: 'mergePR', npub: repoContext.npub, repo: repoContext.repo }, 'Repository not found'); + } + + try { + // Fetch the PR event + const prEvents = await nostrClient.fetchEvents([ + { + kinds: [KIND.PULL_REQUEST], + ids: [prId], + limit: 1 + } + ]); + + if (prEvents.length === 0) { + throw handleApiError(new Error('Pull request not found'), { operation: 'mergePR', npub: repoContext.npub, repo: repoContext.repo }, 'Pull request not found'); + } + + const prEvent = prEvents[0]; + + // Get commit ID from PR + const commitTag = prEvent.tags.find(t => t[0] === 'c'); + if (!commitTag || !commitTag[1]) { + throw handleApiError(new Error('Pull request does not have a commit ID'), { operation: 'mergePR', npub: repoContext.npub, repo: repoContext.repo }, 'Invalid pull request'); + } + + const commitId = commitTag[1]; + + // Get branch name if available + const branchTag = prEvent.tags.find(t => t[0] === 'branch-name'); + const sourceBranch = branchTag?.[1] || `pr-${prId.substring(0, 8)}`; + + const git = simpleGit(repoPath); + + // Checkout target branch + await git.checkout(targetBranch); + + // Fetch the commit (in case it's from a remote) + try { + await git.fetch(['--all']); + } catch (fetchErr) { + logger.debug({ error: fetchErr }, 'Fetch failed, continuing with local merge'); + } + + // Check if commit exists + try { + await git.show([commitId]); + } catch (showErr) { + throw handleApiError(new Error(`Commit ${commitId} not found in repository`), { operation: 'mergePR', npub: repoContext.npub, repo: repoContext.repo }, 'Commit not found'); + } + + let mergeCommitHash: string; + + if (mergeStrategy === 'squash') { + // Squash merge: create a single commit with all changes + await git.raw(['merge', '--squash', commitId]); + await git.add('.'); + + const finalMessage = mergeCommitMessage || `Merge PR ${prId.substring(0, 8)}\n\n${prEvent.content || ''}`; + await git.commit(finalMessage); + + mergeCommitHash = (await git.revparse(['HEAD'])).trim(); + } else if (mergeStrategy === 'rebase') { + // Rebase merge: rebase the PR branch onto target branch + // First, create a temporary branch from the commit + const tempBranch = `temp-merge-${Date.now()}`; + await git.checkout(['-b', tempBranch, commitId]); + + // Rebase onto target branch + await git.rebase([targetBranch]); + + // Switch back to target branch and merge + await git.checkout(targetBranch); + await git.merge([tempBranch, '--no-ff']); + + mergeCommitHash = (await git.revparse(['HEAD'])).trim(); + + // Clean up temporary branch + try { + await git.branch(['-D', tempBranch]); + } catch (cleanupErr) { + logger.warn({ error: cleanupErr }, 'Failed to delete temporary branch'); + } + } else { + // Regular merge + const finalMessage = mergeCommitMessage || `Merge PR ${prId.substring(0, 8)}`; + await git.merge([commitId, '-m', finalMessage]); + mergeCommitHash = (await git.revparse(['HEAD'])).trim(); + } + + // Update PR status to merged + const prAuthor = prEvent.pubkey; + await prsService.updatePRStatus( + prId, + prAuthor, + repoContext.repoOwnerPubkey, + repoContext.repo, + 'merged', + mergeCommitHash + ); + + return json({ + success: true, + commitHash: mergeCommitHash, + message: 'Pull request merged successfully' + }); + } catch (err) { + logger.error({ error: err, npub: repoContext.npub, repo: repoContext.repo, prId }, 'Error merging pull request'); + throw err; + } + }, + { operation: 'mergePR', requireRepoExists: true, requireRepoAccess: true } +); diff --git a/src/routes/api/repos/[npub]/[repo]/releases/+server.ts b/src/routes/api/repos/[npub]/[repo]/releases/+server.ts new file mode 100644 index 0000000..ba44c74 --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/releases/+server.ts @@ -0,0 +1,97 @@ +/** + * API endpoint for Releases (kind 1642) + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { releasesService, nostrClient } 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 { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; +import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js'; +import logger from '$lib/services/logger.js'; + +export const GET: RequestHandler = createRepoGetHandler( + async (context: RepoRequestContext) => { + const releases = await releasesService.getReleases(context.repoOwnerPubkey, context.repo); + return json(releases); + }, + { operation: 'getReleases', requireRepoExists: false, requireRepoAccess: false } // Releases are stored in Nostr +); + +export const POST: RequestHandler = withRepoValidation( + async ({ repoContext, requestContext, event }) => { + const body = await event.request.json(); + const { tagName, tagHash, releaseNotes, isDraft, isPrerelease } = body; + + if (!tagName || !tagHash) { + throw handleValidationError('Missing required fields: tagName, tagHash', { operation: 'createRelease', npub: repoContext.npub, repo: repoContext.repo }); + } + + // Check if user is maintainer or owner + const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); + const isMaintainer = await maintainerService.isMaintainer(requestContext.userPubkeyHex || '', repoContext.repoOwnerPubkey, repoContext.repo); + + if (!isMaintainer && requestContext.userPubkeyHex !== repoContext.repoOwnerPubkey) { + throw handleApiError(new Error('Only repository owners and maintainers can create releases'), { operation: 'createRelease', npub: repoContext.npub, repo: repoContext.repo }, 'Unauthorized'); + } + + // Create release + const release = await releasesService.createRelease( + repoContext.repoOwnerPubkey, + repoContext.repo, + tagName, + tagHash, + releaseNotes || '', + isDraft || false, + isPrerelease || false + ); + + // Forward to messaging platforms if user has unlimited access and preferences configured + if (requestContext.userPubkeyHex) { + forwardEventIfEnabled(release, requestContext.userPubkeyHex) + .catch(err => { + // Log but don't fail the request - forwarding is optional + logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to forward event to messaging platforms'); + }); + } + + return json({ success: true, event: release }); + }, + { operation: 'createRelease', requireRepoAccess: false } +); + +export const PATCH: RequestHandler = withRepoValidation( + async ({ repoContext, requestContext, event }) => { + const body = await event.request.json(); + const { releaseId, tagName, releaseNotes, isDraft, isPrerelease } = body; + + if (!releaseId || !tagName) { + throw handleValidationError('Missing required fields: releaseId, tagName', { operation: 'updateRelease', npub: repoContext.npub, repo: repoContext.repo }); + } + + // Check if user is maintainer or owner + const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); + const isMaintainer = await maintainerService.isMaintainer(requestContext.userPubkeyHex || '', repoContext.repoOwnerPubkey, repoContext.repo); + + if (!isMaintainer && requestContext.userPubkeyHex !== repoContext.repoOwnerPubkey) { + throw handleApiError(new Error('Only repository owners and maintainers can update releases'), { operation: 'updateRelease', npub: repoContext.npub, repo: repoContext.repo }, 'Unauthorized'); + } + + // Update release + const release = await releasesService.updateRelease( + releaseId, + repoContext.repoOwnerPubkey, + repoContext.repo, + tagName, + releaseNotes || '', + isDraft || false, + isPrerelease || false + ); + + return json({ success: true, event: release }); + }, + { operation: 'updateRelease', requireRepoAccess: false } +); diff --git a/src/routes/api/repos/[npub]/[repo]/tags/+server.ts b/src/routes/api/repos/[npub]/[repo]/tags/+server.ts index 32f0599..3a25d86 100644 --- a/src/routes/api/repos/[npub]/[repo]/tags/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/tags/+server.ts @@ -39,7 +39,8 @@ export const GET: RequestHandler = createRepoGetHandler( const tags = apiData.tags.map(t => ({ name: t.name, hash: t.sha, - message: t.message + message: t.message, + date: undefined // API fallback doesn't provide date })); return json(tags); } diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index f24a7a9..cee98bb 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -79,7 +79,7 @@ let userPubkey = $state(null); let userPubkeyHex = $state(null); let showCommitDialog = $state(false); - let activeTab = $state<'files' | 'history' | 'tags' | 'issues' | 'prs' | 'docs' | 'discussions' | 'patches'>('files'); + let activeTab = $state<'files' | 'history' | 'tags' | 'issues' | 'prs' | 'docs' | 'discussions' | 'patches' | 'releases' | 'code-search'>('files'); let showRepoMenu = $state(false); // Tabs will be defined as derived after issues and prs are declared @@ -223,7 +223,8 @@ let diffData = $state>([]); // Tags - let tags = $state>([]); + let tags = $state>([]); + let selectedTag = $state(null); let showCreateTagDialog = $state(false); let newTagName = $state(''); let newTagMessage = $state(''); @@ -352,7 +353,7 @@ let selectedPR = $state(null); // Tabs menu - defined after issues and prs - // Order: Files, Issues, PRs, Patches, Discussion, History, Tags, Docs + // Order: Files, Issues, PRs, Patches, Discussion, History, Tags, Code Search, Docs // Show tabs that require cloned repo when repo is cloned OR API fallback is available const tabs = $derived.by(() => { const allTabs = [ @@ -363,6 +364,7 @@ { id: 'discussions', label: 'Discussions', icon: '/icons/message-circle.svg', requiresClone: false }, { id: 'history', label: 'Commit History', icon: '/icons/git-commit.svg', requiresClone: true }, { id: 'tags', label: 'Tags', icon: '/icons/tag.svg', requiresClone: true }, + { id: 'code-search', label: 'Code Search', icon: '/icons/search.svg', requiresClone: true }, { id: 'docs', label: 'Docs', icon: '/icons/book.svg', requiresClone: false } ]; @@ -416,6 +418,37 @@ let replyContent = $state(''); let creatingReply = $state(false); + // Releases + let releases = $state>([]); + let loadingReleases = $state(false); + let showCreateReleaseDialog = $state(false); + let newReleaseTagName = $state(''); + let newReleaseTagHash = $state(''); + let newReleaseNotes = $state(''); + let newReleaseIsDraft = $state(false); + let newReleaseIsPrerelease = $state(false); + let creatingRelease = $state(false); + + // Code Search + let codeSearchQuery = $state(''); + let codeSearchResults = $state>([]); + let loadingCodeSearch = $state(false); + let codeSearchScope = $state<'repo' | 'all'>('repo'); + // Discussions let selectedDiscussion = $state(null); let discussions = $state 0 && !selectedTag) { + selectedTag = tags[0].name; + } } } catch (err) { console.error('Failed to load tags:', err); @@ -4031,6 +4068,123 @@ } } + async function loadReleases() { + if (repoNotFound) return; + loadingReleases = true; + try { + const response = await fetch(`/api/repos/${npub}/${repo}/releases`, { + headers: buildApiHeaders() + }); + if (response.ok) { + const data = await response.json(); + releases = data.map((release: any) => ({ + id: release.id, + tagName: release.tags.find((t: string[]) => t[0] === 'tag')?.[1] || '', + tagHash: release.tags.find((t: string[]) => t[0] === 'r' && t[2] === 'tag')?.[1], + releaseNotes: release.content || '', + isDraft: release.tags.some((t: string[]) => t[0] === 'draft' && t[1] === 'true'), + isPrerelease: release.tags.some((t: string[]) => t[0] === 'prerelease' && t[1] === 'true'), + created_at: release.created_at, + pubkey: release.pubkey + })); + } + } catch (err) { + console.error('Failed to load releases:', err); + } finally { + loadingReleases = false; + } + } + + async function createRelease() { + if (!newReleaseTagName.trim() || !newReleaseTagHash.trim()) { + alert('Please enter a tag name and tag hash'); + return; + } + + if (!userPubkey) { + alert('Please connect your NIP-07 extension'); + return; + } + + if (!isMaintainer && userPubkeyHex !== repoOwnerPubkeyDerived) { + alert('Only repository owners and maintainers can create releases'); + return; + } + + creatingRelease = true; + error = null; + + try { + const response = await fetch(`/api/repos/${npub}/${repo}/releases`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildApiHeaders() + }, + body: JSON.stringify({ + tagName: newReleaseTagName, + tagHash: newReleaseTagHash, + releaseNotes: newReleaseNotes, + isDraft: newReleaseIsDraft, + isPrerelease: newReleaseIsPrerelease + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to create release'); + } + + showCreateReleaseDialog = false; + newReleaseTagName = ''; + newReleaseTagHash = ''; + newReleaseNotes = ''; + newReleaseIsDraft = false; + newReleaseIsPrerelease = false; + await loadReleases(); + // Reload tags to show release indicator + await loadTags(); + alert('Release created successfully!'); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to create release'; + alert(error); + } finally { + creatingRelease = false; + } + } + + async function performCodeSearch() { + if (!codeSearchQuery.trim() || codeSearchQuery.length < 2) { + codeSearchResults = []; + return; + } + + loadingCodeSearch = true; + error = null; + + try { + const url = codeSearchScope === 'repo' + ? `/api/repos/${npub}/${repo}/code-search?q=${encodeURIComponent(codeSearchQuery.trim())}` + : `/api/code-search?q=${encodeURIComponent(codeSearchQuery.trim())}&repo=${encodeURIComponent(`${npub}/${repo}`)}`; + + const response = await fetch(url, { + headers: buildApiHeaders() + }); + + if (response.ok) { + codeSearchResults = await response.json(); + } else { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to search code'); + } + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to search code'; + codeSearchResults = []; + } finally { + loadingCodeSearch = false; + } + } + async function loadIssues() { loadingIssues = true; error = null; @@ -4437,6 +4591,9 @@ loadCommitHistory(); } else if (activeTab === 'tags') { loadTags(); + loadReleases(); // Load releases to check for tag associations + } else if (activeTab === 'code-search') { + // Code search is performed on demand, not auto-loaded } else if (activeTab === 'issues') { loadIssues(); } else if (activeTab === 'prs') { @@ -4937,6 +5094,55 @@ Show content + {#if tags.length > 0} +
    + {#each tags as tag} + {@const tagHash = tag.hash || ''} + {#if tagHash} +
  • + +
  • + {/if} + {/each} +
+ {:else} +
+

No tags found

+
+ {/if} + + {/if} + + + {#if activeTab === 'code-search' && canViewRepo} + {/if} @@ -5430,24 +5636,131 @@ Show list - {#if tags.length > 0} -
    - {#each tags as tag} - {@const tagHash = tag.hash || ''} - {#if tagHash} -
  • -
    {tag.name}
    -
    {tagHash.slice(0, 7)}
    - {#if tag.message} -
    {tag.message}
    + {#if selectedTag} + {@const tag = tags.find(t => t.name === selectedTag)} + {@const release = releases.find(r => r.tagName === selectedTag)} + {#if tag} +
    +
    +

    {tag.name}

    +
    + Tag: {tag.hash?.slice(0, 7) || 'N/A'} + {#if tag.date} + Created {new Date(tag.date * 1000).toLocaleString()} {/if} -
  • + + Download + Download ZIP + + {#if (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned && !release} + + {/if} + + + {#if tag.message} +
    +

    {tag.message}

    +
    {/if} - {/each} -
+ {#if release} +
+

Release

+
+ {#if release.isDraft} + Draft + {/if} + {#if release.isPrerelease} + Pre-release + {/if} +
+ Released {new Date(release.created_at * 1000).toLocaleDateString()} +
+ {#if release.releaseNotes} +
+ {@html release.releaseNotes.replace(/\n/g, '
')} +
+ {/if} +
+
+ {/if} + + {/if} {:else}
-

No tags found

+

Select a tag from the sidebar to view details

+
+ {/if} + + {/if} + + + {#if activeTab === 'code-search' && canViewRepo} +
+
+ +
+
+
+ e.key === 'Enter' && performCodeSearch()} + class="code-search-input" + /> + + +
+
+ {#if loadingCodeSearch} +
+

Searching...

+
+ {:else if codeSearchResults.length > 0} +
+

Found {codeSearchResults.length} result{codeSearchResults.length !== 1 ? 's' : ''}

+ {#each codeSearchResults as result} +
+
+ {result.file} + Line {result.line} + {#if codeSearchScope === 'all' && 'repo' in result} + {result.repo || npub}/{result.repo || repo} + {/if} +
+
{result.content}
+
+ {/each} +
+ {:else if codeSearchQuery.trim() && !loadingCodeSearch} +
+

No results found

{/if}
@@ -5632,6 +5945,43 @@ #{patch.id.slice(0, 7)} Created {new Date(patch.created_at * 1000).toLocaleString()} + {#if (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned} + + {/if} {#if patch.description && patch.description !== patch.subject}
{patch.description}
@@ -6087,6 +6437,59 @@ {/if} + + {#if showCreateReleaseDialog && userPubkey && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned} + + {/if} + {#if showCreateIssueDialog && userPubkey}