From b4c7b5e24d05aef878e155a1bedc22de54609fbb Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 4 Mar 2026 15:23:54 +0000 Subject: add --comments flag to issue/pr view; show reply threading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - issue view and pr view now show only a comment count by default - pass --comments to include the full thread - JSON output always includes comment_count; comments array only with --comments - each comment in the thread includes reply_to (null for top-level, parent comment id for replies) - human-readable view shows a dim '↳ reply to ' line on replies --- src/bin/ngit/cli.rs | 10 ++- src/bin/ngit/main.rs | 22 ++++- src/bin/ngit/sub_commands/issue_list.rs | 130 ++++++++++++++++++++++-------- src/bin/ngit/sub_commands/list.rs | 138 ++++++++++++++++++++++++-------- 4 files changed, 230 insertions(+), 70 deletions(-) (limited to 'src/bin/ngit') diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs index f18759b..0599b51 100644 --- a/src/bin/ngit/cli.rs +++ b/src/bin/ngit/cli.rs @@ -187,7 +187,7 @@ pub enum PrCommands { #[arg(long)] offline: bool, }, - /// view a PR and its comments + /// view a PR; use --comments to include comment thread View { /// Proposal event-id (hex) or nevent (bech32) #[arg(value_name = "ID|nevent")] @@ -195,6 +195,9 @@ pub enum PrCommands { /// Output as JSON #[arg(long)] json: bool, + /// Include full comment thread (default: show count only) + #[arg(long)] + comments: bool, /// Use local cache only, skip network fetch #[arg(long)] offline: bool, @@ -321,7 +324,7 @@ pub enum IssueCommands { #[arg(long)] offline: bool, }, - /// view an issue and its comments + /// view an issue; use --comments to include comment thread View { /// Issue event-id (hex) or nevent (bech32) #[arg(value_name = "ID|nevent")] @@ -329,6 +332,9 @@ pub enum IssueCommands { /// Output as JSON #[arg(long)] json: bool, + /// Include full comment thread (default: show count only) + #[arg(long)] + comments: bool, /// Use local cache only, skip network fetch #[arg(long)] offline: bool, diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs index 03a5ce9..4c1aa75 100644 --- a/src/bin/ngit/main.rs +++ b/src/bin/ngit/main.rs @@ -60,11 +60,20 @@ async fn main() { json, id, offline, - } => sub_commands::list::launch(status.clone(), *json, id.clone(), *offline).await, - PrCommands::View { id, json, offline } => { + } => { + sub_commands::list::launch(status.clone(), *json, false, id.clone(), *offline) + .await + } + PrCommands::View { + id, + json, + comments, + offline, + } => { sub_commands::list::launch( "open,draft,closed,applied".to_string(), *json, + *comments, Some(id.clone()), *offline, ) @@ -122,16 +131,23 @@ async fn main() { status.clone(), hashtag.clone(), *json, + false, id.clone(), *offline, ) .await } - IssueCommands::View { id, json, offline } => { + IssueCommands::View { + id, + json, + comments, + offline, + } => { sub_commands::issue_list::launch( "open,draft,closed,applied".to_string(), None, *json, + *comments, Some(id.clone()), *offline, ) diff --git a/src/bin/ngit/sub_commands/issue_list.rs b/src/bin/ngit/sub_commands/issue_list.rs index b7abf8d..864cd76 100644 --- a/src/bin/ngit/sub_commands/issue_list.rs +++ b/src/bin/ngit/sub_commands/issue_list.rs @@ -138,6 +138,7 @@ pub async fn launch( status: String, hashtag: Option, json: bool, + show_comments: bool, id: Option, offline: bool, ) -> Result<()> { @@ -236,8 +237,18 @@ pub async fn launch( } else { nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? }; - let comments = get_comments_for_issue(git_repo_path, &target_id).await?; - return show_issue_details(&filtered, event_id_or_nevent, json, &comments); + let comments = if show_comments { + get_comments_for_issue(git_repo_path, &target_id).await? + } else { + vec![] + }; + return show_issue_details( + &filtered, + event_id_or_nevent, + json, + show_comments, + &comments, + ); } if json { @@ -249,10 +260,38 @@ pub async fn launch( Ok(()) } +/// Extract the parent comment ID from a NIP-22 comment event. +/// Returns `Some(id)` when the lowercase `e` tag differs from the root `E` tag +/// (i.e. the comment is a reply to another comment, not a top-level comment). +fn comment_reply_to(comment: &nostr::Event) -> Option { + let root_id = comment.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0].eq("E") { + nostr::EventId::parse(&s[1]).ok() + } else { + None + } + })?; + comment.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0].eq("e") { + let parent_id = nostr::EventId::parse(&s[1]).ok()?; + if parent_id == root_id { + None + } else { + Some(parent_id) + } + } else { + None + } + }) +} + fn show_issue_details( issues: &[(&nostr::Event, Kind, Vec, usize)], event_id_or_nevent: &str, json: bool, + show_comments: bool, comments: &[nostr::Event], ) -> Result<()> { let target_id = if event_id_or_nevent.starts_with("nevent") { @@ -266,7 +305,7 @@ fn show_issue_details( nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? }; - let (issue, status_kind, tags, _comment_count) = issues + let (issue, status_kind, tags, comment_count) = issues .iter() .find(|(e, _, _, _)| e.id == target_id) .context("issue not found")?; @@ -275,26 +314,41 @@ fn show_issue_details( let status = status_kind_to_str(*status_kind); if json { - let comments_json: Vec = comments - .iter() - .map(|c| { - serde_json::json!({ - "id": c.id.to_string(), - "author": c.pubkey.to_bech32().unwrap_or_default(), - "created_at": c.created_at.as_secs(), - "body": c.content, + let json_output = if show_comments { + let comments_json: Vec = comments + .iter() + .map(|c| { + let reply_to = comment_reply_to(c).map(|id| id.to_string()); + serde_json::json!({ + "id": c.id.to_string(), + "author": c.pubkey.to_bech32().unwrap_or_default(), + "created_at": c.created_at.as_secs(), + "reply_to": reply_to, + "body": c.content, + }) }) + .collect(); + serde_json::json!({ + "id": issue.id.to_string(), + "status": status, + "title": title, + "author": issue.pubkey.to_bech32().unwrap_or_default(), + "hashtags": tags, + "comment_count": comment_count, + "comments": comments_json, + "description": issue.content, + }) + } else { + serde_json::json!({ + "id": issue.id.to_string(), + "status": status, + "title": title, + "author": issue.pubkey.to_bech32().unwrap_or_default(), + "hashtags": tags, + "comment_count": comment_count, + "description": issue.content, }) - .collect(); - 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, - "comments": comments_json, - "description": issue.content, - }); + }; println!("{}", serde_json::to_string_pretty(&json_output)?); return Ok(()); } @@ -318,21 +372,31 @@ fn show_issue_details( } } - if comments.is_empty() { - println!("Comments: 0"); - } else { - println!(); - println!("Comments ({}):", comments.len()); - let dim = console::Style::new().color256(247); - for comment in comments { - let author = comment.pubkey.to_bech32().unwrap_or_default(); - let ts = chrono_timestamp(comment.created_at.as_secs()); + if show_comments { + if comments.is_empty() { + println!("Comments: 0"); + } else { println!(); - println!("{}", dim.apply_to(format!(" {author} {ts}"))); - for line in comment.content.lines() { - println!(" {line}"); + println!("Comments ({}):", comments.len()); + let dim = console::Style::new().color256(247); + for comment in comments { + let author = comment.pubkey.to_bech32().unwrap_or_default(); + let ts = chrono_timestamp(comment.created_at.as_secs()); + println!(); + if let Some(parent_id) = comment_reply_to(comment) { + println!( + "{}", + dim.apply_to(format!(" ↳ reply to {}", &parent_id.to_hex()[..8])) + ); + } + println!("{}", dim.apply_to(format!(" {author} {ts}"))); + for line in comment.content.lines() { + println!(" {line}"); + } } } + } else { + println!("Comments: {comment_count} (use --comments to view)"); } Ok(()) diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs index a583ca5..60e129f 100644 --- a/src/bin/ngit/sub_commands/list.rs +++ b/src/bin/ngit/sub_commands/list.rs @@ -95,7 +95,13 @@ fn run_git_fetch(remote_name: &str) -> Result<()> { } #[allow(clippy::too_many_lines)] -pub async fn launch(status: String, json: bool, id: Option, offline: bool) -> Result<()> { +pub async fn launch( + status: String, + json: bool, + show_comments: bool, + id: Option, + offline: bool, +) -> Result<()> { if std::env::var("NGIT_INTERACTIVE_MODE").is_ok() { return launch_interactive().await; } @@ -203,12 +209,26 @@ pub async fn launch(status: String, json: bool, id: Option, offline: boo if let Some(ref event_id_or_nevent) = id { // Resolve the target proposal ID so we can fetch its comments. let target_id = resolve_event_id(event_id_or_nevent)?; - let comments = get_comments_for_proposal(git_repo_path, &target_id).await?; + let comments = if show_comments { + get_comments_for_proposal(git_repo_path, &target_id).await? + } else { + vec![] + }; + // Always fetch the count so we can display it even without --comments. + let comment_count = if show_comments { + comments.len() + } else { + get_comments_for_proposal(git_repo_path, &target_id) + .await? + .len() + }; return show_proposal_details( &filtered_proposals, &repo_ref, event_id_or_nevent, json, + show_comments, + comment_count, &comments, ); } @@ -353,11 +373,40 @@ fn output_json(proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef) -> Resu Ok(()) } +/// Extract the parent comment ID from a NIP-22 comment event. +/// Returns `Some(id)` when the lowercase `e` tag differs from the root `E` tag +/// (i.e. the comment is a reply to another comment, not a top-level comment). +fn comment_reply_to(comment: &nostr::Event) -> Option { + let root_id = comment.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0].eq("E") { + nostr::EventId::parse(&s[1]).ok() + } else { + None + } + })?; + comment.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0].eq("e") { + let parent_id = nostr::EventId::parse(&s[1]).ok()?; + if parent_id == root_id { + None + } else { + Some(parent_id) + } + } else { + None + } + }) +} + fn show_proposal_details( proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef, event_id_or_nevent: &str, json: bool, + show_comments: bool, + comment_count: usize, comments: &[nostr::Event], ) -> Result<()> { use nostr::ToBech32; @@ -373,26 +422,41 @@ fn show_proposal_details( .context("failed to extract proposal details from proposal root event")?; if json { - let comments_json: Vec = comments - .iter() - .map(|c| { - serde_json::json!({ - "id": c.id.to_string(), - "author": c.pubkey.to_bech32().unwrap_or_default(), - "created_at": c.created_at.as_secs(), - "body": c.content, + let json_output = if show_comments { + let comments_json: Vec = comments + .iter() + .map(|c| { + let reply_to = comment_reply_to(c).map(|id| id.to_string()); + serde_json::json!({ + "id": c.id.to_string(), + "author": c.pubkey.to_bech32().unwrap_or_default(), + "created_at": c.created_at.as_secs(), + "reply_to": reply_to, + "body": c.content, + }) }) + .collect(); + serde_json::json!({ + "id": proposal.id.to_string(), + "status": status_kind_to_str(*status_kind), + "title": cover_letter.title, + "author": proposal.pubkey.to_bech32().unwrap_or_default(), + "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, + "comment_count": comment_count, + "comments": comments_json, + "description": cover_letter.description, }) - .collect(); - let json_output = serde_json::json!({ - "id": proposal.id.to_string(), - "status": status_kind_to_str(*status_kind), - "title": cover_letter.title, - "author": proposal.pubkey.to_bech32().unwrap_or_default(), - "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, - "comments": comments_json, - "description": cover_letter.description, - }); + } else { + serde_json::json!({ + "id": proposal.id.to_string(), + "status": status_kind_to_str(*status_kind), + "title": cover_letter.title, + "author": proposal.pubkey.to_bech32().unwrap_or_default(), + "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, + "comment_count": comment_count, + "description": cover_letter.description, + }) + }; println!("{}", serde_json::to_string_pretty(&json_output)?); return Ok(()); } @@ -416,21 +480,31 @@ fn show_proposal_details( } } - if comments.is_empty() { - println!("Comments: 0"); - } else { - println!(); - println!("Comments ({}):", comments.len()); - let dim = console::Style::new().color256(247); - for comment in comments { - let author = comment.pubkey.to_bech32().unwrap_or_default(); - let ts = chrono_timestamp(comment.created_at.as_secs()); + if show_comments { + if comments.is_empty() { + println!("Comments: 0"); + } else { println!(); - println!("{}", dim.apply_to(format!(" {author} {ts}"))); - for line in comment.content.lines() { - println!(" {line}"); + println!("Comments ({comment_count}):"); + let dim = console::Style::new().color256(247); + for comment in comments { + let author = comment.pubkey.to_bech32().unwrap_or_default(); + let ts = chrono_timestamp(comment.created_at.as_secs()); + println!(); + if let Some(parent_id) = comment_reply_to(comment) { + println!( + "{}", + dim.apply_to(format!(" ↳ reply to {}", &parent_id.to_hex()[..8])) + ); + } + println!("{}", dim.apply_to(format!(" {author} {ts}"))); + for line in comment.content.lines() { + println!(" {line}"); + } } } + } else { + println!("Comments: {comment_count} (use --comments to view)"); } println!(); -- cgit v1.2.3