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 | |
| 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')
| -rw-r--r-- | src/bin/ngit/sub_commands/issue_list.rs | 136 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/list.rs | 128 |
2 files changed, 215 insertions, 49 deletions
diff --git a/src/bin/ngit/sub_commands/issue_list.rs b/src/bin/ngit/sub_commands/issue_list.rs index cfc0d49..b7abf8d 100644 --- a/src/bin/ngit/sub_commands/issue_list.rs +++ b/src/bin/ngit/sub_commands/issue_list.rs | |||
| @@ -23,7 +23,13 @@ fn get_issue_title(event: &nostr::Event) -> String { | |||
| 23 | .ok() | 23 | .ok() |
| 24 | .filter(|s| !s.is_empty()) | 24 | .filter(|s| !s.is_empty()) |
| 25 | .unwrap_or_else(|| { | 25 | .unwrap_or_else(|| { |
| 26 | let first_line = event.content.lines().next().unwrap_or("").trim().to_string(); | 26 | let first_line = event |
| 27 | .content | ||
| 28 | .lines() | ||
| 29 | .next() | ||
| 30 | .unwrap_or("") | ||
| 31 | .trim() | ||
| 32 | .to_string(); | ||
| 27 | if first_line.is_empty() { | 33 | if first_line.is_empty() { |
| 28 | event.id.to_string() | 34 | event.id.to_string() |
| 29 | } else { | 35 | } else { |
| @@ -67,12 +73,14 @@ async fn get_comment_counts( | |||
| 67 | // Comments use an uppercase `E` tag pointing to the root event ID. | 73 | // Comments use an uppercase `E` tag pointing to the root event ID. |
| 68 | let comments = get_events_from_local_cache( | 74 | let comments = get_events_from_local_cache( |
| 69 | git_repo_path, | 75 | git_repo_path, |
| 70 | vec![nostr::Filter::default() | 76 | vec![ |
| 71 | .custom_tags( | 77 | nostr::Filter::default() |
| 72 | SingleLetterTag::uppercase(Alphabet::E), | 78 | .custom_tags( |
| 73 | issues.iter().map(|e| e.id), | 79 | SingleLetterTag::uppercase(Alphabet::E), |
| 74 | ) | 80 | issues.iter().map(|e| e.id), |
| 75 | .kind(KIND_COMMENT)], | 81 | ) |
| 82 | .kind(KIND_COMMENT), | ||
| 83 | ], | ||
| 76 | ) | 84 | ) |
| 77 | .await?; | 85 | .await?; |
| 78 | 86 | ||
| @@ -94,6 +102,37 @@ async fn get_comment_counts( | |||
| 94 | Ok(counts) | 102 | Ok(counts) |
| 95 | } | 103 | } |
| 96 | 104 | ||
| 105 | /// Fetch NIP-22 kind-1111 comments for a single issue, sorted oldest-first. | ||
| 106 | async fn get_comments_for_issue( | ||
| 107 | git_repo_path: &std::path::Path, | ||
| 108 | issue_id: &nostr::EventId, | ||
| 109 | ) -> Result<Vec<nostr::Event>> { | ||
| 110 | let mut comments = get_events_from_local_cache( | ||
| 111 | git_repo_path, | ||
| 112 | vec![ | ||
| 113 | nostr::Filter::default() | ||
| 114 | .custom_tags( | ||
| 115 | SingleLetterTag::uppercase(Alphabet::E), | ||
| 116 | std::iter::once(*issue_id), | ||
| 117 | ) | ||
| 118 | .kind(KIND_COMMENT), | ||
| 119 | ], | ||
| 120 | ) | ||
| 121 | .await?; | ||
| 122 | comments.retain(|c| { | ||
| 123 | c.tags.iter().any(|t| { | ||
| 124 | let s = t.as_slice(); | ||
| 125 | s.len() >= 2 | ||
| 126 | && s[0].eq("E") | ||
| 127 | && nostr::EventId::parse(&s[1]) | ||
| 128 | .map(|id| id == *issue_id) | ||
| 129 | .unwrap_or(false) | ||
| 130 | }) | ||
| 131 | }); | ||
| 132 | comments.sort_by_key(|e| e.created_at); | ||
| 133 | Ok(comments) | ||
| 134 | } | ||
| 135 | |||
| 97 | #[allow(clippy::too_many_lines)] | 136 | #[allow(clippy::too_many_lines)] |
| 98 | pub async fn launch( | 137 | pub async fn launch( |
| 99 | status: String, | 138 | status: String, |
| @@ -185,7 +224,20 @@ pub async fn launch( | |||
| 185 | } | 224 | } |
| 186 | 225 | ||
| 187 | if let Some(ref event_id_or_nevent) = id { | 226 | if let Some(ref event_id_or_nevent) = id { |
| 188 | return show_issue_details(&filtered, event_id_or_nevent, json); | 227 | // Resolve the target issue ID so we can fetch its comments. |
| 228 | let target_id = if event_id_or_nevent.starts_with("nevent") { | ||
| 229 | let nip19 = nostr::nips::nip19::Nip19::from_bech32(event_id_or_nevent) | ||
| 230 | .context("failed to parse nevent")?; | ||
| 231 | match nip19 { | ||
| 232 | nostr::nips::nip19::Nip19::EventId(id) => id, | ||
| 233 | nostr::nips::nip19::Nip19::Event(event) => event.event_id, | ||
| 234 | _ => anyhow::bail!("invalid nevent format"), | ||
| 235 | } | ||
| 236 | } else { | ||
| 237 | nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? | ||
| 238 | }; | ||
| 239 | let comments = get_comments_for_issue(git_repo_path, &target_id).await?; | ||
| 240 | return show_issue_details(&filtered, event_id_or_nevent, json, &comments); | ||
| 189 | } | 241 | } |
| 190 | 242 | ||
| 191 | if json { | 243 | if json { |
| @@ -201,6 +253,7 @@ fn show_issue_details( | |||
| 201 | issues: &[(&nostr::Event, Kind, Vec<String>, usize)], | 253 | issues: &[(&nostr::Event, Kind, Vec<String>, usize)], |
| 202 | event_id_or_nevent: &str, | 254 | event_id_or_nevent: &str, |
| 203 | json: bool, | 255 | json: bool, |
| 256 | comments: &[nostr::Event], | ||
| 204 | ) -> Result<()> { | 257 | ) -> Result<()> { |
| 205 | let target_id = if event_id_or_nevent.starts_with("nevent") { | 258 | let target_id = if event_id_or_nevent.starts_with("nevent") { |
| 206 | let nip19 = Nip19::from_bech32(event_id_or_nevent).context("failed to parse nevent")?; | 259 | let nip19 = Nip19::from_bech32(event_id_or_nevent).context("failed to parse nevent")?; |
| @@ -213,7 +266,7 @@ fn show_issue_details( | |||
| 213 | nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? | 266 | nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? |
| 214 | }; | 267 | }; |
| 215 | 268 | ||
| 216 | let (issue, status_kind, tags, comment_count) = issues | 269 | let (issue, status_kind, tags, _comment_count) = issues |
| 217 | .iter() | 270 | .iter() |
| 218 | .find(|(e, _, _, _)| e.id == target_id) | 271 | .find(|(e, _, _, _)| e.id == target_id) |
| 219 | .context("issue not found")?; | 272 | .context("issue not found")?; |
| @@ -222,13 +275,24 @@ fn show_issue_details( | |||
| 222 | let status = status_kind_to_str(*status_kind); | 275 | let status = status_kind_to_str(*status_kind); |
| 223 | 276 | ||
| 224 | if json { | 277 | if json { |
| 278 | let comments_json: Vec<serde_json::Value> = comments | ||
| 279 | .iter() | ||
| 280 | .map(|c| { | ||
| 281 | serde_json::json!({ | ||
| 282 | "id": c.id.to_string(), | ||
| 283 | "author": c.pubkey.to_bech32().unwrap_or_default(), | ||
| 284 | "created_at": c.created_at.as_secs(), | ||
| 285 | "body": c.content, | ||
| 286 | }) | ||
| 287 | }) | ||
| 288 | .collect(); | ||
| 225 | let json_output = serde_json::json!({ | 289 | let json_output = serde_json::json!({ |
| 226 | "id": issue.id.to_string(), | 290 | "id": issue.id.to_string(), |
| 227 | "status": status, | 291 | "status": status, |
| 228 | "title": title, | 292 | "title": title, |
| 229 | "author": issue.pubkey.to_bech32().unwrap_or_default(), | 293 | "author": issue.pubkey.to_bech32().unwrap_or_default(), |
| 230 | "hashtags": tags, | 294 | "hashtags": tags, |
| 231 | "comments": comment_count, | 295 | "comments": comments_json, |
| 232 | "description": issue.content, | 296 | "description": issue.content, |
| 233 | }); | 297 | }); |
| 234 | println!("{}", serde_json::to_string_pretty(&json_output)?); | 298 | println!("{}", serde_json::to_string_pretty(&json_output)?); |
| @@ -238,9 +302,12 @@ fn show_issue_details( | |||
| 238 | println!("Title: {title}"); | 302 | println!("Title: {title}"); |
| 239 | println!("Author: {}", issue.pubkey.to_bech32().unwrap_or_default()); | 303 | println!("Author: {}", issue.pubkey.to_bech32().unwrap_or_default()); |
| 240 | println!("Status: {status}"); | 304 | println!("Status: {status}"); |
| 241 | println!("Comments: {comment_count}"); | ||
| 242 | if !tags.is_empty() { | 305 | if !tags.is_empty() { |
| 243 | let tags_str = tags.iter().map(|t| format!("#{t}")).collect::<Vec<_>>().join(" "); | 306 | let tags_str = tags |
| 307 | .iter() | ||
| 308 | .map(|t| format!("#{t}")) | ||
| 309 | .collect::<Vec<_>>() | ||
| 310 | .join(" "); | ||
| 244 | println!("Tags: {tags_str}"); | 311 | println!("Tags: {tags_str}"); |
| 245 | } | 312 | } |
| 246 | 313 | ||
| @@ -251,9 +318,46 @@ fn show_issue_details( | |||
| 251 | } | 318 | } |
| 252 | } | 319 | } |
| 253 | 320 | ||
| 321 | if comments.is_empty() { | ||
| 322 | println!("Comments: 0"); | ||
| 323 | } else { | ||
| 324 | println!(); | ||
| 325 | println!("Comments ({}):", comments.len()); | ||
| 326 | let dim = console::Style::new().color256(247); | ||
| 327 | for comment in comments { | ||
| 328 | let author = comment.pubkey.to_bech32().unwrap_or_default(); | ||
| 329 | let ts = chrono_timestamp(comment.created_at.as_secs()); | ||
| 330 | println!(); | ||
| 331 | println!("{}", dim.apply_to(format!(" {author} {ts}"))); | ||
| 332 | for line in comment.content.lines() { | ||
| 333 | println!(" {line}"); | ||
| 334 | } | ||
| 335 | } | ||
| 336 | } | ||
| 337 | |||
| 254 | Ok(()) | 338 | Ok(()) |
| 255 | } | 339 | } |
| 256 | 340 | ||
| 341 | fn chrono_timestamp(unix_secs: u64) -> String { | ||
| 342 | let secs = unix_secs % 60; | ||
| 343 | let mins = (unix_secs / 60) % 60; | ||
| 344 | let hours = (unix_secs / 3600) % 24; | ||
| 345 | let days_since_epoch = unix_secs / 86400; | ||
| 346 | |||
| 347 | let z = days_since_epoch + 719_468; | ||
| 348 | let era = z / 146_097; | ||
| 349 | let doe = z - era * 146_097; | ||
| 350 | let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; | ||
| 351 | let y = yoe + era * 400; | ||
| 352 | let day_of_year = doe - (365 * yoe + yoe / 4 - yoe / 100); | ||
| 353 | let mp = (5 * day_of_year + 2) / 153; | ||
| 354 | let d = day_of_year - (153 * mp + 2) / 5 + 1; | ||
| 355 | let m = if mp < 10 { mp + 3 } else { mp - 9 }; | ||
| 356 | let y = if m <= 2 { y + 1 } else { y }; | ||
| 357 | |||
| 358 | format!("{y:04}-{m:02}-{d:02} {hours:02}:{mins:02}:{secs:02} UTC") | ||
| 359 | } | ||
| 360 | |||
| 257 | fn output_table( | 361 | fn output_table( |
| 258 | issues: &[(&nostr::Event, Kind, Vec<String>, usize)], | 362 | issues: &[(&nostr::Event, Kind, Vec<String>, usize)], |
| 259 | status_filter: &str, | 363 | status_filter: &str, |
| @@ -283,7 +387,13 @@ fn output_table( | |||
| 283 | print!("--status {status_filter}"); | 387 | print!("--status {status_filter}"); |
| 284 | if let Some(hf) = hashtag_filter { | 388 | if let Some(hf) = hashtag_filter { |
| 285 | let tags: Vec<&String> = hf.iter().collect(); | 389 | let tags: Vec<&String> = hf.iter().collect(); |
| 286 | print!(" --hashtag {}", tags.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(",")); | 390 | print!( |
| 391 | " --hashtag {}", | ||
| 392 | tags.iter() | ||
| 393 | .map(|s| s.as_str()) | ||
| 394 | .collect::<Vec<_>>() | ||
| 395 | .join(",") | ||
| 396 | ); | ||
| 287 | } | 397 | } |
| 288 | println!(); | 398 | println!(); |
| 289 | } | 399 | } |
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")?; |