improve pull request flow

This commit is contained in:
2026-09-02 09:53:50 +07:00
parent c054f61593
commit db3eaff4b9
6 changed files with 1210 additions and 279 deletions
+56 -2
View File
@@ -1302,6 +1302,55 @@ impl Backend {
})
}
/// Broadcast and locally store an already-signed event, like
/// [`Self::send`] without the signing step. Callers that signed early
/// (e.g. to learn the event id before pushing a commit to the grasp
/// servers) publish through this.
pub fn publish_event(
&mut self,
event: Event,
cx: &mut Context<Self>,
) -> Task<Result<Event, Error>> {
let client = self.client.clone();
cx.spawn(async move |this, cx| {
let work = cx.background_spawn(async move {
let output = client.send_event(&event).await?;
if output.success.is_empty() && !output.failed.is_empty() {
let reasons = output
.failed
.values()
.cloned()
.collect::<Vec<String>>()
.join(", ");
return Err(anyhow!("event not accepted by any relay: {reasons}"));
}
Ok(event.clone())
});
let result = work.await;
match &result {
Ok(event) => {
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::Published(Box::new(event.clone())));
})
.ok();
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::error(e.to_string()));
})
.ok();
}
}
result
})
}
/// Publish a NIP-34 repository announcement (kind 30617) with the
/// current signer. The returned task yields the published event, so
/// callers can show inline progress/errors.
@@ -1414,7 +1463,12 @@ async fn connect_repo_relays_only(
let relays = &relays;
let sync_opts = sync_opts.clone();
async move {
if let Err(e) = client.sync(filter).with(relays.iter()).opts(sync_opts).await {
if let Err(e) = client
.sync(filter)
.with(relays.iter())
.opts(sync_opts)
.await
{
log::warn!("repo relay negentropy sync failed: {e}");
}
}
@@ -1469,7 +1523,7 @@ fn with_master_key(uri: &str, keys: &Keys) -> String {
/// A `https://<host>` (or `http://<host>` for `ws://` grasp servers, like
/// ngit) base URL for a grasp server. The repository then lives at
/// `{base}/{npub}/{repo-id}.git`.
fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
// `domain()` drops the port; parse the full URL to keep it (local dev
// grasp servers commonly run on a custom port).
let parsed = Url::parse(relay.as_str()).ok()?;
+354 -92
View File
@@ -1,10 +1,11 @@
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Error;
use bitcoin_hashes::sha1::Hash as Sha1Hash;
use gpui::{AppContext, Context, Subscription, Task};
use gpui::{AppContext, AsyncApp, Context, Subscription, Task, WeakEntity};
use nostr::event::IntoEventBuilder;
use nostr_sdk::prelude::*;
use signed_core::{
@@ -13,13 +14,17 @@ use signed_core::{
subject_override,
};
use crate::backend::{Backend, BackendEvent};
use crate::backend::{Backend, BackendEvent, grasp_base_url};
use crate::git_store::GitStore;
/// Delay between a refresh request and the actual re-query, so bursts of
/// events (e.g. per-event `NostrUpdate`s) collapse into one query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// Maximum size of one patch event, following NIP-34's guidance that
/// patches should be used when each event is under 60kb.
const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
/// Per-repository store: announcement, state, issues, patches, PRs,
/// comments and their resolved statuses. Always derived from the local
/// database.
@@ -52,6 +57,9 @@ pub struct RepoStore {
version: u64,
/// Error of the last action initiated from this store, if any.
pub last_error: Option<String>,
/// Non-fatal warning of the last action (e.g. a PR published without
/// its commit reaching a grasp server), if any.
pub last_warning: Option<String>,
/// Relays announced by this repository (NIP-34 `relays` tag) that we
/// have already been asked to connect to and fetch from, to avoid
/// re-subscribing on every refresh.
@@ -130,6 +138,7 @@ impl RepoStore {
labels: Vec::new(),
version: 0,
last_error: None,
last_warning: None,
repo_relays: HashSet::new(),
root_fetches: HashSet::new(),
refreshing: false,
@@ -620,14 +629,23 @@ impl RepoStore {
/// (kind 1617) carrying the `git format-patch` output, which the PR
/// references via an `e` tag (NIP-34).
///
/// The patch is published first so the PR can reference its id. The
/// proposed commit is parsed from the patch's `From <commit>` header;
/// without one publishing is refused, because the PR's `c` tag must
/// carry a real commit id for other NIP-34 clients to verify and apply
/// the proposal. The `clone` tag carries the announced mirror URLs; the
/// linked patch is the source of truth until the commit is pushed there.
/// The patch series is published first (one kind-1617 event per commit,
/// chained with NIP-10 `e` replies, each under [`MAX_PATCH_EVENT_BYTES`])
/// so the PR can reference the root patch's id. The proposed commit is
/// parsed from the series' last `From <commit>` header (the tip); without
/// one publishing is refused, because the PR's `c` tag must carry a real
/// commit id for other NIP-34 clients to verify and apply the proposal.
/// The `clone` tag carries the announced mirror URLs, and when
/// `push_from` is set the tip is pushed to those servers under
/// `refs/nostr/<event-id>` (best-effort) before the PR is published, so
/// the commit is actually downloadable there; the linked patch stays the
/// source of truth either way.
///
/// `branch_name` lands in the PR's `branch-name` tag (NIP-34); `draft`
/// publishes a kind-1633 status right after the PR event.
/// publishes a kind-1633 status right after the PR event. `merge_base`
/// is the hex commit the proposed branch forked from, computed from a
/// local checkout when the patch was generated there.
#[allow(clippy::too_many_arguments)]
pub fn open_pull_request(
&mut self,
subject: Option<String>,
@@ -635,12 +653,36 @@ impl RepoStore {
branch_name: Option<String>,
patch: String,
draft: bool,
merge_base: Option<String>,
push_from: Option<PathBuf>,
cx: &mut Context<Self>,
) {
self.last_error = None;
self.last_warning = None;
let Some(current_commit) =
patch_current_commit(&patch).and_then(|hex| hex.parse::<Sha1Hash>().ok())
let series: Vec<String> = signed_git::split_patch_series(&patch)
.into_iter()
.map(str::to_owned)
.collect();
if let Some(oversized) = series
.iter()
.find(|part| part.len() > MAX_PATCH_EVENT_BYTES)
{
self.last_error = Some(format!(
"patch too large ({} bytes; NIP-34 suggests keeping each patch under {} bytes)",
oversized.len(),
MAX_PATCH_EVENT_BYTES
));
cx.notify();
return;
}
// The tip of the series is its last commit; `git format-patch`
// orders patches oldest first.
let Some(current_commit) = series
.last()
.and_then(|part| patch_current_commit(part))
.and_then(|hex| hex.parse::<Sha1Hash>().ok())
else {
self.last_error = Some(
"Patch must be `git format-patch` output with a `From <commit-id>` header".into(),
@@ -649,35 +691,41 @@ impl RepoStore {
return;
};
let Ok(root_marker) = Tag::parse(["t", "root"]) else {
return;
};
let commit_hex = current_commit.to_string();
let mut patch_tags = vec![
Tag::coordinate(self.addr.clone(), None),
Tag::public_key(self.addr.public_key),
root_marker,
];
// NIP-34: the `r` EUC tag lets clients subscribe to all patches of
// this repository; `commit`/`r` tags reference the proposed commit.
if let Some(euc) = self.announcement.as_ref().and_then(|a| a.euc.clone())
&& let Ok(tag) = Tag::parse(["r", &euc])
{
patch_tags.push(tag);
}
if let Ok(tag) = Tag::parse(["commit", &commit_hex]) {
patch_tags.push(tag);
}
if let Ok(tag) = Tag::parse(["r", &commit_hex]) {
patch_tags.push(tag);
}
let patch_builder = EventBuilder::new(Kind::GitPatch, patch).tags(patch_tags);
let backend = Backend::global(cx);
let patch_task = backend.update(cx, |backend, cx| backend.send(patch_builder, cx));
if backend.read(cx).current_user().is_none() {
self.last_error = Some("Sign in to open a pull request".into());
cx.notify();
return;
}
let signer = backend.read(cx).signer();
let addr = self.addr.clone();
let owner = self.addr.public_key;
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let (push_owner, push_repo_id, push_relays) = self
.announcement
.as_ref()
.map(|a| {
let owner = a.owner.to_bech32().unwrap_or_else(|_| a.owner.to_hex());
(owner, a.id.clone(), a.relays.clone())
})
.unwrap_or_default();
self.tasks.push(cx.spawn(async move |this, cx| {
let patch_event = match patch_task.await {
// The PR references the root patch event so viewers can find
// the patch without carrying it inline.
let root_patch = match publish_patch_series(
&this,
cx,
&addr,
owner,
euc.as_deref(),
&series,
"root",
None,
)
.await
{
Ok(event) => event,
Err(e) => {
return this.update(cx, |this, cx| {
@@ -687,9 +735,7 @@ impl RepoStore {
}
};
// The PR references the patch event so viewers can find the
// patch without carrying it inline.
let pr_task = this.update(cx, |this, cx| {
let builder = this.update(cx, |this, _cx| {
let builder = GitPullRequest {
repository: this.addr.clone(),
content: description,
@@ -697,30 +743,82 @@ impl RepoStore {
labels: Vec::new(),
branch_name,
// NIP-34: PRs carry at least one clone URL where the
// tip commit can be downloaded; use the repository's
// announced mirrors until a push backend exists.
// tip commit can be downloaded; the announced mirrors
// are also the servers the tip is pushed to below.
clone: this
.announcement
.as_ref()
.map(|a| a.clone.clone())
.unwrap_or_default(),
current_commit,
root_patch_event: Some(patch_event.id),
merge_base: None,
root_patch_event: Some(root_patch.id),
merge_base: merge_base
.and_then(|hex| hex.parse::<bitcoin_hashes::Sha1>().ok()),
}
.into_event_builder();
// NIP-34: the `r` EUC tag lets clients subscribe to all PRs of this repository.
let builder = match this.announcement.as_ref().and_then(|a| a.euc.clone()) {
// NIP-34: the `r` EUC tag lets clients subscribe to all
// PRs of this repository; the SDK builder omits it.
match this.announcement.as_ref().and_then(|a| a.euc.clone()) {
Some(euc) => builder.tag(Tag::parse(["r", &euc]).expect("valid r tag")),
None => builder,
};
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.send(builder, cx))
}
})?;
let pr_event = match pr_task.await {
// Sign before publishing so the tip can be pushed to the grasp
// servers under `refs/nostr/<event-id>` (nak's convention):
// readers fetch that ref to get the commit behind the `c` tag.
let event = cx
.background_spawn({
let signer = signer.clone();
async move { builder.finalize_async(&signer).await }
})
.await?;
if let Some(path) = push_from.as_ref() {
let tip = current_commit.to_string();
let reference = format!("refs/nostr/{}", event.id.to_hex());
let (pushed, failures) = cx
.background_spawn({
let path = path.clone();
let tip = tip.clone();
let reference = reference.clone();
let owner = push_owner.clone();
let repo_id = push_repo_id.clone();
let relays = push_relays.clone();
async move {
let mut failures = Vec::new();
let mut pushed = 0;
for relay in &relays {
let Some(base) = grasp_base_url(relay) else {
continue;
};
let url = format!("{base}/{owner}/{repo_id}.git");
match signed_git::push_commit_ref(&path, &url, &tip, &reference) {
Ok(()) => pushed += 1,
Err(e) => failures.push(format!("{relay}: {e}")),
}
}
(pushed, failures)
}
})
.await;
if pushed == 0 {
this.update(cx, |this, cx| {
this.last_warning = Some(format!(
"Pull request published, but the commit could not be pushed to any grasp server ({}); the patch is still the source of truth",
failures.join("; ")
));
cx.notify();
})?;
}
}
let publish_task = this.update(cx, |_this, cx| {
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.publish_event(event, cx))
})?;
let pr_event = match publish_task.await {
Ok(event) => event,
Err(e) => {
return this.update(cx, |this, cx| {
@@ -742,13 +840,15 @@ impl RepoStore {
}));
}
/// Update a pull request: publish a revision patch event chained to the
/// original root patch (`t root-revision` and a NIP-10 `e` reply, per
/// NIP-34), then a kind-1619 PR update event carrying the new tip.
/// Update a pull request: publish revision patch events chained to the
/// original root patch (`t root-revision` and a NIP-10 `e` reply on the
/// first, per NIP-34), then a kind-1619 PR update event carrying the
/// new tip.
///
/// Only the PR author may update it; other authors must open a new PR.
pub fn update_pull_request(&mut self, root: &Event, patch: String, cx: &mut Context<Self>) {
self.last_error = None;
self.last_warning = None;
let backend = Backend::global(cx);
@@ -764,8 +864,28 @@ impl RepoStore {
return;
}
let Some(current_commit) =
patch_current_commit(&patch).and_then(|hex| hex.parse::<Sha1Hash>().ok())
let series: Vec<String> = signed_git::split_patch_series(&patch)
.into_iter()
.map(str::to_owned)
.collect();
if let Some(oversized) = series
.iter()
.find(|part| part.len() > MAX_PATCH_EVENT_BYTES)
{
self.last_error = Some(format!(
"patch too large ({} bytes; NIP-34 suggests keeping each patch under {} bytes)",
oversized.len(),
MAX_PATCH_EVENT_BYTES
));
cx.notify();
return;
}
// The new tip of the PR is the last commit of the series.
let Some(current_commit) = series
.last()
.and_then(|part| patch_current_commit(part))
.and_then(|hex| hex.parse::<Sha1Hash>().ok())
else {
self.last_error = Some(
"Patch must be `git format-patch` output with a `From <commit-id>` header".into(),
@@ -783,45 +903,29 @@ impl RepoStore {
.map(|p| p.id)
});
let commit_hex = current_commit.to_string();
let mut patch_tags = vec![
Tag::coordinate(self.addr.clone(), None),
Tag::public_key(self.addr.public_key),
Tag::parse(["t", "root-revision"]).expect("valid root-revision tag"),
];
if let Some(root_patch_id) = root_patch_id
&& let Ok(tag) = Tag::parse(["e", &root_patch_id.to_hex(), "", "reply"])
{
patch_tags.push(tag);
}
// NIP-34: the `r` EUC tag lets clients subscribe to all patches of
// this repository; `commit`/`r` tags reference the new tip.
if let Some(euc) = self.announcement.as_ref().and_then(|a| a.euc.clone())
&& let Ok(tag) = Tag::parse(["r", &euc])
{
patch_tags.push(tag);
}
if let Ok(tag) = Tag::parse(["commit", &commit_hex]) {
patch_tags.push(tag);
}
if let Ok(tag) = Tag::parse(["r", &commit_hex]) {
patch_tags.push(tag);
}
let patch_builder = EventBuilder::new(Kind::GitPatch, patch).tags(patch_tags);
let backend = Backend::global(cx);
let patch_task = backend.update(cx, |backend, cx| backend.send(patch_builder, cx));
let addr = self.addr.clone();
let owner = self.addr.public_key;
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let root = root.clone();
let clone: Vec<Url> = self
.announcement
.as_ref()
.map(|a| a.clone.clone())
.unwrap_or_default();
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = patch_task.await {
if let Err(e) = publish_patch_series(
&this,
cx,
&addr,
owner,
euc.as_deref(),
&series,
"root-revision",
root_patch_id,
)
.await
{
return this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
@@ -846,6 +950,7 @@ impl RepoStore {
None => builder,
};
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.send(builder, cx))
})?;
@@ -953,7 +1058,10 @@ impl RepoStore {
/// Merge a pull request: apply its patch (the content of the linked
/// root patch event) to the local clone of this repository, then publish
/// the merged status.
/// a kind-1631 (Applied) status event with merge provenance: the commits
/// `git am` created (`applied-as-commits` + `r` tags) and the applied
/// patch events (`q` tags, plus `e` reply tags for every patch beyond
/// the root, per NIP-34).
///
/// Only the repository author may merge. The clone is created on demand
/// from the announcement's clone URLs when needed. Patch application
@@ -961,6 +1069,7 @@ impl RepoStore {
/// no longer applies) surface in [`Self::last_error`].
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
self.last_error = None;
self.last_warning = None;
let is_author = Backend::global(cx)
.read(cx)
@@ -979,21 +1088,46 @@ impl RepoStore {
.map(|a| a.clone.iter().map(ToString::to_string).collect())
.unwrap_or_default();
let patch = pull_request_patch(root, self.patches.iter());
// The applied patch events, for the status tags below.
let patches: Vec<Event> = pull_request_patches(root, self.patches.iter())
.into_iter()
.cloned()
.collect();
let relay_hint = self
.announcement
.as_ref()
.and_then(|a| a.relays.first())
.map(ToString::to_string)
.unwrap_or_default();
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let root = root.clone();
let apply = cx.background_spawn(async move {
let repo = cache.ensure_clone(&addr, &clone_urls)?;
let workdir = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?;
signed_git::apply_patch(workdir, &patch)
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
.to_path_buf();
// The commits created by the apply: everything between the
// previous HEAD and the new one, oldest first.
let previous = signed_git::head_commit_id(&workdir)?;
signed_git::apply_patch(&workdir, &patch)?;
let applied = signed_git::commits_since(&workdir, previous.as_deref())?;
Ok::<_, Error>(applied)
});
self.tasks.push(cx.spawn(async move |this, cx| {
match apply.await {
Ok(()) => {
Ok(applied) => {
this.update(cx, |this, cx| {
this.set_status(&root, RepoStatus::Applied, cx);
this.publish_applied_status(
&root,
&patches,
&applied,
&relay_hint,
euc.as_deref(),
cx,
);
})?;
}
Err(e) => {
@@ -1007,6 +1141,62 @@ impl RepoStore {
}));
}
/// Publish a kind-1631 (Applied) status event for `root` after a merge:
/// `applied-as-commits` + `r` tags for the commits `git am` created,
/// `q` tags for the applied patch events, and `e` reply tags for every
/// patch of the series beyond the root (NIP-34).
fn publish_applied_status(
&mut self,
root: &Event,
patches: &[Event],
applied: &[String],
relay_hint: &str,
euc: Option<&str>,
cx: &mut Context<Self>,
) {
let mut tags = vec![
Tag::parse(["e", &root.id.to_hex(), "", "root"]).expect("valid root tag"),
Tag::public_key(self.addr.public_key),
Tag::public_key(root.pubkey),
Tag::coordinate(self.addr.clone(), None),
];
if let Some(euc) = euc
&& let Ok(tag) = Tag::parse(["r", euc])
{
tags.push(tag);
}
// The applied patch events: a `q` tag per event, plus an `e` reply
// for every event beyond the root (chain parts and revisions), so
// their statuses resolve to Applied too.
for (ix, patch) in patches.iter().enumerate() {
if let Ok(tag) =
Tag::parse(["q", &patch.id.to_hex(), relay_hint, &patch.pubkey.to_hex()])
{
tags.push(tag);
}
if ix > 0
&& let Ok(tag) = Tag::parse(["e", &patch.id.to_hex(), "", "reply"])
{
tags.push(tag);
}
}
// The commits `git am` created on top of the previous HEAD.
if !applied.is_empty() {
let mut applied_tag = vec!["applied-as-commits".to_string()];
applied_tag.extend(applied.iter().cloned());
if let Ok(tag) = Tag::parse(applied_tag) {
tags.push(tag);
}
for commit in applied {
if let Ok(tag) = Tag::parse(["r", commit]) {
tags.push(tag);
}
}
}
self.send(EventBuilder::new(Kind::GitStatusApplied, "").tags(tags), cx);
}
fn send(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
self.last_error = None;
@@ -1092,6 +1282,78 @@ fn patch_current_commit(patch: &str) -> Option<&str> {
hex.split_whitespace().next().filter(|hex| hex.len() == 40)
}
/// Publish a `git format-patch` series as chained kind-1617 events and
/// return the root event (the one a PR references). The first part carries
/// `first_marker` (`t root`, or `t root-revision` with an `e` reply to
/// `reply_to` for revisions); every later part replies to the previous one
/// (NIP-34). Every part gets the repository coordinate, the owner, its own
/// `commit`/`r` tags, and the repository EUC when known.
#[allow(clippy::too_many_arguments)]
async fn publish_patch_series(
this: &WeakEntity<RepoStore>,
cx: &mut AsyncApp,
addr: &RepoAddr,
owner: PublicKey,
euc: Option<&str>,
series: &[String],
first_marker: &str,
reply_to: Option<EventId>,
) -> Result<Event, Error> {
let mut root: Option<Event> = None;
let mut previous = reply_to;
for (ix, part) in series.iter().enumerate() {
let Some(commit) = patch_current_commit(part).filter(|hex| hex.len() == 40) else {
return Err(anyhow::anyhow!(
"patch {} of the series has no `From <commit-id>` header",
ix + 1
));
};
let mut tags = vec![Tag::coordinate(addr.clone(), None), Tag::public_key(owner)];
if ix == 0 {
if let Ok(tag) = Tag::parse(["t", first_marker]) {
tags.push(tag);
}
if let Some(root_id) = reply_to
&& let Ok(tag) = Tag::parse(["e", &root_id.to_hex(), "", "reply"])
{
tags.push(tag);
}
} else if let Some(previous) = previous
&& let Ok(tag) = Tag::parse(["e", &previous.to_hex(), "", "reply"])
{
tags.push(tag);
}
if let Some(euc) = euc
&& let Ok(tag) = Tag::parse(["r", euc])
{
tags.push(tag);
}
if let Ok(tag) = Tag::parse(["commit", commit]) {
tags.push(tag);
}
if let Ok(tag) = Tag::parse(["r", commit]) {
tags.push(tag);
}
let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags);
let task = this.update(cx, |_this, cx| {
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.send(builder, cx))
})?;
let event = task.await?;
if root.is_none() {
root = Some(event.clone());
}
previous = Some(event.id);
}
root.ok_or_else(|| anyhow::anyhow!("patch series is empty"))
}
/// Build a NIP-22 kind-1111 comment: uppercase `E`/`K`/`P` tags scope the
/// thread root, lowercase `e`/`k`/`p` the direct parent (or the root for a
/// top-level comment). An `a` tag with the repository coordinate (not part