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. --- CHANGELOG.md | 11 ++ src/bin/ngit/cli.rs | 220 +++++++++++++++++++++----- src/bin/ngit/main.rs | 91 +++++++++-- src/bin/ngit/sub_commands/comment.rs | 182 ++++++++++++++++++++++ src/bin/ngit/sub_commands/issue_create.rs | 122 +++++++++++++++ src/bin/ngit/sub_commands/issue_status.rs | 178 +++++++++++++++++++++ src/bin/ngit/sub_commands/mod.rs | 5 + src/bin/ngit/sub_commands/pr_merge.rs | 249 ++++++++++++++++++++++++++++++ src/bin/ngit/sub_commands/pr_status.rs | 199 ++++++++++++++++++++++++ 9 files changed, 1205 insertions(+), 52 deletions(-) create mode 100644 src/bin/ngit/sub_commands/comment.rs create mode 100644 src/bin/ngit/sub_commands/issue_create.rs create mode 100644 src/bin/ngit/sub_commands/issue_status.rs create mode 100644 src/bin/ngit/sub_commands/pr_merge.rs create mode 100644 src/bin/ngit/sub_commands/pr_status.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 66a29a2..2cb140e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `ngit pr` subcommand group: `list`, `view`, `checkout`, `apply`, `send`, `close`, `reopen`, `ready`, `comment`, `merge`; replaces the former top-level `ngit list`, `ngit checkout`, and `ngit apply` commands (hard-migrated); `ngit send` remains at the top level unchanged +- `ngit pr view ` — view a PR with its full details and comment count +- `ngit pr close ` / `ngit pr reopen ` — change PR status (author or maintainer only) +- `ngit pr ready ` — mark a draft PR as ready for review (author or maintainer only) +- `ngit pr comment --body ` — post a NIP-22 comment on a PR +- `ngit pr merge [--squash]` — merge a PR branch and publish a `GitStatusApplied` event (maintainer only); prints a reminder to push afterwards +- `ngit issue` subcommand group expanded: `list`, `view`, `create`, `close`, `reopen`, `comment` +- `ngit issue view ` — view an issue with its full details and comment count +- `ngit issue create --title [--body ] [--label ...]` — publish a NIP-34 GitIssue event +- `ngit issue close ` / `ngit issue reopen ` — change issue status (author or maintainer only) +- `ngit issue comment --body ` — post a NIP-22 comment on an issue - `ngit issue list` command: lists NIP-34 issues with their status; supports `--status` (comma-separated: open,draft,closed,applied; default: open), `--hashtag` (comma-separated label filter), `--json`, `--offline`, and an optional `` positional argument to show full details of a specific issue; hashtags are shown at the end of each row - `nostr.repo-relay-only` git config key: when set to `true`, nostr events are sent only to the repository's own relays, skipping the user's personal write relays and default/blaster relays; set persistently via `git config nostr.repo-relay-only true` or in one step with `ngit init --repo-relay-only` diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs index c2364e6..452491c 100644 --- a/src/bin/ngit/cli.rs +++ b/src/bin/ngit/cli.rs @@ -8,7 +8,7 @@ use crate::sub_commands; #[command( author, version, - help_template = "{name} {version}\nnostr plugin for git\n includes a remote helper so native git commands (clone, fetch, push) work with nostr:// URLs\n - clone a nostr repository, or add as a remote, by using the url format nostr://npub123/identifier\n - remote branches beginning with `pr/` are open PRs from contributors; `ngit list` can be used to view all PRs\n - to open a PR, push a branch with the prefix `pr/` or use `ngit send` for advanced options\n set title and description via push options:\n git push -o 'title=My PR' -o 'description=line1\\n\\nline2' -u origin pr/branch\n - publish a repository to nostr with `ngit init`\n\n{usage}\n{all-args}" + help_template = "{name} {version}\nnostr plugin for git\n includes a remote helper so native git commands (clone, fetch, push) work with nostr:// URLs\n - clone a nostr repository, or add as a remote, by using the url format nostr://npub123/identifier\n - remote branches beginning with `pr/` are open PRs from contributors; `ngit pr list` can be used to view all PRs\n - to open a PR, push a branch with the prefix `pr/` or use `ngit send` for advanced options\n set title and description via push options:\n git push -o 'title=My PR' -o 'description=line1\\n\\nline2' -u origin pr/branch\n - publish a repository to nostr with `ngit init`\n\n{usage}\n{all-args}" )] #[command(propagate_version = true)] #[allow(clippy::struct_excessive_bools)] @@ -119,6 +119,59 @@ pub enum Commands { long_about = "submit PR with advanced options\n\nfor a simpler flow, push a branch with the `pr/` prefix using native git:\n git push -o 'title=My PR' -o 'description=details here' -u origin pr/my-branch" )] Send(sub_commands::send::SubCommandArgs), + /// work with pull requests + #[command( + long_about = "work with pull requests\n\nPRs are created by pushing a branch with the `pr/` prefix:\n git push -u origin pr/my-branch\nor with advanced options via `ngit send`" + )] + Pr(PrSubCommandArgs), + /// work with issues + Issue(IssueSubCommandArgs), + /// update repo git servers to reflect nostr state (add, update or delete + /// remote refs) + Sync(sub_commands::sync::SubCommandArgs), + /// create account, login, logout or export keys + Account(AccountSubCommandArgs), +} + +#[derive(Subcommand)] +pub enum AccountCommands { + /// login with nsec or nostr connect + Login(sub_commands::login::SubCommandArgs), + /// remove nostr account details stored in git config + Logout, + /// export nostr keys to login to other nostr clients + ExportKeys, + /// create a new nostr account + Create(sub_commands::create::SubCommandArgs), +} + +#[derive(clap::Parser)] +pub struct AccountSubCommandArgs { + #[command(subcommand)] + pub account_command: AccountCommands, +} + +#[derive(clap::Parser)] +pub struct RepoSubCommandArgs { + #[command(subcommand)] + pub repo_command: Option, + /// Use local cache only, skip network fetch + #[arg(long)] + pub offline: bool, +} + +// --------------------------------------------------------------------------- +// PR subcommand group +// --------------------------------------------------------------------------- + +#[derive(clap::Parser)] +pub struct PrSubCommandArgs { + #[command(subcommand)] + pub pr_command: PrCommands, +} + +#[derive(Subcommand)] +pub enum PrCommands { /// list PRs and view details List { /// Filter by status (comma-separated: open,draft,closed,applied) @@ -134,11 +187,21 @@ pub enum Commands { #[arg(long)] offline: bool, }, - /// list issues - Issue(IssueSubCommandArgs), + /// view a PR and its comments + View { + /// Proposal event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Output as JSON + #[arg(long)] + json: bool, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, /// checkout a proposal branch by event-id or nevent #[command( - long_about = "checkout a proposal branch by event-id or nevent\n\nuse `ngit list` to find proposal IDs" + long_about = "checkout a proposal branch by event-id or nevent\n\nuse `ngit pr list` to find proposal IDs" )] Checkout { /// Proposal event-id (hex) or nevent (bech32) @@ -150,7 +213,7 @@ pub enum Commands { }, /// apply proposal patches to current branch #[command( - long_about = "apply proposal patches to current branch\n\nuse `ngit list` to find proposal IDs" + long_about = "apply proposal patches to current branch\n\nuse `ngit pr list` to find proposal IDs" )] Apply { /// Proposal event-id or nevent @@ -163,39 +226,70 @@ pub enum Commands { #[arg(long)] offline: bool, }, - /// update repo git servers to reflect nostr state (add, update or delete - /// remote refs) - Sync(sub_commands::sync::SubCommandArgs), - /// create account, login, logout or export keys - Account(AccountSubCommandArgs), -} - -#[derive(Subcommand)] -pub enum AccountCommands { - /// login with nsec or nostr connect - Login(sub_commands::login::SubCommandArgs), - /// remove nostr account details stored in git config - Logout, - /// export nostr keys to login to other nostr clients - ExportKeys, - /// create a new nostr account - Create(sub_commands::create::SubCommandArgs), -} - -#[derive(clap::Parser)] -pub struct AccountSubCommandArgs { - #[command(subcommand)] - pub account_command: AccountCommands, + /// submit PR with advanced options (alias for `ngit send`) + #[command( + long_about = "submit PR with advanced options\n\nfor a simpler flow, push a branch with the `pr/` prefix using native git:\n git push -o 'title=My PR' -o 'description=details here' -u origin pr/my-branch" + )] + Send(sub_commands::send::SubCommandArgs), + /// close a PR (author or maintainer only) + Close { + /// Proposal event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, + /// reopen a closed PR (author or maintainer only) + Reopen { + /// Proposal event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, + /// mark a draft PR as ready for review (author or maintainer only) + Ready { + /// Proposal event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, + /// add a comment to a PR + Comment { + /// Proposal event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Comment body + #[arg(long)] + body: String, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, + /// merge a PR into the current branch (maintainer only) + #[command( + long_about = "merge a PR into the current branch (maintainer only)\n\nperforms a git merge of the PR branch; push afterwards to update the nostr state" + )] + Merge { + /// Proposal event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Use squash merge + #[arg(long)] + squash: bool, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, } -#[derive(clap::Parser)] -pub struct RepoSubCommandArgs { - #[command(subcommand)] - pub repo_command: Option, - /// Use local cache only, skip network fetch - #[arg(long)] - pub offline: bool, -} +// --------------------------------------------------------------------------- +// Issue subcommand group +// --------------------------------------------------------------------------- #[derive(clap::Parser)] pub struct IssueSubCommandArgs { @@ -223,6 +317,60 @@ pub enum IssueCommands { #[arg(long)] offline: bool, }, + /// view an issue and its comments + View { + /// Issue event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Output as JSON + #[arg(long)] + json: bool, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, + /// create a new issue + Create { + /// Issue title + #[arg(long)] + title: Option, + /// Issue body / description + #[arg(long)] + body: Option, + /// Hashtag labels (repeatable: --label bug --label help-wanted) + #[arg(long = "label", value_name = "LABEL")] + labels: Vec, + }, + /// close an issue (author or maintainer only) + Close { + /// Issue event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, + /// reopen a closed issue (author or maintainer only) + Reopen { + /// Issue event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, + /// add a comment to an issue + Comment { + /// Issue event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Comment body + #[arg(long)] + body: String, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, } #[derive(Subcommand)] diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs index cb0cc52..2982b61 100644 --- a/src/bin/ngit/main.rs +++ b/src/bin/ngit/main.rs @@ -3,7 +3,7 @@ #![cfg_attr(not(test), warn(clippy::expect_used))] use clap::Parser; -use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands, IssueCommands}; +use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands, IssueCommands, PrCommands}; mod cli; use ngit::{ @@ -16,6 +16,7 @@ use ngit::{ mod sub_commands; #[tokio::main] +#[allow(clippy::too_many_lines)] async fn main() { let cli = Cli::parse(); @@ -52,12 +53,52 @@ async fn main() { Commands::Repo(args) => { sub_commands::repo::launch(&cli, args.repo_command.as_ref(), args.offline).await } - Commands::List { - status, - json, - id, - offline, - } => sub_commands::list::launch(status.clone(), *json, id.clone(), *offline).await, + Commands::Send(args) => sub_commands::send::launch(&cli, args, false).await, + Commands::Pr(args) => match &args.pr_command { + PrCommands::List { + status, + json, + id, + offline, + } => sub_commands::list::launch(status.clone(), *json, id.clone(), *offline).await, + PrCommands::View { id, json, offline } => { + sub_commands::list::launch( + "open,draft,closed,applied".to_string(), + *json, + Some(id.clone()), + *offline, + ) + .await + } + PrCommands::Checkout { id, offline } => { + sub_commands::checkout::launch(id, *offline).await + } + PrCommands::Apply { + id, + stdout, + offline, + } => sub_commands::apply::launch(id, *stdout, *offline).await, + PrCommands::Send(sub_args) => { + sub_commands::send::launch(&cli, sub_args, false).await + } + PrCommands::Close { id, offline } => { + sub_commands::pr_status::launch_close(id, *offline).await + } + PrCommands::Reopen { id, offline } => { + sub_commands::pr_status::launch_reopen(id, *offline).await + } + PrCommands::Ready { id, offline } => { + sub_commands::pr_status::launch_ready(id, *offline).await + } + PrCommands::Comment { id, body, offline } => { + sub_commands::comment::launch_pr_comment(id, body, *offline).await + } + PrCommands::Merge { + id, + squash, + offline, + } => sub_commands::pr_merge::launch(id, *squash, *offline).await, + }, Commands::Issue(args) => match &args.issue_command { IssueCommands::List { status, @@ -75,17 +116,35 @@ async fn main() { ) .await } + IssueCommands::View { id, json, offline } => { + sub_commands::issue_list::launch( + "open,draft,closed,applied".to_string(), + None, + *json, + Some(id.clone()), + *offline, + ) + .await + } + IssueCommands::Create { + title, + body, + labels, + } => { + sub_commands::issue_create::launch(title.clone(), body.clone(), labels.clone()) + .await + } + IssueCommands::Close { id, offline } => { + sub_commands::issue_status::launch_close(id, *offline).await + } + IssueCommands::Reopen { id, offline } => { + sub_commands::issue_status::launch_reopen(id, *offline).await + } + IssueCommands::Comment { id, body, offline } => { + sub_commands::comment::launch_issue_comment(id, body, *offline).await + } }, - Commands::Send(args) => sub_commands::send::launch(&cli, args, false).await, Commands::Sync(args) => sub_commands::sync::launch(args).await, - Commands::Checkout { id, offline } => { - sub_commands::checkout::launch(id, *offline).await - } - Commands::Apply { - id, - stdout, - offline, - } => sub_commands::apply::launch(id, *stdout, *offline).await, } } else { // Show help when no command is provided diff --git a/src/bin/ngit/sub_commands/comment.rs b/src/bin/ngit/sub_commands/comment.rs new file mode 100644 index 0000000..a9b0aa7 --- /dev/null +++ b/src/bin/ngit/sub_commands/comment.rs @@ -0,0 +1,182 @@ +use anyhow::{Context, Result, bail}; +use ngit::{ + client::{ + Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events, + sign_event, + }, + git_events::KIND_COMMENT, +}; +use nostr::{EventBuilder, Tag, nips::nip19::Nip19}; +use nostr_sdk::{EventId, FromBech32, Kind}; + +use crate::{ + client::{Client, Connect, fetching_with_report, 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}") +} + +/// Build and publish a NIP-22 kind-1111 comment on any event. +/// +/// NIP-22 threading tags: +/// - uppercase `E` — root event id +/// - uppercase `K` — root event kind (as string) +/// - lowercase `e` — parent event id (same as root for top-level comments) +/// - lowercase `k` — parent event kind +async fn publish_comment( + id: &str, + body: &str, + offline: bool, + root_kind: Kind, + entity_name: &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?; + + // Login + let (signer, user_ref, _) = + login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?; + + let root_kind_str = root_kind.as_u16().to_string(); + + // NIP-22: uppercase E = root event, uppercase K = root kind, + // lowercase e = parent event (same as root for top-level), + // lowercase k = parent kind + let comment_event = sign_event( + EventBuilder::new(KIND_COMMENT, body).tags(vec![ + // Root event (uppercase E) + Tag::parse(vec![ + "E".to_string(), + event_id.to_hex(), + repo_ref + .relays + .first() + .map(ToString::to_string) + .unwrap_or_default(), + String::new(), // root marker + ])?, + // Root kind (uppercase K) + Tag::parse(vec!["K".to_string(), root_kind_str.clone()])?, + // Parent event (lowercase e, same as root for top-level comment) + Tag::parse(vec![ + "e".to_string(), + event_id.to_hex(), + repo_ref + .relays + .first() + .map(ToString::to_string) + .unwrap_or_default(), + "reply".to_string(), + ])?, + // Parent kind (lowercase k) + Tag::parse(vec!["k".to_string(), root_kind_str])?, + ]), + &signer, + format!("comment on {entity_name}"), + ) + .await?; + + let mut client = client; + client.set_signer(signer).await; + + send_events( + &client, + Some(git_repo_path), + vec![comment_event], + user_ref.relays.write(), + repo_ref.relays.clone(), + true, + false, + ) + .await?; + + println!( + "comment posted on {entity_name} {}", + &event_id.to_hex()[..8] + ); + Ok(()) +} + +pub async fn launch_pr_comment(id: &str, body: &str, offline: bool) -> Result<()> { + // Verify the PR exists in cache + 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 proposals = + get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?; + + let proposal = proposals + .iter() + .find(|e| e.id == event_id) + .context(format!( + "PR with id {} not found in cache", + event_id.to_hex() + ))?; + + let root_kind = proposal.kind; + + publish_comment(id, body, true /* already fetched */, root_kind, "PR").await +} + +pub async fn launch_issue_comment(id: &str, body: &str, offline: bool) -> Result<()> { + // Verify the issue exists in cache + 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?; + + issues.iter().find(|e| e.id == event_id).context(format!( + "issue with id {} not found in cache", + event_id.to_hex() + ))?; + + publish_comment( + id, + body, + true, /* already fetched */ + Kind::GitIssue, + "issue", + ) + .await +} diff --git a/src/bin/ngit/sub_commands/issue_create.rs b/src/bin/ngit/sub_commands/issue_create.rs new file mode 100644 index 0000000..0c4b677 --- /dev/null +++ b/src/bin/ngit/sub_commands/issue_create.rs @@ -0,0 +1,122 @@ +use anyhow::{Context, Result, bail}; +use ngit::client::{Params, send_events, sign_event}; +use nostr::{EventBuilder, Tag, TagStandard, ToBech32, nips::nip19::Nip19Event}; +use nostr_sdk::Kind; + +use crate::{ + client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache}, + git::{Repo, RepoActions}, + login, + repo_ref::get_repo_coordinates_when_remote_unknown, +}; + +pub async fn launch( + title: Option, + body: Option, + labels: Vec, +) -> Result<()> { + 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?; + + 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?; + + // Resolve title — required + let title = match title { + Some(t) if !t.trim().is_empty() => t, + _ => bail!("--title is required to create an issue"), + }; + + // Body defaults to empty string if not provided + let body = body.unwrap_or_default(); + + // Login + let (signer, user_ref, _) = + login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?; + + // Build NIP-34 GitIssue event (kind 1621) + // Tags: + // - `a` coordinate tags for each maintainer's repo announcement + // - `subject` — issue title + // - `t` — hashtag labels + // - `alt` — human-readable summary + let mut tags: Vec = vec![]; + + // Repo coordinate tags (one per maintainer) + for coord in repo_ref.coordinates() { + tags.push(Tag::from_standardized(TagStandard::Coordinate { + coordinate: coord.coordinate.clone(), + relay_url: coord.relays.first().cloned(), + uppercase: false, + })); + } + + // Subject (title) + tags.push(Tag::parse(vec!["subject".to_string(), title.clone()])?); + + // Hashtag labels + for label in &labels { + tags.push(Tag::hashtag(label)); + } + + // Alt text + tags.push(Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), + vec![format!("git issue: {title}")], + )); + + // Maintainer p-tags (so they get notified) + for pk in &repo_ref.maintainers { + tags.push(Tag::public_key(*pk)); + } + + let issue_event = sign_event( + EventBuilder::new(Kind::GitIssue, body).tags(tags), + &signer, + "create issue".to_string(), + ) + .await?; + + let event_id = issue_event.id; + + let mut client = client; + client.set_signer(signer).await; + + send_events( + &client, + Some(git_repo_path), + vec![issue_event], + user_ref.relays.write(), + repo_ref.relays.clone(), + true, + false, + ) + .await?; + + let event_bech32 = if let Some(relay) = repo_ref.relays.first() { + Nip19Event { + event_id, + relays: vec![relay.clone()], + author: None, + kind: None, + } + .to_bech32()? + } else { + event_id.to_bech32()? + }; + + println!("issue created: {event_id}"); + let dim = console::Style::new().color256(247); + println!( + "{}", + dim.apply_to(format!( + "view in gitworkshop.dev: https://gitworkshop.dev/{}", + &event_bech32, + )) + ); + Ok(()) +} 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 +} diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs index d864391..60dc413 100644 --- a/src/bin/ngit/sub_commands/mod.rs +++ b/src/bin/ngit/sub_commands/mod.rs @@ -1,12 +1,17 @@ pub mod apply; pub mod checkout; +pub mod comment; pub mod create; pub mod export_keys; pub mod init; +pub mod issue_create; pub mod issue_list; +pub mod issue_status; pub mod list; pub mod login; pub mod logout; +pub mod pr_merge; +pub mod pr_status; pub mod repo; pub mod send; pub mod sync; diff --git a/src/bin/ngit/sub_commands/pr_merge.rs b/src/bin/ngit/sub_commands/pr_merge.rs new file mode 100644 index 0000000..df00e7e --- /dev/null +++ b/src/bin/ngit/sub_commands/pr_merge.rs @@ -0,0 +1,249 @@ +use anyhow::{Context, Result, bail}; +use ngit::{ + client::{ + Params, get_all_proposal_patch_pr_pr_update_events_from_cache, + get_proposals_and_revisions_from_cache, send_events, sign_event, + }, + git_events::{ + KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, + get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, status_kinds, tag_value, + }, +}; +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, str_to_sha1}, + git_events::event_to_cover_letter, + 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)] +pub async fn launch(id: &str, squash: bool, offline: bool) -> 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?; + + // Login to verify maintainer status + 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?; + + if !repo_ref.maintainers.contains(&user_pubkey) { + bail!("only a repository maintainer can merge a PR"); + } + + let proposals_and_revisions = + get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?; + + let proposal = proposals_and_revisions + .iter() + .find(|e| e.id == event_id) + .context(format!( + "PR with id {} not found in cache", + event_id.to_hex() + ))? + .clone(); + + // Check current status — only open/draft PRs can be merged + let statuses = { + let mut s = get_events_from_local_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .kinds(status_kinds().clone()) + .events(proposals_and_revisions.iter().map(|e| e.id)), + nostr::Filter::default() + .custom_tags( + nostr::filter::SingleLetterTag::uppercase(nostr::filter::Alphabet::E), + proposals_and_revisions.iter().map(|e| e.id), + ) + .kinds(status_kinds().clone()), + ], + ) + .await?; + s.sort_by_key(|e| e.created_at); + s.reverse(); + s + }; + + let proposals_vec: Vec = proposals_and_revisions + .iter() + .filter(|e| !ngit::git_events::event_is_revision_root(e)) + .cloned() + .collect(); + + let current_status = get_status(&proposal, &repo_ref, &statuses, &proposals_vec); + + if current_status == Kind::GitStatusApplied { + bail!("PR is already applied/merged"); + } + if current_status == Kind::GitStatusClosed { + bail!("PR is closed; reopen it before merging"); + } + + let cover_letter = event_to_cover_letter(&proposal).context("failed to extract PR details")?; + + let branch_name = cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?; + + // Get the PR tip commit + let commits_events = get_all_proposal_patch_pr_pr_update_events_from_cache( + git_repo_path, + &repo_ref, + &proposal.id, + ) + .await?; + + let tip_chain = get_pr_tip_event_or_most_recent_patch_with_ancestors(commits_events) + .context("failed to find any PR or patch events on this proposal")?; + + let tip_commit_str = if tip_chain + .iter() + .any(|e| [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&e.kind)) + { + let tip_event = tip_chain.first().context("tip chain is empty")?; + tag_value(tip_event, "c").context("PR event missing tip commit tag 'c'")? + } else { + ngit::git_events::get_commit_id_from_patch( + tip_chain.first().context("patch chain is empty")?, + ) + .context("failed to get commit id from patch")? + }; + + let _tip_commit = str_to_sha1(&tip_commit_str).context("invalid tip commit OID")?; + + // Ensure the branch exists locally + let local_branch_exists = git_repo + .get_local_branch_names() + .context("failed to get local branch names")? + .iter() + .any(|n| n.eq(&branch_name)); + + if !local_branch_exists { + // Try to create the branch at the tip commit + if !git_repo.does_commit_exist(&tip_commit_str)? { + bail!( + "PR tip commit {tip_commit_str} not found locally. Run `ngit pr checkout {id}` first." + ); + } + git_repo.create_branch_at_commit(&branch_name, &tip_commit_str)?; + println!("created local branch '{branch_name}' at PR tip"); + } + + // Perform the git merge + let merge_args = if squash { + vec!["merge", "--squash", &branch_name] + } else { + vec!["merge", "--no-ff", &branch_name] + }; + + let output = std::process::Command::new("git") + .args(&merge_args) + .output() + .context("failed to run git merge")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("git merge failed:\n{stderr}"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.trim().is_empty() { + print!("{stdout}"); + } + + // Publish GitStatusApplied event + let mut public_keys: std::collections::HashSet = + repo_ref.maintainers.iter().copied().collect(); + public_keys.insert(proposal.pubkey); + + let applied_event = sign_event( + EventBuilder::new(Kind::GitStatusApplied, "").tags( + [ + vec![ + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), + vec!["PR merged".to_string()], + ), + Tag::from_standardized(TagStandard::Event { + event_id: proposal.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, + "mark PR as applied".to_string(), + ) + .await?; + + let mut client = client; + client.set_signer(signer).await; + + send_events( + &client, + Some(git_repo_path), + vec![applied_event], + user_ref.relays.write(), + repo_ref.relays.clone(), + true, + false, + ) + .await?; + + println!("PR '{}' merged and marked as applied", cover_letter.title); + println!( + "{}", + console::style("Push to update the nostr state: git push").yellow() + ); + + Ok(()) +} diff --git a/src/bin/ngit/sub_commands/pr_status.rs b/src/bin/ngit/sub_commands/pr_status.rs new file mode 100644 index 0000000..e84117d --- /dev/null +++ b/src/bin/ngit/sub_commands/pr_status.rs @@ -0,0 +1,199 @@ +use anyhow::{Context, Result, bail}; +use ngit::{ + client::{Params, get_proposals_and_revisions_from_cache, send_events, sign_event}, + git_events::{get_status, status_kinds}, +}; +use nostr::{EventBuilder, Tag, TagStandard, ToBech32, 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 proposals_and_revisions = + get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?; + + let proposal = proposals_and_revisions + .iter() + .find(|e| e.id == event_id) + .context(format!( + "PR 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 proposal.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { + bail!("only the PR author or a repository maintainer can {action} a PR"); + } + + // 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(proposals_and_revisions.iter().map(|e| e.id)), + nostr::Filter::default() + .custom_tags( + nostr::filter::SingleLetterTag::uppercase(nostr::filter::Alphabet::E), + proposals_and_revisions.iter().map(|e| e.id), + ) + .kinds(status_kinds().clone()), + ], + ) + .await?; + s.sort_by_key(|e| e.created_at); + s.reverse(); + s + }; + + let proposals_vec: Vec = proposals_and_revisions + .iter() + .filter(|e| !ngit::git_events::event_is_revision_root(e)) + .cloned() + .collect(); + + let current_status = get_status(&proposal, &repo_ref, &statuses, &proposals_vec); + + // Guard against no-op transitions + if current_status == new_kind { + let status_str = match new_kind { + Kind::GitStatusOpen => "open", + Kind::GitStatusClosed => "closed", + Kind::GitStatusDraft => "draft", + Kind::GitStatusApplied => "applied", + _ => "unknown", + }; + println!("PR is already {status_str}"); + return Ok(()); + } + + let alt_text = match new_kind { + Kind::GitStatusOpen => "PR reopened", + Kind::GitStatusClosed => "PR closed", + Kind::GitStatusDraft => "PR marked as draft", + Kind::GitStatusApplied => "PR applied/merged", + _ => "PR status updated", + }; + + // Build status event following the same pattern as push.rs + let mut public_keys: std::collections::HashSet = + repo_ref.maintainers.iter().copied().collect(); + public_keys.insert(proposal.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: proposal.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} PR"), + ) + .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!( + "PR {} {}d: {}", + &event_id.to_hex()[..8], + action, + proposal.pubkey.to_bech32().unwrap_or_default() + ); + 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 +} + +pub async fn launch_ready(id: &str, offline: bool) -> Result<()> { + launch_status(id, offline, Kind::GitStatusOpen, "mark as ready").await +} -- cgit v1.2.3