upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-03-04 14:32:39 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-03-04 14:50:01 +0000
commit7dcbdc7841e932570359ccef3b82459b89e6f2bc (patch)
tree4f709d99f650ac3bf2eb4dc11fc97ebf20b45fef
parentb3b1a949463d8e18622519866ecee3f1b65cc888 (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.
-rw-r--r--CHANGELOG.md4
-rw-r--r--src/bin/ngit/sub_commands/issue_list.rs136
-rw-r--r--src/bin/ngit/sub_commands/list.rs128
3 files changed, 217 insertions, 51 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2cb140e..37200c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,13 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10### Added 10### Added
11 11
12- `ngit pr` subcommand group: `list`, `view`, `checkout`, `apply`, `send`, `close`, `reopen`, `ready`, `comment`, `merge`; replaces the former top-level `ngit list`, `ngit checkout`, and `ngit apply` commands (hard-migrated); `ngit send` remains at the top level unchanged 12- `ngit pr` subcommand group: `list`, `view`, `checkout`, `apply`, `send`, `close`, `reopen`, `ready`, `comment`, `merge`; replaces the former top-level `ngit list`, `ngit checkout`, and `ngit apply` commands (hard-migrated); `ngit send` remains at the top level unchanged
13- `ngit pr view <id>` — view a PR with its full details and comment count 13- `ngit pr view <id>` — view a PR with its full details and all comments (author, timestamp, body) in chronological order
14- `ngit pr close <id>` / `ngit pr reopen <id>` — change PR status (author or maintainer only) 14- `ngit pr close <id>` / `ngit pr reopen <id>` — change PR status (author or maintainer only)
15- `ngit pr ready <id>` — mark a draft PR as ready for review (author or maintainer only) 15- `ngit pr ready <id>` — mark a draft PR as ready for review (author or maintainer only)
16- `ngit pr comment <id> --body <text>` — post a NIP-22 comment on a PR 16- `ngit pr comment <id> --body <text>` — post a NIP-22 comment on a PR
17- `ngit pr merge <id> [--squash]` — merge a PR branch and publish a `GitStatusApplied` event (maintainer only); prints a reminder to push afterwards 17- `ngit pr merge <id> [--squash]` — merge a PR branch and publish a `GitStatusApplied` event (maintainer only); prints a reminder to push afterwards
18- `ngit issue` subcommand group expanded: `list`, `view`, `create`, `close`, `reopen`, `comment` 18- `ngit issue` subcommand group expanded: `list`, `view`, `create`, `close`, `reopen`, `comment`
19- `ngit issue view <id>` — view an issue with its full details and comment count 19- `ngit issue view <id>` — view an issue with its full details and all comments (author, timestamp, body) in chronological order
20- `ngit issue create --title <T> [--body <B>] [--label <L>...]` — publish a NIP-34 GitIssue event 20- `ngit issue create --title <T> [--body <B>] [--label <L>...]` — publish a NIP-34 GitIssue event
21- `ngit issue close <id>` / `ngit issue reopen <id>` — change issue status (author or maintainer only) 21- `ngit issue close <id>` / `ngit issue reopen <id>` — change issue status (author or maintainer only)
22- `ngit issue comment <id> --body <text>` — post a NIP-22 comment on an issue 22- `ngit issue comment <id> --body <text>` — post a NIP-22 comment on an issue
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.
106async 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)]
98pub async fn launch( 137pub 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
341fn 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
257fn output_table( 361fn 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`,
239async fn get_comment_count_for_proposal( 239/// sorted oldest-first.
240async 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
271fn status_kind_to_str(kind: Kind) -> &'static str { 272fn 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
449fn 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)]
417async fn launch_interactive() -> Result<()> { 473async 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")?;