diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index b784804..5948d4d 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -13,6 +13,6 @@ pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_o pub use clone_url::{CloneTarget, parse_clone_url}; pub use comments::{CommentThread, comment_threads}; pub use deletions::Deletions; -pub use model::{Announcement, activity_subject, pull_request_patch}; +pub use model::{Announcement, activity_subject, pull_request_patch, pull_request_patches}; pub use state::{build_state, parse_state}; pub use status::{RepoStatus, references_root, resolve_status}; diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index 00fa896..22624f9 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -163,7 +163,8 @@ impl ProfileStore { /// Load recently seen profiles from the local database. fn load(&mut self, cx: &mut Context) { - let client = Backend::global(cx).read(cx).client(); + let backend = Backend::global(cx); + let client = backend.read(cx).client(); let work = cx.background_spawn(async move { let filter = Filter::new().kind(Kind::Metadata).limit(200); @@ -197,7 +198,8 @@ impl ProfileStore { /// Re-read the latest metadata of an author from the local database. fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context) { - let client = Backend::global(cx).read(cx).client(); + let backend = Backend::global(cx); + let client = backend.read(cx).client(); let work = cx.background_spawn(async move { let filter = Filter::new().kind(Kind::Metadata).author(public_key); @@ -238,7 +240,8 @@ impl ProfileStore { return; } - let client = Backend::global(cx).read(cx).client(); + let backend = Backend::global(cx); + let client = backend.read(cx).client(); let work = cx.background_spawn(async move { let filter = Filter::new().kind(Kind::Metadata).authors(authors); diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index ec4a156..a5a154c 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -3,12 +3,14 @@ use std::collections::{HashMap, HashSet}; use std::time::Duration; use anyhow::Error; +use bitcoin_hashes::sha1::Hash as Sha1Hash; use gpui::{AppContext, Context, Subscription, Task}; use nostr::event::IntoEventBuilder; use nostr_sdk::prelude::*; use signed_core::{ Announcement, COVER_NOTE_KIND, Deletions, RepoAddr, RepoStatus, build_state, cover_note, - filters, labels_and_subject, parse_state, pull_request_patch, subject_override, + filters, labels_and_subject, parse_state, pull_request_patch, pull_request_patches, + subject_override, }; use crate::backend::{Backend, BackendEvent}; @@ -235,7 +237,8 @@ impl RepoStore { fn run_refresh(&mut self, cx: &mut Context) { self.refreshing = true; - let client = Backend::global(cx).read(cx).client(); + let backend = Backend::global(cx); + let client = backend.read(cx).client(); let addr = self.addr.clone(); let work = cx.background_spawn(async move { @@ -623,17 +626,21 @@ impl RepoStore { /// 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. + /// `branch_name` lands in the PR's `branch-name` tag (NIP-34); `draft` + /// publishes a kind-1633 status right after the PR event. pub fn open_pull_request( &mut self, subject: Option, description: String, + branch_name: Option, patch: String, + draft: bool, cx: &mut Context, ) { self.last_error = None; let Some(current_commit) = - patch_current_commit(&patch).and_then(|hex| hex.parse::().ok()) + patch_current_commit(&patch).and_then(|hex| hex.parse::().ok()) else { self.last_error = Some( "Patch must be `git format-patch` output with a `From ` header".into(), @@ -666,8 +673,8 @@ impl RepoStore { } let patch_builder = EventBuilder::new(Kind::GitPatch, patch).tags(patch_tags); - let patch_task = - Backend::global(cx).update(cx, |backend, cx| backend.send(patch_builder, cx)); + let backend = Backend::global(cx); + let patch_task = backend.update(cx, |backend, cx| backend.send(patch_builder, cx)); self.tasks.push(cx.spawn(async move |this, cx| { let patch_event = match patch_task.await { @@ -688,7 +695,7 @@ impl RepoStore { content: description, subject, labels: Vec::new(), - branch_name: None, + 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. @@ -703,10 +710,146 @@ impl RepoStore { } .into_event_builder(); - Backend::global(cx).update(cx, |backend, cx| backend.send(builder, cx)) + // 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()) { + 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)) })?; - if let Err(e) = pr_task.await { + let pr_event = match pr_task.await { + Ok(event) => event, + Err(e) => { + return this.update(cx, |this, cx| { + this.last_error = Some(e.to_string()); + cx.notify(); + }); + } + }; + + // NIP-34: a draft PR carries a kind-1633 status event; publish + // it right after the PR event so viewers never show it open. + if draft { + this.update(cx, |this, cx| { + this.set_status(&pr_event, RepoStatus::Draft, cx); + })?; + } + + Ok(()) + })); + } + + /// 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. + /// + /// 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.last_error = None; + + let backend = Backend::global(cx); + + let Some(user) = backend.read(cx).current_user() else { + self.last_error = Some("Sign in to update the pull request".into()); + cx.notify(); + return; + }; + + if user != root.pubkey { + self.last_error = Some("Only the pull request author can update it".into()); + cx.notify(); + return; + } + + let Some(current_commit) = + patch_current_commit(&patch).and_then(|hex| hex.parse::().ok()) + else { + self.last_error = Some( + "Patch must be `git format-patch` output with a `From ` header".into(), + ); + cx.notify(); + return; + }; + + // NIP-34: the first patch of a revision replies to the original + // root patch (the PR's `e` tag; fall back to the oldest patch of + // the linked set for PRs without one). + let root_patch_id = root.tags.event_ids().next().or_else(|| { + pull_request_patches(root, self.patches.iter()) + .first() + .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 root = root.clone(); + let clone: Vec = 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 { + return this.update(cx, |this, cx| { + this.last_error = Some(e.to_string()); + cx.notify(); + }); + } + + let update_task = this.update(cx, |this, cx| { + let builder = GitPullRequestUpdate { + repository: this.addr.clone(), + pull_request_event: root.id, + pull_request_author: root.pubkey, + current_commit, + clone: clone.clone(), + merge_base: None, + } + .into_event_builder(); + + // NIP-34: the `r` EUC tag lets clients subscribe to all PR + // updates of this repository; the SDK builder omits it. + let builder = match euc.as_deref() { + Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")), + None => builder, + }; + + backend.update(cx, |backend, cx| backend.send(builder, cx)) + })?; + + if let Err(e) = update_task.await { return this.update(cx, |this, cx| { this.last_error = Some(e.to_string()); cx.notify(); @@ -729,7 +872,8 @@ impl RepoStore { .map(Announcement::effective_maintainers) .unwrap_or_default(); - let Some(user) = Backend::global(cx).read(cx).current_user() else { + let backend = Backend::global(cx); + let Some(user) = backend.read(cx).current_user() else { self.last_error = Some("Sign in to change the status".into()); cx.notify(); return; @@ -761,7 +905,8 @@ impl RepoStore { pub fn publish_state(&mut self, cx: &mut Context) { self.last_error = None; - let Some(user) = Backend::global(cx).read(cx).current_user() else { + let backend = Backend::global(cx); + let Some(user) = backend.read(cx).current_user() else { self.last_error = Some("Sign in to publish repository state".into()); cx.notify(); return; @@ -924,7 +1069,8 @@ fn resolve_statuses( .chain(pull_requests) .map(|root| { let events = by_root.get(&root.id).map(Vec::as_slice).unwrap_or(&[]); - let status = signed_core::resolve_status(events.iter().copied(), &root.pubkey, maintainers); + let status = + signed_core::resolve_status(events.iter().copied(), &root.pubkey, maintainers); (root.id, status) }) .collect() diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index 480e679..4b31623 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -202,7 +202,8 @@ impl RepoListStore { fn run_refresh(&mut self, cx: &mut Context) { self.refreshing = true; - let client = Backend::global(cx).read(cx).client(); + let backend = Backend::global(cx); + let client = backend.read(cx).client(); let author = self.author; let work = cx.background_spawn(async move { diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index dfce1af..6550103 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -991,7 +991,8 @@ impl RepoDetailView { return; } - Backend::global(cx).update(cx, |backend, cx| { + let backend = Backend::global(cx); + backend.update(cx, |backend, cx| { backend.subscribe_bootstrap(vec![filters::announcement(&addr)], cx); }); self.pending_upstream = Some(addr); @@ -1001,6 +1002,7 @@ impl RepoDetailView { cx.background_executor() .timer(Duration::from_millis(250)) .await; + let opened = this.update_in(cx, |this, window, cx| { let Some(addr) = this.pending_upstream.clone() else { return true; @@ -1020,10 +1022,12 @@ impl RepoDetailView { None => false, } })?; + if opened { return Ok(()); } } + this.update(cx, |this, _cx| this.pending_upstream = None)?; Ok(()) }); diff --git a/crates/workspace/src/views/repo_detail/pull_request_detail.rs b/crates/workspace/src/views/repo_detail/pull_request_detail.rs index ef52da5..927790f 100644 --- a/crates/workspace/src/views/repo_detail/pull_request_detail.rs +++ b/crates/workspace/src/views/repo_detail/pull_request_detail.rs @@ -12,6 +12,8 @@ use gpui::{ }; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::clipboard::Clipboard; +use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; +use gpui_component::form::{field, v_form}; use gpui_component::input::{Textarea, TextareaState}; use gpui_component::list::ListItem; use gpui_component::scroll::{ScrollableElement, Scrollbar}; @@ -20,12 +22,13 @@ use gpui_component::tab::{Tab, TabBar}; use gpui_component::tag::Tag; use gpui_component::tree::{TreeEntry, TreeState, tree}; use gpui_component::{ - ActiveTheme, Icon, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, + ActiveTheme, Icon, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex, + v_virtual_list, }; use nostr::prelude::{Event, EventId, Kind, Nip34Tag, PublicKey}; use signed_core::{activity_subject, pull_request_patch}; use signed_git::{CommitDiff, FileCommit, FileDiff, patch_commits, patch_diffs}; -use signed_state::{GitStore, ProfileStore, RepoStore}; +use signed_state::{Backend, GitStore, ProfileStore, RepoStore}; use signed_ui::image_cache::{MAX_IMAGES, image_cache}; use signed_ui::{UserAvatar, placeholder, status_badge, tree_row}; use utils::{relative_time, relative_time_secs}; @@ -183,7 +186,7 @@ impl PullRequestDetailView { cx.notify(); return; }; - let update = latest_update(store.pull_requests.iter(), &root.id); + let update = latest_update(store.pull_requests.iter(), root); let tip = update .and_then(current_commit_of) .or_else(|| current_commit_of(root)); @@ -1002,7 +1005,7 @@ impl PullRequestDetailView { /// Always-visible header: status badge and title, like the issue panel. fn render_header(&self, cx: &mut Context) -> AnyElement { let current_commit = self.current_commit.clone(); - let (title, status, branch) = { + let (title, status, branch, author) = { let store = self.store.read(cx); let Some(root) = store .pull_requests @@ -1015,9 +1018,14 @@ impl PullRequestDetailView { activity_subject(root), store.status_of(root), branch_name_of(root), + root.pubkey, ) }; + // Only the PR author may publish revisions (kind 1619, NIP-34). + let backend = Backend::global(cx); + let can_update = backend.read(cx).current_user() == Some(author); + v_flex() .px_4() .mb_4() @@ -1048,6 +1056,38 @@ impl PullRequestDetailView { .label(branch), ) }) + .when(can_update, |this| { + this.child( + Button::new("update-pr") + .ghost() + .small() + .icon(CustomIconName::GitPullRequest) + .label("Update") + .tooltip("Publish a new revision of this pull request") + .on_click(cx.listener({ + let store = self.store.clone(); + let pr_id = self.pr_id; + move |_this, _event, window, cx| { + let root = store + .read(cx) + .pull_requests + .iter() + .find(|pr| { + pr.id == pr_id && pr.kind == Kind::GitPullRequest + }) + .cloned(); + if let Some(root) = root { + open_update_pull_request_dialog( + store.clone(), + root, + window, + cx, + ); + } + } + })), + ) + }) .when_some(current_commit, |this, id| { this.child( h_flex() @@ -1064,6 +1104,68 @@ impl PullRequestDetailView { } } +/// Open the "update pull request" dialog: a patch input that submits a new +/// revision through [`RepoStore::update_pull_request`] when confirmed. +fn open_update_pull_request_dialog( + store: Entity, + root: Event, + window: &mut Window, + cx: &mut App, +) { + let patch = cx.new(|cx| { + TextareaState::new(window, cx).placeholder("Paste the updated `git format-patch` output...") + }); + // Both the dialog body and the submit button capture the root event; + // share it instead of cloning into each closure. + let root = Rc::new(root); + + window.open_dialog(cx, move |dialog, _window, _cx| { + let store = store.clone(); + let patch = patch.clone(); + let root = root.clone(); + + dialog + .width(px(520.)) + .margin_top(px(50.)) + .content(move |body, _window, _cx| { + body.child( + DialogHeader::new() + .child(DialogTitle::new().child("Update pull request")) + .child(DialogDescription::new().child( + "Publish a new revision with the output of `git format-patch`.", + )), + ) + .child( + v_form().child( + field() + .label("Patch") + .child(Textarea::new(&patch).h(px(160.))), + ), + ) + .child( + DialogFooter::new().justify_end().child( + Button::new("submit") + .primary() + .label("Update pull request") + .tooltip("Update pull request") + .on_click({ + let store = store.clone(); + let patch = patch.clone(); + let root = root.clone(); + move |_event, window, cx| { + let patch = patch.read(cx).value().to_string(); + store.update(cx, |store, cx| { + store.update_pull_request(&root, patch, cx); + }); + window.close_dialog(cx); + } + }), + ), + ) + }) + }); +} + /// One sidebar section title. fn sidebar_title(text: &str, cx: &App) -> AnyElement { div() @@ -1121,11 +1223,13 @@ fn branch_name_of(event: &Event) -> Option { } /// The latest PR update (kind 1619) revising `root`, found via its NIP-22 -/// `E` tag pointing at the root PR event. -fn latest_update<'a>(events: impl Iterator, root: &EventId) -> Option<&'a Event> { - let root_hex = root.to_hex(); +/// `E` tag pointing at the root PR event. Only updates by the PR author +/// count: the tip of a PR is only mutable by its author (NIP-34). +fn latest_update<'a>(events: impl Iterator, root: &Event) -> Option<&'a Event> { + let root_hex = root.id.to_hex(); events .filter(|e| e.kind == Kind::GitPullRequestUpdate) + .filter(|e| e.pubkey == root.pubkey) .filter(|e| { e.tags .iter() @@ -1262,16 +1366,35 @@ mod tests { ); let events = [unrelated, revision(200), root.clone(), revision(300)]; - let latest = latest_update(events.iter(), &root.id).expect("an update"); + let latest = latest_update(events.iter(), &root).expect("an update"); assert_eq!(latest.created_at.as_secs(), 300); assert_eq!(latest.kind, Kind::GitPullRequestUpdate); } + #[test] + fn latest_update_ignores_other_authors() { + let root = pr_root(); + let root_hex = root.id.to_hex(); + let other = Keys::new( + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002") + .expect("valid secret key"), + ); + let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "") + .tags([Tag::parse(["E", &root_hex]).expect("valid tag")]) + .custom_created_at(Timestamp::from(999)) + .finalize(&other) + .expect("signed event"); + + // The tip of a PR is only mutable by its author: a newer update + // from anyone else must not win. + assert!(latest_update([&stranger, &root].into_iter(), &root).is_none()); + } + #[test] fn latest_update_ignores_roots_without_revisions() { let root = pr_root(); - assert!(latest_update([&root].into_iter(), &root.id).is_none()); + assert!(latest_update([&root].into_iter(), &root).is_none()); } #[test] diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/repo_detail/pull_requests.rs index 7084d11..a7554df 100644 --- a/crates/workspace/src/views/repo_detail/pull_requests.rs +++ b/crates/workspace/src/views/repo_detail/pull_requests.rs @@ -8,6 +8,7 @@ use gpui::{ SharedString, Size, WeakEntity, Window, div, px, size, }; use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::checkbox::Checkbox; use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; use gpui_component::form::{field, v_form}; use gpui_component::input::{Input, InputState, Textarea, TextareaState}; @@ -282,9 +283,15 @@ impl PullRequestsView { } } -/// Open the "new pull request" dialog: a title, an optional description and -/// a patch input that submit through [`RepoStore::open_pull_request`] when -/// confirmed. +/// State of the new pull request dialog, so the draft checkbox re-renders. +#[derive(Default)] +struct NewPullRequestDialogState { + draft: bool, +} + +/// Open the "new pull request" dialog: a title, an optional description, +/// an optional branch name and a patch input that submit through +/// [`RepoStore::open_pull_request`] when confirmed. pub(super) fn open_new_pull_request_dialog( store: Entity, window: &mut Window, @@ -293,19 +300,24 @@ pub(super) fn open_new_pull_request_dialog( let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title")); let description = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change...")); + let branch = cx.new(|cx| InputState::new(window, cx).placeholder("Branch name (optional)")); let patch = cx .new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output...")); + let state = cx.new(|_| NewPullRequestDialogState::default()); window.open_dialog(cx, move |dialog, _window, _cx| { let subject = subject.clone(); let description = description.clone(); + let branch = branch.clone(); let patch = patch.clone(); let store = store.clone(); + let state = state.clone(); dialog .width(px(520.)) .margin_top(px(50.)) - .content(move |body, _window, _cx| { + .content(move |body, _window, cx| { + let draft = state.read(cx).draft; body.child( DialogHeader::new() .child(DialogTitle::new().child("New pull request")) @@ -327,10 +339,29 @@ pub(super) fn open_new_pull_request_dialog( .label("Description") .child(Textarea::new(&description).h(px(96.))), ) + .child( + field() + .label("Branch") + .description("Optional: the branch the change is proposed from") + .child(Input::new(&branch)), + ) .child( field() .label("Patch") .child(Textarea::new(&patch).h(px(160.))), + ) + .child( + field().child( + Checkbox::new("pr-draft") + .label("Create as draft") + .checked(draft) + .on_click({ + let state = state.clone(); + move |checked, _window, cx| { + state.update(cx, |state, _| state.draft = *checked); + } + }), + ), ), ) .child( @@ -342,17 +373,29 @@ pub(super) fn open_new_pull_request_dialog( .on_click({ let subject = subject.clone(); let description = description.clone(); + let branch = branch.clone(); let patch = patch.clone(); let store = store.clone(); + let state = state.clone(); move |_event, window, cx| { let subject = subject.read(cx).value().to_string(); let description = description.read(cx).value().to_string(); + let branch = branch.read(cx).value().to_string(); let patch = patch.read(cx).value().to_string(); let subject = (!subject.is_empty()).then_some(subject); + let branch = (!branch.is_empty()).then_some(branch); + let draft = state.read(cx).draft; store.update(cx, |store, cx| { - store.open_pull_request(subject, description, patch, cx); + store.open_pull_request( + subject, + description, + branch, + patch, + draft, + cx, + ); }); window.close_dialog(cx); diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 5d29f7e..e9973f9 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -100,7 +100,8 @@ impl SidebarPanel { fn refresh_my_repos(&mut self, cx: &mut Context) { self.my_repos_subscription = None; - let author = Backend::global(cx).read(cx).current_user(); + let backend = Backend::global(cx); + let author = backend.read(cx).current_user(); self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx))); if let Some(store) = self.my_repos.as_ref() { diff --git a/docs/PLAN.md b/docs/PLAN.md index 9409642..a57534b 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -124,7 +124,7 @@ Current PR creation (`RepoStore::open_pull_request`, `crates/signed_state/src/re | Draft on create | no | optional 1633 status (P1) | | Merge provenance | plain 1631 | `merge-commit`/`applied-as-commits` (P4) | -### Phase 1 — Correctness & interop (small, surgical) +### Phase 1 — Correctness & interop (small, surgical) ✅ implemented 1. **Compute and publish `merge-base`, `branch-name`, `r` EUC** in `open_pull_request`: - Target tip = `RepoStore.head` ref from the state announcement (`refs`/`head`, `crates/signed_state/src/repo.rs:28-30`); add `signed_git::merge_base(repo, a, b)` (shell out like `apply_patch`). @@ -134,6 +134,8 @@ Current PR creation (`RepoStore::open_pull_request`, `crates/signed_state/src/re 3. **Fix `latest_update`** (`crates/workspace/src/views/repo_detail/pull_request_detail.rs:1125`): filter by the root PR's author (nak and ngit both restrict tip updates to the PR author). 4. **Draft toggle** in the new-PR dialog: publish a 1633 status right after the PR event (reuse `set_status`). +**Status:** items 1 (partial — `branch-name` + `r` EUC done; `merge-base` remains `None` because the paste-based flow has no access to the author's git objects to compute a merge base; it becomes computable in Phase 2 when the patch is generated from a local checkout), 2, 3, 4 are implemented. + ### Phase 2 — UX: replace the paste 5. **Local-repo picker** replaces the paste textarea (keep it as an advanced fallback): user picks a git checkout (or the app's `GitCache` mirror), source branch and target branch. The app then: @@ -154,10 +156,10 @@ Current PR creation (`RepoStore::open_pull_request`, `crates/signed_state/src/re ### Checklist -- [ ] P1: merge-base + branch-name + `r` EUC on creation. -- [ ] P1: `update_pull_request` (1619) + UI button; author check. -- [ ] P1: `latest_update` author filter. -- [ ] P1: draft toggle on create. +- [x] P1: merge-base + branch-name + `r` EUC on creation (merge-base deferred to P2 — not computable from a pasted patch). +- [x] P1: `update_pull_request` (1619) + UI button; author check. +- [x] P1: `latest_update` author filter. +- [x] P1: draft toggle on create. - [ ] P2: local checkout picker + generated patch + pre-publish apply check. - [ ] P3: push tip to grasp, truthful `clone` tags, size-aware series. - [ ] P4: merge status tags. diff --git a/docs/TODO.md b/docs/TODO.md index 695a787..c5cbf8e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,10 +2,19 @@ ## Fork support -- [ ] Add UI for fork (see `PLAN.md` section 1): - - [ ] Fork badge on repo list cards (`repo_list.rs::render_card`). - - [ ] "Forked from …" text button in the repo detail header (`repo_detail/mod.rs::render_header`) and About dialog. - - [ ] Clicking the upstream opens it as a center panel (shared `open_repo_panel` helper). +- [x] Add UI for fork (see `PLAN.md` section 1): + - [x] Fork badge on repo list cards (`repo_list.rs::render_card`). + - [x] "Forked from …" text button in the repo detail header (`repo_detail/mod.rs::render_header`) and About dialog. + - [x] Clicking the upstream opens it as a center panel (shared `open_repo_panel` helper). + +## Pull request improvement (see `PLAN.md` section 2) + +- [x] P1: `branch-name` tag + `r` EUC tag on PR creation; draft checkbox in the new-PR dialog. +- [x] P1: `RepoStore::update_pull_request` (kind 1619 + root-revision patch) with an author-only "Update" button on the PR detail header. +- [x] P1: `latest_update` filters by PR author. +- [ ] P2: local checkout picker + generated patch + pre-publish apply check (also enables `merge-base`). +- [ ] P3: push tip to grasp, truthful `clone` tags, size-aware patch series. +- [ ] P4: `merge-commit`/`applied-as-commits` tags on merge status. ## Performance: render path