From 6d9b0cc8fff65447849d0d55db177dcdff315c48 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 4 Mar 2026 13:54:32 +0000 Subject: feat: fetch and display NIP-22 comment counts on issues and proposals Download kind-1111 NIP-22 comments from relays and show a comment count in the detail view of `ngit list ` and `ngit issue list `. - git_events: add KIND_COMMENT constant (kind 1111) - client: import KIND_COMMENT; add `comments` field to FetchReport; route kind-1111 events into report.comments in process_fetched_events; consolidate comments across relay reports; display "N comment(s)" in FetchReport; add a #E-tagged kind-1111 filter in get_fetch_filters covering all known issue and proposal root IDs - issue_list: add get_comment_counts() to query the local cache for kind-1111 events by #E tag and count per issue; extend the filtered tuple with comment_count; show a CMTS column in the table, a "Comments: N" line in the detail view, and a "comments" field in JSON - list: add KIND_COMMENT import; add resolve_event_id() helper and get_comment_count_for_proposal() to look up the count for a single proposal from the local cache; pass comment_count into show_proposal_details(); display "Comments: N" in plain text and "comments" in JSON; align detail-view labels --- src/bin/ngit/sub_commands/issue_list.rs | 91 ++++++++++++++++++++++++--------- src/bin/ngit/sub_commands/list.rs | 81 +++++++++++++++++++++++------ 2 files changed, 132 insertions(+), 40 deletions(-) (limited to 'src/bin/ngit/sub_commands') diff --git a/src/bin/ngit/sub_commands/issue_list.rs b/src/bin/ngit/sub_commands/issue_list.rs index 6b31db2..cfc0d49 100644 --- a/src/bin/ngit/sub_commands/issue_list.rs +++ b/src/bin/ngit/sub_commands/issue_list.rs @@ -1,19 +1,19 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use anyhow::{Context, Result, bail}; use ngit::{ - client::{Params, get_issues_from_cache}, - git_events::{get_status, status_kinds, tag_value}, + client::{Params, get_events_from_local_cache, get_issues_from_cache}, + git_events::{KIND_COMMENT, get_status, status_kinds, tag_value}, }; use nostr::{ - FromBech32, + FromBech32, ToBech32, 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}, + client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache}, git::{Repo, RepoActions}, repo_ref::get_repo_coordinates_when_remote_unknown, }; @@ -54,6 +54,46 @@ fn status_kind_to_str(kind: Kind) -> &'static str { } } +/// Fetch NIP-22 kind-1111 comment counts per issue from the local cache. +/// Returns a map from issue `EventId` to comment count. +async fn get_comment_counts( + git_repo_path: &std::path::Path, + issues: &[nostr::Event], +) -> Result> { + if issues.is_empty() { + return Ok(HashMap::new()); + } + + // 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)], + ) + .await?; + + let mut counts: HashMap = HashMap::new(); + for comment in &comments { + // Find the uppercase E tag that matches one of our issue IDs. + for tag in comment.tags.iter() { + let s = tag.as_slice(); + if s.len() >= 2 && s[0].eq("E") { + if let Ok(root_id) = nostr::EventId::parse(&s[1]) { + if issues.iter().any(|e| e.id == root_id) { + *counts.entry(root_id).or_insert(0) += 1; + break; + } + } + } + } + } + Ok(counts) +} + #[allow(clippy::too_many_lines)] pub async fn launch( status: String, @@ -104,6 +144,8 @@ pub async fn launch( statuses }; + let comment_counts = get_comment_counts(git_repo_path, &issues).await?; + let status_filter: HashSet<&str> = status.split(',').map(str::trim).collect(); let hashtag_filter: Option> = hashtag.map(|h| { @@ -116,7 +158,7 @@ pub async fn launch( // revisions, so we pass an empty slice. let empty_proposals: Vec = vec![]; - let filtered: Vec<(&nostr::Event, Kind, Vec)> = issues + let filtered: Vec<(&nostr::Event, Kind, Vec, usize)> = issues .iter() .filter_map(|issue| { let status_kind = get_status(issue, &repo_ref, &statuses, &empty_proposals); @@ -132,7 +174,8 @@ pub async fn launch( return None; } } - Some((issue, status_kind, tags)) + let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0); + Some((issue, status_kind, tags, comment_count)) }) .collect(); @@ -155,7 +198,7 @@ pub async fn launch( } fn show_issue_details( - issues: &[(&nostr::Event, Kind, Vec)], + issues: &[(&nostr::Event, Kind, Vec, usize)], event_id_or_nevent: &str, json: bool, ) -> Result<()> { @@ -170,35 +213,35 @@ fn show_issue_details( nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? }; - let (issue, status_kind, tags) = issues + let (issue, status_kind, tags, comment_count) = issues .iter() - .find(|(e, _, _)| e.id == target_id) + .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, + "comments": comment_count, "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}"); + 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(" "); - println!("Tags: {tags_str}"); + println!("Tags: {tags_str}"); } if !issue.content.is_empty() { @@ -212,12 +255,12 @@ fn show_issue_details( } fn output_table( - issues: &[(&nostr::Event, Kind, Vec)], + issues: &[(&nostr::Event, Kind, Vec, usize)], status_filter: &str, hashtag_filter: Option<&HashSet>, ) { - println!("{:<66} {:<8} TITLE HASHTAGS", "ID", "STATUS"); - for (issue, status_kind, tags) in issues { + println!("{:<66} {:<8} {:<5} TITLE HASHTAGS", "ID", "STATUS", "CMTS"); + for (issue, status_kind, tags, comment_count) in issues { let id = issue.id.to_string(); let status = status_kind_to_str(*status_kind); let title = get_issue_title(issue); @@ -230,9 +273,9 @@ fn output_table( .join(" ") }; if tags_str.is_empty() { - println!("{id:<66} {status:<8} {title}"); + println!("{id:<66} {status:<8} {comment_count:<5} {title}"); } else { - println!("{id:<66} {status:<8} {title} {tags_str}"); + println!("{id:<66} {status:<8} {comment_count:<5} {title} {tags_str}"); } } @@ -245,17 +288,17 @@ fn output_table( println!(); } -fn output_json(issues: &[(&nostr::Event, Kind, Vec)]) -> Result<()> { - use nostr::ToBech32; +fn output_json(issues: &[(&nostr::Event, Kind, Vec, usize)]) -> Result<()> { let json_output: Vec = issues .iter() - .map(|(issue, status_kind, tags)| { + .map(|(issue, status_kind, tags, comment_count)| { 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, + "comments": comment_count, }) }) .collect(); diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs index 3d5e876..d1b6de8 100644 --- a/src/bin/ngit/sub_commands/list.rs +++ b/src/bin/ngit/sub_commands/list.rs @@ -15,7 +15,7 @@ use ngit::{ }, fetch::fetch_from_git_server, git_events::{ - KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, get_commit_id_from_patch, + KIND_COMMENT, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, get_commit_id_from_patch, get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, status_kinds, tag_value, }, repo_ref::{RepoRef, is_grasp_server_in_list}, @@ -201,7 +201,16 @@ pub async fn launch(status: String, json: bool, id: Option, offline: boo .collect(); if let Some(ref event_id_or_nevent) = id { - return show_proposal_details(&filtered_proposals, &repo_ref, event_id_or_nevent, json); + // Resolve the target proposal ID so we can fetch its comment count. + let target_id = resolve_event_id(event_id_or_nevent)?; + let comment_count = get_comment_count_for_proposal(git_repo_path, &target_id).await?; + return show_proposal_details( + &filtered_proposals, + &repo_ref, + event_id_or_nevent, + json, + comment_count, + ); } if json { @@ -213,6 +222,52 @@ pub async fn launch(status: String, json: bool, id: Option, offline: boo Ok(()) } +fn resolve_event_id(event_id_or_nevent: &str) -> Result { + 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) => Ok(id), + Nip19::Event(event) => Ok(event.event_id), + _ => bail!("invalid nevent format"), + } + } else { + nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id") + } +} + +/// Count NIP-22 kind-1111 comments whose root `#E` tag matches `proposal_id`. +async fn get_comment_count_for_proposal( + git_repo_path: &std::path::Path, + proposal_id: &nostr::EventId, +) -> Result { + let 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)], + ) + .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) + }) + }) + .count(); + Ok(count) +} + fn status_kind_to_str(kind: Kind) -> &'static str { match kind { Kind::GitStatusOpen => "open", @@ -299,17 +354,9 @@ fn show_proposal_details( _repo_ref: &RepoRef, event_id_or_nevent: &str, json: bool, + comment_count: usize, ) -> 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 target_id = resolve_event_id(event_id_or_nevent)?; let (proposal, status_kind) = proposals .iter() @@ -326,22 +373,24 @@ fn show_proposal_details( "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, "description": cover_letter.description, }); println!("{}", serde_json::to_string_pretty(&json_output)?); return Ok(()); } - println!("Title: {}", cover_letter.title); + println!("Title: {}", cover_letter.title); println!( - "Author: {}", + "Author: {}", proposal.pubkey.to_bech32().unwrap_or_default() ); - println!("Status: {}", status_kind_to_str(*status_kind)); + println!("Status: {}", status_kind_to_str(*status_kind)); println!( - "Branch: {}", + "Branch: {}", cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()? ); + println!("Comments: {comment_count}"); if !cover_letter.description.is_empty() { println!(); -- cgit v1.2.3