upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin/ngit
diff options
context:
space:
mode:
Diffstat (limited to 'src/bin/ngit')
-rw-r--r--src/bin/ngit/cli.rs10
-rw-r--r--src/bin/ngit/main.rs22
-rw-r--r--src/bin/ngit/sub_commands/issue_list.rs130
-rw-r--r--src/bin/ngit/sub_commands/list.rs138
4 files changed, 230 insertions, 70 deletions
diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs
index f18759b..0599b51 100644
--- a/src/bin/ngit/cli.rs
+++ b/src/bin/ngit/cli.rs
@@ -187,7 +187,7 @@ pub enum PrCommands {
187 #[arg(long)] 187 #[arg(long)]
188 offline: bool, 188 offline: bool,
189 }, 189 },
190 /// view a PR and its comments 190 /// view a PR; use --comments to include comment thread
191 View { 191 View {
192 /// Proposal event-id (hex) or nevent (bech32) 192 /// Proposal event-id (hex) or nevent (bech32)
193 #[arg(value_name = "ID|nevent")] 193 #[arg(value_name = "ID|nevent")]
@@ -195,6 +195,9 @@ pub enum PrCommands {
195 /// Output as JSON 195 /// Output as JSON
196 #[arg(long)] 196 #[arg(long)]
197 json: bool, 197 json: bool,
198 /// Include full comment thread (default: show count only)
199 #[arg(long)]
200 comments: bool,
198 /// Use local cache only, skip network fetch 201 /// Use local cache only, skip network fetch
199 #[arg(long)] 202 #[arg(long)]
200 offline: bool, 203 offline: bool,
@@ -321,7 +324,7 @@ pub enum IssueCommands {
321 #[arg(long)] 324 #[arg(long)]
322 offline: bool, 325 offline: bool,
323 }, 326 },
324 /// view an issue and its comments 327 /// view an issue; use --comments to include comment thread
325 View { 328 View {
326 /// Issue event-id (hex) or nevent (bech32) 329 /// Issue event-id (hex) or nevent (bech32)
327 #[arg(value_name = "ID|nevent")] 330 #[arg(value_name = "ID|nevent")]
@@ -329,6 +332,9 @@ pub enum IssueCommands {
329 /// Output as JSON 332 /// Output as JSON
330 #[arg(long)] 333 #[arg(long)]
331 json: bool, 334 json: bool,
335 /// Include full comment thread (default: show count only)
336 #[arg(long)]
337 comments: bool,
332 /// Use local cache only, skip network fetch 338 /// Use local cache only, skip network fetch
333 #[arg(long)] 339 #[arg(long)]
334 offline: bool, 340 offline: bool,
diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs
index 03a5ce9..4c1aa75 100644
--- a/src/bin/ngit/main.rs
+++ b/src/bin/ngit/main.rs
@@ -60,11 +60,20 @@ async fn main() {
60 json, 60 json,
61 id, 61 id,
62 offline, 62 offline,
63 } => sub_commands::list::launch(status.clone(), *json, id.clone(), *offline).await, 63 } => {
64 PrCommands::View { id, json, offline } => { 64 sub_commands::list::launch(status.clone(), *json, false, id.clone(), *offline)
65 .await
66 }
67 PrCommands::View {
68 id,
69 json,
70 comments,
71 offline,
72 } => {
65 sub_commands::list::launch( 73 sub_commands::list::launch(
66 "open,draft,closed,applied".to_string(), 74 "open,draft,closed,applied".to_string(),
67 *json, 75 *json,
76 *comments,
68 Some(id.clone()), 77 Some(id.clone()),
69 *offline, 78 *offline,
70 ) 79 )
@@ -122,16 +131,23 @@ async fn main() {
122 status.clone(), 131 status.clone(),
123 hashtag.clone(), 132 hashtag.clone(),
124 *json, 133 *json,
134 false,
125 id.clone(), 135 id.clone(),
126 *offline, 136 *offline,
127 ) 137 )
128 .await 138 .await
129 } 139 }
130 IssueCommands::View { id, json, offline } => { 140 IssueCommands::View {
141 id,
142 json,
143 comments,
144 offline,
145 } => {
131 sub_commands::issue_list::launch( 146 sub_commands::issue_list::launch(
132 "open,draft,closed,applied".to_string(), 147 "open,draft,closed,applied".to_string(),
133 None, 148 None,
134 *json, 149 *json,
150 *comments,
135 Some(id.clone()), 151 Some(id.clone()),
136 *offline, 152 *offline,
137 ) 153 )
diff --git a/src/bin/ngit/sub_commands/issue_list.rs b/src/bin/ngit/sub_commands/issue_list.rs
index b7abf8d..864cd76 100644
--- a/src/bin/ngit/sub_commands/issue_list.rs
+++ b/src/bin/ngit/sub_commands/issue_list.rs
@@ -138,6 +138,7 @@ pub async fn launch(
138 status: String, 138 status: String,
139 hashtag: Option<String>, 139 hashtag: Option<String>,
140 json: bool, 140 json: bool,
141 show_comments: bool,
141 id: Option<String>, 142 id: Option<String>,
142 offline: bool, 143 offline: bool,
143) -> Result<()> { 144) -> Result<()> {
@@ -236,8 +237,18 @@ pub async fn launch(
236 } else { 237 } else {
237 nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? 238 nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")?
238 }; 239 };
239 let comments = get_comments_for_issue(git_repo_path, &target_id).await?; 240 let comments = if show_comments {
240 return show_issue_details(&filtered, event_id_or_nevent, json, &comments); 241 get_comments_for_issue(git_repo_path, &target_id).await?
242 } else {
243 vec![]
244 };
245 return show_issue_details(
246 &filtered,
247 event_id_or_nevent,
248 json,
249 show_comments,
250 &comments,
251 );
241 } 252 }
242 253
243 if json { 254 if json {
@@ -249,10 +260,38 @@ pub async fn launch(
249 Ok(()) 260 Ok(())
250} 261}
251 262
263/// Extract the parent comment ID from a NIP-22 comment event.
264/// Returns `Some(id)` when the lowercase `e` tag differs from the root `E` tag
265/// (i.e. the comment is a reply to another comment, not a top-level comment).
266fn comment_reply_to(comment: &nostr::Event) -> Option<nostr::EventId> {
267 let root_id = comment.tags.iter().find_map(|t| {
268 let s = t.as_slice();
269 if s.len() >= 2 && s[0].eq("E") {
270 nostr::EventId::parse(&s[1]).ok()
271 } else {
272 None
273 }
274 })?;
275 comment.tags.iter().find_map(|t| {
276 let s = t.as_slice();
277 if s.len() >= 2 && s[0].eq("e") {
278 let parent_id = nostr::EventId::parse(&s[1]).ok()?;
279 if parent_id == root_id {
280 None
281 } else {
282 Some(parent_id)
283 }
284 } else {
285 None
286 }
287 })
288}
289
252fn show_issue_details( 290fn show_issue_details(
253 issues: &[(&nostr::Event, Kind, Vec<String>, usize)], 291 issues: &[(&nostr::Event, Kind, Vec<String>, usize)],
254 event_id_or_nevent: &str, 292 event_id_or_nevent: &str,
255 json: bool, 293 json: bool,
294 show_comments: bool,
256 comments: &[nostr::Event], 295 comments: &[nostr::Event],
257) -> Result<()> { 296) -> Result<()> {
258 let target_id = if event_id_or_nevent.starts_with("nevent") { 297 let target_id = if event_id_or_nevent.starts_with("nevent") {
@@ -266,7 +305,7 @@ fn show_issue_details(
266 nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")? 305 nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")?
267 }; 306 };
268 307
269 let (issue, status_kind, tags, _comment_count) = issues 308 let (issue, status_kind, tags, comment_count) = issues
270 .iter() 309 .iter()
271 .find(|(e, _, _, _)| e.id == target_id) 310 .find(|(e, _, _, _)| e.id == target_id)
272 .context("issue not found")?; 311 .context("issue not found")?;
@@ -275,26 +314,41 @@ fn show_issue_details(
275 let status = status_kind_to_str(*status_kind); 314 let status = status_kind_to_str(*status_kind);
276 315
277 if json { 316 if json {
278 let comments_json: Vec<serde_json::Value> = comments 317 let json_output = if show_comments {
279 .iter() 318 let comments_json: Vec<serde_json::Value> = comments
280 .map(|c| { 319 .iter()
281 serde_json::json!({ 320 .map(|c| {
282 "id": c.id.to_string(), 321 let reply_to = comment_reply_to(c).map(|id| id.to_string());
283 "author": c.pubkey.to_bech32().unwrap_or_default(), 322 serde_json::json!({
284 "created_at": c.created_at.as_secs(), 323 "id": c.id.to_string(),
285 "body": c.content, 324 "author": c.pubkey.to_bech32().unwrap_or_default(),
325 "created_at": c.created_at.as_secs(),
326 "reply_to": reply_to,
327 "body": c.content,
328 })
286 }) 329 })
330 .collect();
331 serde_json::json!({
332 "id": issue.id.to_string(),
333 "status": status,
334 "title": title,
335 "author": issue.pubkey.to_bech32().unwrap_or_default(),
336 "hashtags": tags,
337 "comment_count": comment_count,
338 "comments": comments_json,
339 "description": issue.content,
340 })
341 } else {
342 serde_json::json!({
343 "id": issue.id.to_string(),
344 "status": status,
345 "title": title,
346 "author": issue.pubkey.to_bech32().unwrap_or_default(),
347 "hashtags": tags,
348 "comment_count": comment_count,
349 "description": issue.content,
287 }) 350 })
288 .collect(); 351 };
289 let json_output = serde_json::json!({
290 "id": issue.id.to_string(),
291 "status": status,
292 "title": title,
293 "author": issue.pubkey.to_bech32().unwrap_or_default(),
294 "hashtags": tags,
295 "comments": comments_json,
296 "description": issue.content,
297 });
298 println!("{}", serde_json::to_string_pretty(&json_output)?); 352 println!("{}", serde_json::to_string_pretty(&json_output)?);
299 return Ok(()); 353 return Ok(());
300 } 354 }
@@ -318,21 +372,31 @@ fn show_issue_details(
318 } 372 }
319 } 373 }
320 374
321 if comments.is_empty() { 375 if show_comments {
322 println!("Comments: 0"); 376 if comments.is_empty() {
323 } else { 377 println!("Comments: 0");
324 println!(); 378 } else {
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!(); 379 println!();
331 println!("{}", dim.apply_to(format!(" {author} {ts}"))); 380 println!("Comments ({}):", comments.len());
332 for line in comment.content.lines() { 381 let dim = console::Style::new().color256(247);
333 println!(" {line}"); 382 for comment in comments {
383 let author = comment.pubkey.to_bech32().unwrap_or_default();
384 let ts = chrono_timestamp(comment.created_at.as_secs());
385 println!();
386 if let Some(parent_id) = comment_reply_to(comment) {
387 println!(
388 "{}",
389 dim.apply_to(format!(" ↳ reply to {}", &parent_id.to_hex()[..8]))
390 );
391 }
392 println!("{}", dim.apply_to(format!(" {author} {ts}")));
393 for line in comment.content.lines() {
394 println!(" {line}");
395 }
334 } 396 }
335 } 397 }
398 } else {
399 println!("Comments: {comment_count} (use --comments to view)");
336 } 400 }
337 401
338 Ok(()) 402 Ok(())
diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs
index a583ca5..60e129f 100644
--- a/src/bin/ngit/sub_commands/list.rs
+++ b/src/bin/ngit/sub_commands/list.rs
@@ -95,7 +95,13 @@ fn run_git_fetch(remote_name: &str) -> Result<()> {
95} 95}
96 96
97#[allow(clippy::too_many_lines)] 97#[allow(clippy::too_many_lines)]
98pub async fn launch(status: String, json: bool, id: Option<String>, offline: bool) -> Result<()> { 98pub async fn launch(
99 status: String,
100 json: bool,
101 show_comments: bool,
102 id: Option<String>,
103 offline: bool,
104) -> Result<()> {
99 if std::env::var("NGIT_INTERACTIVE_MODE").is_ok() { 105 if std::env::var("NGIT_INTERACTIVE_MODE").is_ok() {
100 return launch_interactive().await; 106 return launch_interactive().await;
101 } 107 }
@@ -203,12 +209,26 @@ pub async fn launch(status: String, json: bool, id: Option<String>, offline: boo
203 if let Some(ref event_id_or_nevent) = id { 209 if let Some(ref event_id_or_nevent) = id {
204 // Resolve the target proposal ID so we can fetch its comments. 210 // Resolve the target proposal ID so we can fetch its comments.
205 let target_id = resolve_event_id(event_id_or_nevent)?; 211 let target_id = resolve_event_id(event_id_or_nevent)?;
206 let comments = get_comments_for_proposal(git_repo_path, &target_id).await?; 212 let comments = if show_comments {
213 get_comments_for_proposal(git_repo_path, &target_id).await?
214 } else {
215 vec![]
216 };
217 // Always fetch the count so we can display it even without --comments.
218 let comment_count = if show_comments {
219 comments.len()
220 } else {
221 get_comments_for_proposal(git_repo_path, &target_id)
222 .await?
223 .len()
224 };
207 return show_proposal_details( 225 return show_proposal_details(
208 &filtered_proposals, 226 &filtered_proposals,
209 &repo_ref, 227 &repo_ref,
210 event_id_or_nevent, 228 event_id_or_nevent,
211 json, 229 json,
230 show_comments,
231 comment_count,
212 &comments, 232 &comments,
213 ); 233 );
214 } 234 }
@@ -353,11 +373,40 @@ fn output_json(proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef) -> Resu
353 Ok(()) 373 Ok(())
354} 374}
355 375
376/// Extract the parent comment ID from a NIP-22 comment event.
377/// Returns `Some(id)` when the lowercase `e` tag differs from the root `E` tag
378/// (i.e. the comment is a reply to another comment, not a top-level comment).
379fn comment_reply_to(comment: &nostr::Event) -> Option<nostr::EventId> {
380 let root_id = comment.tags.iter().find_map(|t| {
381 let s = t.as_slice();
382 if s.len() >= 2 && s[0].eq("E") {
383 nostr::EventId::parse(&s[1]).ok()
384 } else {
385 None
386 }
387 })?;
388 comment.tags.iter().find_map(|t| {
389 let s = t.as_slice();
390 if s.len() >= 2 && s[0].eq("e") {
391 let parent_id = nostr::EventId::parse(&s[1]).ok()?;
392 if parent_id == root_id {
393 None
394 } else {
395 Some(parent_id)
396 }
397 } else {
398 None
399 }
400 })
401}
402
356fn show_proposal_details( 403fn show_proposal_details(
357 proposals: &[(&nostr::Event, Kind)], 404 proposals: &[(&nostr::Event, Kind)],
358 _repo_ref: &RepoRef, 405 _repo_ref: &RepoRef,
359 event_id_or_nevent: &str, 406 event_id_or_nevent: &str,
360 json: bool, 407 json: bool,
408 show_comments: bool,
409 comment_count: usize,
361 comments: &[nostr::Event], 410 comments: &[nostr::Event],
362) -> Result<()> { 411) -> Result<()> {
363 use nostr::ToBech32; 412 use nostr::ToBech32;
@@ -373,26 +422,41 @@ fn show_proposal_details(
373 .context("failed to extract proposal details from proposal root event")?; 422 .context("failed to extract proposal details from proposal root event")?;
374 423
375 if json { 424 if json {
376 let comments_json: Vec<serde_json::Value> = comments 425 let json_output = if show_comments {
377 .iter() 426 let comments_json: Vec<serde_json::Value> = comments
378 .map(|c| { 427 .iter()
379 serde_json::json!({ 428 .map(|c| {
380 "id": c.id.to_string(), 429 let reply_to = comment_reply_to(c).map(|id| id.to_string());
381 "author": c.pubkey.to_bech32().unwrap_or_default(), 430 serde_json::json!({
382 "created_at": c.created_at.as_secs(), 431 "id": c.id.to_string(),
383 "body": c.content, 432 "author": c.pubkey.to_bech32().unwrap_or_default(),
433 "created_at": c.created_at.as_secs(),
434 "reply_to": reply_to,
435 "body": c.content,
436 })
384 }) 437 })
438 .collect();
439 serde_json::json!({
440 "id": proposal.id.to_string(),
441 "status": status_kind_to_str(*status_kind),
442 "title": cover_letter.title,
443 "author": proposal.pubkey.to_bech32().unwrap_or_default(),
444 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
445 "comment_count": comment_count,
446 "comments": comments_json,
447 "description": cover_letter.description,
385 }) 448 })
386 .collect(); 449 } else {
387 let json_output = serde_json::json!({ 450 serde_json::json!({
388 "id": proposal.id.to_string(), 451 "id": proposal.id.to_string(),
389 "status": status_kind_to_str(*status_kind), 452 "status": status_kind_to_str(*status_kind),
390 "title": cover_letter.title, 453 "title": cover_letter.title,
391 "author": proposal.pubkey.to_bech32().unwrap_or_default(), 454 "author": proposal.pubkey.to_bech32().unwrap_or_default(),
392 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, 455 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
393 "comments": comments_json, 456 "comment_count": comment_count,
394 "description": cover_letter.description, 457 "description": cover_letter.description,
395 }); 458 })
459 };
396 println!("{}", serde_json::to_string_pretty(&json_output)?); 460 println!("{}", serde_json::to_string_pretty(&json_output)?);
397 return Ok(()); 461 return Ok(());
398 } 462 }
@@ -416,21 +480,31 @@ fn show_proposal_details(
416 } 480 }
417 } 481 }
418 482
419 if comments.is_empty() { 483 if show_comments {
420 println!("Comments: 0"); 484 if comments.is_empty() {
421 } else { 485 println!("Comments: 0");
422 println!(); 486 } else {
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!(); 487 println!();
429 println!("{}", dim.apply_to(format!(" {author} {ts}"))); 488 println!("Comments ({comment_count}):");
430 for line in comment.content.lines() { 489 let dim = console::Style::new().color256(247);
431 println!(" {line}"); 490 for comment in comments {
491 let author = comment.pubkey.to_bech32().unwrap_or_default();
492 let ts = chrono_timestamp(comment.created_at.as_secs());
493 println!();
494 if let Some(parent_id) = comment_reply_to(comment) {
495 println!(
496 "{}",
497 dim.apply_to(format!(" ↳ reply to {}", &parent_id.to_hex()[..8]))
498 );
499 }
500 println!("{}", dim.apply_to(format!(" {author} {ts}")));
501 for line in comment.content.lines() {
502 println!(" {line}");
503 }
432 } 504 }
433 } 505 }
506 } else {
507 println!("Comments: {comment_count} (use --comments to view)");
434 } 508 }
435 509
436 println!(); 510 println!();