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 +++++++++++++++++++++++------ src/lib/client.rs | 67 ++++++++++++++++++------ src/lib/git_events.rs | 2 + 4 files changed, 185 insertions(+), 56 deletions(-) (limited to 'src') 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!(); diff --git a/src/lib/client.rs b/src/lib/client.rs index 1f46e3c..8501a1f 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs @@ -56,9 +56,9 @@ use crate::{ get_dirs, git::{Repo, RepoActions, get_git_config_item}, git_events::{ - KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, KIND_USER_GRASP_LIST, event_is_cover_letter, - event_is_patch_set_root, event_is_revision_root, event_is_valid_pr_or_pr_update, - status_kinds, + KIND_COMMENT, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, KIND_USER_GRASP_LIST, + event_is_cover_letter, event_is_patch_set_root, event_is_revision_root, + event_is_valid_pr_or_pr_update, status_kinds, }, login::{get_likely_logged_in_user, user::get_user_ref_from_cache}, repo_ref::{RepoRef, normalize_grasp_server_url}, @@ -1877,7 +1877,7 @@ async fn create_relays_request( }) } -#[allow(clippy::too_many_lines)] +#[allow(clippy::too_many_lines, clippy::too_many_arguments)] async fn process_fetched_events( events: Vec, request: &FetchRequest, @@ -1996,6 +1996,8 @@ async fn process_fetched_events( { fresh_profiles.insert(event.pubkey); } + } else if event.kind.eq(&KIND_COMMENT) { + report.comments.insert(event.id); } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind) { if request.missing_contributor_profiles.contains(&event.pubkey) { @@ -2023,9 +2025,7 @@ async fn process_fetched_events( for event in &events { if !request.existing_events.contains(&event.id) { let tagged_root_id = event.tags.iter().find_map(|t| { - if t.as_slice().len() > 1 - && (t.as_slice()[0].eq("E") || t.as_slice()[0].eq("e")) - { + if t.as_slice().len() > 1 && (t.as_slice()[0].eq("E") || t.as_slice()[0].eq("e")) { EventId::parse(&t.as_slice()[1]).ok() } else { None @@ -2038,9 +2038,11 @@ async fn process_fetched_events( // as their parent (new issues/proposals already inflate the count). if let Some(root_id) = &tagged_root_id { if report.issues.contains(root_id) { - // status for a new issue in this batch — skip (counted via issues) + // status for a new issue in this batch — skip (counted + // via issues) } else if report.proposals.contains(root_id) { - // status for a new proposal in this batch — skip (counted via proposals) + // status for a new proposal in this batch — skip + // (counted via proposals) } else if request.issue_ids.contains(root_id) { report.issue_statuses.insert(event.id); } else { @@ -2052,12 +2054,11 @@ async fn process_fetched_events( let not_tagged_with_new_proposal = tagged_root_id .as_ref() .is_none_or(|id| !report.proposals.contains(id)); - if not_tagged_with_new_proposal { - if (event.kind.eq(&Kind::GitPatch) && !event_is_patch_set_root(event)) - || event.kind.eq(&KIND_PULL_REQUEST_UPDATE) - { - report.commits.insert(event.id); - } + if not_tagged_with_new_proposal + && ((event.kind.eq(&Kind::GitPatch) && !event_is_patch_set_root(event)) + || event.kind.eq(&KIND_PULL_REQUEST_UPDATE)) + { + report.commits.insert(event.id); } } } @@ -2117,6 +2118,9 @@ pub fn consolidate_fetch_reports(reports: Vec>) -> FetchRepo for c in relay_report.issue_statuses { report.issue_statuses.insert(c); } + for c in relay_report.comments { + report.comments.insert(c); + } report.deletions += relay_report.deletions; for c in relay_report.contributor_profiles { report.contributor_profiles.insert(c); @@ -2223,6 +2227,24 @@ pub fn get_fetch_filters( .kinds(status_kinds()), ] }, + // Fetch NIP-22 kind-1111 comments for issues and proposals (patches/PRs). + // Comments use an uppercase `E` tag pointing to the root event ID. + { + let all_root_ids: HashSet = issue_ids + .iter() + .chain(proposal_ids.iter()) + .copied() + .collect(); + if all_root_ids.is_empty() { + vec![] + } else { + vec![ + nostr::Filter::default() + .custom_tags(SingleLetterTag::uppercase(Alphabet::E), all_root_ids) + .kind(KIND_COMMENT), + ] + } + }, // Request kind-5 deletions for state events and repo announcements by // their event ID (#e tag), as per NIP-09. The #a-tagged filter above // covers addressable-event deletions; this covers the specific event IDs @@ -2309,6 +2331,8 @@ pub struct FetchReport { statuses: HashSet, issues: HashSet, issue_statuses: HashSet, + /// NIP-22 kind-1111 comments against issues, patches, and PRs. + comments: HashSet, /// Count of kind-5 deletion events received (for display purposes). deletions: u32, contributor_profiles: HashSet, @@ -2383,7 +2407,18 @@ impl Display for FetchReport { display_items.push(format!( "{} issue status{}", self.issue_statuses.len(), - if self.issue_statuses.len() > 1 { "es" } else { "" }, + if self.issue_statuses.len() > 1 { + "es" + } else { + "" + }, + )); + } + if !self.comments.is_empty() { + display_items.push(format!( + "{} comment{}", + self.comments.len(), + if self.comments.len() > 1 { "s" } else { "" }, )); } if self.deletions > 0 { diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs index 32c23ac..dde0e1a 100644 --- a/src/lib/git_events.rs +++ b/src/lib/git_events.rs @@ -88,6 +88,8 @@ pub fn status_kinds() -> Vec { pub const KIND_PULL_REQUEST: Kind = Kind::Custom(1618); pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::Custom(1619); pub const KIND_USER_GRASP_LIST: Kind = Kind::Custom(10317); +/// NIP-22 comment (kind 1111) — threaded comments on any event. +pub const KIND_COMMENT: Kind = Kind::Custom(1111); pub fn event_is_patch_set_root(event: &Event) -> bool { event.kind.eq(&Kind::GitPatch) -- cgit v1.2.3