upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/bin/ngit/sub_commands/issue_list.rs91
-rw-r--r--src/bin/ngit/sub_commands/list.rs81
-rw-r--r--src/lib/client.rs67
-rw-r--r--src/lib/git_events.rs2
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 @@
1use std::collections::HashSet; 1use std::collections::{HashMap, HashSet};
2 2
3use anyhow::{Context, Result, bail}; 3use anyhow::{Context, Result, bail};
4use ngit::{ 4use 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};
8use nostr::{ 8use nostr::{
9 FromBech32, 9 FromBech32, ToBech32,
10 filter::{Alphabet, SingleLetterTag}, 10 filter::{Alphabet, SingleLetterTag},
11 nips::nip19::Nip19, 11 nips::nip19::Nip19,
12}; 12};
13use nostr_sdk::Kind; 13use nostr_sdk::Kind;
14 14
15use crate::{ 15use 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.
59async 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)]
58pub async fn launch( 98pub 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
157fn show_issue_details( 200fn 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
214fn output_table( 257fn 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
248fn output_json(issues: &[(&nostr::Event, Kind, Vec<String>)]) -> Result<()> { 291fn 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
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!();
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)]
1881async fn process_fetched_events( 1881async 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> {
88pub const KIND_PULL_REQUEST: Kind = Kind::Custom(1618); 88pub const KIND_PULL_REQUEST: Kind = Kind::Custom(1618);
89pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::Custom(1619); 89pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::Custom(1619);
90pub const KIND_USER_GRASP_LIST: Kind = Kind::Custom(10317); 90pub const KIND_USER_GRASP_LIST: Kind = Kind::Custom(10317);
91/// NIP-22 comment (kind 1111) — threaded comments on any event.
92pub const KIND_COMMENT: Kind = Kind::Custom(1111);
91 93
92pub fn event_is_patch_set_root(event: &Event) -> bool { 94pub fn event_is_patch_set_root(event: &Event) -> bool {
93 event.kind.eq(&Kind::GitPatch) 95 event.kind.eq(&Kind::GitPatch)