Browse Source

implemented releases and code serach

add contributors to private repos
apply/merge buttons for patches and PRs
highlgihts and comments on patches and prs
added tagged downloads

Nostr-Signature: e822be2b0fbf3285bbedf9d8f9d1692b5503080af17a4d28941a1dc81c96187c 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 70c8b6e499551ce43478116cf694992102a29572d5380cbe3b070a3026bc2c9e35177587712c7414f25d1ca50038c9614479f7758bbdc48f69cc44cd52bf4842
main
Silberengel 3 weeks ago
parent
commit
ac91bcb003
  1. 1
      nostr/commit-signatures.jsonl
  2. 17
      src/lib/components/PRDetail.svelte
  3. 37
      src/lib/services/git/file-manager.ts
  4. 68
      src/lib/services/nostr/highlights-service.ts
  5. 98
      src/lib/services/nostr/maintainer-service.ts
  6. 181
      src/lib/services/nostr/releases-service.ts
  7. 13
      src/lib/services/service-registry.ts
  8. 1
      src/lib/types/nostr.ts
  9. 10
      src/routes/+layout.svelte
  10. 232
      src/routes/api/code-search/+server.ts
  11. 134
      src/routes/api/repos/[npub]/[repo]/code-search/+server.ts
  12. 120
      src/routes/api/repos/[npub]/[repo]/download/+server.ts
  13. 3
      src/routes/api/repos/[npub]/[repo]/highlights/+server.ts
  14. 160
      src/routes/api/repos/[npub]/[repo]/patches/[patchId]/apply/+server.ts
  15. 156
      src/routes/api/repos/[npub]/[repo]/prs/[prId]/merge/+server.ts
  16. 97
      src/routes/api/repos/[npub]/[repo]/releases/+server.ts
  17. 3
      src/routes/api/repos/[npub]/[repo]/tags/+server.ts
  18. 490
      src/routes/repos/[npub]/[repo]/+page.svelte

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

17
src/lib/components/PRDetail.svelte

@ -175,11 +175,12 @@
pr.author, pr.author,
repoOwnerPubkey, repoOwnerPubkey,
repo, repo,
currentFilePath || undefined, KIND.PULL_REQUEST, // targetKind
selectedStartLine, currentFilePath || undefined, // filePath
selectedEndLine, selectedStartLine, // lineStart
selectedEndLine, // lineEnd
undefined, // context undefined, // context
highlightComment.trim() || undefined highlightComment.trim() || undefined // comment
); );
const signedEvent = await signEventWithNIP07(eventTemplate); const signedEvent = await signEventWithNIP07(eventTemplate);
@ -350,15 +351,13 @@
error = null; error = null;
try { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
prId: pr.id,
prAuthor: pr.author,
prCommitId: pr.commitId,
targetBranch: mergeTargetBranch, 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'
}) })
}); });

37
src/lib/services/git/file-manager.ts

@ -46,6 +46,7 @@ export interface Tag {
name: string; name: string;
hash: string; hash: string;
message?: string; message?: string;
date?: number; // Unix timestamp of the commit the tag points to
} }
export class FileManager { export class FileManager {
@ -2268,24 +2269,46 @@ export class FileManager {
for (const tagName of tags.all) { for (const tagName of tags.all) {
try { try {
// Try to get tag message // Get the commit hash the tag points to
const hash = await git.raw(['rev-parse', tagName]);
const commitHash = 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 tagInfo = await git.raw(['cat-file', '-p', tagName]);
const messageMatch = tagInfo.match(/^(.+)$/m); const messageMatch = tagInfo.match(/^(.+)$/m);
const hash = await git.raw(['rev-parse', tagName]);
tagList.push({ tagList.push({
name: tagName, name: tagName,
hash: hash.trim(), hash: commitHash,
message: messageMatch ? messageMatch[1] : undefined message: messageMatch ? messageMatch[1] : undefined,
date: commitDate
}); });
} catch { } catch {
// Lightweight tag // Lightweight tag - no message
const hash = await git.raw(['rev-parse', tagName]);
tagList.push({ tagList.push({
name: tagName, name: tagName,
hash: hash.trim() hash: commitHash,
date: commitDate
}); });
} }
} catch (err) {
// If we can't process this tag, skip it
logger.warn({ error: err, tagName }, 'Error processing tag, skipping');
}
} }
return tagList; return tagList;

68
src/lib/services/nostr/highlights-service.ts

@ -57,12 +57,12 @@ export class HighlightsService {
/** /**
* Get PR address (a tag format for PR) * Get PR address (a tag format for PR)
*/ */
private getPRAddress(prId: string, prAuthor: string, repoOwnerPubkey: string, repoId: string): string { private getTargetAddress(targetId: string, targetAuthor: string, repoOwnerPubkey: string, repoId: string, targetKind: typeof KIND.PULL_REQUEST | typeof KIND.PATCH): string {
return `${KIND.PULL_REQUEST}:${prAuthor}:${repoId}`; return `${targetKind}:${targetAuthor}:${repoId}`;
} }
/** /**
* Fetch highlights for a pull request * Fetch highlights for a pull request or patch
*/ */
async getHighlightsForPR( async getHighlightsForPR(
prId: string, prId: string,
@ -70,22 +70,47 @@ export class HighlightsService {
repoOwnerPubkey: string, repoOwnerPubkey: string,
repoId: string repoId: string
): Promise<HighlightWithComments[]> { ): Promise<HighlightWithComments[]> {
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<HighlightWithComments[]> {
return this.getHighlightsForTarget(patchId, patchAuthor, repoOwnerPubkey, repoId, KIND.PATCH);
}
// Fetch highlights that reference this PR /**
* 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<HighlightWithComments[]> {
const targetAddress = this.getTargetAddress(targetId, targetAuthor, repoOwnerPubkey, repoId, targetKind);
// Fetch highlights that reference this target
const highlights = await this.nostrClient.fetchEvents([ const highlights = await this.nostrClient.fetchEvents([
{ {
kinds: [KIND.HIGHLIGHT], kinds: [KIND.HIGHLIGHT],
'#a': [prAddress], '#a': [targetAddress],
limit: 100 limit: 100
} }
]) as Highlight[]; ]) 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([ const highlightsByEvent = await this.nostrClient.fetchEvents([
{ {
kinds: [KIND.HIGHLIGHT], kinds: [KIND.HIGHLIGHT],
'#e': [prId], '#e': [targetId],
limit: 100 limit: 100
} }
]) as Highlight[]; ]) 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<Comment[]> { async getCommentsForTarget(targetId: string, targetKind: typeof KIND.PULL_REQUEST | typeof KIND.PATCH = KIND.PULL_REQUEST): Promise<Comment[]> {
const comments = await this.nostrClient.fetchEvents([ const comments = await this.nostrClient.fetchEvents([
{ {
kinds: [KIND.COMMENT], 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 limit: 100
} }
]) as NostrEvent[]; ]) as NostrEvent[];
@ -265,10 +291,11 @@ export class HighlightsService {
* Create a highlight event template * Create a highlight event template
* *
* @param highlightedContent - The selected code/text content * @param highlightedContent - The selected code/text content
* @param prId - Pull request event ID * @param targetId - Pull request or patch event ID
* @param prAuthor - PR author pubkey * @param targetAuthor - PR/patch author pubkey
* @param repoOwnerPubkey - Repository owner pubkey * @param repoOwnerPubkey - Repository owner pubkey
* @param repoId - Repository identifier * @param repoId - Repository identifier
* @param targetKind - Kind of target (PULL_REQUEST or PATCH)
* @param filePath - Path to the file being highlighted * @param filePath - Path to the file being highlighted
* @param lineStart - Starting line number (optional) * @param lineStart - Starting line number (optional)
* @param lineEnd - Ending line number (optional) * @param lineEnd - Ending line number (optional)
@ -277,23 +304,24 @@ export class HighlightsService {
*/ */
createHighlightEvent( createHighlightEvent(
highlightedContent: string, highlightedContent: string,
prId: string, targetId: string,
prAuthor: string, targetAuthor: string,
repoOwnerPubkey: string, repoOwnerPubkey: string,
repoId: string, repoId: string,
targetKind: typeof KIND.PULL_REQUEST | typeof KIND.PATCH = KIND.PULL_REQUEST,
filePath?: string, filePath?: string,
lineStart?: number, lineStart?: number,
lineEnd?: number, lineEnd?: number,
context?: string, context?: string,
comment?: string comment?: string
): Omit<NostrEvent, 'sig' | 'id'> { ): Omit<NostrEvent, 'sig' | 'id'> {
const prAddress = `${KIND.PULL_REQUEST}:${prAuthor}:${repoId}`; const targetAddress = `${targetKind}:${targetAuthor}:${repoId}`;
const tags: string[][] = [ const tags: string[][] = [
['a', prAddress], // Reference to PR ['a', targetAddress], // Reference to PR or patch
['e', prId], // PR event ID ['e', targetId], // PR/patch event ID
['P', prAuthor], // PR author ['P', targetAuthor], // PR/patch author
['K', KIND.PULL_REQUEST.toString()], // Root kind ['K', targetKind.toString()], // Root kind
]; ];
// Add file path and line numbers if provided // Add file path and line numbers if provided

98
src/lib/services/nostr/maintainer-service.ts

@ -41,12 +41,13 @@ export interface RepoPrivacyInfo {
isPrivate: boolean; isPrivate: boolean;
owner: string; owner: string;
maintainers: string[]; maintainers: string[];
contributors: string[];
} }
export class MaintainerService { export class MaintainerService {
private nostrClient: NostrClient; private nostrClient: NostrClient;
private ownershipTransferService: OwnershipTransferService; private ownershipTransferService: OwnershipTransferService;
private cache: Map<string, { maintainers: string[]; owner: string; timestamp: number; isPrivate: boolean }> = new Map(); private cache: Map<string, { maintainers: string[]; contributors: string[]; owner: string; timestamp: number; isPrivate: boolean }> = new Map();
private cacheTTL = 5 * 60 * 1000; // 5 minutes private cacheTTL = 5 * 60 * 1000; // 5 minutes
constructor(relays: string[]) { 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 cacheKey = `${repoOwnerPubkey}:${repoId}`;
const cached = this.cache.get(cacheKey); const cached = this.cache.get(cacheKey);
// Return cached if still valid // Return cached if still valid
if (cached && Date.now() - cached.timestamp < this.cacheTTL) { 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 { try {
@ -87,7 +88,7 @@ export class MaintainerService {
if (events.length === 0) { if (events.length === 0) {
// If no announcement found, only the owner is a maintainer, and repo is public by default // 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() }); this.cache.set(cacheKey, { ...result, timestamp: Date.now() });
return result; return result;
} }
@ -105,6 +106,7 @@ export class MaintainerService {
const currentOwner = await fileManager.getCurrentOwnerFromRepo(npub, repoId) || announcement.pubkey; const currentOwner = await fileManager.getCurrentOwnerFromRepo(npub, repoId) || announcement.pubkey;
const maintainers: string[] = [currentOwner]; // Current owner is always a maintainer const maintainers: string[] = [currentOwner]; // Current owner is always a maintainer
const contributors: string[] = []; // Contributors can view but not modify
// Extract maintainers from tags // Extract maintainers from tags
// Maintainers tag format: ['maintainers', 'pubkey1', 'pubkey2', 'pubkey3', ...] // 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() }); this.cache.set(cacheKey, { ...result, timestamp: Date.now() });
return result; return result;
} catch (error) { } catch (error) {
const logger = await getLogger(); const logger = await getLogger();
logger.error({ error, repoOwnerPubkey, repoId }, 'Error fetching maintainers'); logger.error({ error, repoOwnerPubkey, repoId }, 'Error fetching maintainers');
// Fallback: only owner is maintainer, repo is public by default // 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() }); this.cache.set(cacheKey, { ...result, timestamp: Date.now() });
return result; return result;
} }
@ -161,10 +197,10 @@ export class MaintainerService {
/** /**
* Check if a user can view a repository * Check if a user can view a repository
* Public repos: anyone can view * 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<boolean> { async canView(userPubkey: string | null, repoOwnerPubkey: string, repoId: string): Promise<boolean> {
const { isPrivate, maintainers, owner } = await this.getMaintainers(repoOwnerPubkey, repoId); const { isPrivate, maintainers, contributors, owner } = await this.getMaintainers(repoOwnerPubkey, repoId);
const logger = await getLogger(); const logger = await getLogger();
logger.debug({ logger.debug({
@ -173,7 +209,8 @@ export class MaintainerService {
currentOwner: owner.substring(0, 16) + '...', currentOwner: owner.substring(0, 16) + '...',
repoId, repoId,
userPubkey: userPubkey ? userPubkey.substring(0, 16) + '...' : null, userPubkey: userPubkey ? userPubkey.substring(0, 16) + '...' : null,
maintainerCount: maintainers.length maintainerCount: maintainers.length,
contributorCount: contributors.length
}, 'canView check'); }, 'canView check');
// Public repos are viewable by anyone // Public repos are viewable by anyone
@ -202,33 +239,38 @@ export class MaintainerService {
// Normalize to lowercase for comparison // Normalize to lowercase for comparison
userPubkeyHex = userPubkeyHex.toLowerCase(); userPubkeyHex = userPubkeyHex.toLowerCase();
const normalizedMaintainers = maintainers.map(m => m.toLowerCase()); const normalizedMaintainers = maintainers.map(m => m.toLowerCase());
const normalizedContributors = contributors.map(c => c.toLowerCase());
const normalizedOwner = owner.toLowerCase(); const normalizedOwner = owner.toLowerCase();
logger.debug({ logger.debug({
userPubkeyHex: userPubkeyHex.substring(0, 16) + '...', userPubkeyHex: userPubkeyHex.substring(0, 16) + '...',
normalizedOwner: normalizedOwner.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'); }, 'Comparing pubkeys');
// Check if user is in maintainers list OR is the current owner // Check if user is in maintainers list, contributors list, OR is the current owner
const hasAccess = normalizedMaintainers.includes(userPubkeyHex) || userPubkeyHex === normalizedOwner; const hasAccess = normalizedMaintainers.includes(userPubkeyHex) ||
normalizedContributors.includes(userPubkeyHex) ||
userPubkeyHex === normalizedOwner;
if (!hasAccess) { if (!hasAccess) {
logger.debug({ logger.debug({
userPubkeyHex: userPubkeyHex.substring(0, 16) + '...', userPubkeyHex: userPubkeyHex.substring(0, 16) + '...',
currentOwner: normalizedOwner.substring(0, 16) + '...', currentOwner: normalizedOwner.substring(0, 16) + '...',
repoId, repoId,
maintainers: normalizedMaintainers.map(m => m.substring(0, 16) + '...') maintainers: normalizedMaintainers.map(m => m.substring(0, 16) + '...'),
}, 'Access denied: user not in maintainers list and not current owner'); contributors: normalizedContributors.map(c => c.substring(0, 16) + '...')
}, 'Access denied: user not in maintainers/contributors list and not current owner');
} else { } else {
logger.debug({ logger.debug({
userPubkeyHex: userPubkeyHex.substring(0, 16) + '...', userPubkeyHex: userPubkeyHex.substring(0, 16) + '...',
currentOwner: normalizedOwner.substring(0, 16) + '...', currentOwner: normalizedOwner.substring(0, 16) + '...',
repoId 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; return hasAccess;
} }
@ -236,8 +278,26 @@ export class MaintainerService {
* Get privacy info for a repository * Get privacy info for a repository
*/ */
async getPrivacyInfo(repoOwnerPubkey: string, repoId: string): Promise<RepoPrivacyInfo> { async getPrivacyInfo(repoOwnerPubkey: string, repoId: string): Promise<RepoPrivacyInfo> {
const { owner, maintainers, isPrivate } = await this.getMaintainers(repoOwnerPubkey, repoId); const { owner, maintainers, contributors, isPrivate } = await this.getMaintainers(repoOwnerPubkey, repoId);
return { isPrivate, owner, maintainers }; 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<boolean> {
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());
} }
/** /**

181
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<Release[]> {
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<Release | null> {
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<Release> {
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<Release> {
// 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
};
}
}

13
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 { ForkCountService } from './nostr/fork-count-service.js';
import { PRsService } from './nostr/prs-service.js'; import { PRsService } from './nostr/prs-service.js';
import { HighlightsService } from './nostr/highlights-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'; import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } from '../config.js';
// Get repo root from environment or use default // Get repo root from environment or use default
@ -33,6 +34,7 @@ let _issuesService: IssuesService | null = null;
let _forkCountService: ForkCountService | null = null; let _forkCountService: ForkCountService | null = null;
let _prsService: PRsService | null = null; let _prsService: PRsService | null = null;
let _highlightsService: HighlightsService | null = null; let _highlightsService: HighlightsService | null = null;
let _releasesService: ReleasesService | null = null;
/** /**
* Get singleton FileManager instance * Get singleton FileManager instance
@ -144,6 +146,16 @@ export function getHighlightsService(): HighlightsService {
return _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) // Convenience exports for direct access (common pattern)
export const fileManager = getFileManager(); export const fileManager = getFileManager();
export const repoManager = getRepoManager(); export const repoManager = getRepoManager();
@ -156,3 +168,4 @@ export const issuesService = getIssuesService();
export const forkCountService = getForkCountService(); export const forkCountService = getForkCountService();
export const prsService = getPRsService(); export const prsService = getPRsService();
export const highlightsService = getHighlightsService(); export const highlightsService = getHighlightsService();
export const releasesService = getReleasesService();

1
src/lib/types/nostr.ts

@ -48,6 +48,7 @@ export const KIND = {
STATUS_DRAFT: 1633, // NIP-34: Status draft STATUS_DRAFT: 1633, // NIP-34: Status draft
COMMIT_SIGNATURE: 1640, // Custom: Git commit signature event COMMIT_SIGNATURE: 1640, // Custom: Git commit signature event
OWNERSHIP_TRANSFER: 1641, // Custom: Repository ownership transfer event (non-replaceable for chain integrity) 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 COMMENT: 1111, // NIP-22: Comment event
THREAD: 11, // NIP-7D: Discussion thread THREAD: 11, // NIP-7D: Discussion thread
BRANCH_PROTECTION: 30620, // Custom: Branch protection rules BRANCH_PROTECTION: 30620, // Custom: Branch protection rules

10
src/routes/+layout.svelte

@ -262,10 +262,20 @@
} }
// Provide theme context to child components // Provide theme context to child components
// Guard against SSR issues where setContext might be called outside component initialization
try {
setContext('theme', { setContext('theme', {
get theme() { return { value: theme }; }, get theme() { return { value: theme }; },
toggleTheme 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) // Hide nav bar and footer on splash page (root path)
const isSplashPage = $derived($page.url.pathname === '/'); const isSplashPage = $derived($page.url.pathname === '/');

232
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<GlobalCodeSearchResult[]> {
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;
}

134
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 }
);

120
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( export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext, event: RequestEvent) => { async (context: RepoRequestContext, event: RequestEvent) => {
const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); 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)) { if (!existsSync(repoPath)) {
try { try {
// Fetch repository announcement (case-insensitive) with caching // Fetch repository announcement (case-insensitive) with caching
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache);
const announcement = findRepoAnnouncement(allEvents, context.repo); const announcement = findRepoAnnouncement(allEvents, context.repo);
const events = announcement ? [announcement] : [];
if (events.length > 0) { if (announcement) {
// Download requires the actual repo files, so we can't use API fetching // Try to do a temporary clone for download
// Return helpful error message logger.info({ npub: context.npub, repo: context.repo }, 'Repository not cloned locally, attempting temporary clone for download');
throw handleNotFoundError(
'Repository is not cloned locally. To download this repository, privileged users can clone it using the "Clone to Server" button.', const tempDir = resolve(join(repoRoot, '..', 'temp-clones'));
{ operation: 'download', npub: context.npub, repo: context.repo } 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<void>((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 { } else {
throw handleNotFoundError( throw handleNotFoundError(
'Repository announcement not found in Nostr', 'Repository announcement not found in Nostr',
@ -50,6 +106,11 @@ export const GET: RequestHandler = createRepoGetHandler(
); );
} }
} catch (err) { } 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 // Check if repo was created by another concurrent request
if (existsSync(repoPath)) { if (existsSync(repoPath)) {
// Repo exists now, clear cache and continue with normal flow // Repo exists now, clear cache and continue with normal flow
@ -57,15 +118,18 @@ export const GET: RequestHandler = createRepoGetHandler(
} else { } else {
// If fetching fails, return 404 // If fetching fails, return 404
throw handleNotFoundError( throw handleNotFoundError(
'Repository not found', err instanceof Error ? err.message : 'Repository not found',
{ operation: 'download', npub: context.npub, repo: context.repo } { operation: 'download', npub: context.npub, repo: context.repo }
); );
} }
} }
} }
// Double-check repo exists (should be true if we got here) // Use temp clone path if we created one, otherwise use regular repo path
if (!existsSync(repoPath)) { const sourceRepoPath = useTempClone && tempClonePath ? tempClonePath : repoPath;
// Double-check source repo exists
if (!existsSync(sourceRepoPath)) {
throw handleNotFoundError( throw handleNotFoundError(
'Repository not found', 'Repository not found',
{ operation: 'download', npub: context.npub, repo: context.repo } { operation: 'download', npub: context.npub, repo: context.repo }
@ -77,12 +141,29 @@ export const GET: RequestHandler = createRepoGetHandler(
// If ref is a branch name, validate it exists or use default branch // If ref is a branch name, validate it exists or use default branch
if (ref !== 'HEAD' && !ref.startsWith('refs/')) { if (ref !== 'HEAD' && !ref.startsWith('refs/')) {
// Check if ref is a commit hash (40-character hex string)
const isCommitHash = /^[0-9a-f]{40}$/i.test(ref);
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 // Security: Validate ref to prevent command injection
if (!isValidBranchName(ref)) { if (!isValidBranchName(ref)) {
throw error(400, 'Invalid ref format'); throw error(400, 'Invalid ref format');
} }
// Validate branch exists or use default // 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
}
if (!isTag) {
// Not a tag, validate branch exists or use default
try { try {
const branches = await fileManager.getBranches(context.npub, context.repo); const branches = await fileManager.getBranches(context.npub, context.repo);
if (!branches.includes(ref)) { if (!branches.includes(ref)) {
@ -94,6 +175,9 @@ export const GET: RequestHandler = createRepoGetHandler(
ref = 'HEAD'; ref = 'HEAD';
} }
} }
// If it's a tag, use it directly (git accepts tag names as refs)
}
}
// Security: Validate format // Security: Validate format
if (format !== 'zip' && format !== 'tar.gz') { if (format !== 'zip' && format !== 'tar.gz') {
@ -130,7 +214,7 @@ export const GET: RequestHandler = createRepoGetHandler(
// Clone repository using simple-git (safer than shell commands) // Clone repository using simple-git (safer than shell commands)
const git = simpleGit(); const git = simpleGit();
await git.clone(repoPath, workDir); await git.clone(sourceRepoPath, workDir);
// Checkout specific ref if not HEAD // Checkout specific ref if not HEAD
if (ref !== 'HEAD') { if (ref !== 'HEAD') {
@ -215,6 +299,10 @@ export const GET: RequestHandler = createRepoGetHandler(
// Clean up using fs/promises // Clean up using fs/promises
await rm(workDir, { recursive: true, force: true }).catch(() => {}); await rm(workDir, { recursive: true, force: true }).catch(() => {});
await rm(archivePath, { 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 archive
return new Response(archiveBuffer, { return new Response(archiveBuffer, {
@ -228,6 +316,10 @@ export const GET: RequestHandler = createRepoGetHandler(
// Clean up on error using fs/promises // Clean up on error using fs/promises
await rm(workDir, { recursive: true, force: true }).catch(() => {}); await rm(workDir, { recursive: true, force: true }).catch(() => {});
await rm(archivePath, { 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); const sanitizedError = sanitizeError(archiveError);
logger.error({ error: sanitizedError, npub: context.npub, repo: context.repo, ref, format }, 'Error creating archive'); logger.error({ error: sanitizedError, npub: context.npub, repo: context.repo, ref, format }, 'Error creating archive');
throw archiveError; throw archiveError;

3
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 { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { verifyEvent } from 'nostr-tools'; import { verifyEvent } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js'; 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 { 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 { handleApiError, handleValidationError } from '$lib/utils/error-handler.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 // 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({ return json({
highlights, highlights,

160
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<void>((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<void>((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 }
);

156
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 }
);

97
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 }
);

3
src/routes/api/repos/[npub]/[repo]/tags/+server.ts

@ -39,7 +39,8 @@ export const GET: RequestHandler = createRepoGetHandler(
const tags = apiData.tags.map(t => ({ const tags = apiData.tags.map(t => ({
name: t.name, name: t.name,
hash: t.sha, hash: t.sha,
message: t.message message: t.message,
date: undefined // API fallback doesn't provide date
})); }));
return json(tags); return json(tags);
} }

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

@ -79,7 +79,7 @@
let userPubkey = $state<string | null>(null); let userPubkey = $state<string | null>(null);
let userPubkeyHex = $state<string | null>(null); let userPubkeyHex = $state<string | null>(null);
let showCommitDialog = $state(false); 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); let showRepoMenu = $state(false);
// Tabs will be defined as derived after issues and prs are declared // Tabs will be defined as derived after issues and prs are declared
@ -223,7 +223,8 @@
let diffData = $state<Array<{ file: string; additions: number; deletions: number; diff: string }>>([]); let diffData = $state<Array<{ file: string; additions: number; deletions: number; diff: string }>>([]);
// Tags // Tags
let tags = $state<Array<{ name: string; hash: string; message?: string }>>([]); let tags = $state<Array<{ name: string; hash: string; message?: string; date?: number }>>([]);
let selectedTag = $state<string | null>(null);
let showCreateTagDialog = $state(false); let showCreateTagDialog = $state(false);
let newTagName = $state(''); let newTagName = $state('');
let newTagMessage = $state(''); let newTagMessage = $state('');
@ -352,7 +353,7 @@
let selectedPR = $state<string | null>(null); let selectedPR = $state<string | null>(null);
// Tabs menu - defined after issues and prs // 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 // Show tabs that require cloned repo when repo is cloned OR API fallback is available
const tabs = $derived.by(() => { const tabs = $derived.by(() => {
const allTabs = [ const allTabs = [
@ -363,6 +364,7 @@
{ id: 'discussions', label: 'Discussions', icon: '/icons/message-circle.svg', requiresClone: false }, { id: 'discussions', label: 'Discussions', icon: '/icons/message-circle.svg', requiresClone: false },
{ id: 'history', label: 'Commit History', icon: '/icons/git-commit.svg', requiresClone: true }, { id: 'history', label: 'Commit History', icon: '/icons/git-commit.svg', requiresClone: true },
{ id: 'tags', label: 'Tags', icon: '/icons/tag.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 } { id: 'docs', label: 'Docs', icon: '/icons/book.svg', requiresClone: false }
]; ];
@ -416,6 +418,37 @@
let replyContent = $state(''); let replyContent = $state('');
let creatingReply = $state(false); let creatingReply = $state(false);
// Releases
let releases = $state<Array<{
id: string;
tagName: string;
tagHash?: string;
releaseNotes?: string;
isDraft?: boolean;
isPrerelease?: boolean;
created_at: number;
pubkey: string;
}>>([]);
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<Array<{
file: string;
line: number;
content: string;
branch: string;
}>>([]);
let loadingCodeSearch = $state(false);
let codeSearchScope = $state<'repo' | 'all'>('repo');
// Discussions // Discussions
let selectedDiscussion = $state<string | null>(null); let selectedDiscussion = $state<string | null>(null);
let discussions = $state<Array<{ let discussions = $state<Array<{
@ -3979,6 +4012,10 @@
}); });
if (response.ok) { if (response.ok) {
tags = await response.json(); tags = await response.json();
// Auto-select first tag if none selected
if (tags.length > 0 && !selectedTag) {
selectedTag = tags[0].name;
}
} }
} catch (err) { } catch (err) {
console.error('Failed to load tags:', 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() { async function loadIssues() {
loadingIssues = true; loadingIssues = true;
error = null; error = null;
@ -4437,6 +4591,9 @@
loadCommitHistory(); loadCommitHistory();
} else if (activeTab === 'tags') { } else if (activeTab === 'tags') {
loadTags(); 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') { } else if (activeTab === 'issues') {
loadIssues(); loadIssues();
} else if (activeTab === 'prs') { } else if (activeTab === 'prs') {
@ -4937,6 +5094,55 @@
<img src="/icons/arrow-right.svg" alt="Show content" class="icon-inline" /> <img src="/icons/arrow-right.svg" alt="Show content" class="icon-inline" />
</button> </button>
</div> </div>
{#if tags.length > 0}
<ul class="tag-list">
{#each tags as tag}
{@const tagHash = tag.hash || ''}
{#if tagHash}
<li class="tag-item" class:selected={selectedTag === tag.name}>
<button
onclick={() => selectedTag = tag.name}
class="tag-item-button"
>
<div class="tag-name">{tag.name}</div>
<div class="tag-hash">{tagHash.slice(0, 7)}</div>
{#if tag.date}
<div class="tag-date">{new Date(tag.date * 1000).toLocaleDateString()}</div>
{/if}
{#if releases.find(r => r.tagName === tag.name)}
<img src="/icons/package.svg" alt="Has release" class="tag-has-release-icon" title="This tag has a release" />
{/if}
</button>
</li>
{/if}
{/each}
</ul>
{:else}
<div class="empty-state">
<p>No tags found</p>
</div>
{/if}
</aside>
{/if}
<!-- Code Search View -->
{#if activeTab === 'code-search' && canViewRepo}
<aside class="code-search-sidebar" class:hide-on-mobile={!showLeftPanelOnMobile && activeTab === 'code-search'}>
<div class="code-search-header">
<TabsMenu
activeTab={activeTab}
{tabs}
onTabChange={(tab) => activeTab = tab as typeof activeTab}
/>
<h2>Code Search</h2>
<button
onclick={() => showLeftPanelOnMobile = !showLeftPanelOnMobile}
class="mobile-toggle-button"
title="Show content"
>
<img src="/icons/arrow-right.svg" alt="Show content" class="icon-inline" />
</button>
</div>
</aside> </aside>
{/if} {/if}
@ -5430,24 +5636,131 @@
<img src="/icons/arrow-right.svg" alt="Show list" class="icon-inline mobile-toggle-left" /> <img src="/icons/arrow-right.svg" alt="Show list" class="icon-inline mobile-toggle-left" />
</button> </button>
</div> </div>
{#if tags.length > 0} {#if selectedTag}
<ul class="tag-list"> {@const tag = tags.find(t => t.name === selectedTag)}
{#each tags as tag} {@const release = releases.find(r => r.tagName === selectedTag)}
{@const tagHash = tag.hash || ''} {#if tag}
{#if tagHash} <div class="tag-detail">
<li class="tag-item"> <div class="tag-detail-header">
<div class="tag-name">{tag.name}</div> <h3>{tag.name}</h3>
<div class="tag-hash">{tagHash.slice(0, 7)}</div> <div class="tag-detail-meta">
<span>Tag: {tag.hash?.slice(0, 7) || 'N/A'}</span>
{#if tag.date}
<span class="tag-date">Created {new Date(tag.date * 1000).toLocaleString()}</span>
{/if}
<a
href={`/api/repos/${npub}/${repo}/download?ref=${tag.name}&format=zip`}
download={`${repo}-${tag.name}.zip`}
class="download-tag-button"
title="Download source code as ZIP"
>
<img src="/icons/download.svg" alt="Download" class="icon-inline" />
Download ZIP
</a>
{#if (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned && !release}
<button
onclick={() => {
newReleaseTagName = tag.name;
newReleaseTagHash = tag.hash || '';
showCreateReleaseDialog = true;
}}
class="release-tag-button"
title="Create a release for this tag"
>
Release this tag
</button>
{/if}
</div>
</div>
{#if tag.message} {#if tag.message}
<div class="tag-message">{tag.message}</div> <div class="tag-message">
<p>{tag.message}</p>
</div>
{/if} {/if}
</li> {#if release}
<div class="tag-release-section">
<h4>Release</h4>
<div class="release-info">
{#if release.isDraft}
<span class="release-badge draft">Draft</span>
{/if}
{#if release.isPrerelease}
<span class="release-badge prerelease">Pre-release</span>
{/if}
<div class="release-meta">
<span>Released {new Date(release.created_at * 1000).toLocaleDateString()}</span>
</div>
{#if release.releaseNotes}
<div class="release-notes">
{@html release.releaseNotes.replace(/\n/g, '<br>')}
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if} {/if}
{/each}
</ul>
{:else} {:else}
<div class="empty-state"> <div class="empty-state">
<p>No tags found</p> <p>Select a tag from the sidebar to view details</p>
</div>
{/if}
</div>
{/if}
{#if activeTab === 'code-search' && canViewRepo}
<div class="code-search-content" class:hide-on-mobile={showLeftPanelOnMobile && activeTab === 'code-search'}>
<div class="content-header-mobile">
<button
onclick={() => showLeftPanelOnMobile = !showLeftPanelOnMobile}
class="mobile-toggle-button"
title="Show list"
>
<img src="/icons/arrow-right.svg" alt="Show list" class="icon-inline mobile-toggle-left" />
</button>
</div>
<div class="code-search-form">
<div class="search-input-group">
<input
type="text"
bind:value={codeSearchQuery}
placeholder="Search code..."
onkeydown={(e) => e.key === 'Enter' && performCodeSearch()}
class="code-search-input"
/>
<select bind:value={codeSearchScope} class="code-search-scope">
<option value="repo">This Repository</option>
<option value="all">All Repositories</option>
</select>
<button onclick={performCodeSearch} disabled={loadingCodeSearch || !codeSearchQuery.trim()} class="search-button">
{loadingCodeSearch ? 'Searching...' : 'Search'}
</button>
</div>
</div>
{#if loadingCodeSearch}
<div class="empty-state">
<p>Searching...</p>
</div>
{:else if codeSearchResults.length > 0}
<div class="code-search-results">
<h3>Found {codeSearchResults.length} result{codeSearchResults.length !== 1 ? 's' : ''}</h3>
{#each codeSearchResults as result}
<div class="code-search-result-item">
<div class="result-header">
<span class="result-file">{result.file}</span>
<span class="result-line">Line {result.line}</span>
{#if codeSearchScope === 'all' && 'repo' in result}
<span class="result-repo">{result.repo || npub}/{result.repo || repo}</span>
{/if}
</div>
<pre class="result-content">{result.content}</pre>
</div>
{/each}
</div>
{:else if codeSearchQuery.trim() && !loadingCodeSearch}
<div class="empty-state">
<p>No results found</p>
</div> </div>
{/if} {/if}
</div> </div>
@ -5632,6 +5945,43 @@
<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>
<EventCopyButton eventId={patch.id} kind={patch.kind} pubkey={patch.author} /> <EventCopyButton eventId={patch.id} kind={patch.kind} pubkey={patch.author} />
{#if (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned}
<button
onclick={async () => {
if (!confirm('Apply this patch to the repository? This will create a commit with the patch changes.')) return;
try {
const response = await fetch(`/api/repos/${npub}/${repo}/patches/${patch.id}/apply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildApiHeaders()
},
body: JSON.stringify({
branch: currentBranch || 'main',
commitMessage: `Apply patch ${patch.id.slice(0, 8)}: ${patch.subject}`
})
});
if (response.ok) {
const data = await response.json();
alert(`Patch applied successfully! Commit: ${data.commitHash.slice(0, 7)}`);
// Reload files to show changes
if (activeTab === 'files') {
loadFiles(currentPath);
}
} else {
const errorData = await response.json();
alert(`Failed to apply patch: ${errorData.message || 'Unknown error'}`);
}
} catch (err) {
alert(`Failed to apply patch: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}}
class="apply-patch-button"
title="Apply this patch to the repository"
>
Apply Patch
</button>
{/if}
</div> </div>
{#if patch.description && patch.description !== patch.subject} {#if patch.description && patch.description !== patch.subject}
<div class="patch-description">{patch.description}</div> <div class="patch-description">{patch.description}</div>
@ -6087,6 +6437,59 @@
</div> </div>
{/if} {/if}
<!-- Create Release Dialog -->
{#if showCreateReleaseDialog && userPubkey && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned}
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Create new release"
onclick={() => showCreateReleaseDialog = false}
onkeydown={(e) => e.key === 'Escape' && (showCreateReleaseDialog = false)}
tabindex="-1"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="modal"
role="document"
onclick={(e) => e.stopPropagation()}
>
<h3>Create New Release</h3>
<label>
Tag Name:
<input type="text" bind:value={newReleaseTagName} placeholder="v1.0.0" />
</label>
<label>
Tag Hash (commit hash):
<input type="text" bind:value={newReleaseTagHash} placeholder="abc1234..." />
</label>
<label>
Release Notes:
<textarea bind:value={newReleaseNotes} rows="10" placeholder="Release notes in markdown..."></textarea>
</label>
<label>
<input type="checkbox" bind:checked={newReleaseIsDraft} />
Draft Release
</label>
<label>
<input type="checkbox" bind:checked={newReleaseIsPrerelease} />
Pre-release
</label>
<div class="modal-actions">
<button onclick={() => showCreateReleaseDialog = false} class="cancel-button">Cancel</button>
<button
onclick={createRelease}
disabled={!newReleaseTagName.trim() || !newReleaseTagHash.trim() || creatingRelease}
class="save-button"
>
{creatingRelease ? 'Creating...' : 'Create Release'}
</button>
</div>
</div>
</div>
{/if}
<!-- Create Issue Dialog --> <!-- Create Issue Dialog -->
{#if showCreateIssueDialog && userPubkey} {#if showCreateIssueDialog && userPubkey}
<div <div
@ -6461,4 +6864,59 @@
border-radius: 4px; border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
} }
/* Tag date styling */
.tag-date {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
/* Tag detail meta styling */
.tag-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
margin-top: 0.5rem;
}
.tag-detail-meta .tag-date {
font-size: 0.9rem;
color: var(--text-muted);
}
/* Download tag button styling */
.download-tag-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
border: none;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
cursor: pointer;
}
.download-tag-button:hover {
background: var(--button-primary-hover);
}
.download-tag-button .icon-inline {
width: 16px;
height: 16px;
}
/* Tag has release icon styling */
.tag-has-release-icon {
width: 16px;
height: 16px;
vertical-align: middle;
opacity: 0.8;
}
</style> </style>

Loading…
Cancel
Save