upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/bin/ngit/sub_commands/list.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-03-04 13:54:32 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-03-04 14:50:01 +0000
commit6d9b0cc8fff65447849d0d55db177dcdff315c48 (patch)
tree604587b7e06149d89a36383eb2e4227043e6955d /src/bin/ngit/sub_commands/list.rs
parent3908abbbfc5e748dd168d22bf5e3ea6aae17de61 (diff)
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 <id>` and `ngit issue list <id>`. - 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
Diffstat (limited to 'src/bin/ngit/sub_commands/list.rs')
-rw-r--r--src/bin/ngit/sub_commands/list.rs81
1 files changed, 65 insertions, 16 deletions
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::{
15 }, 15 },
16 fetch::fetch_from_git_server, 16 fetch::fetch_from_git_server,
17 git_events::{ 17 git_events::{
18 KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, get_commit_id_from_patch, 18 KIND_COMMENT, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, get_commit_id_from_patch,
19 get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, status_kinds, tag_value, 19 get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, status_kinds, tag_value,
20 }, 20 },
21 repo_ref::{RepoRef, is_grasp_server_in_list}, 21 repo_ref::{RepoRef, is_grasp_server_in_list},
@@ -201,7 +201,16 @@ pub async fn launch(status: String, json: bool, id: Option<String>, offline: boo
201 .collect(); 201 .collect();
202 202
203 if let Some(ref event_id_or_nevent) = id { 203 if let Some(ref event_id_or_nevent) = id {
204 return show_proposal_details(&filtered_proposals, &repo_ref, event_id_or_nevent, json); 204 // Resolve the target proposal ID so we can fetch its comment count.
205 let target_id = resolve_event_id(event_id_or_nevent)?;
206 let comment_count = get_comment_count_for_proposal(git_repo_path, &target_id).await?;
207 return show_proposal_details(
208 &filtered_proposals,
209 &repo_ref,
210 event_id_or_nevent,
211 json,
212 comment_count,
213 );
205 } 214 }
206 215
207 if json { 216 if json {
@@ -213,6 +222,52 @@ pub async fn launch(status: String, json: bool, id: Option<String>, offline: boo
213 Ok(()) 222 Ok(())
214} 223}
215 224
225fn resolve_event_id(event_id_or_nevent: &str) -> Result<nostr::EventId> {
226 if event_id_or_nevent.starts_with("nevent") {
227 let nip19 = Nip19::from_bech32(event_id_or_nevent).context("failed to parse nevent")?;
228 match nip19 {
229 Nip19::EventId(id) => Ok(id),
230 Nip19::Event(event) => Ok(event.event_id),
231 _ => bail!("invalid nevent format"),
232 }
233 } else {
234 nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")
235 }
236}
237
238/// Count NIP-22 kind-1111 comments whose root `#E` tag matches `proposal_id`.
239async fn get_comment_count_for_proposal(
240 git_repo_path: &std::path::Path,
241 proposal_id: &nostr::EventId,
242) -> Result<usize> {
243 let comments = get_events_from_local_cache(
244 git_repo_path,
245 vec![nostr::Filter::default()
246 .custom_tags(
247 SingleLetterTag::uppercase(Alphabet::E),
248 std::iter::once(*proposal_id),
249 )
250 .kind(KIND_COMMENT)],
251 )
252 .await?;
253 // Only count comments whose uppercase E tag actually points to this proposal
254 // (the filter is best-effort; verify explicitly).
255 let count = comments
256 .iter()
257 .filter(|c| {
258 c.tags.iter().any(|t| {
259 let s = t.as_slice();
260 s.len() >= 2
261 && s[0].eq("E")
262 && nostr::EventId::parse(&s[1])
263 .map(|id| id == *proposal_id)
264 .unwrap_or(false)
265 })
266 })
267 .count();
268 Ok(count)
269}
270
216fn status_kind_to_str(kind: Kind) -> &'static str { 271fn status_kind_to_str(kind: Kind) -> &'static str {
217 match kind { 272 match kind {
218 Kind::GitStatusOpen => "open", 273 Kind::GitStatusOpen => "open",
@@ -299,17 +354,9 @@ fn show_proposal_details(
299 _repo_ref: &RepoRef, 354 _repo_ref: &RepoRef,
300 event_id_or_nevent: &str, 355 event_id_or_nevent: &str,
301 json: bool, 356 json: bool,
357 comment_count: usize,
302) -> Result<()> { 358) -> Result<()> {
303 let target_id = if event_id_or_nevent.starts_with("nevent") { 359 let target_id = resolve_event_id(event_id_or_nevent)?;
304 let nip19 = Nip19::from_bech32(event_id_or_nevent).context("failed to parse nevent")?;
305 match nip19 {
306 Nip19::EventId(id) => id,
307 Nip19::Event(event) => event.event_id,
308 _ => bail!("invalid nevent format"),
309 }
310 } else {
311 nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")?
312 };
313 360
314 let (proposal, status_kind) = proposals 361 let (proposal, status_kind) = proposals
315 .iter() 362 .iter()
@@ -326,22 +373,24 @@ fn show_proposal_details(
326 "title": cover_letter.title, 373 "title": cover_letter.title,
327 "author": proposal.pubkey.to_bech32().unwrap_or_default(), 374 "author": proposal.pubkey.to_bech32().unwrap_or_default(),
328 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, 375 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
376 "comments": comment_count,
329 "description": cover_letter.description, 377 "description": cover_letter.description,
330 }); 378 });
331 println!("{}", serde_json::to_string_pretty(&json_output)?); 379 println!("{}", serde_json::to_string_pretty(&json_output)?);
332 return Ok(()); 380 return Ok(());
333 } 381 }
334 382
335 println!("Title: {}", cover_letter.title); 383 println!("Title: {}", cover_letter.title);
336 println!( 384 println!(
337 "Author: {}", 385 "Author: {}",
338 proposal.pubkey.to_bech32().unwrap_or_default() 386 proposal.pubkey.to_bech32().unwrap_or_default()
339 ); 387 );
340 println!("Status: {}", status_kind_to_str(*status_kind)); 388 println!("Status: {}", status_kind_to_str(*status_kind));
341 println!( 389 println!(
342 "Branch: {}", 390 "Branch: {}",
343 cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()? 391 cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?
344 ); 392 );
393 println!("Comments: {comment_count}");
345 394
346 if !cover_letter.description.is_empty() { 395 if !cover_letter.description.is_empty() {
347 println!(); 396 println!();