upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin/ngit/sub_commands
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-03-05 14:19:49 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-03-05 14:23:07 +0000
commit37244449d6d0d58bb639f181bd15092de1acaaee (patch)
tree7de03867a1a9578e32fdbdbb2be63e863cea57a4 /src/bin/ngit/sub_commands
parent609f3c3db02d437222e2c8e171189179d06c3e9c (diff)
feat(cover-note): add kind-1624 cover notes for PRs, patches, and issues
Implements experimental kind-1624 cover note events: - KIND_COVER_NOTE constant and process_cover_note() in git_events.rs; replaceable semantics (latest created_at, hex-id tiebreak), author or maintainer only - kind-1624 events fetched alongside labels in the fetch pipeline; cover_notes count added to FetchReport display - ngit pr/issue view: cover note displayed in place of description with a clear 'Cover Note:' header; maintainer-authored notes identify the author; original description shown only with --comments; cover_note object included in --json output - ngit pr set-cover-note / ngit issue set-cover-note: publish a kind-1624 event; nostr: mentions in --body converted to q/p tags via tags_from_content (same rules as issue --body) - Fix pre-existing clippy::too_many_lines on repo/mod.rs show_info
Diffstat (limited to 'src/bin/ngit/sub_commands')
-rw-r--r--src/bin/ngit/sub_commands/issue_list.rs139
-rw-r--r--src/bin/ngit/sub_commands/label.rs22
-rw-r--r--src/bin/ngit/sub_commands/list.rs174
-rw-r--r--src/bin/ngit/sub_commands/mod.rs1
-rw-r--r--src/bin/ngit/sub_commands/pr_status.rs9
-rw-r--r--src/bin/ngit/sub_commands/repo/mod.rs71
-rw-r--r--src/bin/ngit/sub_commands/set_cover_note.rs202
-rw-r--r--src/bin/ngit/sub_commands/set_subject.rs13
-rw-r--r--src/bin/ngit/sub_commands/whoami.rs9
9 files changed, 475 insertions, 165 deletions
diff --git a/src/bin/ngit/sub_commands/issue_list.rs b/src/bin/ngit/sub_commands/issue_list.rs
index 29845ce..9402ac0 100644
--- a/src/bin/ngit/sub_commands/issue_list.rs
+++ b/src/bin/ngit/sub_commands/issue_list.rs
@@ -3,7 +3,10 @@ use std::collections::{HashMap, HashSet};
3use anyhow::{Context, Result, bail}; 3use anyhow::{Context, Result, bail};
4use ngit::{ 4use ngit::{
5 client::{Params, get_events_from_local_cache, get_issues_from_cache}, 5 client::{Params, get_events_from_local_cache, get_issues_from_cache},
6 git_events::{KIND_COMMENT, KIND_LABEL, get_labels_and_subject, get_status, status_kinds, tag_value}, 6 git_events::{
7 KIND_COMMENT, KIND_COVER_NOTE, KIND_LABEL, get_labels_and_subject, get_status,
8 process_cover_note, status_kinds, tag_value,
9 },
7}; 10};
8use nostr::{ 11use nostr::{
9 FromBech32, ToBech32, 12 FromBech32, ToBech32,
@@ -44,8 +47,6 @@ fn get_issue_title(event: &nostr::Event, subject_override: Option<&str>) -> Stri
44 }) 47 })
45} 48}
46 49
47
48
49fn status_kind_to_str(kind: Kind) -> &'static str { 50fn status_kind_to_str(kind: Kind) -> &'static str {
50 match kind { 51 match kind {
51 Kind::GitStatusOpen => "open", 52 Kind::GitStatusOpen => "open",
@@ -220,7 +221,13 @@ pub async fn launch(
220 } 221 }
221 } 222 }
222 let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0); 223 let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0);
223 Some((issue, status_kind, issue_labels, comment_count, subject_override)) 224 Some((
225 issue,
226 status_kind,
227 issue_labels,
228 comment_count,
229 subject_override,
230 ))
224 }) 231 })
225 .collect(); 232 .collect();
226 233
@@ -247,6 +254,16 @@ pub async fn launch(
247 } else { 254 } else {
248 vec![] 255 vec![]
249 }; 256 };
257 // Fetch kind-1624 cover note events for this issue.
258 let cover_note_events = get_events_from_local_cache(
259 git_repo_path,
260 vec![
261 nostr::Filter::default()
262 .event(target_id)
263 .kind(KIND_COVER_NOTE),
264 ],
265 )
266 .await?;
250 let relay_hint = repo_ref.relays.first(); 267 let relay_hint = repo_ref.relays.first();
251 return show_issue_details( 268 return show_issue_details(
252 &filtered, 269 &filtered,
@@ -254,6 +271,8 @@ pub async fn launch(
254 json, 271 json,
255 show_comments, 272 show_comments,
256 &comments, 273 &comments,
274 &cover_note_events,
275 &repo_ref,
257 relay_hint, 276 relay_hint,
258 ); 277 );
259 } 278 }
@@ -295,12 +314,15 @@ fn comment_reply_to(comment: &nostr::Event) -> Option<nostr::EventId> {
295 }) 314 })
296} 315}
297 316
317#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
298fn show_issue_details( 318fn show_issue_details(
299 issues: &[IssueRow<'_>], 319 issues: &[IssueRow<'_>],
300 event_id_or_nevent: &str, 320 event_id_or_nevent: &str,
301 json: bool, 321 json: bool,
302 show_comments: bool, 322 show_comments: bool,
303 comments: &[nostr::Event], 323 comments: &[nostr::Event],
324 cover_note_events: &[nostr::Event],
325 repo_ref: &ngit::repo_ref::RepoRef,
304 relay_hint: Option<&RelayUrl>, 326 relay_hint: Option<&RelayUrl>,
305) -> Result<()> { 327) -> Result<()> {
306 let target_id = if event_id_or_nevent.starts_with("nevent") { 328 let target_id = if event_id_or_nevent.starts_with("nevent") {
@@ -322,13 +344,40 @@ fn show_issue_details(
322 let title = get_issue_title(issue, subject_override.as_deref()); 344 let title = get_issue_title(issue, subject_override.as_deref());
323 let status = status_kind_to_str(*status_kind); 345 let status = status_kind_to_str(*status_kind);
324 346
347 // Resolve the effective cover note (kind 1624) for this issue.
348 let cover_note = process_cover_note(issue, repo_ref, cover_note_events);
349
325 if json { 350 if json {
326 let json_output = if show_comments { 351 let cover_note_json = cover_note.as_ref().map(|(cn, by_different_author)| {
352 let mut obj = serde_json::json!({
353 "id": event_id_to_nevent(cn.id, relay_hint),
354 "author": cn.pubkey.to_bech32().unwrap_or_default(),
355 "created_at": cn.created_at.as_secs(),
356 "body": cn.content,
357 });
358 if *by_different_author {
359 obj["by_maintainer"] = serde_json::Value::Bool(true);
360 }
361 obj
362 });
363
364 let mut json_obj = serde_json::json!({
365 "id": event_id_to_nevent(issue.id, relay_hint),
366 "status": status,
367 "subject": title,
368 "author": issue.pubkey.to_bech32().unwrap_or_default(),
369 "labels": labels,
370 "comment_count": comment_count,
371 "description": issue.content,
372 });
373 if let Some(cn) = cover_note_json {
374 json_obj["cover_note"] = cn;
375 }
376 if show_comments {
327 let comments_json: Vec<serde_json::Value> = comments 377 let comments_json: Vec<serde_json::Value> = comments
328 .iter() 378 .iter()
329 .map(|c| { 379 .map(|c| {
330 let reply_to = 380 let reply_to = comment_reply_to(c).map(|id| event_id_to_nevent(id, relay_hint));
331 comment_reply_to(c).map(|id| event_id_to_nevent(id, relay_hint));
332 serde_json::json!({ 381 serde_json::json!({
333 "id": event_id_to_nevent(c.id, relay_hint), 382 "id": event_id_to_nevent(c.id, relay_hint),
334 "author": c.pubkey.to_bech32().unwrap_or_default(), 383 "author": c.pubkey.to_bech32().unwrap_or_default(),
@@ -338,28 +387,9 @@ fn show_issue_details(
338 }) 387 })
339 }) 388 })
340 .collect(); 389 .collect();
341 serde_json::json!({ 390 json_obj["comments"] = serde_json::Value::Array(comments_json);
342 "id": event_id_to_nevent(issue.id, relay_hint), 391 }
343 "status": status, 392 println!("{}", serde_json::to_string_pretty(&json_obj)?);
344 "subject": title,
345 "author": issue.pubkey.to_bech32().unwrap_or_default(),
346 "labels": labels,
347 "comment_count": comment_count,
348 "comments": comments_json,
349 "description": issue.content,
350 })
351 } else {
352 serde_json::json!({
353 "id": event_id_to_nevent(issue.id, relay_hint),
354 "status": status,
355 "subject": title,
356 "author": issue.pubkey.to_bech32().unwrap_or_default(),
357 "labels": labels,
358 "comment_count": comment_count,
359 "description": issue.content,
360 })
361 };
362 println!("{}", serde_json::to_string_pretty(&json_output)?);
363 return Ok(()); 393 return Ok(());
364 } 394 }
365 395
@@ -375,7 +405,28 @@ fn show_issue_details(
375 println!("Labels: {labels_str}"); 405 println!("Labels: {labels_str}");
376 } 406 }
377 407
378 if !issue.content.is_empty() { 408 if let Some((cn, by_different_author)) = &cover_note {
409 println!();
410 if *by_different_author {
411 println!(
412 "Cover Note (by {}):",
413 cn.pubkey.to_bech32().unwrap_or_default()
414 );
415 } else {
416 println!("Cover Note:");
417 }
418 for line in cn.content.lines() {
419 println!(" {line}");
420 }
421 // Show original description only when --comments is used.
422 if show_comments && !issue.content.is_empty() {
423 println!();
424 println!("Original Description:");
425 for line in issue.content.lines() {
426 println!(" {line}");
427 }
428 }
429 } else if !issue.content.is_empty() {
379 println!(); 430 println!();
380 for line in issue.content.lines() { 431 for line in issue.content.lines() {
381 println!(" {line}"); 432 println!(" {line}");
@@ -432,11 +483,7 @@ fn chrono_timestamp(unix_secs: u64) -> String {
432 format!("{y:04}-{m:02}-{d:02} {hours:02}:{mins:02}:{secs:02} UTC") 483 format!("{y:04}-{m:02}-{d:02} {hours:02}:{mins:02}:{secs:02} UTC")
433} 484}
434 485
435fn output_table( 486fn output_table(issues: &[IssueRow<'_>], status_filter: &str, label_filter: &HashSet<String>) {
436 issues: &[IssueRow<'_>],
437 status_filter: &str,
438 label_filter: &HashSet<String>,
439) {
440 println!("{:<66} {:<8} {:<5} TITLE LABELS", "ID", "STATUS", "CMTS"); 487 println!("{:<66} {:<8} {:<5} TITLE LABELS", "ID", "STATUS", "CMTS");
441 for (issue, status_kind, labels, comment_count, subject_override) in issues { 488 for (issue, status_kind, labels, comment_count, subject_override) in issues {
442 let id = issue.id.to_string(); 489 let id = issue.id.to_string();
@@ -485,16 +532,18 @@ fn event_id_to_nevent(event_id: nostr::EventId, relay: Option<&RelayUrl>) -> Str
485fn output_json(issues: &[IssueRow<'_>], relay_hint: Option<&RelayUrl>) -> Result<()> { 532fn output_json(issues: &[IssueRow<'_>], relay_hint: Option<&RelayUrl>) -> Result<()> {
486 let json_output: Vec<serde_json::Value> = issues 533 let json_output: Vec<serde_json::Value> = issues
487 .iter() 534 .iter()
488 .map(|(issue, status_kind, labels, comment_count, subject_override)| { 535 .map(
489 serde_json::json!({ 536 |(issue, status_kind, labels, comment_count, subject_override)| {
490 "id": event_id_to_nevent(issue.id, relay_hint), 537 serde_json::json!({
491 "status": status_kind_to_str(*status_kind), 538 "id": event_id_to_nevent(issue.id, relay_hint),
492 "subject": get_issue_title(issue, subject_override.as_deref()), 539 "status": status_kind_to_str(*status_kind),
493 "author": issue.pubkey.to_bech32().unwrap_or_default(), 540 "subject": get_issue_title(issue, subject_override.as_deref()),
494 "labels": labels, 541 "author": issue.pubkey.to_bech32().unwrap_or_default(),
495 "comment_count": comment_count, 542 "labels": labels,
496 }) 543 "comment_count": comment_count,
497 }) 544 })
545 },
546 )
498 .collect(); 547 .collect();
499 println!("{}", serde_json::to_string_pretty(&json_output)?); 548 println!("{}", serde_json::to_string_pretty(&json_output)?);
500 Ok(()) 549 Ok(())
diff --git a/src/bin/ngit/sub_commands/label.rs b/src/bin/ngit/sub_commands/label.rs
index 634e0b5..f6714ae 100644
--- a/src/bin/ngit/sub_commands/label.rs
+++ b/src/bin/ngit/sub_commands/label.rs
@@ -3,14 +3,13 @@ use ngit::{
3 client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events}, 3 client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events},
4 git_events::{KIND_LABEL, get_labels}, 4 git_events::{KIND_LABEL, get_labels},
5}; 5};
6use nostr::{EventBuilder, Tag, TagStandard}; 6use nostr::{EventBuilder, Tag, TagStandard, nips::nip19::Nip19};
7use nostr_sdk::{EventId, FromBech32}; 7use nostr_sdk::{EventId, FromBech32};
8use nostr::nips::nip19::Nip19;
9 8
10use crate::{ 9use crate::{
11 client::{ 10 client::{
12 Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, 11 Client, Connect, fetching_with_report, get_events_from_local_cache,
13 save_event_in_local_cache, 12 get_repo_ref_from_cache, save_event_in_local_cache,
14 }, 13 },
15 git::{Repo, RepoActions}, 14 git::{Repo, RepoActions},
16 login, 15 login,
@@ -90,19 +89,13 @@ async fn publish_label_event(
90 89
91 // Permission check: only the author or a maintainer may label. 90 // Permission check: only the author or a maintainer may label.
92 if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { 91 if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) {
93 bail!( 92 bail!("only the {target_kind} author or a repository maintainer can label a {target_kind}");
94 "only the {target_kind} author or a repository maintainer can label a {target_kind}"
95 );
96 } 93 }
97 94
98 // Fetch existing label events so we can warn about duplicates. 95 // Fetch existing label events so we can warn about duplicates.
99 let existing_label_events = get_events_from_local_cache( 96 let existing_label_events = get_events_from_local_cache(
100 git_repo_path, 97 git_repo_path,
101 vec![ 98 vec![nostr::Filter::default().event(event_id).kind(KIND_LABEL)],
102 nostr::Filter::default()
103 .event(event_id)
104 .kind(KIND_LABEL),
105 ],
106 ) 99 )
107 .await?; 100 .await?;
108 101
@@ -117,10 +110,7 @@ async fn publish_label_event(
117 .collect(); 110 .collect();
118 111
119 if new_labels.is_empty() { 112 if new_labels.is_empty() {
120 let already: Vec<String> = labels 113 let already: Vec<String> = labels.iter().map(|l| format!("#{}", l.trim())).collect();
121 .iter()
122 .map(|l| format!("#{}", l.trim()))
123 .collect();
124 println!( 114 println!(
125 "{target_kind} already has label{}: {}", 115 "{target_kind} already has label{}: {}",
126 if already.len() == 1 { "" } else { "s" }, 116 if already.len() == 1 { "" } else { "s" },
diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs
index ee9840e..f040c63 100644
--- a/src/bin/ngit/sub_commands/list.rs
+++ b/src/bin/ngit/sub_commands/list.rs
@@ -15,9 +15,10 @@ use ngit::{
15 }, 15 },
16 fetch::fetch_from_git_server, 16 fetch::fetch_from_git_server,
17 git_events::{ 17 git_events::{
18 KIND_COMMENT, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, 18 KIND_COMMENT, KIND_COVER_NOTE, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE,
19 get_commit_id_from_patch, get_labels_and_subject, 19 get_commit_id_from_patch, get_labels_and_subject,
20 get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, status_kinds, tag_value, 20 get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, process_cover_note,
21 status_kinds, tag_value,
21 }, 22 },
22 repo_ref::{RepoRef, is_grasp_server_in_list}, 23 repo_ref::{RepoRef, is_grasp_server_in_list},
23}; 24};
@@ -222,7 +223,10 @@ pub async fn launch(
222 if !label_filter.is_empty() { 223 if !label_filter.is_empty() {
223 let proposal_labels_lower: HashSet<String> = 224 let proposal_labels_lower: HashSet<String> =
224 proposal_labels.iter().map(|l| l.to_lowercase()).collect(); 225 proposal_labels.iter().map(|l| l.to_lowercase()).collect();
225 if !label_filter.iter().any(|l| proposal_labels_lower.contains(l)) { 226 if !label_filter
227 .iter()
228 .any(|l| proposal_labels_lower.contains(l))
229 {
226 return None; 230 return None;
227 } 231 }
228 } 232 }
@@ -246,6 +250,16 @@ pub async fn launch(
246 .await? 250 .await?
247 .len() 251 .len()
248 }; 252 };
253 // Fetch kind-1624 cover note events for this proposal.
254 let cover_note_events = get_events_from_local_cache(
255 git_repo_path,
256 vec![
257 nostr::Filter::default()
258 .event(target_id)
259 .kind(KIND_COVER_NOTE),
260 ],
261 )
262 .await?;
249 let relay_hint = repo_ref.relays.first(); 263 let relay_hint = repo_ref.relays.first();
250 return show_proposal_details( 264 return show_proposal_details(
251 &filtered_proposals, 265 &filtered_proposals,
@@ -254,6 +268,8 @@ pub async fn launch(
254 show_comments, 268 show_comments,
255 comment_count, 269 comment_count,
256 &comments, 270 &comments,
271 &cover_note_events,
272 &repo_ref,
257 relay_hint, 273 relay_hint,
258 ); 274 );
259 } 275 }
@@ -407,38 +423,40 @@ fn output_json(
407) -> Result<()> { 423) -> Result<()> {
408 let json_output: Vec<serde_json::Value> = proposals 424 let json_output: Vec<serde_json::Value> = proposals
409 .iter() 425 .iter()
410 .map(|(proposal, status_kind, proposal_labels, subject_override)| { 426 .map(
411 let id = event_id_to_nevent(proposal.id, relay_hint); 427 |(proposal, status_kind, proposal_labels, subject_override)| {
412 let status = status_kind_to_str(*status_kind).to_string(); 428 let id = event_id_to_nevent(proposal.id, relay_hint);
413 let (title, author, branch) = if let Ok(cl) = event_to_cover_letter(proposal) { 429 let status = status_kind_to_str(*status_kind).to_string();
414 ( 430 let (title, author, branch) = if let Ok(cl) = event_to_cover_letter(proposal) {
415 subject_override.clone().unwrap_or(cl.title.clone()), 431 (
416 proposal.pubkey.to_bech32().unwrap_or_default(), 432 subject_override.clone().unwrap_or(cl.title.clone()),
417 cl.get_branch_name_with_pr_prefix_and_shorthand_id() 433 proposal.pubkey.to_bech32().unwrap_or_default(),
418 .unwrap_or_default(), 434 cl.get_branch_name_with_pr_prefix_and_shorthand_id()
419 ) 435 .unwrap_or_default(),
420 } else {
421 let title = subject_override.clone().unwrap_or_else(|| {
422 tag_value(proposal, "description").map_or_else(
423 |_| proposal.id.to_string(),
424 |d| d.split('\n').collect::<Vec<&str>>()[0].to_string(),
425 ) 436 )
426 }); 437 } else {
427 ( 438 let title = subject_override.clone().unwrap_or_else(|| {
428 title, 439 tag_value(proposal, "description").map_or_else(
429 proposal.pubkey.to_bech32().unwrap_or_default(), 440 |_| proposal.id.to_string(),
430 String::new(), 441 |d| d.split('\n').collect::<Vec<&str>>()[0].to_string(),
431 ) 442 )
432 }; 443 });
433 serde_json::json!({ 444 (
434 "id": id, 445 title,
435 "status": status, 446 proposal.pubkey.to_bech32().unwrap_or_default(),
436 "subject": title, 447 String::new(),
437 "author": author, 448 )
438 "branch": branch, 449 };
439 "labels": proposal_labels, 450 serde_json::json!({
440 }) 451 "id": id,
441 }) 452 "status": status,
453 "subject": title,
454 "author": author,
455 "branch": branch,
456 "labels": proposal_labels,
457 })
458 },
459 )
442 .collect(); 460 .collect();
443 461
444 println!("{}", serde_json::to_string_pretty(&json_output)?); 462 println!("{}", serde_json::to_string_pretty(&json_output)?);
@@ -472,7 +490,7 @@ fn comment_reply_to(comment: &nostr::Event) -> Option<nostr::EventId> {
472 }) 490 })
473} 491}
474 492
475#[allow(clippy::too_many_lines)] 493#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
476fn show_proposal_details( 494fn show_proposal_details(
477 proposals: &[(&nostr::Event, Kind, Vec<String>, Option<String>)], 495 proposals: &[(&nostr::Event, Kind, Vec<String>, Option<String>)],
478 event_id_or_nevent: &str, 496 event_id_or_nevent: &str,
@@ -480,6 +498,8 @@ fn show_proposal_details(
480 show_comments: bool, 498 show_comments: bool,
481 comment_count: usize, 499 comment_count: usize,
482 comments: &[nostr::Event], 500 comments: &[nostr::Event],
501 cover_note_events: &[nostr::Event],
502 repo_ref: &RepoRef,
483 relay_hint: Option<&RelayUrl>, 503 relay_hint: Option<&RelayUrl>,
484) -> Result<()> { 504) -> Result<()> {
485 use nostr::ToBech32; 505 use nostr::ToBech32;
@@ -500,13 +520,41 @@ fn show_proposal_details(
500 .unwrap_or(&cover_letter.title) 520 .unwrap_or(&cover_letter.title)
501 .to_string(); 521 .to_string();
502 522
523 // Resolve the effective cover note (kind 1624) for this proposal.
524 let cover_note = process_cover_note(proposal, repo_ref, cover_note_events);
525
503 if json { 526 if json {
504 let json_output = if show_comments { 527 let cover_note_json = cover_note.as_ref().map(|(cn, by_different_author)| {
528 let mut obj = serde_json::json!({
529 "id": event_id_to_nevent(cn.id, relay_hint),
530 "author": cn.pubkey.to_bech32().unwrap_or_default(),
531 "created_at": cn.created_at.as_secs(),
532 "body": cn.content,
533 });
534 if *by_different_author {
535 obj["by_maintainer"] = serde_json::Value::Bool(true);
536 }
537 obj
538 });
539
540 let mut json_obj = serde_json::json!({
541 "id": event_id_to_nevent(proposal.id, relay_hint),
542 "status": status_kind_to_str(*status_kind),
543 "subject": display_title,
544 "author": proposal.pubkey.to_bech32().unwrap_or_default(),
545 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
546 "labels": proposal_labels,
547 "comment_count": comment_count,
548 "description": cover_letter.description,
549 });
550 if let Some(cn) = cover_note_json {
551 json_obj["cover_note"] = cn;
552 }
553 if show_comments {
505 let comments_json: Vec<serde_json::Value> = comments 554 let comments_json: Vec<serde_json::Value> = comments
506 .iter() 555 .iter()
507 .map(|c| { 556 .map(|c| {
508 let reply_to = 557 let reply_to = comment_reply_to(c).map(|id| event_id_to_nevent(id, relay_hint));
509 comment_reply_to(c).map(|id| event_id_to_nevent(id, relay_hint));
510 serde_json::json!({ 558 serde_json::json!({
511 "id": event_id_to_nevent(c.id, relay_hint), 559 "id": event_id_to_nevent(c.id, relay_hint),
512 "author": c.pubkey.to_bech32().unwrap_or_default(), 560 "author": c.pubkey.to_bech32().unwrap_or_default(),
@@ -516,30 +564,9 @@ fn show_proposal_details(
516 }) 564 })
517 }) 565 })
518 .collect(); 566 .collect();
519 serde_json::json!({ 567 json_obj["comments"] = serde_json::Value::Array(comments_json);
520 "id": event_id_to_nevent(proposal.id, relay_hint), 568 }
521 "status": status_kind_to_str(*status_kind), 569 println!("{}", serde_json::to_string_pretty(&json_obj)?);
522 "subject": display_title,
523 "author": proposal.pubkey.to_bech32().unwrap_or_default(),
524 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
525 "labels": proposal_labels,
526 "comment_count": comment_count,
527 "comments": comments_json,
528 "description": cover_letter.description,
529 })
530 } else {
531 serde_json::json!({
532 "id": event_id_to_nevent(proposal.id, relay_hint),
533 "status": status_kind_to_str(*status_kind),
534 "subject": display_title,
535 "author": proposal.pubkey.to_bech32().unwrap_or_default(),
536 "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
537 "labels": proposal_labels,
538 "comment_count": comment_count,
539 "description": cover_letter.description,
540 })
541 };
542 println!("{}", serde_json::to_string_pretty(&json_output)?);
543 return Ok(()); 570 return Ok(());
544 } 571 }
545 572
@@ -562,7 +589,28 @@ fn show_proposal_details(
562 println!("Labels: {labels_str}"); 589 println!("Labels: {labels_str}");
563 } 590 }
564 591
565 if !cover_letter.description.is_empty() { 592 if let Some((cn, by_different_author)) = &cover_note {
593 println!();
594 if *by_different_author {
595 println!(
596 "Cover Note (by {}):",
597 cn.pubkey.to_bech32().unwrap_or_default()
598 );
599 } else {
600 println!("Cover Note:");
601 }
602 for line in cn.content.lines() {
603 println!(" {line}");
604 }
605 // Show original description only when --comments is used.
606 if show_comments && !cover_letter.description.is_empty() {
607 println!();
608 println!("Original Description:");
609 for line in cover_letter.description.lines() {
610 println!(" {line}");
611 }
612 }
613 } else if !cover_letter.description.is_empty() {
566 println!(); 614 println!();
567 println!("Description:"); 615 println!("Description:");
568 for line in cover_letter.description.lines() { 616 for line in cover_letter.description.lines() {
diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs
index db8ea54..6d7f2e2 100644
--- a/src/bin/ngit/sub_commands/mod.rs
+++ b/src/bin/ngit/sub_commands/mod.rs
@@ -15,6 +15,7 @@ pub mod pr_merge;
15pub mod pr_status; 15pub mod pr_status;
16pub mod repo; 16pub mod repo;
17pub mod send; 17pub mod send;
18pub mod set_cover_note;
18pub mod set_subject; 19pub mod set_subject;
19pub mod sync; 20pub mod sync;
20pub mod whoami; 21pub mod whoami;
diff --git a/src/bin/ngit/sub_commands/pr_status.rs b/src/bin/ngit/sub_commands/pr_status.rs
index 4a51bb3..f3ac627 100644
--- a/src/bin/ngit/sub_commands/pr_status.rs
+++ b/src/bin/ngit/sub_commands/pr_status.rs
@@ -206,5 +206,12 @@ pub async fn launch_ready(id: &str, offline: bool, reason: Option<&str>) -> Resu
206} 206}
207 207
208pub async fn launch_draft(id: &str, offline: bool, reason: Option<&str>) -> Result<()> { 208pub async fn launch_draft(id: &str, offline: bool, reason: Option<&str>) -> Result<()> {
209 launch_status(id, offline, Kind::GitStatusDraft, "converted to draft", reason).await 209 launch_status(
210 id,
211 offline,
212 Kind::GitStatusDraft,
213 "converted to draft",
214 reason,
215 )
216 .await
210} 217}
diff --git a/src/bin/ngit/sub_commands/repo/mod.rs b/src/bin/ngit/sub_commands/repo/mod.rs
index 63d96bd..766b025 100644
--- a/src/bin/ngit/sub_commands/repo/mod.rs
+++ b/src/bin/ngit/sub_commands/repo/mod.rs
@@ -74,6 +74,7 @@ struct RepoInfoJson {
74// `ngit repo` (no subcommand) — show repository info 74// `ngit repo` (no subcommand) — show repository info
75// --------------------------------------------------------------------------- 75// ---------------------------------------------------------------------------
76 76
77#[allow(clippy::too_many_lines)]
77async fn show_info(cli_args: &Cli, offline: bool, json: bool) -> Result<()> { 78async fn show_info(cli_args: &Cli, offline: bool, json: bool) -> Result<()> {
78 let git_repo = Repo::discover().context("failed to find a git repository")?; 79 let git_repo = Repo::discover().context("failed to find a git repository")?;
79 let git_repo_path = git_repo.get_path()?; 80 let git_repo_path = git_repo.get_path()?;
@@ -98,20 +99,23 @@ async fn show_info(cli_args: &Cli, offline: bool, json: bool) -> Result<()> {
98 99
99 let Some(repo_coordinate) = repo_coordinate else { 100 let Some(repo_coordinate) = repo_coordinate else {
100 if json { 101 if json {
101 println!("{}", serde_json::to_string_pretty(&RepoInfoJson { 102 println!(
102 is_nostr_repo: false, 103 "{}",
103 name: None, 104 serde_json::to_string_pretty(&RepoInfoJson {
104 identifier: None, 105 is_nostr_repo: false,
105 description: None, 106 name: None,
106 nostr_url: None, 107 identifier: None,
107 coordinate: None, 108 description: None,
108 web: None, 109 nostr_url: None,
109 maintainers: None, 110 coordinate: None,
110 grasp_servers: None, 111 web: None,
111 git_servers: None, 112 maintainers: None,
112 relays: None, 113 grasp_servers: None,
113 hashtags: None, 114 git_servers: None,
114 })?); 115 relays: None,
116 hashtags: None,
117 })?
118 );
115 } else { 119 } else {
116 println!("subcommands: init, edit, accept (run `ngit repo --help` for details)"); 120 println!("subcommands: init, edit, accept (run `ngit repo --help` for details)");
117 println!(); 121 println!();
@@ -140,20 +144,23 @@ async fn show_info(cli_args: &Cli, offline: bool, json: bool) -> Result<()> {
140 .ok() 144 .ok()
141 .and_then(|r| r.url().map(std::string::ToString::to_string)) 145 .and_then(|r| r.url().map(std::string::ToString::to_string))
142 .filter(|u| u.starts_with("nostr://")); 146 .filter(|u| u.starts_with("nostr://"));
143 println!("{}", serde_json::to_string_pretty(&RepoInfoJson { 147 println!(
144 is_nostr_repo: true, 148 "{}",
145 name: None, 149 serde_json::to_string_pretty(&RepoInfoJson {
146 identifier: Some(repo_coordinate.identifier.clone()), 150 is_nostr_repo: true,
147 description: None, 151 name: None,
148 nostr_url, 152 identifier: Some(repo_coordinate.identifier.clone()),
149 coordinate: repo_coordinate.to_bech32().ok(), 153 description: None,
150 web: None, 154 nostr_url,
151 maintainers: None, 155 coordinate: repo_coordinate.to_bech32().ok(),
152 grasp_servers: None, 156 web: None,
153 git_servers: None, 157 maintainers: None,
154 relays: None, 158 grasp_servers: None,
155 hashtags: None, 159 git_servers: None,
156 })?); 160 relays: None,
161 hashtags: None,
162 })?
163 );
157 } else { 164 } else {
158 println!("subcommands: init, edit, accept (run `ngit repo --help` for details)"); 165 println!("subcommands: init, edit, accept (run `ngit repo --help` for details)");
159 println!(); 166 println!();
@@ -162,8 +169,12 @@ async fn show_info(cli_args: &Cli, offline: bool, json: bool) -> Result<()> {
162 repo_coordinate.identifier 169 repo_coordinate.identifier
163 ); 170 );
164 println!(); 171 println!();
165 println!("if you created this repository, run `ngit repo init` to publish an announcement"); 172 println!(
166 println!("if you are a co-maintainer, run `ngit repo accept` to publish your announcement"); 173 "if you created this repository, run `ngit repo init` to publish an announcement"
174 );
175 println!(
176 "if you are a co-maintainer, run `ngit repo accept` to publish your announcement"
177 );
167 } 178 }
168 return Ok(()); 179 return Ok(());
169 }; 180 };
diff --git a/src/bin/ngit/sub_commands/set_cover_note.rs b/src/bin/ngit/sub_commands/set_cover_note.rs
new file mode 100644
index 0000000..49d4f34
--- /dev/null
+++ b/src/bin/ngit/sub_commands/set_cover_note.rs
@@ -0,0 +1,202 @@
1use anyhow::{Context, Result, bail};
2use ngit::{
3 client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events},
4 content_tags::{dedup_tags, tags_from_content},
5 git_events::{KIND_COVER_NOTE, process_cover_note},
6};
7use nostr::{EventBuilder, Tag, TagStandard, nips::nip19::Nip19};
8use nostr_sdk::{EventId, FromBech32};
9
10use crate::{
11 client::{
12 Client, Connect, fetching_with_report, get_events_from_local_cache,
13 get_repo_ref_from_cache, save_event_in_local_cache,
14 },
15 git::{Repo, RepoActions},
16 login,
17 repo_ref::get_repo_coordinates_when_remote_unknown,
18};
19
20fn parse_event_id(id: &str) -> Result<EventId> {
21 if let Ok(nip19) = Nip19::from_bech32(id) {
22 match nip19 {
23 Nip19::Event(e) => return Ok(e.event_id),
24 Nip19::EventId(event_id) => return Ok(event_id),
25 _ => {}
26 }
27 }
28 if let Ok(event_id) = EventId::from_hex(id) {
29 return Ok(event_id);
30 }
31 bail!("invalid event-id or nevent: {id}")
32}
33
34/// Shared implementation: publish a kind-1624 cover note event for `target`.
35///
36/// A cover note is a markdown body that replaces the displayed description of a
37/// PR, patch or issue. Only the author of the target event or a repository
38/// maintainer may set it. The latest authorised event wins (replaceable
39/// semantics with hex-id tiebreak).
40///
41/// The `body` is processed for `nostr:` mentions (NIP-21), which are converted
42/// to `q` (event) and `p` (pubkey) tags — the same rules as `--body` in issue
43/// creation.
44#[allow(clippy::too_many_lines)]
45async fn publish_set_cover_note_event(
46 id: &str,
47 body: &str,
48 offline: bool,
49 target_kind: &str, // "issue" or "PR" — used in error messages
50) -> Result<()> {
51 let body = body.trim();
52 if body.is_empty() {
53 bail!("--body value must not be empty");
54 }
55
56 let event_id = parse_event_id(id)?;
57
58 let git_repo = Repo::discover().context("failed to find a git repository")?;
59 let git_repo_path = git_repo.get_path()?;
60
61 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
62 let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?;
63
64 if !offline {
65 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
66 }
67
68 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
69
70 // Resolve the target event from cache.
71 let target = if target_kind == "issue" {
72 let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?;
73 issues
74 .into_iter()
75 .find(|e| e.id == event_id)
76 .context(format!(
77 "issue with id {} not found in cache",
78 event_id.to_hex()
79 ))?
80 } else {
81 let proposals =
82 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?;
83 proposals
84 .into_iter()
85 .find(|e| e.id == event_id)
86 .context(format!(
87 "PR with id {} not found in cache",
88 event_id.to_hex()
89 ))?
90 };
91
92 // Login — we need the signer and user pubkey.
93 let (signer, user_ref, _) =
94 login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?;
95
96 let user_pubkey = signer.get_public_key().await?;
97
98 // Permission check: only the author or a maintainer may set a cover note.
99 if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) {
100 bail!(
101 "only the {target_kind} author or a repository maintainer can set the cover note of a {target_kind}"
102 );
103 }
104
105 // Fetch existing cover note events so we can check whether the body is
106 // already set to the requested value.
107 let existing_cover_note_events = get_events_from_local_cache(
108 git_repo_path,
109 vec![
110 nostr::Filter::default()
111 .event(event_id)
112 .kind(KIND_COVER_NOTE),
113 ],
114 )
115 .await?;
116
117 if let Some((existing_cn, _)) =
118 process_cover_note(&target, &repo_ref, &existing_cover_note_events)
119 {
120 if existing_cn.content.trim() == body {
121 println!(
122 "{target_kind} {} already has this cover note",
123 &event_id.to_hex()[..8],
124 );
125 return Ok(());
126 }
127 }
128
129 // Build the kind-1624 cover note event.
130 //
131 // Shape:
132 // content: "<markdown>"
133 // tags:
134 // ["e", "<pr-issue-or-patch-id>", "<relay-hint>"] — reference to target
135 // ["p", "<author-pubkey>"] — notify the author
136 // ["q", "<referenced-event>", ...] — from body mentions
137 // ["p", "<referenced-pubkey>", ...] — from body mentions
138 // ["alt", "cover note for <target_kind>"]
139 let relay_hint = repo_ref.relays.first().cloned();
140
141 let mut tags: Vec<Tag> = vec![];
142
143 // Reference the target event (lowercase `e`).
144 tags.push(Tag::from_standardized(TagStandard::Event {
145 event_id: target.id,
146 relay_url: relay_hint.clone(),
147 marker: None,
148 public_key: None,
149 uppercase: false,
150 }));
151
152 // Notify the target event author.
153 tags.push(Tag::public_key(target.pubkey));
154
155 // Human-readable alt text.
156 tags.push(Tag::custom(
157 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
158 vec![format!("cover note for {target_kind}")],
159 ));
160
161 // Process body for nostr: mentions → q and p tags (same as --body in issue
162 // creation).
163 let mention_tags = tags_from_content(body, Some(git_repo_path)).await?;
164 tags.extend(mention_tags);
165 let tags = dedup_tags(tags);
166
167 let cover_note_event = ngit::client::sign_event(
168 EventBuilder::new(KIND_COVER_NOTE, body).tags(tags),
169 &signer,
170 format!("set {target_kind} cover note"),
171 )
172 .await?;
173
174 // Save to local cache immediately so subsequent reads reflect the new cover
175 // note.
176 save_event_in_local_cache(git_repo_path, &cover_note_event).await?;
177
178 let mut client = client;
179 client.set_signer(signer).await;
180
181 send_events(
182 &client,
183 Some(git_repo_path),
184 vec![cover_note_event],
185 user_ref.relays.write(),
186 repo_ref.relays.clone(),
187 true,
188 false,
189 )
190 .await?;
191
192 println!("{} {} cover note set", target_kind, &event_id.to_hex()[..8],);
193 Ok(())
194}
195
196pub async fn launch_issue_set_cover_note(id: &str, body: &str, offline: bool) -> Result<()> {
197 publish_set_cover_note_event(id, body, offline, "issue").await
198}
199
200pub async fn launch_pr_set_cover_note(id: &str, body: &str, offline: bool) -> Result<()> {
201 publish_set_cover_note_event(id, body, offline, "PR").await
202}
diff --git a/src/bin/ngit/sub_commands/set_subject.rs b/src/bin/ngit/sub_commands/set_subject.rs
index 65ff1d3..0dc16f5 100644
--- a/src/bin/ngit/sub_commands/set_subject.rs
+++ b/src/bin/ngit/sub_commands/set_subject.rs
@@ -3,14 +3,13 @@ use ngit::{
3 client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events}, 3 client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events},
4 git_events::{KIND_LABEL, get_labels_and_subject}, 4 git_events::{KIND_LABEL, get_labels_and_subject},
5}; 5};
6use nostr::{EventBuilder, Tag, TagStandard}; 6use nostr::{EventBuilder, Tag, TagStandard, nips::nip19::Nip19};
7use nostr_sdk::{EventId, FromBech32}; 7use nostr_sdk::{EventId, FromBech32};
8use nostr::nips::nip19::Nip19;
9 8
10use crate::{ 9use crate::{
11 client::{ 10 client::{
12 Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, 11 Client, Connect, fetching_with_report, get_events_from_local_cache,
13 save_event_in_local_cache, 12 get_repo_ref_from_cache, save_event_in_local_cache,
14 }, 13 },
15 git::{Repo, RepoActions}, 14 git::{Repo, RepoActions},
16 login, 15 login,
@@ -102,11 +101,7 @@ async fn publish_set_subject_event(
102 // already set to the requested value. 101 // already set to the requested value.
103 let existing_label_events = get_events_from_local_cache( 102 let existing_label_events = get_events_from_local_cache(
104 git_repo_path, 103 git_repo_path,
105 vec![ 104 vec![nostr::Filter::default().event(event_id).kind(KIND_LABEL)],
106 nostr::Filter::default()
107 .event(event_id)
108 .kind(KIND_LABEL),
109 ],
110 ) 105 )
111 .await?; 106 .await?;
112 107
diff --git a/src/bin/ngit/sub_commands/whoami.rs b/src/bin/ngit/sub_commands/whoami.rs
index be79c79..19ce573 100644
--- a/src/bin/ngit/sub_commands/whoami.rs
+++ b/src/bin/ngit/sub_commands/whoami.rs
@@ -154,7 +154,14 @@ async fn load_user_for_scope(
154) -> Option<(String, String, Option<String>)> { 154) -> Option<(String, String, Option<String>)> {
155 // First verify signer info exists for this scope without building a full 155 // First verify signer info exists for this scope without building a full
156 // signer — avoids triggering password prompts for ncryptsec. 156 // signer — avoids triggering password prompts for ncryptsec.
157 if get_signer_info(&git_repo, &signer_info.cloned(), &None, &Some(source.clone())).is_err() { 157 if get_signer_info(
158 &git_repo,
159 &signer_info.cloned(),
160 &None,
161 &Some(source.clone()),
162 )
163 .is_err()
164 {
158 return None; 165 return None;
159 } 166 }
160 167