From 3908abbbfc5e748dd168d22bf5e3ea6aae17de61 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 4 Mar 2026 13:28:30 +0000 Subject: feat: add ngit issue list command Non-interactive listing of NIP-34 issues (kind 1621) with status resolution, hashtag display, and detail view. - get_issues_from_cache: fetch GitIssue events from local cache by repo coordinate, mirroring get_proposals_and_revisions_from_cache - ngit issue list: table output of ID, status, title and hashtags at end of each row; status resolved via existing get_status() logic - --status: comma-separated filter (open,draft,closed,applied; default: open) - --hashtag: comma-separated label filter (case-insensitive, OR match) - --json: machine-readable output including hashtags and description - --offline: skip network fetch, use local cache only - : optional positional argument (hex event-id or nevent) to show full details of a specific issue including body content --- src/bin/ngit/cli.rs | 30 ++++ src/bin/ngit/main.rs | 20 ++- src/bin/ngit/sub_commands/issue_list.rs | 264 ++++++++++++++++++++++++++++++++ src/bin/ngit/sub_commands/mod.rs | 1 + 4 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 src/bin/ngit/sub_commands/issue_list.rs (limited to 'src/bin/ngit') diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs index 68b18a7..c2364e6 100644 --- a/src/bin/ngit/cli.rs +++ b/src/bin/ngit/cli.rs @@ -134,6 +134,8 @@ pub enum Commands { #[arg(long)] offline: bool, }, + /// list issues + Issue(IssueSubCommandArgs), /// 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" @@ -195,6 +197,34 @@ pub struct RepoSubCommandArgs { pub offline: bool, } +#[derive(clap::Parser)] +pub struct IssueSubCommandArgs { + #[command(subcommand)] + pub issue_command: IssueCommands, +} + +#[derive(Subcommand)] +pub enum IssueCommands { + /// list issues and their statuses + List { + /// Filter by status (comma-separated: open,draft,closed,applied) + #[arg(long, default_value = "open")] + status: String, + /// Filter by hashtag/label (comma-separated) + #[arg(long)] + hashtag: Option, + /// Output as JSON + #[arg(long)] + json: bool, + /// Show details for a specific issue (event-id or nevent) + #[arg(value_name = "ID|nevent")] + id: Option, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, +} + #[derive(Subcommand)] pub enum RepoCommands { /// publish a repository to nostr (alias for `ngit init`) diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs index 5a7654a..cb0cc52 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}; +use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands, IssueCommands}; mod cli; use ngit::{ @@ -58,6 +58,24 @@ async fn main() { id, offline, } => sub_commands::list::launch(status.clone(), *json, id.clone(), *offline).await, + Commands::Issue(args) => match &args.issue_command { + IssueCommands::List { + status, + hashtag, + json, + id, + offline, + } => { + sub_commands::issue_list::launch( + status.clone(), + hashtag.clone(), + *json, + id.clone(), + *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 } => { diff --git a/src/bin/ngit/sub_commands/issue_list.rs b/src/bin/ngit/sub_commands/issue_list.rs new file mode 100644 index 0000000..6b31db2 --- /dev/null +++ b/src/bin/ngit/sub_commands/issue_list.rs @@ -0,0 +1,264 @@ +use std::collections::HashSet; + +use anyhow::{Context, Result, bail}; +use ngit::{ + client::{Params, get_issues_from_cache}, + git_events::{get_status, status_kinds, tag_value}, +}; +use nostr::{ + FromBech32, + filter::{Alphabet, SingleLetterTag}, + nips::nip19::Nip19, +}; +use nostr_sdk::Kind; + +use crate::{ + client::{Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache}, + git::{Repo, RepoActions}, + repo_ref::get_repo_coordinates_when_remote_unknown, +}; + +fn get_issue_title(event: &nostr::Event) -> String { + tag_value(event, "subject") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| { + let first_line = event.content.lines().next().unwrap_or("").trim().to_string(); + if first_line.is_empty() { + event.id.to_string() + } else { + first_line + } + }) +} + +fn get_issue_hashtags(event: &nostr::Event) -> Vec { + event + .tags + .iter() + .filter(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0].eq("t") + }) + .map(|t| t.as_slice()[1].clone()) + .collect() +} + +fn status_kind_to_str(kind: Kind) -> &'static str { + match kind { + Kind::GitStatusOpen => "open", + Kind::GitStatusDraft => "draft", + Kind::GitStatusClosed => "closed", + Kind::GitStatusApplied => "applied", + _ => "unknown", + } +} + +#[allow(clippy::too_many_lines)] +pub async fn launch( + status: String, + hashtag: Option, + json: bool, + id: Option, + offline: bool, +) -> 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?; + + 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: Vec = + get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?; + + if issues.is_empty() { + println!("no issues found"); + return Ok(()); + } + + let statuses: Vec = { + let mut statuses = 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( + SingleLetterTag::uppercase(Alphabet::E), + issues.iter().map(|e| e.id), + ) + .kinds(status_kinds().clone()), + ], + ) + .await?; + statuses.sort_by_key(|e| e.created_at); + statuses.reverse(); + statuses + }; + + let status_filter: HashSet<&str> = status.split(',').map(str::trim).collect(); + + let hashtag_filter: Option> = hashtag.map(|h| { + h.split(',') + .map(|s| s.trim().to_lowercase()) + .collect::>() + }); + + // Use an empty vec as the "all_pr_roots" argument — issues don't have PR + // revisions, so we pass an empty slice. + let empty_proposals: Vec = vec![]; + + let filtered: Vec<(&nostr::Event, Kind, Vec)> = issues + .iter() + .filter_map(|issue| { + let status_kind = get_status(issue, &repo_ref, &statuses, &empty_proposals); + let status_str = status_kind_to_str(status_kind); + if !status_filter.contains(status_str) && !status_filter.contains("unknown") { + return None; + } + let tags = get_issue_hashtags(issue); + if let Some(ref hf) = hashtag_filter { + let issue_tags_lower: HashSet = + tags.iter().map(|t| t.to_lowercase()).collect(); + if !hf.iter().any(|h| issue_tags_lower.contains(h)) { + return None; + } + } + Some((issue, status_kind, tags)) + }) + .collect(); + + if filtered.is_empty() { + println!("no issues found matching the given filters"); + return Ok(()); + } + + if let Some(ref event_id_or_nevent) = id { + return show_issue_details(&filtered, event_id_or_nevent, json); + } + + if json { + output_json(&filtered)?; + } else { + output_table(&filtered, &status, hashtag_filter.as_ref()); + } + + Ok(()) +} + +fn show_issue_details( + issues: &[(&nostr::Event, Kind, Vec)], + event_id_or_nevent: &str, + json: bool, +) -> Result<()> { + let target_id = if event_id_or_nevent.starts_with("nevent") { + let nip19 = Nip19::from_bech32(event_id_or_nevent).context("failed to parse nevent")?; + match nip19 { + Nip19::EventId(id) => id, + Nip19::Event(event) => event.event_id, + _ => bail!("invalid nevent format"), + } + } else { + nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? + }; + + let (issue, status_kind, tags) = issues + .iter() + .find(|(e, _, _)| e.id == target_id) + .context("issue not found")?; + + let title = get_issue_title(issue); + let status = status_kind_to_str(*status_kind); + + if json { + use nostr::ToBech32; + let json_output = serde_json::json!({ + "id": issue.id.to_string(), + "status": status, + "title": title, + "author": issue.pubkey.to_bech32().unwrap_or_default(), + "hashtags": tags, + "description": issue.content, + }); + println!("{}", serde_json::to_string_pretty(&json_output)?); + return Ok(()); + } + + println!("Title: {title}"); + use nostr::ToBech32; + println!("Author: {}", issue.pubkey.to_bech32().unwrap_or_default()); + println!("Status: {status}"); + if !tags.is_empty() { + let tags_str = tags.iter().map(|t| format!("#{t}")).collect::>().join(" "); + println!("Tags: {tags_str}"); + } + + if !issue.content.is_empty() { + println!(); + for line in issue.content.lines() { + println!(" {line}"); + } + } + + Ok(()) +} + +fn output_table( + issues: &[(&nostr::Event, Kind, Vec)], + status_filter: &str, + hashtag_filter: Option<&HashSet>, +) { + println!("{:<66} {:<8} TITLE HASHTAGS", "ID", "STATUS"); + for (issue, status_kind, tags) in issues { + let id = issue.id.to_string(); + let status = status_kind_to_str(*status_kind); + let title = get_issue_title(issue); + let tags_str = if tags.is_empty() { + String::new() + } else { + tags.iter() + .map(|t| format!("#{t}")) + .collect::>() + .join(" ") + }; + if tags_str.is_empty() { + println!("{id:<66} {status:<8} {title}"); + } else { + println!("{id:<66} {status:<8} {title} {tags_str}"); + } + } + + println!(); + print!("--status {status_filter}"); + if let Some(hf) = hashtag_filter { + let tags: Vec<&String> = hf.iter().collect(); + print!(" --hashtag {}", tags.iter().map(|s| s.as_str()).collect::>().join(",")); + } + println!(); +} + +fn output_json(issues: &[(&nostr::Event, Kind, Vec)]) -> Result<()> { + use nostr::ToBech32; + let json_output: Vec = issues + .iter() + .map(|(issue, status_kind, tags)| { + serde_json::json!({ + "id": issue.id.to_string(), + "status": status_kind_to_str(*status_kind), + "title": get_issue_title(issue), + "author": issue.pubkey.to_bech32().unwrap_or_default(), + "hashtags": tags, + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json_output)?); + Ok(()) +} diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs index d132240..d864391 100644 --- a/src/bin/ngit/sub_commands/mod.rs +++ b/src/bin/ngit/sub_commands/mod.rs @@ -3,6 +3,7 @@ pub mod checkout; pub mod create; pub mod export_keys; pub mod init; +pub mod issue_list; pub mod list; pub mod login; pub mod logout; -- cgit v1.2.3