Browse Source

finish implementing nip-34

Nostr-Signature: e036526abc826e4435a562f1f334e594577d78a7a50a02cb78f8e5565ea68872 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 12642202ef028dfbac68ce53e9cf9f7a64ce3242d2dd995fd0b4c4014c9aa2b18891b72dc281fa5aadacd636646ebd8d2b69fd29bf36407658dff9725b779be5
main
Silberengel 3 weeks ago
parent
commit
78d3ac36ce
  1. 3
      README.md
  2. 83
      docs/34.md
  3. 124
      docs/tutorial.md
  4. 1
      nostr/commit-signatures.jsonl
  5. 224
      src/lib/components/PRDetail.svelte
  6. 44
      src/lib/services/nostr/prs-service.ts
  7. 36
      src/routes/api/repos/[npub]/[repo]/issues/+server.ts
  8. 33
      src/routes/api/repos/[npub]/[repo]/prs/+server.ts
  9. 88
      src/routes/api/repos/[npub]/[repo]/prs/merge/+server.ts
  10. 43
      src/routes/api/repos/[npub]/[repo]/prs/update/+server.ts
  11. 368
      src/routes/repos/[npub]/[repo]/+page.svelte

3
README.md

@ -182,6 +182,9 @@ These are not part of any NIP but are used by this application: @@ -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

83
docs/34.md

@ -71,10 +71,53 @@ The `refs` tag can be optionally extended to enable clients to identify how many @@ -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 @@ -82,10 +125,10 @@ The first patch revision in a patch revision SHOULD include a [NIP-10](10.md) `e
```jsonc
{
"kind": 1617,
"content": "<patch>", // contents of <git format-patch>
"content": "<patch-content>", // Patch content in any format (git format-patch, unified diff, or plain text)
"tags": [
["a", "30617:<base-repo-owner-pubkey>:<base-repo-id>"],
["r", "<earliest-unique-commit-id-of-repo>"] // so clients can subscribe to all patches sent to a local git repo
["r", "<earliest-unique-commit-id-of-repo>"], // so clients can subscribe to all patches sent to a local git repo
["p", "<repository-owner>"],
["p", "<other-user>"], // 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 @@ -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", "<current-commit-id>"],
["r", "<current-commit-id>"] // so clients can find existing patches for a specific commit
["r", "<current-commit-id>"], // so clients can find existing patches for a specific commit
["parent-commit", "<parent-commit-id>"],
["commit-pgp-sig", "-----BEGIN PGP SIGNATURE-----..."], // empty string for unsigned commit
["committer", "<name>", "<email>", "<timestamp>", "<timezone offset in minutes>"],
@ -106,7 +149,7 @@ The first patch revision in a patch revision SHOULD include a [NIP-10](10.md) `e @@ -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: @@ -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`
- Status events: Used throughout PR, patch, and issue management

124
docs/tutorial.md

@ -320,9 +320,118 @@ Pull requests can have the following statuses: @@ -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-content-from-event>" > 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: @@ -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

1
nostr/commit-signatures.jsonl

@ -27,3 +27,4 @@ @@ -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"}

224
src/lib/components/PRDetail.svelte

@ -23,9 +23,12 @@ @@ -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<Array<{
id: string;
@ -74,6 +77,13 @@ @@ -74,6 +77,13 @@
let currentFilePath = $state<string | null>(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 @@ @@ -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;
}
}
</script>
<div class="pr-detail-view">
@ -302,6 +394,27 @@ @@ -302,6 +394,27 @@
{/if}
<span>Created {new Date(pr.created_at * 1000).toLocaleString()}</span>
</div>
{#if isMaintainer && userPubkeyHex}
<div class="pr-actions">
{#if pr.status === 'open'}
<button onclick={() => showMergeDialog = true} disabled={merging || !pr.commitId} class="action-btn merge-btn">
{merging ? 'Merging...' : 'Merge'}
</button>
<button onclick={() => updatePRStatus('closed')} disabled={updatingStatus} class="action-btn close-btn">
{updatingStatus ? 'Closing...' : 'Close'}
</button>
{:else if pr.status === 'closed'}
<button onclick={() => updatePRStatus('open')} disabled={updatingStatus} class="action-btn reopen-btn">
{updatingStatus ? 'Reopening...' : 'Reopen'}
</button>
{/if}
{#if pr.status !== 'draft'}
<button onclick={() => updatePRStatus('draft')} disabled={updatingStatus} class="action-btn draft-btn">
{updatingStatus ? 'Updating...' : 'Mark as Draft'}
</button>
{/if}
</div>
{/if}
</div>
<div class="pr-body">
@ -751,4 +864,113 @@ @@ -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;
}
}
</style>
<!-- Merge Dialog -->
{#if showMergeDialog}
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Merge pull request"
tabindex="-1"
onclick={() => showMergeDialog = false}
onkeydown={(e) => {
if (e.key === 'Escape') {
showMergeDialog = false;
}
}}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="modal" role="document" onclick={(e) => e.stopPropagation()}>
<h3>Merge Pull Request</h3>
<label>
Target Branch:
<input type="text" bind:value={mergeTargetBranch} placeholder="main" />
</label>
<label>
Merge Message (optional):
<textarea bind:value={mergeMessage} rows="3" placeholder="Merge pull request..."></textarea>
</label>
<div class="modal-actions">
<button onclick={() => { showMergeDialog = false; mergeMessage = ''; }} class="cancel-btn">Cancel</button>
<button onclick={mergePR} disabled={merging || !mergeTargetBranch.trim()} class="save-btn">
{merging ? 'Merging...' : 'Merge'}
</button>
</div>
</div>
</div>
{/if}

44
src/lib/services/nostr/prs-service.ts

@ -200,4 +200,48 @@ export class PRsService { @@ -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<NostrEvent> {
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;
}
}

36
src/routes/api/repos/[npub]/[repo]/issues/+server.ts

@ -9,9 +9,12 @@ import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handler @@ -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( @@ -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 }
);

33
src/routes/api/repos/[npub]/[repo]/prs/+server.ts

@ -55,3 +55,36 @@ export const POST: RequestHandler = withRepoValidation( @@ -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 }
);

88
src/routes/api/repos/[npub]/[repo]/prs/merge/+server.ts

@ -0,0 +1,88 @@ @@ -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 }
);

43
src/routes/api/repos/[npub]/[repo]/prs/update/+server.ts

@ -0,0 +1,43 @@ @@ -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 }
);

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

@ -290,6 +290,7 @@ @@ -290,6 +290,7 @@
let newIssueSubject = $state('');
let newIssueContent = $state('');
let newIssueLabels = $state<string[]>(['']);
let updatingIssueStatus = $state<Record<string, boolean>>({});
// Pull Requests
let prs = $state<Array<{ id: string; subject: string; content: string; status: string; author: string; created_at: number; commitId?: string; kind: number }>>([]);
@ -302,6 +303,12 @@ @@ -302,6 +303,12 @@
let newPRLabels = $state<string[]>(['']);
let selectedPR = $state<string | null>(null);
// Patches
let showCreatePatchDialog = $state(false);
let newPatchContent = $state('');
let newPatchSubject = $state('');
let creatingPatch = $state(false);
// Documentation
let documentationContent = $state<string | null>(null);
let documentationHtml = $state<string | null>(null);
@ -2980,6 +2987,47 @@ @@ -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 @@ @@ -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<NostrEvent, 'sig' | 'id'> = {
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<string | null>(null);
$effect(() => {
@ -3212,6 +3328,15 @@ @@ -3212,6 +3328,15 @@
<button onclick={() => { forkRepository(); showRepoMenu = false; }} disabled={forking} class="repo-menu-item">
{forking ? 'Forking...' : 'Fork'}
</button>
<button onclick={() => { showCreateIssueDialog = true; showRepoMenu = false; }} class="repo-menu-item">
Create Issue
</button>
<button onclick={() => { showCreatePRDialog = true; showRepoMenu = false; }} class="repo-menu-item">
Create Pull Request
</button>
<button onclick={() => { showCreatePatchDialog = true; showRepoMenu = false; }} class="repo-menu-item">
Create Patch
</button>
{#if hasUnlimitedAccess($userStore.userLevel) && (isRepoCloned === false || (isRepoCloned === null && !checkingCloneStatus))}
<button
onclick={() => { cloneRepository(); showRepoMenu = false; }}
@ -3695,6 +3820,22 @@ @@ -3695,6 +3820,22 @@
<span>{new Date(issue.created_at * 1000).toLocaleDateString()}</span>
<EventCopyButton eventId={issue.id} kind={issue.kind} pubkey={issue.author} />
</div>
{#if userPubkeyHex && (isMaintainer || userPubkeyHex === issue.author)}
<div class="issue-actions">
{#if issue.status === 'open'}
<button onclick={() => updateIssueStatus(issue.id, issue.author, 'closed')} disabled={updatingIssueStatus[issue.id]} class="issue-action-btn close-btn">
{updatingIssueStatus[issue.id] ? 'Closing...' : 'Close'}
</button>
<button onclick={() => updateIssueStatus(issue.id, issue.author, 'resolved')} disabled={updatingIssueStatus[issue.id]} class="issue-action-btn resolve-btn">
{updatingIssueStatus[issue.id] ? 'Resolving...' : 'Resolve'}
</button>
{:else if issue.status === 'closed' || issue.status === 'resolved'}
<button onclick={() => updateIssueStatus(issue.id, issue.author, 'open')} disabled={updatingIssueStatus[issue.id]} class="issue-action-btn reopen-btn">
{updatingIssueStatus[issue.id] ? 'Reopening...' : 'Reopen'}
</button>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
@ -3915,6 +4056,9 @@ @@ -3915,6 +4056,9 @@
{npub}
{repo}
{repoOwnerPubkey}
isMaintainer={isMaintainer}
userPubkeyHex={userPubkeyHex}
onStatusUpdate={loadPRs}
/>
<button onclick={() => selectedPR = null} class="back-btn">← Back to PR List</button>
{/if}
@ -4551,6 +4695,44 @@ @@ -4551,6 +4695,44 @@
</div>
{/if}
<!-- Create Patch Dialog -->
{#if showCreatePatchDialog && userPubkey}
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Create new patch"
onclick={() => showCreatePatchDialog = false}
onkeydown={(e) => e.key === 'Escape' && (showCreatePatchDialog = false)}
tabindex="-1"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="modal"
role="document"
onclick={(e) => e.stopPropagation()}
>
<h3>Create New Patch</h3>
<p class="help-text">Enter your patch content in git format-patch format. Patches should be under 60KB.</p>
<label>
Subject (optional):
<input type="text" bind:value={newPatchSubject} placeholder="Patch title..." />
</label>
<label>
Patch Content:
<textarea bind:value={newPatchContent} rows="15" placeholder="Paste your git format-patch output here..."></textarea>
</label>
<div class="modal-actions">
<button onclick={() => showCreatePatchDialog = false} class="cancel-button">Cancel</button>
<button onclick={createPatch} disabled={!newPatchContent.trim() || creatingPatch} class="save-button">
{creatingPatch ? 'Creating...' : 'Create Patch'}
</button>
</div>
</div>
</div>
{/if}
<!-- Commit Dialog -->
{#if showCommitDialog && userPubkey && isMaintainer}
<div
@ -5057,6 +5239,143 @@ @@ -5057,6 +5239,143 @@
gap: 0.5rem;
}
/* Modal responsive styles */
.modal {
width: 95%;
max-width: 95%;
max-height: 90vh;
margin: 1rem;
padding: 1rem;
overflow-y: auto;
}
.modal h3 {
font-size: 1.25rem;
margin-bottom: 1rem;
}
.modal label {
display: block;
margin-bottom: 0.75rem;
font-size: 0.9rem;
}
.modal input,
.modal textarea,
.modal select {
width: 100%;
font-size: 0.9rem;
padding: 0.5rem;
}
.modal-actions {
flex-direction: column;
gap: 0.5rem;
}
.modal-actions button {
width: 100%;
padding: 0.75rem;
}
/* Tab buttons responsive */
.tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tab-button {
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
white-space: nowrap;
flex-shrink: 0;
}
/* Repo menu dropdown responsive */
.repo-menu-dropdown {
right: 0;
left: auto;
min-width: 200px;
max-width: calc(100vw - 2rem);
}
/* Issue and PR lists responsive */
.issue-item, .pr-item {
padding: 0.5rem;
}
.issue-header, .pr-header {
flex-wrap: wrap;
gap: 0.25rem;
}
.issue-subject, .pr-subject {
font-size: 0.9rem;
word-break: break-word;
}
.issue-meta, .pr-meta {
flex-wrap: wrap;
font-size: 0.7rem;
gap: 0.5rem;
}
.issue-actions {
flex-direction: column;
width: 100%;
}
.issue-action-btn {
width: 100%;
padding: 0.5rem;
}
/* Sidebars responsive */
.prs-sidebar, .issues-sidebar {
width: 100%;
max-width: 100%;
}
.prs-header, .issues-header {
flex-wrap: wrap;
gap: 0.5rem;
}
.create-pr-button, .create-issue-button {
width: 100%;
padding: 0.75rem;
}
/* File tree responsive */
.file-tree-header {
flex-wrap: wrap;
gap: 0.5rem;
}
.file-tree-actions {
flex-wrap: wrap;
gap: 0.5rem;
}
.create-file-button {
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
}
/* Back button responsive */
.back-btn {
width: 100%;
margin-bottom: 1rem;
padding: 0.75rem;
}
}
.non-maintainer-notice {
font-size: 0.7rem;
flex: 1 1 100%;
@ -6509,6 +6828,55 @@ @@ -6509,6 +6828,55 @@
flex: 1;
}
.issue-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.issue-action-btn {
padding: 0.4rem 0.8rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.issue-action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.issue-action-btn.close-btn {
background: var(--error-text, #dc3545);
color: white;
}
.issue-action-btn.close-btn:hover:not(:disabled) {
background: var(--error-hover, #c82333);
}
.issue-action-btn.resolve-btn {
background: var(--success-text, #28a745);
color: white;
}
.issue-action-btn.resolve-btn:hover:not(:disabled) {
background: var(--success-hover, #218838);
}
.issue-action-btn.reopen-btn {
background: var(--accent, #007bff);
color: white;
}
.issue-action-btn.reopen-btn:hover:not(:disabled) {
background: var(--accent-hover, #0056b3);
}
.issue-item, .pr-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);

Loading…
Cancel
Save