diff options
Diffstat (limited to 'src/bin')
| -rw-r--r-- | src/bin/ngit/sub_commands/issue_list.rs | 91 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/list.rs | 81 |
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 @@ | |||
| 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!(); |