You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
203 lines
5.5 KiB
203 lines
5.5 KiB
/** |
|
* Service for managing NIP-34 Pull Requests (kind 1618) |
|
*/ |
|
|
|
import { NostrClient } from './nostr-client.js'; |
|
import { KIND } from '../../types/nostr.js'; |
|
import type { PullRequest, NostrEvent, StatusEvent } from '../../types/nostr.js'; |
|
import { signEventWithNIP07 } from './nip07-signer.js'; |
|
|
|
export interface PRWithStatus extends PullRequest { |
|
status: 'open' | 'merged' | 'closed' | 'draft'; |
|
statusEvent?: StatusEvent; |
|
} |
|
|
|
export class PRsService { |
|
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}`; |
|
} |
|
|
|
/** |
|
* Get earliest unique commit ID from repo announcement |
|
*/ |
|
private getEarliestUniqueCommit(announcement: NostrEvent): string | null { |
|
const eucTag = announcement.tags.find(t => t[0] === 'r' && t[2] === 'euc'); |
|
return eucTag?.[1] || null; |
|
} |
|
|
|
/** |
|
* Fetch pull requests for a repository |
|
*/ |
|
async getPullRequests(repoOwnerPubkey: string, repoId: string): Promise<PRWithStatus[]> { |
|
const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId); |
|
|
|
const prs = await this.nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.PULL_REQUEST], |
|
'#a': [repoAddress], |
|
limit: 100 |
|
} |
|
]) as PullRequest[]; |
|
|
|
// Fetch status events for each PR |
|
const prIds = prs.map(pr => pr.id); |
|
const statusEvents = await this.nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.STATUS_OPEN, KIND.STATUS_APPLIED, KIND.STATUS_CLOSED, KIND.STATUS_DRAFT], |
|
'#e': prIds, |
|
limit: 1000 |
|
} |
|
]) as StatusEvent[]; |
|
|
|
// Group status events by PR ID and get the most recent one |
|
const statusMap = new Map<string, StatusEvent>(); |
|
for (const status of statusEvents) { |
|
const rootTag = status.tags.find(t => t[0] === 'e' && t[3] === 'root'); |
|
if (rootTag && rootTag[1]) { |
|
const prId = rootTag[1]; |
|
const existing = statusMap.get(prId); |
|
if (!existing || status.created_at > existing.created_at) { |
|
statusMap.set(prId, status); |
|
} |
|
} |
|
} |
|
|
|
// Combine PRs with their status |
|
return prs.map(pr => { |
|
const statusEvent = statusMap.get(pr.id); |
|
let status: 'open' | 'merged' | 'closed' | 'draft' = 'open'; |
|
|
|
if (statusEvent) { |
|
if (statusEvent.kind === KIND.STATUS_OPEN) status = 'open'; |
|
else if (statusEvent.kind === KIND.STATUS_APPLIED) status = 'merged'; |
|
else if (statusEvent.kind === KIND.STATUS_CLOSED) status = 'closed'; |
|
else if (statusEvent.kind === KIND.STATUS_DRAFT) status = 'draft'; |
|
} |
|
|
|
return { |
|
...pr, |
|
status, |
|
statusEvent |
|
}; |
|
}); |
|
} |
|
|
|
/** |
|
* Create a new pull request |
|
*/ |
|
async createPullRequest( |
|
repoOwnerPubkey: string, |
|
repoId: string, |
|
subject: string, |
|
content: string, |
|
commitId: string, |
|
cloneUrl: string, |
|
branchName?: string, |
|
labels: string[] = [] |
|
): Promise<PullRequest> { |
|
const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId); |
|
|
|
const tags: string[][] = [ |
|
['a', repoAddress], |
|
['p', repoOwnerPubkey], |
|
['subject', subject], |
|
['c', commitId], |
|
['clone', cloneUrl] |
|
]; |
|
|
|
if (branchName) { |
|
tags.push(['branch-name', branchName]); |
|
} |
|
|
|
// Add labels |
|
for (const label of labels) { |
|
tags.push(['t', label]); |
|
} |
|
|
|
const event = await signEventWithNIP07({ |
|
kind: KIND.PULL_REQUEST, |
|
content, |
|
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 pull request to all relays'); |
|
} |
|
|
|
return event as PullRequest; |
|
} |
|
|
|
/** |
|
* Update PR status |
|
*/ |
|
async updatePRStatus( |
|
prId: string, |
|
prAuthor: string, |
|
repoOwnerPubkey: string, |
|
repoId: string, |
|
status: 'open' | 'merged' | 'closed' | 'draft', |
|
mergeCommitId?: string |
|
): Promise<StatusEvent> { |
|
const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId); |
|
|
|
let kind: number; |
|
switch (status) { |
|
case 'open': |
|
kind = KIND.STATUS_OPEN; |
|
break; |
|
case 'merged': |
|
kind = KIND.STATUS_APPLIED; |
|
break; |
|
case 'closed': |
|
kind = KIND.STATUS_CLOSED; |
|
break; |
|
case 'draft': |
|
kind = KIND.STATUS_DRAFT; |
|
break; |
|
} |
|
|
|
const tags: string[][] = [ |
|
['e', prId, '', 'root'], |
|
['p', repoOwnerPubkey], |
|
['p', prAuthor], |
|
['a', repoAddress] |
|
]; |
|
|
|
// Note: earliest unique commit should be added by caller if available |
|
// We don't have access to repo announcement here, so it's optional |
|
|
|
if (status === 'merged' && mergeCommitId) { |
|
tags.push(['merge-commit', mergeCommitId]); |
|
tags.push(['r', mergeCommitId]); |
|
} |
|
|
|
const event = await signEventWithNIP07({ |
|
kind, |
|
content: `Pull request ${status}`, |
|
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 status update to all relays'); |
|
} |
|
|
|
return event as StatusEvent; |
|
} |
|
}
|
|
|