upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin
diff options
context:
space:
mode:
Diffstat (limited to 'src/bin')
-rw-r--r--src/bin/ngit/sub_commands/issue_list.rs91
-rw-r--r--src/bin/ngit/sub_commands/list.rs81
2 files changed, 132 insertions, 40 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!();