diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-04 13:54:32 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-04 14:50:01 +0000 |
| commit | 6d9b0cc8fff65447849d0d55db177dcdff315c48 (patch) | |
| tree | 604587b7e06149d89a36383eb2e4227043e6955d | |
| parent | 3908abbbfc5e748dd168d22bf5e3ea6aae17de61 (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
| -rw-r--r-- | src/bin/ngit/sub_commands/issue_list.rs | 91 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/list.rs | 81 | ||||
| -rw-r--r-- | src/lib/client.rs | 67 | ||||
| -rw-r--r-- | src/lib/git_events.rs | 2 |
4 files changed, 185 insertions, 56 deletions
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 @@ | |||
| 1 | use std::collections::HashSet; | 1 | use std::collections::{HashMap, HashSet}; |
| 2 | 2 | ||
| 3 | use anyhow::{Context, Result, bail}; | 3 | use anyhow::{Context, Result, bail}; |
| 4 | use ngit::{ | 4 | use ngit::{ |
| 5 | client::{Params, get_issues_from_cache}, | 5 | client::{Params, get_events_from_local_cache, get_issues_from_cache}, |
| 6 | git_events::{get_status, status_kinds, tag_value}, | 6 | git_events::{KIND_COMMENT, get_status, status_kinds, tag_value}, |
| 7 | }; | 7 | }; |
| 8 | use nostr::{ | 8 | use nostr::{ |
| 9 | FromBech32, | 9 | FromBech32, ToBech32, |
| 10 | filter::{Alphabet, SingleLetterTag}, | 10 | filter::{Alphabet, SingleLetterTag}, |
| 11 | nips::nip19::Nip19, | 11 | nips::nip19::Nip19, |
| 12 | }; | 12 | }; |
| 13 | use nostr_sdk::Kind; | 13 | use nostr_sdk::Kind; |
| 14 | 14 | ||
| 15 | use crate::{ | 15 | use crate::{ |
| 16 | client::{Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache}, | 16 | client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache}, |
| 17 | git::{Repo, RepoActions}, | 17 | git::{Repo, RepoActions}, |
| 18 | repo_ref::get_repo_coordinates_when_remote_unknown, | 18 | repo_ref::get_repo_coordinates_when_remote_unknown, |
| 19 | }; | 19 | }; |
| @@ -54,6 +54,46 @@ fn status_kind_to_str(kind: Kind) -> &'static str { | |||
| 54 | } | 54 | } |
| 55 | } | 55 | } |
| 56 | 56 | ||
| 57 | /// Fetch NIP-22 kind-1111 comment counts per issue from the local cache. | ||
| 58 | /// Returns a map from issue `EventId` to comment count. | ||
| 59 | async fn get_comment_counts( | ||
| 60 | git_repo_path: &std::path::Path, | ||
| 61 | issues: &[nostr::Event], | ||
| 62 | ) -> Result<HashMap<nostr::EventId, usize>> { | ||
| 63 | if issues.is_empty() { | ||
| 64 | return Ok(HashMap::new()); | ||
| 65 | } | ||
| 66 | |||
| 67 | // Comments use an uppercase `E` tag pointing to the root event ID. | ||
| 68 | let comments = get_events_from_local_cache( | ||
| 69 | git_repo_path, | ||
| 70 | vec![nostr::Filter::default() | ||
| 71 | .custom_tags( | ||
| 72 | SingleLetterTag::uppercase(Alphabet::E), | ||
| 73 | issues.iter().map(|e| e.id), | ||
| 74 | ) | ||
| 75 | .kind(KIND_COMMENT)], | ||
| 76 | ) | ||
| 77 | .await?; | ||
| 78 | |||
| 79 | let mut counts: HashMap<nostr::EventId, usize> = HashMap::new(); | ||
| 80 | for comment in &comments { | ||
| 81 | // Find the uppercase E tag that matches one of our issue IDs. | ||
| 82 | for tag in comment.tags.iter() { | ||
| 83 | let s = tag.as_slice(); | ||
| 84 | if s.len() >= 2 && s[0].eq("E") { | ||
| 85 | if let Ok(root_id) = nostr::EventId::parse(&s[1]) { | ||
| 86 | if issues.iter().any(|e| e.id == root_id) { | ||
| 87 | *counts.entry(root_id).or_insert(0) += 1; | ||
| 88 | break; | ||
| 89 | } | ||
| 90 | } | ||
| 91 | } | ||
| 92 | } | ||
| 93 | } | ||
| 94 | Ok(counts) | ||
| 95 | } | ||
| 96 | |||
| 57 | #[allow(clippy::too_many_lines)] | 97 | #[allow(clippy::too_many_lines)] |
| 58 | pub async fn launch( | 98 | pub async fn launch( |
| 59 | status: String, | 99 | status: String, |
| @@ -104,6 +144,8 @@ pub async fn launch( | |||
| 104 | statuses | 144 | statuses |
| 105 | }; | 145 | }; |
| 106 | 146 | ||
| 147 | let comment_counts = get_comment_counts(git_repo_path, &issues).await?; | ||
| 148 | |||
| 107 | let status_filter: HashSet<&str> = status.split(',').map(str::trim).collect(); | 149 | let status_filter: HashSet<&str> = status.split(',').map(str::trim).collect(); |
| 108 | 150 | ||
| 109 | let hashtag_filter: Option<HashSet<String>> = hashtag.map(|h| { | 151 | let hashtag_filter: Option<HashSet<String>> = hashtag.map(|h| { |
| @@ -116,7 +158,7 @@ pub async fn launch( | |||
| 116 | // revisions, so we pass an empty slice. | 158 | // revisions, so we pass an empty slice. |
| 117 | let empty_proposals: Vec<nostr::Event> = vec![]; | 159 | let empty_proposals: Vec<nostr::Event> = vec![]; |
| 118 | 160 | ||
| 119 | let filtered: Vec<(&nostr::Event, Kind, Vec<String>)> = issues | 161 | let filtered: Vec<(&nostr::Event, Kind, Vec<String>, usize)> = issues |
| 120 | .iter() | 162 | .iter() |
| 121 | .filter_map(|issue| { | 163 | .filter_map(|issue| { |
| 122 | let status_kind = get_status(issue, &repo_ref, &statuses, &empty_proposals); | 164 | let status_kind = get_status(issue, &repo_ref, &statuses, &empty_proposals); |
| @@ -132,7 +174,8 @@ pub async fn launch( | |||
| 132 | return None; | 174 | return None; |
| 133 | } | 175 | } |
| 134 | } | 176 | } |
| 135 | Some((issue, status_kind, tags)) | 177 | let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0); |
| 178 | Some((issue, status_kind, tags, comment_count)) | ||
| 136 | }) | 179 | }) |
| 137 | .collect(); | 180 | .collect(); |
| 138 | 181 | ||
| @@ -155,7 +198,7 @@ pub async fn launch( | |||
| 155 | } | 198 | } |
| 156 | 199 | ||
| 157 | fn show_issue_details( | 200 | fn show_issue_details( |
| 158 | issues: &[(&nostr::Event, Kind, Vec<String>)], | 201 | issues: &[(&nostr::Event, Kind, Vec<String>, usize)], |
| 159 | event_id_or_nevent: &str, | 202 | event_id_or_nevent: &str, |
| 160 | json: bool, | 203 | json: bool, |
| 161 | ) -> Result<()> { | 204 | ) -> Result<()> { |
| @@ -170,35 +213,35 @@ fn show_issue_details( | |||
| 170 | nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? | 213 | nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? |
| 171 | }; | 214 | }; |
| 172 | 215 | ||
| 173 | let (issue, status_kind, tags) = issues | 216 | let (issue, status_kind, tags, comment_count) = issues |
| 174 | .iter() | 217 | .iter() |
| 175 | .find(|(e, _, _)| e.id == target_id) | 218 | .find(|(e, _, _, _)| e.id == target_id) |
| 176 | .context("issue not found")?; | 219 | .context("issue not found")?; |
| 177 | 220 | ||
| 178 | let title = get_issue_title(issue); | 221 | let title = get_issue_title(issue); |
| 179 | let status = status_kind_to_str(*status_kind); | 222 | let status = status_kind_to_str(*status_kind); |
| 180 | 223 | ||
| 181 | if json { | 224 | if json { |
| 182 | use nostr::ToBech32; | ||
| 183 | let json_output = serde_json::json!({ | 225 | let json_output = serde_json::json!({ |
| 184 | "id": issue.id.to_string(), | 226 | "id": issue.id.to_string(), |
| 185 | "status": status, | 227 | "status": status, |
| 186 | "title": title, | 228 | "title": title, |
| 187 | "author": issue.pubkey.to_bech32().unwrap_or_default(), | 229 | "author": issue.pubkey.to_bech32().unwrap_or_default(), |
| 188 | "hashtags": tags, | 230 | "hashtags": tags, |
| 231 | "comments": comment_count, | ||
| 189 | "description": issue.content, | 232 | "description": issue.content, |
| 190 | }); | 233 | }); |
| 191 | println!("{}", serde_json::to_string_pretty(&json_output)?); | 234 | println!("{}", serde_json::to_string_pretty(&json_output)?); |
| 192 | return Ok(()); | 235 | return Ok(()); |
| 193 | } | 236 | } |
| 194 | 237 | ||
| 195 | println!("Title: {title}"); | 238 | println!("Title: {title}"); |
| 196 | use nostr::ToBech32; | 239 | println!("Author: {}", issue.pubkey.to_bech32().unwrap_or_default()); |
| 197 | println!("Author: {}", issue.pubkey.to_bech32().unwrap_or_default()); | 240 | println!("Status: {status}"); |
| 198 | println!("Status: {status}"); | 241 | println!("Comments: {comment_count}"); |
| 199 | if !tags.is_empty() { | 242 | if !tags.is_empty() { |
| 200 | let tags_str = tags.iter().map(|t| format!("#{t}")).collect::<Vec<_>>().join(" "); | 243 | let tags_str = tags.iter().map(|t| format!("#{t}")).collect::<Vec<_>>().join(" "); |
| 201 | println!("Tags: {tags_str}"); | 244 | println!("Tags: {tags_str}"); |
| 202 | } | 245 | } |
| 203 | 246 | ||
| 204 | if !issue.content.is_empty() { | 247 | if !issue.content.is_empty() { |
| @@ -212,12 +255,12 @@ fn show_issue_details( | |||
| 212 | } | 255 | } |
| 213 | 256 | ||
| 214 | fn output_table( | 257 | fn output_table( |
| 215 | issues: &[(&nostr::Event, Kind, Vec<String>)], | 258 | issues: &[(&nostr::Event, Kind, Vec<String>, usize)], |
| 216 | status_filter: &str, | 259 | status_filter: &str, |
| 217 | hashtag_filter: Option<&HashSet<String>>, | 260 | hashtag_filter: Option<&HashSet<String>>, |
| 218 | ) { | 261 | ) { |
| 219 | println!("{:<66} {:<8} TITLE HASHTAGS", "ID", "STATUS"); | 262 | println!("{:<66} {:<8} {:<5} TITLE HASHTAGS", "ID", "STATUS", "CMTS"); |
| 220 | for (issue, status_kind, tags) in issues { | 263 | for (issue, status_kind, tags, comment_count) in issues { |
| 221 | let id = issue.id.to_string(); | 264 | let id = issue.id.to_string(); |
| 222 | let status = status_kind_to_str(*status_kind); | 265 | let status = status_kind_to_str(*status_kind); |
| 223 | let title = get_issue_title(issue); | 266 | let title = get_issue_title(issue); |
| @@ -230,9 +273,9 @@ fn output_table( | |||
| 230 | .join(" ") | 273 | .join(" ") |
| 231 | }; | 274 | }; |
| 232 | if tags_str.is_empty() { | 275 | if tags_str.is_empty() { |
| 233 | println!("{id:<66} {status:<8} {title}"); | 276 | println!("{id:<66} {status:<8} {comment_count:<5} {title}"); |
| 234 | } else { | 277 | } else { |
| 235 | println!("{id:<66} {status:<8} {title} {tags_str}"); | 278 | println!("{id:<66} {status:<8} {comment_count:<5} {title} {tags_str}"); |
| 236 | } | 279 | } |
| 237 | } | 280 | } |
| 238 | 281 | ||
| @@ -245,17 +288,17 @@ fn output_table( | |||
| 245 | println!(); | 288 | println!(); |
| 246 | } | 289 | } |
| 247 | 290 | ||
| 248 | fn output_json(issues: &[(&nostr::Event, Kind, Vec<String>)]) -> Result<()> { | 291 | fn output_json(issues: &[(&nostr::Event, Kind, Vec<String>, usize)]) -> Result<()> { |
| 249 | use nostr::ToBech32; | ||
| 250 | let json_output: Vec<serde_json::Value> = issues | 292 | let json_output: Vec<serde_json::Value> = issues |
| 251 | .iter() | 293 | .iter() |
| 252 | .map(|(issue, status_kind, tags)| { | 294 | .map(|(issue, status_kind, tags, comment_count)| { |
| 253 | serde_json::json!({ | 295 | serde_json::json!({ |
| 254 | "id": issue.id.to_string(), | 296 | "id": issue.id.to_string(), |
| 255 | "status": status_kind_to_str(*status_kind), | 297 | "status": status_kind_to_str(*status_kind), |
| 256 | "title": get_issue_title(issue), | 298 | "title": get_issue_title(issue), |
| 257 | "author": issue.pubkey.to_bech32().unwrap_or_default(), | 299 | "author": issue.pubkey.to_bech32().unwrap_or_default(), |
| 258 | "hashtags": tags, | 300 | "hashtags": tags, |
| 301 | "comments": comment_count, | ||
| 259 | }) | 302 | }) |
| 260 | }) | 303 | }) |
| 261 | .collect(); | 304 | .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::{ | |||
| 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 | ||
| 225 | fn 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`. | ||
| 239 | async 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 | |||
| 216 | fn status_kind_to_str(kind: Kind) -> &'static str { | 271 | fn 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!(); |
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::{ | |||
| 56 | get_dirs, | 56 | get_dirs, |
| 57 | git::{Repo, RepoActions, get_git_config_item}, | 57 | git::{Repo, RepoActions, get_git_config_item}, |
| 58 | git_events::{ | 58 | git_events::{ |
| 59 | KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, KIND_USER_GRASP_LIST, event_is_cover_letter, | 59 | KIND_COMMENT, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, KIND_USER_GRASP_LIST, |
| 60 | event_is_patch_set_root, event_is_revision_root, event_is_valid_pr_or_pr_update, | 60 | event_is_cover_letter, event_is_patch_set_root, event_is_revision_root, |
| 61 | status_kinds, | 61 | event_is_valid_pr_or_pr_update, status_kinds, |
| 62 | }, | 62 | }, |
| 63 | login::{get_likely_logged_in_user, user::get_user_ref_from_cache}, | 63 | login::{get_likely_logged_in_user, user::get_user_ref_from_cache}, |
| 64 | repo_ref::{RepoRef, normalize_grasp_server_url}, | 64 | repo_ref::{RepoRef, normalize_grasp_server_url}, |
| @@ -1877,7 +1877,7 @@ async fn create_relays_request( | |||
| 1877 | }) | 1877 | }) |
| 1878 | } | 1878 | } |
| 1879 | 1879 | ||
| 1880 | #[allow(clippy::too_many_lines)] | 1880 | #[allow(clippy::too_many_lines, clippy::too_many_arguments)] |
| 1881 | async fn process_fetched_events( | 1881 | async fn process_fetched_events( |
| 1882 | events: Vec<nostr::Event>, | 1882 | events: Vec<nostr::Event>, |
| 1883 | request: &FetchRequest, | 1883 | request: &FetchRequest, |
| @@ -1996,6 +1996,8 @@ async fn process_fetched_events( | |||
| 1996 | { | 1996 | { |
| 1997 | fresh_profiles.insert(event.pubkey); | 1997 | fresh_profiles.insert(event.pubkey); |
| 1998 | } | 1998 | } |
| 1999 | } else if event.kind.eq(&KIND_COMMENT) { | ||
| 2000 | report.comments.insert(event.id); | ||
| 1999 | } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind) | 2001 | } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind) |
| 2000 | { | 2002 | { |
| 2001 | if request.missing_contributor_profiles.contains(&event.pubkey) { | 2003 | if request.missing_contributor_profiles.contains(&event.pubkey) { |
| @@ -2023,9 +2025,7 @@ async fn process_fetched_events( | |||
| 2023 | for event in &events { | 2025 | for event in &events { |
| 2024 | if !request.existing_events.contains(&event.id) { | 2026 | if !request.existing_events.contains(&event.id) { |
| 2025 | let tagged_root_id = event.tags.iter().find_map(|t| { | 2027 | let tagged_root_id = event.tags.iter().find_map(|t| { |
| 2026 | if t.as_slice().len() > 1 | 2028 | if t.as_slice().len() > 1 && (t.as_slice()[0].eq("E") || t.as_slice()[0].eq("e")) { |
| 2027 | && (t.as_slice()[0].eq("E") || t.as_slice()[0].eq("e")) | ||
| 2028 | { | ||
| 2029 | EventId::parse(&t.as_slice()[1]).ok() | 2029 | EventId::parse(&t.as_slice()[1]).ok() |
| 2030 | } else { | 2030 | } else { |
| 2031 | None | 2031 | None |
| @@ -2038,9 +2038,11 @@ async fn process_fetched_events( | |||
| 2038 | // as their parent (new issues/proposals already inflate the count). | 2038 | // as their parent (new issues/proposals already inflate the count). |
| 2039 | if let Some(root_id) = &tagged_root_id { | 2039 | if let Some(root_id) = &tagged_root_id { |
| 2040 | if report.issues.contains(root_id) { | 2040 | if report.issues.contains(root_id) { |
| 2041 | // status for a new issue in this batch — skip (counted via issues) | 2041 | // status for a new issue in this batch — skip (counted |
| 2042 | // via issues) | ||
| 2042 | } else if report.proposals.contains(root_id) { | 2043 | } else if report.proposals.contains(root_id) { |
| 2043 | // status for a new proposal in this batch — skip (counted via proposals) | 2044 | // status for a new proposal in this batch — skip |
| 2045 | // (counted via proposals) | ||
| 2044 | } else if request.issue_ids.contains(root_id) { | 2046 | } else if request.issue_ids.contains(root_id) { |
| 2045 | report.issue_statuses.insert(event.id); | 2047 | report.issue_statuses.insert(event.id); |
| 2046 | } else { | 2048 | } else { |
| @@ -2052,12 +2054,11 @@ async fn process_fetched_events( | |||
| 2052 | let not_tagged_with_new_proposal = tagged_root_id | 2054 | let not_tagged_with_new_proposal = tagged_root_id |
| 2053 | .as_ref() | 2055 | .as_ref() |
| 2054 | .is_none_or(|id| !report.proposals.contains(id)); | 2056 | .is_none_or(|id| !report.proposals.contains(id)); |
| 2055 | if not_tagged_with_new_proposal { | 2057 | if not_tagged_with_new_proposal |
| 2056 | if (event.kind.eq(&Kind::GitPatch) && !event_is_patch_set_root(event)) | 2058 | && ((event.kind.eq(&Kind::GitPatch) && !event_is_patch_set_root(event)) |
| 2057 | || event.kind.eq(&KIND_PULL_REQUEST_UPDATE) | 2059 | || event.kind.eq(&KIND_PULL_REQUEST_UPDATE)) |
| 2058 | { | 2060 | { |
| 2059 | report.commits.insert(event.id); | 2061 | report.commits.insert(event.id); |
| 2060 | } | ||
| 2061 | } | 2062 | } |
| 2062 | } | 2063 | } |
| 2063 | } | 2064 | } |
| @@ -2117,6 +2118,9 @@ pub fn consolidate_fetch_reports(reports: Vec<Result<FetchReport>>) -> FetchRepo | |||
| 2117 | for c in relay_report.issue_statuses { | 2118 | for c in relay_report.issue_statuses { |
| 2118 | report.issue_statuses.insert(c); | 2119 | report.issue_statuses.insert(c); |
| 2119 | } | 2120 | } |
| 2121 | for c in relay_report.comments { | ||
| 2122 | report.comments.insert(c); | ||
| 2123 | } | ||
| 2120 | report.deletions += relay_report.deletions; | 2124 | report.deletions += relay_report.deletions; |
| 2121 | for c in relay_report.contributor_profiles { | 2125 | for c in relay_report.contributor_profiles { |
| 2122 | report.contributor_profiles.insert(c); | 2126 | report.contributor_profiles.insert(c); |
| @@ -2223,6 +2227,24 @@ pub fn get_fetch_filters( | |||
| 2223 | .kinds(status_kinds()), | 2227 | .kinds(status_kinds()), |
| 2224 | ] | 2228 | ] |
| 2225 | }, | 2229 | }, |
| 2230 | // Fetch NIP-22 kind-1111 comments for issues and proposals (patches/PRs). | ||
| 2231 | // Comments use an uppercase `E` tag pointing to the root event ID. | ||
| 2232 | { | ||
| 2233 | let all_root_ids: HashSet<EventId> = issue_ids | ||
| 2234 | .iter() | ||
| 2235 | .chain(proposal_ids.iter()) | ||
| 2236 | .copied() | ||
| 2237 | .collect(); | ||
| 2238 | if all_root_ids.is_empty() { | ||
| 2239 | vec![] | ||
| 2240 | } else { | ||
| 2241 | vec![ | ||
| 2242 | nostr::Filter::default() | ||
| 2243 | .custom_tags(SingleLetterTag::uppercase(Alphabet::E), all_root_ids) | ||
| 2244 | .kind(KIND_COMMENT), | ||
| 2245 | ] | ||
| 2246 | } | ||
| 2247 | }, | ||
| 2226 | // Request kind-5 deletions for state events and repo announcements by | 2248 | // Request kind-5 deletions for state events and repo announcements by |
| 2227 | // their event ID (#e tag), as per NIP-09. The #a-tagged filter above | 2249 | // their event ID (#e tag), as per NIP-09. The #a-tagged filter above |
| 2228 | // covers addressable-event deletions; this covers the specific event IDs | 2250 | // covers addressable-event deletions; this covers the specific event IDs |
| @@ -2309,6 +2331,8 @@ pub struct FetchReport { | |||
| 2309 | statuses: HashSet<EventId>, | 2331 | statuses: HashSet<EventId>, |
| 2310 | issues: HashSet<EventId>, | 2332 | issues: HashSet<EventId>, |
| 2311 | issue_statuses: HashSet<EventId>, | 2333 | issue_statuses: HashSet<EventId>, |
| 2334 | /// NIP-22 kind-1111 comments against issues, patches, and PRs. | ||
| 2335 | comments: HashSet<EventId>, | ||
| 2312 | /// Count of kind-5 deletion events received (for display purposes). | 2336 | /// Count of kind-5 deletion events received (for display purposes). |
| 2313 | deletions: u32, | 2337 | deletions: u32, |
| 2314 | contributor_profiles: HashSet<PublicKey>, | 2338 | contributor_profiles: HashSet<PublicKey>, |
| @@ -2383,7 +2407,18 @@ impl Display for FetchReport { | |||
| 2383 | display_items.push(format!( | 2407 | display_items.push(format!( |
| 2384 | "{} issue status{}", | 2408 | "{} issue status{}", |
| 2385 | self.issue_statuses.len(), | 2409 | self.issue_statuses.len(), |
| 2386 | if self.issue_statuses.len() > 1 { "es" } else { "" }, | 2410 | if self.issue_statuses.len() > 1 { |
| 2411 | "es" | ||
| 2412 | } else { | ||
| 2413 | "" | ||
| 2414 | }, | ||
| 2415 | )); | ||
| 2416 | } | ||
| 2417 | if !self.comments.is_empty() { | ||
| 2418 | display_items.push(format!( | ||
| 2419 | "{} comment{}", | ||
| 2420 | self.comments.len(), | ||
| 2421 | if self.comments.len() > 1 { "s" } else { "" }, | ||
| 2387 | )); | 2422 | )); |
| 2388 | } | 2423 | } |
| 2389 | if self.deletions > 0 { | 2424 | 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<Kind> { | |||
| 88 | pub const KIND_PULL_REQUEST: Kind = Kind::Custom(1618); | 88 | pub const KIND_PULL_REQUEST: Kind = Kind::Custom(1618); |
| 89 | pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::Custom(1619); | 89 | pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::Custom(1619); |
| 90 | pub const KIND_USER_GRASP_LIST: Kind = Kind::Custom(10317); | 90 | pub const KIND_USER_GRASP_LIST: Kind = Kind::Custom(10317); |
| 91 | /// NIP-22 comment (kind 1111) — threaded comments on any event. | ||
| 92 | pub const KIND_COMMENT: Kind = Kind::Custom(1111); | ||
| 91 | 93 | ||
| 92 | pub fn event_is_patch_set_root(event: &Event) -> bool { | 94 | pub fn event_is_patch_set_root(event: &Event) -> bool { |
| 93 | event.kind.eq(&Kind::GitPatch) | 95 | event.kind.eq(&Kind::GitPatch) |