Browse Source
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 70c8b6e499551ce43478116cf694992102a29572d5380cbe3b070a3026bc2c9e35177587712c7414f25d1ca50038c9614479f7758bbdc48f69cc44cd52bf4842main
18 changed files with 1759 additions and 112 deletions
@ -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 |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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 } |
||||||
|
); |
||||||
@ -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 } |
||||||
|
); |
||||||
@ -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 } |
||||||
|
); |
||||||
@ -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 } |
||||||
|
); |
||||||
Loading…
Reference in new issue