From fe5addc2a61022824e83c3523a83fce7690ca8e8 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 17 Feb 2026 10:42:33 +0000 Subject: feat: add push-options for title and description to git-remote-nostr Allows setting PR title and description via git push options: git push --push-option=title="My PR" \ --push-option=description="Details" origin pr/branch --- CHANGELOG.md | 1 + src/bin/git_remote_nostr/main.rs | 21 +++++++ src/bin/git_remote_nostr/push.rs | 22 ++++++- tests/git_remote_nostr/push.rs | 121 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cbfddd..2c2b07c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ngit list --json` - output proposals as JSON - `ngit list --status` - filter by status (open,draft,closed,applied) - `ngit init --hashtag` - specify repository hashtag +- Push options for PR title/description: `git push --push-option=title="..." --push-option=description="..."` ### Removed diff --git a/src/bin/git_remote_nostr/main.rs b/src/bin/git_remote_nostr/main.rs index d7151d8..e57dff7 100644 --- a/src/bin/git_remote_nostr/main.rs +++ b/src/bin/git_remote_nostr/main.rs @@ -24,6 +24,12 @@ use nostr::nips::nip19::Nip19Coordinate; use crate::{client::Client, git::Repo}; +#[derive(Default, Clone)] +struct PushOptions { + title: Option, + description: Option, +} + mod fetch; mod list; mod push; @@ -71,6 +77,7 @@ async fn main() -> Result<()> { let mut line = String::new(); let mut list_outputs = None; + let mut push_options: PushOptions = PushOptions::default(); loop { let tokens = read_line(&stdin, &mut line)?; @@ -79,11 +86,23 @@ async fn main() -> Result<()> { println!("option"); println!("push"); println!("fetch"); + println!("push-options"); println!(); } ["option", "verbosity"] => { println!("ok"); } + ["option", "push-option", rest @ ..] => { + let option = rest.join(" "); + if let Some((key, value)) = option.split_once('=') { + match key { + "title" => push_options.title = Some(value.to_string()), + "description" => push_options.description = Some(value.to_string()), + _ => {} + } + } + println!("ok"); + } ["option", ..] => { println!("unsupported"); } @@ -98,8 +117,10 @@ async fn main() -> Result<()> { refspec, &client, list_outputs.clone(), + push_options.title.as_ref().zip(push_options.description.as_ref()).map(|(t, d)| (t.clone(), d.clone())), ) .await?; + push_options = PushOptions::default(); } ["list"] => { list_outputs = Some(list::run_list(&git_repo, &repo_ref, false).await?); diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs index c2f4ddd..f20c264 100644 --- a/src/bin/git_remote_nostr/push.rs +++ b/src/bin/git_remote_nostr/push.rs @@ -52,6 +52,7 @@ pub async fn run_push( initial_refspec: &str, client: &Client, list_outputs: Option, bool)>>, + title_description: Option<(String, String)>, ) -> Result<()> { let refspecs = get_refspecs_from_push_batch(stdin, initial_refspec)?; @@ -129,6 +130,7 @@ pub async fn run_push( client, existing_state, &term, + title_description.as_ref(), ) .await?; @@ -174,6 +176,7 @@ pub async fn run_push( } #[allow(clippy::too_many_lines)] +#[allow(clippy::too_many_arguments)] async fn create_and_publish_events_and_proposals( git_repo: &Repo, repo_ref: &RepoRef, @@ -182,6 +185,7 @@ async fn create_and_publish_events_and_proposals( client: &Client, existing_state: HashMap, term: &Term, + title_description: Option<&(String, String)>, ) -> Result<(Vec, bool)> { let (signer, mut user_ref, _) = load_existing_login( &Some(git_repo), @@ -276,6 +280,7 @@ async fn create_and_publish_events_and_proposals( &mut user_ref, &signer, term, + title_description, ) .await?; for e in proposal_events { @@ -300,6 +305,7 @@ async fn create_and_publish_events_and_proposals( } #[allow(clippy::too_many_lines)] +#[allow(clippy::too_many_arguments)] async fn process_proposal_refspecs( client: &Client, git_repo: &Repo, @@ -308,6 +314,7 @@ async fn process_proposal_refspecs( user_ref: &mut UserRef, signer: &Arc, term: &Term, + title_description: Option<&(String, String)>, ) -> Result<(Vec, Vec)> { let mut events = vec![]; let mut rejected_proposal_refspecs = vec![]; @@ -349,6 +356,7 @@ async fn process_proposal_refspecs( Some(proposal), signer, term, + title_description, ) .await? { @@ -389,6 +397,7 @@ async fn process_proposal_refspecs( Some(proposal), signer, term, + title_description, ) .await? { @@ -451,7 +460,15 @@ async fn process_proposal_refspecs( ); } for event in generate_patches_or_pr_event_or_pr_updates( - client, git_repo, repo_ref, &ahead, user_ref, None, signer, term, + client, + git_repo, + repo_ref, + &ahead, + user_ref, + None, + signer, + term, + title_description, ) .await? { @@ -474,6 +491,7 @@ async fn generate_patches_or_pr_event_or_pr_updates( root_proposal: Option<&Event>, signer: &Arc, term: &Term, + title_description: Option<&(String, String)>, ) -> Result> { let parent_is_pr = root_proposal.is_some_and(|proposal| proposal.kind.eq(&KIND_PULL_REQUEST)); let use_pr = parent_is_pr || git_repo.are_commits_too_big_for_patches(ahead); @@ -490,7 +508,7 @@ async fn generate_patches_or_pr_event_or_pr_updates( git_repo.get_commit_parent(first_commit).ok().as_ref(), user_ref, root_proposal, - &None, + &title_description.map(|(t, d)| (t.clone(), d.clone())), signer, false, term, diff --git a/tests/git_remote_nostr/push.rs b/tests/git_remote_nostr/push.rs index 404d07f..38b1f8c 100644 --- a/tests/git_remote_nostr/push.rs +++ b/tests/git_remote_nostr/push.rs @@ -1,4 +1,5 @@ use git2::Signature; +use ngit::git_events::KIND_PULL_REQUEST; use rstest::*; use super::*; @@ -1774,6 +1775,126 @@ async fn push_new_pr_branch_creates_proposal() -> Result<()> { Ok(()) } +#[tokio::test] +#[serial] +async fn push_new_pr_branch_with_title_description_options_creates_pr_with_custom_title_description() +-> Result<()> { + let (events, source_git_repo) = prep_source_repo_and_events_including_proposals().await?; + let _source_path = source_git_repo.dir.to_str().unwrap().to_string(); + + let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( + Relay::new(8051, None, None), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + Relay::new(8057, None, None), + ); + r51.events = events.clone(); + r55.events = events.clone(); + + #[allow(clippy::mutable_key_type)] + let before = r55.events.iter().cloned().collect::>(); + let branch_name = "pr/my-pr-with-title"; + + let cli_tester_handle = std::thread::spawn(move || -> Result { + let mut git_repo = clone_git_repo_with_nostr_url()?; + git_repo.delete_dir_on_drop = false; + git_repo.create_branch(branch_name)?; + git_repo.checkout(branch_name)?; + + let large_content = "x".repeat(70 * 1024); + std::fs::write(git_repo.dir.join("large_file.txt"), large_content)?; + git_repo.stage_and_commit("large_file.txt")?; + + let mut p = CliTester::new_git_with_remote_helper_from_dir( + &git_repo.dir, + [ + "push", + "--push-option=title=Custom PR Title", + "--push-option=description=Custom PR description here", + "-u", + "origin", + branch_name, + ], + ); + cli_expect_nostr_fetch(&mut p)?; + p.expect("git servers: listing refs...\r\n")?; + p.expect_eventually_and_print(format!("To {}\r\n", get_nostr_remote_url()?).as_str())?; + let output = p.expect_end_eventually()?; + + for p in [51, 52, 53, 55, 56, 57] { + relay::shutdown_relay(8000 + p)?; + } + + Ok(output) + }); + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + r57.listen_until_close(), + ); + + let output = cli_tester_handle.join().unwrap()?; + + assert_eq!( + output, + format!(" * [new branch] {branch_name} -> {branch_name}\r\nbranch '{branch_name}' set up to track 'origin/{branch_name}'.\r\n").as_str(), + ); + + let new_events = r55 + .events + .iter() + .cloned() + .collect::>() + .difference(&before) + .cloned() + .collect::>(); + assert_eq!(new_events.len(), 1, "should create exactly 1 PR event"); + + let pr_event = new_events.first().unwrap(); + + assert!( + pr_event.kind.eq(&KIND_PULL_REQUEST), + "event should be a PR event" + ); + + let title_tag = pr_event.tags.iter().find(|t| t.as_slice()[0].eq("subject")); + assert!( + title_tag.is_some(), + "PR event should have a subject tag for title" + ); + assert_eq!( + title_tag.unwrap().as_slice()[1], + "Custom PR Title", + "title should match push-option" + ); + + assert_eq!( + pr_event.content, "Custom PR description here", + "description should match push-option" + ); + + let branch_name_tag = pr_event + .tags + .iter() + .find(|t| t.as_slice()[0].eq("branch-name")); + assert!( + branch_name_tag.is_some(), + "PR event should have a branch-name tag" + ); + assert_eq!( + branch_name_tag.unwrap().as_slice()[1], + branch_name.replace("pr/", ""), + "branch-name tag should match" + ); + + Ok(()) +} + mod push_from_another_maintainer { // TODO that has issued announcement -- cgit v1.2.3