From 7dcbdc7841e932570359ccef3b82459b89e6f2bc Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 4 Mar 2026 14:32:39 +0000 Subject: show full comment content in pr view and issue view ngit pr view and ngit issue view now fetch and display all NIP-22 comments in chronological order with author npub and timestamp, rather than just a count. JSON output includes the full comment array. Also updates list table hint text to reference ngit pr subcommands. --- CHANGELOG.md | 4 +- src/bin/ngit/sub_commands/issue_list.rs | 136 +++++++++++++++++++++++++++++--- src/bin/ngit/sub_commands/list.rs | 128 +++++++++++++++++++++--------- 3 files changed, 217 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb140e..37200c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,13 @@ 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 view ` — view a PR with its full details and all comments (author, timestamp, body) in chronological order - `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 view ` — view an issue with its full details and all comments (author, timestamp, body) in chronological order - `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 diff --git a/src/bin/ngit/sub_commands/issue_list.rs b/src/bin/ngit/sub_commands/issue_list.rs index cfc0d49..b7abf8d 100644 --- a/src/bin/ngit/sub_commands/issue_list.rs +++ b/src/bin/ngit/sub_commands/issue_list.rs @@ -23,7 +23,13 @@ fn get_issue_title(event: &nostr::Event) -> String { .ok() .filter(|s| !s.is_empty()) .unwrap_or_else(|| { - let first_line = event.content.lines().next().unwrap_or("").trim().to_string(); + let first_line = event + .content + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); if first_line.is_empty() { event.id.to_string() } else { @@ -67,12 +73,14 @@ async fn get_comment_counts( // Comments use an uppercase `E` tag pointing to the root event ID. let comments = get_events_from_local_cache( git_repo_path, - vec![nostr::Filter::default() - .custom_tags( - SingleLetterTag::uppercase(Alphabet::E), - issues.iter().map(|e| e.id), - ) - .kind(KIND_COMMENT)], + vec![ + nostr::Filter::default() + .custom_tags( + SingleLetterTag::uppercase(Alphabet::E), + issues.iter().map(|e| e.id), + ) + .kind(KIND_COMMENT), + ], ) .await?; @@ -94,6 +102,37 @@ async fn get_comment_counts( Ok(counts) } +/// Fetch NIP-22 kind-1111 comments for a single issue, sorted oldest-first. +async fn get_comments_for_issue( + git_repo_path: &std::path::Path, + issue_id: &nostr::EventId, +) -> Result> { + let mut comments = get_events_from_local_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .custom_tags( + SingleLetterTag::uppercase(Alphabet::E), + std::iter::once(*issue_id), + ) + .kind(KIND_COMMENT), + ], + ) + .await?; + comments.retain(|c| { + c.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 + && s[0].eq("E") + && nostr::EventId::parse(&s[1]) + .map(|id| id == *issue_id) + .unwrap_or(false) + }) + }); + comments.sort_by_key(|e| e.created_at); + Ok(comments) +} + #[allow(clippy::too_many_lines)] pub async fn launch( status: String, @@ -185,7 +224,20 @@ pub async fn launch( } if let Some(ref event_id_or_nevent) = id { - return show_issue_details(&filtered, event_id_or_nevent, json); + // Resolve the target issue ID so we can fetch its comments. + let target_id = if event_id_or_nevent.starts_with("nevent") { + let nip19 = nostr::nips::nip19::Nip19::from_bech32(event_id_or_nevent) + .context("failed to parse nevent")?; + match nip19 { + nostr::nips::nip19::Nip19::EventId(id) => id, + nostr::nips::nip19::Nip19::Event(event) => event.event_id, + _ => anyhow::bail!("invalid nevent format"), + } + } 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); } if json { @@ -201,6 +253,7 @@ fn show_issue_details( issues: &[(&nostr::Event, Kind, Vec, usize)], event_id_or_nevent: &str, json: bool, + comments: &[nostr::Event], ) -> 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")?; @@ -213,7 +266,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")?; @@ -222,13 +275,24 @@ 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, + }) + }) + .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": comment_count, + "comments": comments_json, "description": issue.content, }); println!("{}", serde_json::to_string_pretty(&json_output)?); @@ -238,9 +302,12 @@ fn show_issue_details( println!("Title: {title}"); println!("Author: {}", issue.pubkey.to_bech32().unwrap_or_default()); println!("Status: {status}"); - println!("Comments: {comment_count}"); if !tags.is_empty() { - let tags_str = tags.iter().map(|t| format!("#{t}")).collect::>().join(" "); + let tags_str = tags + .iter() + .map(|t| format!("#{t}")) + .collect::>() + .join(" "); println!("Tags: {tags_str}"); } @@ -251,9 +318,46 @@ 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()); + println!(); + println!("{}", dim.apply_to(format!(" {author} {ts}"))); + for line in comment.content.lines() { + println!(" {line}"); + } + } + } + Ok(()) } +fn chrono_timestamp(unix_secs: u64) -> String { + let secs = unix_secs % 60; + let mins = (unix_secs / 60) % 60; + let hours = (unix_secs / 3600) % 24; + let days_since_epoch = unix_secs / 86400; + + let z = days_since_epoch + 719_468; + let era = z / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let day_of_year = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * day_of_year + 2) / 153; + let d = day_of_year - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + format!("{y:04}-{m:02}-{d:02} {hours:02}:{mins:02}:{secs:02} UTC") +} + fn output_table( issues: &[(&nostr::Event, Kind, Vec, usize)], status_filter: &str, @@ -283,7 +387,13 @@ fn output_table( 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(",")); + print!( + " --hashtag {}", + tags.iter() + .map(|s| s.as_str()) + .collect::>() + .join(",") + ); } println!(); } diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs index d1b6de8..a583ca5 100644 --- a/src/bin/ngit/sub_commands/list.rs +++ b/src/bin/ngit/sub_commands/list.rs @@ -201,15 +201,15 @@ pub async fn launch(status: String, json: bool, id: Option, offline: boo .collect(); if let Some(ref event_id_or_nevent) = id { - // Resolve the target proposal ID so we can fetch its comment count. + // Resolve the target proposal ID so we can fetch its comments. let target_id = resolve_event_id(event_id_or_nevent)?; - let comment_count = get_comment_count_for_proposal(git_repo_path, &target_id).await?; + let comments = get_comments_for_proposal(git_repo_path, &target_id).await?; return show_proposal_details( &filtered_proposals, &repo_ref, event_id_or_nevent, json, - comment_count, + &comments, ); } @@ -235,37 +235,38 @@ fn resolve_event_id(event_id_or_nevent: &str) -> Result { } } -/// Count NIP-22 kind-1111 comments whose root `#E` tag matches `proposal_id`. -async fn get_comment_count_for_proposal( +/// Fetch NIP-22 kind-1111 comments whose root `#E` tag matches `proposal_id`, +/// sorted oldest-first. +async fn get_comments_for_proposal( git_repo_path: &std::path::Path, proposal_id: &nostr::EventId, -) -> Result { - let comments = get_events_from_local_cache( +) -> Result> { + let mut comments = get_events_from_local_cache( git_repo_path, - vec![nostr::Filter::default() - .custom_tags( - SingleLetterTag::uppercase(Alphabet::E), - std::iter::once(*proposal_id), - ) - .kind(KIND_COMMENT)], + vec![ + nostr::Filter::default() + .custom_tags( + SingleLetterTag::uppercase(Alphabet::E), + std::iter::once(*proposal_id), + ) + .kind(KIND_COMMENT), + ], ) .await?; - // Only count comments whose uppercase E tag actually points to this proposal - // (the filter is best-effort; verify explicitly). - let count = comments - .iter() - .filter(|c| { - c.tags.iter().any(|t| { - let s = t.as_slice(); - s.len() >= 2 - && s[0].eq("E") - && nostr::EventId::parse(&s[1]) - .map(|id| id == *proposal_id) - .unwrap_or(false) - }) + // Only keep comments whose uppercase E tag actually points to this proposal. + comments.retain(|c| { + c.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 + && s[0].eq("E") + && nostr::EventId::parse(&s[1]) + .map(|id| id == *proposal_id) + .unwrap_or(false) }) - .count(); - Ok(count) + }); + // Oldest first + comments.sort_by_key(|e| e.created_at); + Ok(comments) } fn status_kind_to_str(kind: Kind) -> &'static str { @@ -300,14 +301,17 @@ fn output_table(proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef, status println!(); println!("--status {status_filter}"); - println!("{}", console::style("To view: ngit list ").yellow()); println!( "{}", - console::style("To checkout: ngit checkout ").yellow() + console::style("To view: ngit pr view ").yellow() + ); + println!( + "{}", + console::style("To checkout: ngit pr checkout ").yellow() ); println!( "{}", - console::style("To apply: ngit apply ").yellow() + console::style("To apply: ngit pr apply ").yellow() ); } @@ -354,8 +358,10 @@ fn show_proposal_details( _repo_ref: &RepoRef, event_id_or_nevent: &str, json: bool, - comment_count: usize, + comments: &[nostr::Event], ) -> Result<()> { + use nostr::ToBech32; + let target_id = resolve_event_id(event_id_or_nevent)?; let (proposal, status_kind) = proposals @@ -367,13 +373,24 @@ 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, + }) + }) + .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": comment_count, + "comments": comments_json, "description": cover_letter.description, }); println!("{}", serde_json::to_string_pretty(&json_output)?); @@ -390,7 +407,6 @@ fn show_proposal_details( "Branch: {}", cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()? ); - println!("Comments: {comment_count}"); if !cover_letter.description.is_empty() { println!(); @@ -400,19 +416,59 @@ 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()); + println!(); + println!("{}", dim.apply_to(format!(" {author} {ts}"))); + for line in comment.content.lines() { + println!(" {line}"); + } + } + } + println!(); println!( "{}", - console::style(format!("To checkout: ngit checkout {}", proposal.id)).yellow() + console::style(format!("To checkout: ngit pr checkout {}", proposal.id)).yellow() ); println!( "{}", - console::style(format!("To apply: ngit apply {}", proposal.id)).yellow() + console::style(format!("To apply: ngit pr apply {}", proposal.id)).yellow() ); Ok(()) } +fn chrono_timestamp(unix_secs: u64) -> String { + // Format as YYYY-MM-DD HH:MM UTC without pulling in chrono. + // unix_secs → days since epoch, then decompose. + let secs = unix_secs % 60; + let mins = (unix_secs / 60) % 60; + let hours = (unix_secs / 3600) % 24; + let days_since_epoch = unix_secs / 86400; + + // Gregorian calendar decomposition (Fliegel-Van Flandern algorithm) + let z = days_since_epoch + 719_468; + let era = z / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let day_of_year = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * day_of_year + 2) / 153; + let d = day_of_year - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + format!("{y:04}-{m:02}-{d:02} {hours:02}:{mins:02}:{secs:02} UTC") +} + #[allow(clippy::too_many_lines)] async fn launch_interactive() -> Result<()> { let git_repo = Repo::discover().context("failed to find a git repository")?; -- cgit v1.2.3