From b3b1a949463d8e18622519866ecee3f1b65cc888 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 4 Mar 2026 14:28:38 +0000 Subject: restructure CLI around ngit pr/issue subcommand groups Introduce ngit pr subcommand group (list, view, checkout, apply, send, close, reopen, ready, comment, merge) replacing the former top-level ngit list/checkout/apply commands. ngit send is kept at the top level. Expand ngit issue with view, create, close, reopen, comment subcommands. Status changes (close/reopen/ready) are gated to the PR/issue author or a repository maintainer. ngit pr merge is maintainer-only and publishes a GitStatusApplied event immediately after the git merge. --- src/bin/ngit/sub_commands/issue_status.rs | 178 ++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 src/bin/ngit/sub_commands/issue_status.rs (limited to 'src/bin/ngit/sub_commands/issue_status.rs') diff --git a/src/bin/ngit/sub_commands/issue_status.rs b/src/bin/ngit/sub_commands/issue_status.rs new file mode 100644 index 0000000..3facee3 --- /dev/null +++ b/src/bin/ngit/sub_commands/issue_status.rs @@ -0,0 +1,178 @@ +use anyhow::{Context, Result, bail}; +use ngit::{ + client::{Params, get_issues_from_cache, send_events, sign_event}, + git_events::{get_status, status_kinds}, +}; +use nostr::{EventBuilder, Tag, TagStandard, nips::nip19::Nip19}; +use nostr_sdk::{EventId, FromBech32, Kind, nips::nip10::Marker}; + +use crate::{ + client::{ + Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, + }, + git::{Repo, RepoActions}, + login, + repo_ref::get_repo_coordinates_when_remote_unknown, +}; + +fn parse_event_id(id: &str) -> Result { + if let Ok(nip19) = Nip19::from_bech32(id) { + match nip19 { + nostr::nips::nip19::Nip19::Event(e) => return Ok(e.event_id), + nostr::nips::nip19::Nip19::EventId(event_id) => return Ok(event_id), + _ => {} + } + } + if let Ok(event_id) = EventId::from_hex(id) { + return Ok(event_id); + } + bail!("invalid event-id or nevent: {id}") +} + +#[allow(clippy::too_many_lines)] +async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> Result<()> { + let event_id = parse_event_id(id)?; + + let git_repo = Repo::discover().context("failed to find a git repository")?; + let git_repo_path = git_repo.get_path()?; + + let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); + let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; + + if !offline { + fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; + } + + let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?; + + let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?; + + let issue = issues + .iter() + .find(|e| e.id == event_id) + .context(format!( + "issue with id {} not found in cache", + event_id.to_hex() + ))? + .clone(); + + // Login to get signer and user pubkey + let (signer, user_ref, _) = + login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?; + + let user_pubkey = signer.get_public_key().await?; + + // Only author or maintainer may change status + if issue.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { + bail!("only the issue author or a repository maintainer can {action} an issue"); + } + + // Fetch existing statuses to check current state + let statuses = { + let mut s = get_events_from_local_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .kinds(status_kinds().clone()) + .events(issues.iter().map(|e| e.id)), + nostr::Filter::default() + .custom_tags( + nostr::filter::SingleLetterTag::uppercase(nostr::filter::Alphabet::E), + issues.iter().map(|e| e.id), + ) + .kinds(status_kinds().clone()), + ], + ) + .await?; + s.sort_by_key(|e| e.created_at); + s.reverse(); + s + }; + + let empty_proposals: Vec = vec![]; + let current_status = get_status(&issue, &repo_ref, &statuses, &empty_proposals); + + if current_status == new_kind { + let status_str = match new_kind { + Kind::GitStatusOpen => "open", + Kind::GitStatusClosed => "closed", + _ => "unknown", + }; + println!("issue is already {status_str}"); + return Ok(()); + } + + let alt_text = match new_kind { + Kind::GitStatusOpen => "issue reopened", + Kind::GitStatusClosed => "issue closed", + _ => "issue status updated", + }; + + let mut public_keys: std::collections::HashSet = + repo_ref.maintainers.iter().copied().collect(); + public_keys.insert(issue.pubkey); + + let status_event = sign_event( + EventBuilder::new(new_kind, "").tags( + [ + vec![ + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), + vec![alt_text.to_string()], + ), + Tag::from_standardized(TagStandard::Event { + event_id: issue.id, + relay_url: repo_ref.relays.first().cloned(), + marker: Some(Marker::Root), + public_key: None, + uppercase: false, + }), + ], + public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(), + repo_ref + .coordinates() + .iter() + .map(|c| { + Tag::from_standardized(TagStandard::Coordinate { + coordinate: c.coordinate.clone(), + relay_url: c.relays.first().cloned(), + uppercase: false, + }) + }) + .collect::>(), + vec![Tag::from_standardized(nostr::TagStandard::Reference( + repo_ref.root_commit.to_string(), + ))], + ] + .concat(), + ), + &signer, + format!("{action} issue"), + ) + .await?; + + let mut client = client; + client.set_signer(signer).await; + + send_events( + &client, + Some(git_repo_path), + vec![status_event], + user_ref.relays.write(), + repo_ref.relays.clone(), + true, + false, + ) + .await?; + + println!("issue {} {}d", &event_id.to_hex()[..8], action,); + Ok(()) +} + +pub async fn launch_close(id: &str, offline: bool) -> Result<()> { + launch_status(id, offline, Kind::GitStatusClosed, "close").await +} + +pub async fn launch_reopen(id: &str, offline: bool) -> Result<()> { + launch_status(id, offline, Kind::GitStatusOpen, "reopen").await +} -- cgit v1.2.3