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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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