From 78d3ac36ce0cf91bc03400b8f16ddc57e7ec66b7 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 20 Feb 2026 22:16:52 +0100 Subject: [PATCH] finish implementing nip-34 Nostr-Signature: e036526abc826e4435a562f1f334e594577d78a7a50a02cb78f8e5565ea68872 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 12642202ef028dfbac68ce53e9cf9f7a64ce3242d2dd995fd0b4c4014c9aa2b18891b72dc281fa5aadacd636646ebd8d2b69fd29bf36407658dff9725b779be5 --- README.md | 3 + docs/34.md | 83 ++-- docs/tutorial.md | 124 +++++- nostr/commit-signatures.jsonl | 1 + src/lib/components/PRDetail.svelte | 224 ++++++++++- src/lib/services/nostr/prs-service.ts | 44 +++ .../api/repos/[npub]/[repo]/issues/+server.ts | 36 ++ .../api/repos/[npub]/[repo]/prs/+server.ts | 33 ++ .../repos/[npub]/[repo]/prs/merge/+server.ts | 88 +++++ .../repos/[npub]/[repo]/prs/update/+server.ts | 43 ++ src/routes/repos/[npub]/[repo]/+page.svelte | 368 ++++++++++++++++++ 11 files changed, 1017 insertions(+), 30 deletions(-) create mode 100644 src/routes/api/repos/[npub]/[repo]/prs/merge/+server.ts create mode 100644 src/routes/api/repos/[npub]/[repo]/prs/update/+server.ts diff --git a/README.md b/README.md index 944b9c2..c5b837b 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,9 @@ These are not part of any NIP but are used by this application: - Owner/maintainer creates status events (kind 1630-1633) - Links to PR/Issue via event references - Status changes: open → applied/closed/draft + - **PR Merging**: Creates merge commit and publishes status event (kind 1631) with merge commit ID + - **PR Updates**: PR author can update PR tip commit using kind 1619 events + - **Issue Management**: Owners, maintainers, and issue authors can update issue status 3. **Highlights & Comments**: - User selects code in PR diff view diff --git a/docs/34.md b/docs/34.md index 54a11b4..6723705 100644 --- a/docs/34.md +++ b/docs/34.md @@ -71,10 +71,53 @@ The `refs` tag can be optionally extended to enable clients to identify how many Patches and PRs can be sent by anyone to any repository. Patches and PRs to a specific repository SHOULD be sent to the relays specified in that repository's announcement event's `"relays"` tag. Patch and PR events SHOULD include an `a` tag pointing to that repository's announcement address. -Patches SHOULD be used if each event is under 60kb, otherwise PRs SHOULD be used. +### When to Use Patches vs. Pull Requests + +**Patches SHOULD be used if each event is under 60kb, otherwise PRs SHOULD be used.** + +However, the choice between patches and pull requests isn't just about size. Here are the key differences: + +#### Patches (Kind 1617) +- **Content**: Patch content is embedded directly in the Nostr event content field +- **Size**: Each patch event must be under 60KB +- **Workflow**: Event-based, sequential patch series linked via NIP-10 reply tags +- **Use Cases**: + - Small, self-contained changes + - Bug fixes and typo corrections + - Simple feature additions + - When you want to send code changes as self-contained events without maintaining a fork +- **Series**: Patches in a series are linked via NIP-10 reply tags +- **Format**: Patch content can be in any format (git format-patch, unified diff, or plain text description) +- **Discovery**: Patches are discoverable as Nostr events on relays, independent of git repository access + +#### Pull Requests (Kind 1618) +- **Content**: Markdown description with references to commits via clone URLs +- **Size**: No strict limit (commits are stored in git repositories, not in the event) +- **Workflow**: Branch-based, iterative collaboration +- **Use Cases**: + - Large, complex changes + - Multi-file refactoring + - Features requiring discussion and iteration + - When working with forks and branches +- **Updates**: PRs can be updated (kind 1619) to change the tip commit +- **Format**: References commits that must be accessible via clone URLs + +#### Key Differences Summary + +| Aspect | Patches | Pull Requests | +|--------|---------|---------------| +| **Event Size** | Under 60KB | No limit (commits stored separately) | +| **Content Location** | In event content | In referenced git repository | +| **Workflow Style** | Email-style, sequential | Branch-based, iterative | +| **Best For** | Small, self-contained changes | Large, complex changes | +| **Maintenance** | Static (new patch for revisions) | Dynamic (can update tip commit) | +| **Application** | Extract from event, then `git am` (if applicable) | `git merge` or `git cherry-pick` | +| **Discussion** | Via NIP-22 comments | Via NIP-22 comments + inline code comments | ### Patches +wPatches are created as Nostr events (kind 1617) with the patch content embedded in the event's content field. This makes patches self-contained and discoverable on Nostr relays without requiring access to git repositories. + Patches in a patch set SHOULD include a [NIP-10](10.md) `e` `reply` tag pointing to the previous patch. The first patch revision in a patch revision SHOULD include a [NIP-10](10.md) `e` `reply` to the original root patch. @@ -82,10 +125,10 @@ The first patch revision in a patch revision SHOULD include a [NIP-10](10.md) `e ```jsonc { "kind": 1617, - "content": "", // contents of + "content": "", // Patch content in any format (git format-patch, unified diff, or plain text) "tags": [ ["a", "30617::"], - ["r", ""] // so clients can subscribe to all patches sent to a local git repo + ["r", ""], // so clients can subscribe to all patches sent to a local git repo ["p", ""], ["p", ""], // optionally send the patch to another user to bring it to their attention @@ -98,7 +141,7 @@ The first patch revision in a patch revision SHOULD include a [NIP-10](10.md) `e // has the same id as it had in the proposer's machine -- all these tags can be omitted // if the maintainer doesn't care about these things ["commit", ""], - ["r", ""] // so clients can find existing patches for a specific commit + ["r", ""], // so clients can find existing patches for a specific commit ["parent-commit", ""], ["commit-pgp-sig", "-----BEGIN PGP SIGNATURE-----..."], // empty string for unsigned commit ["committer", "", "", "", ""], @@ -106,7 +149,7 @@ The first patch revision in a patch revision SHOULD include a [NIP-10](10.md) `e } ``` -The first patch in a series MAY be a cover letter in the format produced by `git format-patch`. +The patch content can be in any format that describes code changes. Common formats include git format-patch output, unified diff format, or plain text descriptions. The first patch in a series MAY be a cover letter describing the patch series. ### Pull Requests @@ -284,31 +327,19 @@ GitRepublic uses repository announcements to: - **Branch Tracking**: Optional repository state events track branch and tag positions - **State Updates**: Repository state can be updated to reflect current branch/tag positions -### Ownership Transfers (Kind 1641) - -GitRepublic implements ownership transfers using kind 1641 events: +### Patches (Kind 1617) -- **Transfer Initiation**: Current owner creates a kind 1641 event with: - - `a` tag: Repository identifier (`30617:{owner}:{repo}`) - - `p` tag: New owner pubkey - - `d` tag: Repository name -- **Event Publishing**: Transfer events are published to Nostr relays for online papertrail -- **Repository Storage**: Transfer events are saved to `nostr/repo-events.jsonl` in the repository for offline papertrail -- **New Owner Notification**: New owners are notified when they log into GitRepublic web -- **Transfer Completion**: New owner completes the transfer by publishing a new repository announcement (kind 30617) -- **Verification**: The new announcement is saved to `nostr/repo-events.jsonl`, and the transfer is complete +GitRepublic supports event-based patches for small, self-contained code changes: -**Transfer Workflow**: -1. Current owner initiates transfer by creating and publishing a kind 1641 event -2. Transfer event is saved to `nostr/repo-events.jsonl` for offline verification -3. New owner is notified via GitRepublic web interface -4. New owner publishes a new repository announcement (kind 30617) to complete the transfer -5. New announcement is saved to `nostr/repo-events.jsonl` for offline verification -6. Transfer is complete and repository ownership is verified +- **Patch Creation**: Users create patches as Nostr events (kind 1617) via the web interface +- **Patch Content**: Patch content is embedded directly in the event's content field (can be in any format: git format-patch, unified diff, or plain text) +- **Event-Based**: Patches are self-contained Nostr events, discoverable on relays without requiring git repository access +- **Patch Series**: Multiple related patches can be linked using NIP-10 reply tags +- **Patch Status**: Patches use status events (kinds 1630-1633) to track state (open, applied, closed, draft) +- **Patch Application**: Maintainers extract patch content from events and apply them, then mark as applied with status events **Implementation**: - Repository announcements: `src/routes/signup/+page.svelte`, `src/lib/services/nostr/repo-polling.ts` - Pull requests: `src/lib/services/nostr/prs-service.ts` - Issues: `src/lib/services/nostr/issues-service.ts` -- Status events: Used throughout PR and issue management -- Ownership transfers: `src/routes/api/repos/[npub]/[repo]/transfer/+server.ts`, `src/lib/services/nostr/ownership-transfer-service.ts` \ No newline at end of file +- Status events: Used throughout PR, patch, and issue management \ No newline at end of file diff --git a/docs/tutorial.md b/docs/tutorial.md index e5a713c..d397dd7 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -320,9 +320,118 @@ Pull requests can have the following statuses: Only repository owners and maintainers can merge PRs: -1. **Review the PR** and ensure all checks pass -2. **Click "Merge"** or change the status to "Applied" -3. **The changes will be merged** into the target branch +1. **Open the PR detail view** by clicking on a PR +2. **Review the changes** in the diff view +3. **Click "Merge"** button +4. **Select target branch** (default: main) +5. **Optionally add a merge message** +6. **Click "Merge"** to: + - Create a merge commit in the target branch + - Update PR status to "merged" (kind 1631) + - Include merge commit ID in the status event + +### Managing PR Status + +Repository owners and maintainers can manage PR status: + +- **Close PR**: Click "Close" button to mark PR as closed (kind 1632) +- **Reopen PR**: Click "Reopen" button on a closed PR to reopen it (kind 1630) +- **Mark as Draft**: Click "Mark as Draft" to mark PR as draft (kind 1633) + +### PR Updates (Kind 1619) + +Only the PR author can update their PR: + +- PR updates change the tip commit of the PR +- Use this when you've pushed new commits to your branch +- The update creates a kind 1619 event with the new commit ID + +--- + +## Patches + +Patches (kind 1617) are an alternative to pull requests for proposing changes to a repository. They're particularly useful for smaller changes or when you want to send code changes directly without creating a full pull request. + +### Patches vs. Pull Requests + +**When to use Patches:** +- **Small changes**: Each patch event should be under 60KB (per NIP-34 specification) +- **Simple fixes**: Bug fixes, typo corrections, or small feature additions +- **Direct code submission**: When you want to send code changes directly without maintaining a fork +- **Email-style workflow**: Similar to traditional git email-based patch workflows + +**When to use Pull Requests:** +- **Large changes**: Changes that exceed 60KB per event +- **Complex features**: Multi-file changes that need discussion and review +- **Ongoing collaboration**: When you need to iterate on changes with maintainers +- **Branch-based workflow**: When you're working with forks and branches + +### Key Differences + +| Feature | Patches | Pull Requests | +|---------|---------|---------------| +| **Size Limit** | Under 60KB per event | No strict limit | +| **Format** | Git format-patch output | Markdown description + commit reference | +| **Workflow** | Email-style, sequential | Branch-based, iterative | +| **Series Support** | Yes (linked via NIP-10 reply tags) | Yes (via PR updates) | +| **Content** | Full patch content in event | Reference to commits in clone URL | +| **Use Case** | Small, self-contained changes | Large, complex changes | + +### Creating a Patch + +wPatches are created as Nostr events (kind 1617). The patch content is embedded directly in the event, making it easy to share and review without requiring access to a git repository. + +#### Via Web Interface + +1. **Navigate to the repository** you want to contribute to +2. **Click the repository menu** (three dots) and select "Create Patch" +3. **Fill in the patch details**: + - **Subject** (optional): A brief title for your patch + - **Patch Content**: Enter your patch content (can be in git format-patch format, but any patch format is acceptable) +4. **Submit the patch** - it will be published as a Nostr event (kind 1617) to the repository's relays + +#### Patch Content Format + +The patch content can be in any format that describes code changes. Common formats include: +- **Git format-patch output**: Standard git patch format +- **Unified diff format**: `git diff` output +- **Plain text description**: For simple changes, you can describe the changes in plain text + +The key advantage of event-based patches is that they're self-contained Nostr events that can be discovered, shared, and reviewed without requiring access to the original git repository. + +### Patch Series + +For multiple related patches, you can create a patch series: + +1. **First patch**: Marked with `t` tag `"root"` +2. **Subsequent patches**: Include NIP-10 `e` reply tags pointing to the previous patch +3. **Patch revisions**: If you need to revise a patch, mark the first revision with `t` tag `"root-revision"` and link to the original root patch + +### Patch Status + +Like pull requests, patches can have status events: +- **Open**: The patch is active and ready for review +- **Applied/Merged**: The patch has been applied to the repository +- **Closed**: The patch was closed without applying +- **Draft**: The patch is still a work in progress + +### Applying Patches + +Repository owners and maintainers can apply patches from Nostr events: + +1. **Review the patch event** - patches are stored as Nostr events (kind 1617) and can be found on relays +2. **Extract the patch content** from the event content field +3. **Apply the patch** using git (if in git format): + ```bash + # If the patch content is in git format-patch format, save it to a file and apply: + echo "" > patch.patch + git am patch.patch + ``` + Or manually apply the changes if the patch is in a different format +4. **Create a status event** (kind 1631) marking the patch as applied, referencing the patch event ID +5. **Push the changes** to the repository + +The status event creates a permanent record that the patch was applied, linking the patch event to the resulting commits. --- @@ -350,6 +459,15 @@ Issues can have the following statuses: - **Closed**: The issue was closed (e.g., duplicate, won't fix) - **Draft**: The issue is still being written +### Managing Issue Status + +Repository owners, maintainers, and issue authors can update issue status: + +- **Close Issue**: Click "Close" button to mark issue as closed (kind 1632) +- **Resolve Issue**: Click "Resolve" button to mark issue as resolved (kind 1631) +- **Reopen Issue**: Click "Reopen" button on a closed or resolved issue to reopen it (kind 1630) +- **Mark as Draft**: Mark issue as draft (kind 1633) + ### Managing Issues - **Assign issues** to maintainers diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 5ce72c5..3505ee0 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -27,3 +27,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771618298,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","restrict repos to announced events"]],"content":"Signed commit: restrict repos to announced events","id":"d7ee36680a38fac493b27fba26d6e1c496dee9a3099db68a4352f7709a41e860","sig":"071cc8031940590785e5566a45159e5324e36e8a06023282ab1d50b608902d3b06d95efc03d0a4da861a88f12381f7b64999c09a49dfe5f36fbd8ec6aefd8aeb"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771618514,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"0a4b94a90de38e64e657c3ef5aca2bc61b5a563edf504d10f4cf5ab386b1bd9c","sig":"d7502da3f1f7d7b35b810a09cbcd3a467589afd8b97e0a7a04fb47996bb4959b510580a0f33f21c318c2733004f23840f73929ddc0dfb2572edc83ad967b09d2"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771619647,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor"]],"content":"Signed commit: refactor","id":"190b84b2cff8b8db7b3509e05d5470c073fc88e50ba7ad4fa54fd9a9d8dc0045","sig":"638b9986b5e534d09752125721a04d8cef7af892c0394515d6deb4116c2fcab378313abc270f47a6605f50457d5bb83fdb8b34af0607725b6d774028dc6a4fb6"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771619895,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","update docs"]],"content":"Signed commit: update docs","id":"82efc8b4dbac67dec5e02ebd46e504d7a6a3bbe7a53963984c3c4cbf6ac52a3b","sig":"5f5643be35aa997558ac79e99aa70f680a0e449bd1027afd83d65b2d7a1eee5f65d23d0d89b069e6118add1e78a3becd33d47f1d2fd82c6f86d9d12e14a5bc2e"} diff --git a/src/lib/components/PRDetail.svelte b/src/lib/components/PRDetail.svelte index 905cfb6..c9c0b1c 100644 --- a/src/lib/components/PRDetail.svelte +++ b/src/lib/components/PRDetail.svelte @@ -23,9 +23,12 @@ npub: string; repo: string; repoOwnerPubkey: string; + isMaintainer?: boolean; + userPubkeyHex?: string; + onStatusUpdate?: () => void; } - let { pr, npub, repo, repoOwnerPubkey }: Props = $props(); + let { pr, npub, repo, repoOwnerPubkey, isMaintainer = false, userPubkeyHex, onStatusUpdate }: Props = $props(); let highlights = $state(null); let loadingDiff = $state(false); + + // Status management + let updatingStatus = $state(false); + let merging = $state(false); + let showMergeDialog = $state(false); + let mergeTargetBranch = $state('main'); + let mergeMessage = $state(''); const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); @@ -288,6 +298,88 @@ return pubkey.slice(0, 8) + '...'; } } + + async function updatePRStatus(status: 'open' | 'merged' | 'closed' | 'draft') { + if (!userPubkeyHex || !isMaintainer) { + alert('Only repository maintainers can update PR status'); + return; + } + + updatingStatus = true; + error = null; + + try { + const response = await fetch(`/api/repos/${npub}/${repo}/prs`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prId: pr.id, + prAuthor: pr.author, + status + }) + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to update PR status'); + } + + if (onStatusUpdate) { + onStatusUpdate(); + } + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to update PR status'; + console.error('Error updating PR status:', err); + } finally { + updatingStatus = false; + } + } + + async function mergePR() { + if (!userPubkeyHex || !isMaintainer) { + alert('Only repository maintainers can merge PRs'); + return; + } + + if (!pr.commitId) { + alert('PR does not have a commit ID'); + return; + } + + merging = true; + error = null; + + try { + const response = await fetch(`/api/repos/${npub}/${repo}/prs/merge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prId: pr.id, + prAuthor: pr.author, + prCommitId: pr.commitId, + targetBranch: mergeTargetBranch, + mergeMessage: mergeMessage.trim() || `Merge pull request ${pr.id.slice(0, 7)}` + }) + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to merge PR'); + } + + showMergeDialog = false; + mergeMessage = ''; + if (onStatusUpdate) { + onStatusUpdate(); + } + alert('PR merged successfully!'); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to merge PR'; + console.error('Error merging PR:', err); + } finally { + merging = false; + } + }
@@ -302,6 +394,27 @@ {/if} Created {new Date(pr.created_at * 1000).toLocaleString()}
+ {#if isMaintainer && userPubkeyHex} +
+ {#if pr.status === 'open'} + + + {:else if pr.status === 'closed'} + + {/if} + {#if pr.status !== 'draft'} + + {/if} +
+ {/if}
@@ -751,4 +864,113 @@ border: 1px solid var(--error-text); border-radius: 4px; } + + .pr-actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + flex-wrap: wrap; + } + + .action-btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-family: 'IBM Plex Serif', serif; + font-size: 0.9rem; + transition: background 0.2s ease; + } + + .action-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .merge-btn { + background: var(--success-text, #28a745); + color: white; + } + + .merge-btn:hover:not(:disabled) { + background: var(--success-hover, #218838); + } + + .close-btn { + background: var(--error-text, #dc3545); + color: white; + } + + .close-btn:hover:not(:disabled) { + background: var(--error-hover, #c82333); + } + + .reopen-btn { + background: var(--accent, #007bff); + color: white; + } + + .reopen-btn:hover:not(:disabled) { + background: var(--accent-hover, #0056b3); + } + + .draft-btn { + background: var(--bg-tertiary, #6c757d); + color: white; + } + + .draft-btn:hover:not(:disabled) { + background: var(--bg-secondary, #5a6268); + } + + @media (max-width: 768px) { + .pr-actions { + flex-direction: column; + } + + .action-btn { + width: 100%; + } + + .pr-content { + grid-template-columns: 1fr; + } + } + + +{#if showMergeDialog} + +{/if} diff --git a/src/lib/services/nostr/prs-service.ts b/src/lib/services/nostr/prs-service.ts index ea838c0..c2e821b 100644 --- a/src/lib/services/nostr/prs-service.ts +++ b/src/lib/services/nostr/prs-service.ts @@ -200,4 +200,48 @@ export class PRsService { return event as StatusEvent; } + + /** + * Update PR tip commit (kind 1619) + */ + async updatePullRequest( + prId: string, + prAuthor: string, + repoOwnerPubkey: string, + repoId: string, + newCommitId: string, + cloneUrl: string, + mergeBase?: string + ): Promise { + const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId); + + const tags: string[][] = [ + ['a', repoAddress], + ['p', repoOwnerPubkey], + ['p', prAuthor], + ['E', prId], // NIP-22: Root PR event + ['P', prAuthor], // NIP-22: Root PR author + ['c', newCommitId], // New tip commit + ['clone', cloneUrl] + ]; + + if (mergeBase) { + tags.push(['merge-base', mergeBase]); + } + + const event = await signEventWithNIP07({ + kind: KIND.PULL_REQUEST_UPDATE, + 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 PR update to all relays'); + } + + return event; + } } diff --git a/src/routes/api/repos/[npub]/[repo]/issues/+server.ts b/src/routes/api/repos/[npub]/[repo]/issues/+server.ts index 9f48159..d9e247d 100644 --- a/src/routes/api/repos/[npub]/[repo]/issues/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/issues/+server.ts @@ -9,9 +9,12 @@ import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handler 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'; +const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); + export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext) => { const issues = await issuesService.getIssues(context.repoOwnerPubkey, context.repo); @@ -54,3 +57,36 @@ export const POST: RequestHandler = withRepoValidation( }, { operation: 'createIssue', requireRepoAccess: false } // Issues can be created by anyone with access ); + +export const PATCH: RequestHandler = withRepoValidation( + async ({ repoContext, requestContext, event }) => { + const body = await event.request.json(); + const { issueId, issueAuthor, status } = body; + + if (!issueId || !issueAuthor || !status) { + throw handleValidationError('Missing required fields: issueId, issueAuthor, status', { operation: 'updateIssueStatus', npub: repoContext.npub, repo: repoContext.repo }); + } + + // Check if user is maintainer or issue author + const { IssuesService } = await import('$lib/services/nostr/issues-service.js'); + const issuesService = new IssuesService(DEFAULT_NOSTR_RELAYS); + const isMaintainer = await maintainerService.isMaintainer(requestContext.userPubkeyHex || '', repoContext.repoOwnerPubkey, repoContext.repo); + const isAuthor = requestContext.userPubkeyHex === issueAuthor; + + if (!isMaintainer && !isAuthor && requestContext.userPubkeyHex !== repoContext.repoOwnerPubkey) { + throw handleApiError(new Error('Only repository owners, maintainers, or issue authors can update issue status'), { operation: 'updateIssueStatus', npub: repoContext.npub, repo: repoContext.repo }, 'Unauthorized'); + } + + // Update issue status + const statusEvent = await issuesService.updateIssueStatus( + issueId, + issueAuthor, + repoContext.repoOwnerPubkey, + repoContext.repo, + status + ); + + return json({ success: true, event: statusEvent }); + }, + { operation: 'updateIssueStatus', requireRepoAccess: false } +); diff --git a/src/routes/api/repos/[npub]/[repo]/prs/+server.ts b/src/routes/api/repos/[npub]/[repo]/prs/+server.ts index d277995..e85b64d 100644 --- a/src/routes/api/repos/[npub]/[repo]/prs/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/prs/+server.ts @@ -55,3 +55,36 @@ export const POST: RequestHandler = withRepoValidation( }, { operation: 'createPR', requireRepoAccess: false } // PRs can be created by anyone with access ); + +export const PATCH: RequestHandler = withRepoValidation( + async ({ repoContext, requestContext, event }) => { + const body = await event.request.json(); + const { prId, prAuthor, status, mergeCommitId } = body; + + if (!prId || !prAuthor || !status) { + throw handleValidationError('Missing required fields: prId, prAuthor, status', { operation: 'updatePRStatus', npub: repoContext.npub, repo: repoContext.repo }); + } + + // Check if user is maintainer + const { MaintainerService } = await import('$lib/services/nostr/maintainer-service.js'); + 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 PR status'), { operation: 'updatePRStatus', npub: repoContext.npub, repo: repoContext.repo }, 'Unauthorized'); + } + + // Update PR status + const statusEvent = await prsService.updatePRStatus( + prId, + prAuthor, + repoContext.repoOwnerPubkey, + repoContext.repo, + status, + mergeCommitId + ); + + return json({ success: true, event: statusEvent }); + }, + { operation: 'updatePRStatus', requireRepoAccess: false } +); diff --git a/src/routes/api/repos/[npub]/[repo]/prs/merge/+server.ts b/src/routes/api/repos/[npub]/[repo]/prs/merge/+server.ts new file mode 100644 index 0000000..84a4b68 --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/prs/merge/+server.ts @@ -0,0 +1,88 @@ +/** + * API endpoint for merging Pull Requests + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { withRepoValidation } from '$lib/utils/api-handlers.js'; +import type { RepoRequestContext } from '$lib/utils/api-context.js'; +import { handleValidationError, handleApiError } from '$lib/utils/error-handler.js'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { RepoManager } from '$lib/services/git/repo-manager.js'; +import { FileManager } from '$lib/services/git/file-manager.js'; +import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; +import { prsService } from '$lib/services/service-registry.js'; +import { simpleGit } from 'simple-git'; +import { join, resolve } from 'path'; +import { existsSync } from 'fs'; +import logger from '$lib/services/logger.js'; + +const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; +const repoManager = new RepoManager(repoRoot); +const fileManager = new FileManager(repoRoot); +const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); + +export const POST: RequestHandler = withRepoValidation( + async ({ repoContext, requestContext, event }) => { + const body = await event.request.json(); + const { prId, prAuthor, prCommitId, targetBranch = 'main', mergeMessage } = body; + + if (!prId || !prAuthor || !prCommitId) { + throw handleValidationError('Missing required fields: prId, prAuthor, prCommitId', { operation: 'mergePR', npub: repoContext.npub, repo: repoContext.repo }); + } + + // Check if user is maintainer + 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 PRs'), { operation: 'mergePR', npub: repoContext.npub, repo: repoContext.repo }, 'Unauthorized'); + } + + // Check if repo exists locally + const repoPath = join(repoRoot, repoContext.npub, `${repoContext.repo}.git`); + if (!existsSync(repoPath)) { + throw handleApiError(new Error('Repository not cloned locally. Please clone the repository first.'), { operation: 'mergePR', npub: repoContext.npub, repo: repoContext.repo }, 'Repository not found'); + } + + // Get user info for commit + const authorName = requestContext.userName || 'GitRepublic User'; + const authorEmail = requestContext.userEmail || `${requestContext.userPubkeyHex?.slice(0, 20)}@gitrepublic.web`; + + try { + const git = simpleGit(repoPath); + + // Fetch latest changes + await git.fetch(['origin']).catch(() => {}); // Ignore errors if no remote + + // Checkout target branch + await git.checkout(targetBranch); + + // Merge the PR commit + const mergeMessageText = mergeMessage || `Merge pull request ${prId.slice(0, 7)}`; + await git.merge([prCommitId, '--no-ff', '-m', mergeMessageText]); + + // Get the merge commit ID + const mergeCommitId = (await git.revparse(['HEAD'])).trim(); + + // Update PR status to merged + const statusEvent = await prsService.updatePRStatus( + prId, + prAuthor, + repoContext.repoOwnerPubkey, + repoContext.repo, + 'merged', + mergeCommitId + ); + + return json({ + success: true, + mergeCommitId, + statusEvent + }); + } catch (err) { + logger.error({ error: err, npub: repoContext.npub, repo: repoContext.repo, prId, prCommitId }, 'Error merging PR'); + throw handleApiError(err instanceof Error ? err : new Error('Failed to merge PR'), { operation: 'mergePR', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to merge pull request'); + } + }, + { operation: 'mergePR', requireRepoAccess: true } +); diff --git a/src/routes/api/repos/[npub]/[repo]/prs/update/+server.ts b/src/routes/api/repos/[npub]/[repo]/prs/update/+server.ts new file mode 100644 index 0000000..c6a859d --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/prs/update/+server.ts @@ -0,0 +1,43 @@ +/** + * API endpoint for updating Pull Requests (kind 1619) + */ + +import { json } from '@sveltejs/kit'; +// @ts-ignore - SvelteKit generates this type +import type { RequestHandler } from './$types'; +import { withRepoValidation } from '$lib/utils/api-handlers.js'; +import type { RepoRequestContext } from '$lib/utils/api-context.js'; +import { handleValidationError, handleApiError } from '$lib/utils/error-handler.js'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { prsService } from '$lib/services/service-registry.js'; +import { getGitUrl } from '$lib/config.js'; + +export const POST: RequestHandler = withRepoValidation( + async ({ repoContext, requestContext, event }) => { + const body = await event.request.json(); + const { prId, prAuthor, newCommitId, mergeBase } = body; + + if (!prId || !prAuthor || !newCommitId) { + throw handleValidationError('Missing required fields: prId, prAuthor, newCommitId', { operation: 'updatePR', npub: repoContext.npub, repo: repoContext.repo }); + } + + // Only PR author can update their PR + if (requestContext.userPubkeyHex !== prAuthor) { + throw handleApiError(new Error('Only the PR author can update the PR'), { operation: 'updatePR', npub: repoContext.npub, repo: repoContext.repo }, 'Unauthorized'); + } + + const cloneUrl = getGitUrl(repoContext.npub, repoContext.repo); + const updateEvent = await prsService.updatePullRequest( + prId, + prAuthor, + repoContext.repoOwnerPubkey, + repoContext.repo, + newCommitId, + cloneUrl, + mergeBase + ); + + return json({ success: true, event: updateEvent }); + }, + { operation: 'updatePR', requireRepoAccess: false } +); diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 3668782..dd397d9 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -290,6 +290,7 @@ let newIssueSubject = $state(''); let newIssueContent = $state(''); let newIssueLabels = $state(['']); + let updatingIssueStatus = $state>({}); // Pull Requests let prs = $state>([]); @@ -302,6 +303,12 @@ let newPRLabels = $state(['']); let selectedPR = $state(null); + // Patches + let showCreatePatchDialog = $state(false); + let newPatchContent = $state(''); + let newPatchSubject = $state(''); + let creatingPatch = $state(false); + // Documentation let documentationContent = $state(null); let documentationHtml = $state(null); @@ -2980,6 +2987,47 @@ } } + async function updateIssueStatus(issueId: string, issueAuthor: string, status: 'open' | 'closed' | 'resolved' | 'draft') { + if (!userPubkeyHex) { + alert('Please connect your NIP-07 extension'); + return; + } + + // Check if user is maintainer or issue author + const isAuthor = userPubkeyHex === issueAuthor; + if (!isMaintainer && !isAuthor) { + alert('Only repository maintainers or issue authors can update issue status'); + return; + } + + updatingIssueStatus = { ...updatingIssueStatus, [issueId]: true }; + error = null; + + try { + const response = await fetch(`/api/repos/${npub}/${repo}/issues`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + issueId, + issueAuthor, + status + }) + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to update issue status'); + } + + await loadIssues(); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to update issue status'; + console.error('Error updating issue status:', err); + } finally { + updatingIssueStatus = { ...updatingIssueStatus, [issueId]: false }; + } + } + async function loadPRs() { loadingPRs = true; error = null; @@ -3065,6 +3113,74 @@ } } + async function createPatch() { + if (!newPatchContent.trim()) { + alert('Please enter patch content'); + return; + } + + if (!userPubkey || !userPubkeyHex) { + alert('Please connect your NIP-07 extension'); + return; + } + + creatingPatch = true; + error = null; + + try { + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + throw new Error('Invalid npub format'); + } + const repoOwnerPubkey = decoded.data as string; + const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repo}`; + + // Get user's relays and combine with defaults + const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const { outbox } = await getUserRelays(userPubkey, tempClient); + const combinedRelays = combineRelays(outbox); + + // Create patch event (kind 1617) + const patchEventTemplate: Omit = { + kind: KIND.PATCH, + pubkey: userPubkeyHex, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['a', repoAddress], + ['p', repoOwnerPubkey], + ['t', 'root'] + ], + content: newPatchContent.trim() + }; + + // Add subject if provided + if (newPatchSubject.trim()) { + patchEventTemplate.tags.push(['subject', newPatchSubject.trim()]); + } + + // Sign the event using NIP-07 + const signedEvent = await signEventWithNIP07(patchEventTemplate); + + // Publish to all available relays + const publishClient = new NostrClient(combinedRelays); + const result = await publishClient.publishEvent(signedEvent, combinedRelays); + + if (result.failed.length > 0 && result.success.length === 0) { + throw new Error('Failed to publish patch to all relays'); + } + + showCreatePatchDialog = false; + newPatchContent = ''; + newPatchSubject = ''; + alert('Patch created successfully!'); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to create patch'; + console.error('Error creating patch:', err); + } finally { + creatingPatch = false; + } + } + // Only load tab content when tab actually changes, not on every render let lastTab = $state(null); $effect(() => { @@ -3212,6 +3328,15 @@ + + + {#if hasUnlimitedAccess($userStore.userLevel) && (isRepoCloned === false || (isRepoCloned === null && !checkingCloneStatus))}
+ {#if userPubkeyHex && (isMaintainer || userPubkeyHex === issue.author)} +
+ {#if issue.status === 'open'} + + + {:else if issue.status === 'closed' || issue.status === 'resolved'} + + {/if} +
+ {/if} {/each} @@ -3915,6 +4056,9 @@ {npub} {repo} {repoOwnerPubkey} + isMaintainer={isMaintainer} + userPubkeyHex={userPubkeyHex} + onStatusUpdate={loadPRs} /> {/if} @@ -4551,6 +4695,44 @@ {/if} + + {#if showCreatePatchDialog && userPubkey} + + {/if} + {#if showCommitDialog && userPubkey && isMaintainer}