diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-04 14:32:39 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-04 14:50:01 +0000 |
| commit | 7dcbdc7841e932570359ccef3b82459b89e6f2bc (patch) | |
| tree | 4f709d99f650ac3bf2eb4dc11fc97ebf20b45fef /src/bin/ngit/sub_commands/list.rs | |
| parent | b3b1a949463d8e18622519866ecee3f1b65cc888 (diff) | |
show full comment content in pr view and issue view
ngit pr view <id> and ngit issue view <id> now fetch and display all
NIP-22 comments in chronological order with author npub and timestamp,
rather than just a count. JSON output includes the full comment array.
Also updates list table hint text to reference ngit pr subcommands.
Diffstat (limited to 'src/bin/ngit/sub_commands/list.rs')
| -rw-r--r-- | src/bin/ngit/sub_commands/list.rs | 128 |
1 files changed, 92 insertions, 36 deletions
diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs index d1b6de8..a583ca5 100644 --- a/src/bin/ngit/sub_commands/list.rs +++ b/src/bin/ngit/sub_commands/list.rs | |||
| @@ -201,15 +201,15 @@ 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 | // Resolve the target proposal ID so we can fetch its comment count. | 204 | // Resolve the target proposal ID so we can fetch its comments. |
| 205 | let target_id = resolve_event_id(event_id_or_nevent)?; | 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?; | 206 | let comments = get_comments_for_proposal(git_repo_path, &target_id).await?; |
| 207 | return show_proposal_details( | 207 | return show_proposal_details( |
| 208 | &filtered_proposals, | 208 | &filtered_proposals, |
| 209 | &repo_ref, | 209 | &repo_ref, |
| 210 | event_id_or_nevent, | 210 | event_id_or_nevent, |
| 211 | json, | 211 | json, |
| 212 | comment_count, | 212 | &comments, |
| 213 | ); | 213 | ); |
| 214 | } | 214 | } |
| 215 | 215 | ||
| @@ -235,37 +235,38 @@ fn resolve_event_id(event_id_or_nevent: &str) -> Result<nostr::EventId> { | |||
| 235 | } | 235 | } |
| 236 | } | 236 | } |
| 237 | 237 | ||
| 238 | /// Count NIP-22 kind-1111 comments whose root `#E` tag matches `proposal_id`. | 238 | /// Fetch NIP-22 kind-1111 comments whose root `#E` tag matches `proposal_id`, |
| 239 | async fn get_comment_count_for_proposal( | 239 | /// sorted oldest-first. |
| 240 | async fn get_comments_for_proposal( | ||
| 240 | git_repo_path: &std::path::Path, | 241 | git_repo_path: &std::path::Path, |
| 241 | proposal_id: &nostr::EventId, | 242 | proposal_id: &nostr::EventId, |
| 242 | ) -> Result<usize> { | 243 | ) -> Result<Vec<nostr::Event>> { |
| 243 | let comments = get_events_from_local_cache( | 244 | let mut comments = get_events_from_local_cache( |
| 244 | git_repo_path, | 245 | git_repo_path, |
| 245 | vec![nostr::Filter::default() | 246 | vec![ |
| 246 | .custom_tags( | 247 | nostr::Filter::default() |
| 247 | SingleLetterTag::uppercase(Alphabet::E), | 248 | .custom_tags( |
| 248 | std::iter::once(*proposal_id), | 249 | SingleLetterTag::uppercase(Alphabet::E), |
| 249 | ) | 250 | std::iter::once(*proposal_id), |
| 250 | .kind(KIND_COMMENT)], | 251 | ) |
| 252 | .kind(KIND_COMMENT), | ||
| 253 | ], | ||
| 251 | ) | 254 | ) |
| 252 | .await?; | 255 | .await?; |
| 253 | // Only count comments whose uppercase E tag actually points to this proposal | 256 | // Only keep comments whose uppercase E tag actually points to this proposal. |
| 254 | // (the filter is best-effort; verify explicitly). | 257 | comments.retain(|c| { |
| 255 | let count = comments | 258 | c.tags.iter().any(|t| { |
| 256 | .iter() | 259 | let s = t.as_slice(); |
| 257 | .filter(|c| { | 260 | s.len() >= 2 |
| 258 | c.tags.iter().any(|t| { | 261 | && s[0].eq("E") |
| 259 | let s = t.as_slice(); | 262 | && nostr::EventId::parse(&s[1]) |
| 260 | s.len() >= 2 | 263 | .map(|id| id == *proposal_id) |
| 261 | && s[0].eq("E") | 264 | .unwrap_or(false) |
| 262 | && nostr::EventId::parse(&s[1]) | ||
| 263 | .map(|id| id == *proposal_id) | ||
| 264 | .unwrap_or(false) | ||
| 265 | }) | ||
| 266 | }) | 265 | }) |
| 267 | .count(); | 266 | }); |
| 268 | Ok(count) | 267 | // Oldest first |
| 268 | comments.sort_by_key(|e| e.created_at); | ||
| 269 | Ok(comments) | ||
| 269 | } | 270 | } |
| 270 | 271 | ||
| 271 | fn status_kind_to_str(kind: Kind) -> &'static str { | 272 | fn status_kind_to_str(kind: Kind) -> &'static str { |
| @@ -300,14 +301,17 @@ fn output_table(proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef, status | |||
| 300 | 301 | ||
| 301 | println!(); | 302 | println!(); |
| 302 | println!("--status {status_filter}"); | 303 | println!("--status {status_filter}"); |
| 303 | println!("{}", console::style("To view: ngit list <id>").yellow()); | ||
| 304 | println!( | 304 | println!( |
| 305 | "{}", | 305 | "{}", |
| 306 | console::style("To checkout: ngit checkout <id>").yellow() | 306 | console::style("To view: ngit pr view <id>").yellow() |
| 307 | ); | ||
| 308 | println!( | ||
| 309 | "{}", | ||
| 310 | console::style("To checkout: ngit pr checkout <id>").yellow() | ||
| 307 | ); | 311 | ); |
| 308 | println!( | 312 | println!( |
| 309 | "{}", | 313 | "{}", |
| 310 | console::style("To apply: ngit apply <id>").yellow() | 314 | console::style("To apply: ngit pr apply <id>").yellow() |
| 311 | ); | 315 | ); |
| 312 | } | 316 | } |
| 313 | 317 | ||
| @@ -354,8 +358,10 @@ fn show_proposal_details( | |||
| 354 | _repo_ref: &RepoRef, | 358 | _repo_ref: &RepoRef, |
| 355 | event_id_or_nevent: &str, | 359 | event_id_or_nevent: &str, |
| 356 | json: bool, | 360 | json: bool, |
| 357 | comment_count: usize, | 361 | comments: &[nostr::Event], |
| 358 | ) -> Result<()> { | 362 | ) -> Result<()> { |
| 363 | use nostr::ToBech32; | ||
| 364 | |||
| 359 | let target_id = resolve_event_id(event_id_or_nevent)?; | 365 | let target_id = resolve_event_id(event_id_or_nevent)?; |
| 360 | 366 | ||
| 361 | let (proposal, status_kind) = proposals | 367 | let (proposal, status_kind) = proposals |
| @@ -367,13 +373,24 @@ fn show_proposal_details( | |||
| 367 | .context("failed to extract proposal details from proposal root event")?; | 373 | .context("failed to extract proposal details from proposal root event")?; |
| 368 | 374 | ||
| 369 | if json { | 375 | if json { |
| 376 | let comments_json: Vec<serde_json::Value> = comments | ||
| 377 | .iter() | ||
| 378 | .map(|c| { | ||
| 379 | serde_json::json!({ | ||
| 380 | "id": c.id.to_string(), | ||
| 381 | "author": c.pubkey.to_bech32().unwrap_or_default(), | ||
| 382 | "created_at": c.created_at.as_secs(), | ||
| 383 | "body": c.content, | ||
| 384 | }) | ||
| 385 | }) | ||
| 386 | .collect(); | ||
| 370 | let json_output = serde_json::json!({ | 387 | let json_output = serde_json::json!({ |
| 371 | "id": proposal.id.to_string(), | 388 | "id": proposal.id.to_string(), |
| 372 | "status": status_kind_to_str(*status_kind), | 389 | "status": status_kind_to_str(*status_kind), |
| 373 | "title": cover_letter.title, | 390 | "title": cover_letter.title, |
| 374 | "author": proposal.pubkey.to_bech32().unwrap_or_default(), | 391 | "author": proposal.pubkey.to_bech32().unwrap_or_default(), |
| 375 | "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, | 392 | "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, |
| 376 | "comments": comment_count, | 393 | "comments": comments_json, |
| 377 | "description": cover_letter.description, | 394 | "description": cover_letter.description, |
| 378 | }); | 395 | }); |
| 379 | println!("{}", serde_json::to_string_pretty(&json_output)?); | 396 | println!("{}", serde_json::to_string_pretty(&json_output)?); |
| @@ -390,7 +407,6 @@ fn show_proposal_details( | |||
| 390 | "Branch: {}", | 407 | "Branch: {}", |
| 391 | cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()? | 408 | cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()? |
| 392 | ); | 409 | ); |
| 393 | println!("Comments: {comment_count}"); | ||
| 394 | 410 | ||
| 395 | if !cover_letter.description.is_empty() { | 411 | if !cover_letter.description.is_empty() { |
| 396 | println!(); | 412 | println!(); |
| @@ -400,19 +416,59 @@ fn show_proposal_details( | |||
| 400 | } | 416 | } |
| 401 | } | 417 | } |
| 402 | 418 | ||
| 419 | if comments.is_empty() { | ||
| 420 | println!("Comments: 0"); | ||
| 421 | } else { | ||
| 422 | println!(); | ||
| 423 | println!("Comments ({}):", comments.len()); | ||
| 424 | let dim = console::Style::new().color256(247); | ||
| 425 | for comment in comments { | ||
| 426 | let author = comment.pubkey.to_bech32().unwrap_or_default(); | ||
| 427 | let ts = chrono_timestamp(comment.created_at.as_secs()); | ||
| 428 | println!(); | ||
| 429 | println!("{}", dim.apply_to(format!(" {author} {ts}"))); | ||
| 430 | for line in comment.content.lines() { | ||
| 431 | println!(" {line}"); | ||
| 432 | } | ||
| 433 | } | ||
| 434 | } | ||
| 435 | |||
| 403 | println!(); | 436 | println!(); |
| 404 | println!( | 437 | println!( |
| 405 | "{}", | 438 | "{}", |
| 406 | console::style(format!("To checkout: ngit checkout {}", proposal.id)).yellow() | 439 | console::style(format!("To checkout: ngit pr checkout {}", proposal.id)).yellow() |
| 407 | ); | 440 | ); |
| 408 | println!( | 441 | println!( |
| 409 | "{}", | 442 | "{}", |
| 410 | console::style(format!("To apply: ngit apply {}", proposal.id)).yellow() | 443 | console::style(format!("To apply: ngit pr apply {}", proposal.id)).yellow() |
| 411 | ); | 444 | ); |
| 412 | 445 | ||
| 413 | Ok(()) | 446 | Ok(()) |
| 414 | } | 447 | } |
| 415 | 448 | ||
| 449 | fn chrono_timestamp(unix_secs: u64) -> String { | ||
| 450 | // Format as YYYY-MM-DD HH:MM UTC without pulling in chrono. | ||
| 451 | // unix_secs → days since epoch, then decompose. | ||
| 452 | let secs = unix_secs % 60; | ||
| 453 | let mins = (unix_secs / 60) % 60; | ||
| 454 | let hours = (unix_secs / 3600) % 24; | ||
| 455 | let days_since_epoch = unix_secs / 86400; | ||
| 456 | |||
| 457 | // Gregorian calendar decomposition (Fliegel-Van Flandern algorithm) | ||
| 458 | let z = days_since_epoch + 719_468; | ||
| 459 | let era = z / 146_097; | ||
| 460 | let doe = z - era * 146_097; | ||
| 461 | let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; | ||
| 462 | let y = yoe + era * 400; | ||
| 463 | let day_of_year = doe - (365 * yoe + yoe / 4 - yoe / 100); | ||
| 464 | let mp = (5 * day_of_year + 2) / 153; | ||
| 465 | let d = day_of_year - (153 * mp + 2) / 5 + 1; | ||
| 466 | let m = if mp < 10 { mp + 3 } else { mp - 9 }; | ||
| 467 | let y = if m <= 2 { y + 1 } else { y }; | ||
| 468 | |||
| 469 | format!("{y:04}-{m:02}-{d:02} {hours:02}:{mins:02}:{secs:02} UTC") | ||
| 470 | } | ||
| 471 | |||
| 416 | #[allow(clippy::too_many_lines)] | 472 | #[allow(clippy::too_many_lines)] |
| 417 | async fn launch_interactive() -> Result<()> { | 473 | async fn launch_interactive() -> Result<()> { |
| 418 | let git_repo = Repo::discover().context("failed to find a git repository")?; | 474 | let git_repo = Repo::discover().context("failed to find a git repository")?; |