From 37244449d6d0d58bb639f181bd15092de1acaaee Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 5 Mar 2026 14:19:49 +0000 Subject: 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 --- src/bin/ngit/cli.rs | 35 ++++- src/bin/ngit/main.rs | 75 ++++++++--- src/bin/ngit/sub_commands/issue_list.rs | 139 ++++++++++++------- src/bin/ngit/sub_commands/label.rs | 22 +-- src/bin/ngit/sub_commands/list.rs | 174 +++++++++++++++--------- src/bin/ngit/sub_commands/mod.rs | 1 + src/bin/ngit/sub_commands/pr_status.rs | 9 +- src/bin/ngit/sub_commands/repo/mod.rs | 71 +++++----- src/bin/ngit/sub_commands/set_cover_note.rs | 202 ++++++++++++++++++++++++++++ src/bin/ngit/sub_commands/set_subject.rs | 13 +- src/bin/ngit/sub_commands/whoami.rs | 9 +- 11 files changed, 565 insertions(+), 185 deletions(-) create mode 100644 src/bin/ngit/sub_commands/set_cover_note.rs (limited to 'src/bin') diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs index 8660b60..d558525 100644 --- a/src/bin/ngit/cli.rs +++ b/src/bin/ngit/cli.rs @@ -160,7 +160,8 @@ pub struct RepoSubCommandArgs { /// Use local cache only, skip network fetch #[arg(long)] pub offline: bool, - /// Output repository info as JSON; `is_nostr_repo` is false when not in a nostr repository + /// Output repository info as JSON; `is_nostr_repo` is false when not in a + /// nostr repository #[arg(long)] pub json: bool, } @@ -347,6 +348,22 @@ pub enum PrCommands { #[arg(long)] offline: bool, }, + /// set or update the cover note for a PR (author or maintainer only) + /// + /// A cover note is a markdown body that replaces the displayed description. + /// nostr: mentions in --body are converted to q/p tags automatically. + #[command(name = "set-cover-note")] + SetCoverNote { + /// Proposal event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Markdown body for the cover note + #[arg(long)] + body: String, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, } // --------------------------------------------------------------------------- @@ -487,6 +504,22 @@ pub enum IssueCommands { #[arg(long)] offline: bool, }, + /// set or update the cover note for an issue (author or maintainer only) + /// + /// A cover note is a markdown body that replaces the displayed description. + /// nostr: mentions in --body are converted to q/p tags automatically. + #[command(name = "set-cover-note")] + SetCoverNote { + /// Issue event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Markdown body for the cover note + #[arg(long)] + body: String, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, } #[derive(Subcommand)] diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs index 3f5e65c..a656412 100644 --- a/src/bin/ngit/main.rs +++ b/src/bin/ngit/main.rs @@ -3,7 +3,7 @@ #![cfg_attr(not(test), warn(clippy::expect_used))] use clap::Parser; -use cli::{AccountCommands, Cli, Commands, IssueCommands, PrCommands, CUSTOMISE_TEMPLATE}; +use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands, IssueCommands, PrCommands}; mod cli; use ngit::{ @@ -54,7 +54,13 @@ async fn main() { }, Commands::Init(args) => sub_commands::init::launch(&cli, args).await, Commands::Repo(args) => { - sub_commands::repo::launch(&cli, args.repo_command.as_ref(), args.offline, args.json).await + sub_commands::repo::launch( + &cli, + args.repo_command.as_ref(), + args.offline, + args.json, + ) + .await } Commands::Send(args) => sub_commands::send::launch(&cli, args, false).await, Commands::Pr(args) => match &args.pr_command { @@ -102,18 +108,26 @@ async fn main() { PrCommands::Send(sub_args) => { sub_commands::send::launch(&cli, sub_args, false).await } - PrCommands::Close { id, reason, offline } => { - sub_commands::pr_status::launch_close(id, *offline, reason.as_deref()).await - } - PrCommands::Reopen { id, reason, offline } => { - sub_commands::pr_status::launch_reopen(id, *offline, reason.as_deref()).await - } - PrCommands::Ready { id, reason, offline } => { - sub_commands::pr_status::launch_ready(id, *offline, reason.as_deref()).await - } - PrCommands::Draft { id, reason, offline } => { - sub_commands::pr_status::launch_draft(id, *offline, reason.as_deref()).await - } + PrCommands::Close { + id, + reason, + offline, + } => sub_commands::pr_status::launch_close(id, *offline, reason.as_deref()).await, + PrCommands::Reopen { + id, + reason, + offline, + } => sub_commands::pr_status::launch_reopen(id, *offline, reason.as_deref()).await, + PrCommands::Ready { + id, + reason, + offline, + } => sub_commands::pr_status::launch_ready(id, *offline, reason.as_deref()).await, + PrCommands::Draft { + id, + reason, + offline, + } => sub_commands::pr_status::launch_draft(id, *offline, reason.as_deref()).await, PrCommands::Comment { id, body, @@ -143,6 +157,9 @@ async fn main() { subject, offline, } => sub_commands::set_subject::launch_pr_set_subject(id, subject, *offline).await, + PrCommands::SetCoverNote { id, body, offline } => { + sub_commands::set_cover_note::launch_pr_set_cover_note(id, body, *offline).await + } }, Commands::Issue(args) => match &args.issue_command { IssueCommands::List { @@ -183,17 +200,33 @@ async fn main() { body, labels, } => { - sub_commands::issue_create::launch(subject.clone(), body.clone(), labels.clone()) - .await + sub_commands::issue_create::launch( + subject.clone(), + body.clone(), + labels.clone(), + ) + .await } - IssueCommands::Close { id, reason, offline } => { + IssueCommands::Close { + id, + reason, + offline, + } => { sub_commands::issue_status::launch_close(id, *offline, reason.as_deref()).await } - IssueCommands::Resolved { id, reason, offline } => { + IssueCommands::Resolved { + id, + reason, + offline, + } => { sub_commands::issue_status::launch_resolved(id, *offline, reason.as_deref()) .await } - IssueCommands::Reopen { id, reason, offline } => { + IssueCommands::Reopen { + id, + reason, + offline, + } => { sub_commands::issue_status::launch_reopen(id, *offline, reason.as_deref()).await } IssueCommands::Comment { @@ -222,6 +255,10 @@ async fn main() { } => { sub_commands::set_subject::launch_issue_set_subject(id, subject, *offline).await } + IssueCommands::SetCoverNote { id, body, offline } => { + sub_commands::set_cover_note::launch_issue_set_cover_note(id, body, *offline) + .await + } }, Commands::Sync(args) => sub_commands::sync::launch(args).await, } 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}; use anyhow::{Context, Result, bail}; use ngit::{ client::{Params, get_events_from_local_cache, get_issues_from_cache}, - git_events::{KIND_COMMENT, KIND_LABEL, get_labels_and_subject, get_status, status_kinds, tag_value}, + git_events::{ + KIND_COMMENT, KIND_COVER_NOTE, KIND_LABEL, get_labels_and_subject, get_status, + process_cover_note, status_kinds, tag_value, + }, }; use nostr::{ FromBech32, ToBech32, @@ -44,8 +47,6 @@ fn get_issue_title(event: &nostr::Event, subject_override: Option<&str>) -> Stri }) } - - fn status_kind_to_str(kind: Kind) -> &'static str { match kind { Kind::GitStatusOpen => "open", @@ -220,7 +221,13 @@ pub async fn launch( } } let comment_count = comment_counts.get(&issue.id).copied().unwrap_or(0); - Some((issue, status_kind, issue_labels, comment_count, subject_override)) + Some(( + issue, + status_kind, + issue_labels, + comment_count, + subject_override, + )) }) .collect(); @@ -247,6 +254,16 @@ pub async fn launch( } else { vec![] }; + // Fetch kind-1624 cover note events for this issue. + let cover_note_events = get_events_from_local_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .event(target_id) + .kind(KIND_COVER_NOTE), + ], + ) + .await?; let relay_hint = repo_ref.relays.first(); return show_issue_details( &filtered, @@ -254,6 +271,8 @@ pub async fn launch( json, show_comments, &comments, + &cover_note_events, + &repo_ref, relay_hint, ); } @@ -295,12 +314,15 @@ fn comment_reply_to(comment: &nostr::Event) -> Option { }) } +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] fn show_issue_details( issues: &[IssueRow<'_>], event_id_or_nevent: &str, json: bool, show_comments: bool, comments: &[nostr::Event], + cover_note_events: &[nostr::Event], + repo_ref: &ngit::repo_ref::RepoRef, relay_hint: Option<&RelayUrl>, ) -> Result<()> { let target_id = if event_id_or_nevent.starts_with("nevent") { @@ -322,13 +344,40 @@ fn show_issue_details( let title = get_issue_title(issue, subject_override.as_deref()); let status = status_kind_to_str(*status_kind); + // Resolve the effective cover note (kind 1624) for this issue. + let cover_note = process_cover_note(issue, repo_ref, cover_note_events); + if json { - let json_output = if show_comments { + let cover_note_json = cover_note.as_ref().map(|(cn, by_different_author)| { + let mut obj = serde_json::json!({ + "id": event_id_to_nevent(cn.id, relay_hint), + "author": cn.pubkey.to_bech32().unwrap_or_default(), + "created_at": cn.created_at.as_secs(), + "body": cn.content, + }); + if *by_different_author { + obj["by_maintainer"] = serde_json::Value::Bool(true); + } + obj + }); + + let mut json_obj = serde_json::json!({ + "id": event_id_to_nevent(issue.id, relay_hint), + "status": status, + "subject": title, + "author": issue.pubkey.to_bech32().unwrap_or_default(), + "labels": labels, + "comment_count": comment_count, + "description": issue.content, + }); + if let Some(cn) = cover_note_json { + json_obj["cover_note"] = cn; + } + if show_comments { let comments_json: Vec = comments .iter() .map(|c| { - let reply_to = - comment_reply_to(c).map(|id| event_id_to_nevent(id, relay_hint)); + let reply_to = comment_reply_to(c).map(|id| event_id_to_nevent(id, relay_hint)); serde_json::json!({ "id": event_id_to_nevent(c.id, relay_hint), "author": c.pubkey.to_bech32().unwrap_or_default(), @@ -338,28 +387,9 @@ fn show_issue_details( }) }) .collect(); - serde_json::json!({ - "id": event_id_to_nevent(issue.id, relay_hint), - "status": status, - "subject": title, - "author": issue.pubkey.to_bech32().unwrap_or_default(), - "labels": labels, - "comment_count": comment_count, - "comments": comments_json, - "description": issue.content, - }) - } else { - serde_json::json!({ - "id": event_id_to_nevent(issue.id, relay_hint), - "status": status, - "subject": title, - "author": issue.pubkey.to_bech32().unwrap_or_default(), - "labels": labels, - "comment_count": comment_count, - "description": issue.content, - }) - }; - println!("{}", serde_json::to_string_pretty(&json_output)?); + json_obj["comments"] = serde_json::Value::Array(comments_json); + } + println!("{}", serde_json::to_string_pretty(&json_obj)?); return Ok(()); } @@ -375,7 +405,28 @@ fn show_issue_details( println!("Labels: {labels_str}"); } - if !issue.content.is_empty() { + if let Some((cn, by_different_author)) = &cover_note { + println!(); + if *by_different_author { + println!( + "Cover Note (by {}):", + cn.pubkey.to_bech32().unwrap_or_default() + ); + } else { + println!("Cover Note:"); + } + for line in cn.content.lines() { + println!(" {line}"); + } + // Show original description only when --comments is used. + if show_comments && !issue.content.is_empty() { + println!(); + println!("Original Description:"); + for line in issue.content.lines() { + println!(" {line}"); + } + } + } else if !issue.content.is_empty() { println!(); for line in issue.content.lines() { println!(" {line}"); @@ -432,11 +483,7 @@ fn chrono_timestamp(unix_secs: u64) -> String { format!("{y:04}-{m:02}-{d:02} {hours:02}:{mins:02}:{secs:02} UTC") } -fn output_table( - issues: &[IssueRow<'_>], - status_filter: &str, - label_filter: &HashSet, -) { +fn output_table(issues: &[IssueRow<'_>], status_filter: &str, label_filter: &HashSet) { println!("{:<66} {:<8} {:<5} TITLE LABELS", "ID", "STATUS", "CMTS"); for (issue, status_kind, labels, comment_count, subject_override) in issues { let id = issue.id.to_string(); @@ -485,16 +532,18 @@ fn event_id_to_nevent(event_id: nostr::EventId, relay: Option<&RelayUrl>) -> Str fn output_json(issues: &[IssueRow<'_>], relay_hint: Option<&RelayUrl>) -> Result<()> { let json_output: Vec = issues .iter() - .map(|(issue, status_kind, labels, comment_count, subject_override)| { - serde_json::json!({ - "id": event_id_to_nevent(issue.id, relay_hint), - "status": status_kind_to_str(*status_kind), - "subject": get_issue_title(issue, subject_override.as_deref()), - "author": issue.pubkey.to_bech32().unwrap_or_default(), - "labels": labels, - "comment_count": comment_count, - }) - }) + .map( + |(issue, status_kind, labels, comment_count, subject_override)| { + serde_json::json!({ + "id": event_id_to_nevent(issue.id, relay_hint), + "status": status_kind_to_str(*status_kind), + "subject": get_issue_title(issue, subject_override.as_deref()), + "author": issue.pubkey.to_bech32().unwrap_or_default(), + "labels": labels, + "comment_count": comment_count, + }) + }, + ) .collect(); println!("{}", serde_json::to_string_pretty(&json_output)?); 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::{ client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events}, git_events::{KIND_LABEL, get_labels}, }; -use nostr::{EventBuilder, Tag, TagStandard}; +use nostr::{EventBuilder, Tag, TagStandard, nips::nip19::Nip19}; use nostr_sdk::{EventId, FromBech32}; -use nostr::nips::nip19::Nip19; use crate::{ client::{ - Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, - save_event_in_local_cache, + Client, Connect, fetching_with_report, get_events_from_local_cache, + get_repo_ref_from_cache, save_event_in_local_cache, }, git::{Repo, RepoActions}, login, @@ -90,19 +89,13 @@ async fn publish_label_event( // Permission check: only the author or a maintainer may label. if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { - bail!( - "only the {target_kind} author or a repository maintainer can label a {target_kind}" - ); + bail!("only the {target_kind} author or a repository maintainer can label a {target_kind}"); } // Fetch existing label events so we can warn about duplicates. let existing_label_events = get_events_from_local_cache( git_repo_path, - vec![ - nostr::Filter::default() - .event(event_id) - .kind(KIND_LABEL), - ], + vec![nostr::Filter::default().event(event_id).kind(KIND_LABEL)], ) .await?; @@ -117,10 +110,7 @@ async fn publish_label_event( .collect(); if new_labels.is_empty() { - let already: Vec = labels - .iter() - .map(|l| format!("#{}", l.trim())) - .collect(); + let already: Vec = labels.iter().map(|l| format!("#{}", l.trim())).collect(); println!( "{target_kind} already has label{}: {}", 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::{ }, fetch::fetch_from_git_server, git_events::{ - KIND_COMMENT, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, + KIND_COMMENT, KIND_COVER_NOTE, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, get_commit_id_from_patch, get_labels_and_subject, - get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, status_kinds, tag_value, + get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, process_cover_note, + status_kinds, tag_value, }, repo_ref::{RepoRef, is_grasp_server_in_list}, }; @@ -222,7 +223,10 @@ pub async fn launch( if !label_filter.is_empty() { let proposal_labels_lower: HashSet = proposal_labels.iter().map(|l| l.to_lowercase()).collect(); - if !label_filter.iter().any(|l| proposal_labels_lower.contains(l)) { + if !label_filter + .iter() + .any(|l| proposal_labels_lower.contains(l)) + { return None; } } @@ -246,6 +250,16 @@ pub async fn launch( .await? .len() }; + // Fetch kind-1624 cover note events for this proposal. + let cover_note_events = get_events_from_local_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .event(target_id) + .kind(KIND_COVER_NOTE), + ], + ) + .await?; let relay_hint = repo_ref.relays.first(); return show_proposal_details( &filtered_proposals, @@ -254,6 +268,8 @@ pub async fn launch( show_comments, comment_count, &comments, + &cover_note_events, + &repo_ref, relay_hint, ); } @@ -407,38 +423,40 @@ fn output_json( ) -> Result<()> { let json_output: Vec = proposals .iter() - .map(|(proposal, status_kind, proposal_labels, subject_override)| { - let id = event_id_to_nevent(proposal.id, relay_hint); - let status = status_kind_to_str(*status_kind).to_string(); - let (title, author, branch) = if let Ok(cl) = event_to_cover_letter(proposal) { - ( - subject_override.clone().unwrap_or(cl.title.clone()), - proposal.pubkey.to_bech32().unwrap_or_default(), - cl.get_branch_name_with_pr_prefix_and_shorthand_id() - .unwrap_or_default(), - ) - } else { - let title = subject_override.clone().unwrap_or_else(|| { - tag_value(proposal, "description").map_or_else( - |_| proposal.id.to_string(), - |d| d.split('\n').collect::>()[0].to_string(), + .map( + |(proposal, status_kind, proposal_labels, subject_override)| { + let id = event_id_to_nevent(proposal.id, relay_hint); + let status = status_kind_to_str(*status_kind).to_string(); + let (title, author, branch) = if let Ok(cl) = event_to_cover_letter(proposal) { + ( + subject_override.clone().unwrap_or(cl.title.clone()), + proposal.pubkey.to_bech32().unwrap_or_default(), + cl.get_branch_name_with_pr_prefix_and_shorthand_id() + .unwrap_or_default(), ) - }); - ( - title, - proposal.pubkey.to_bech32().unwrap_or_default(), - String::new(), - ) - }; - serde_json::json!({ - "id": id, - "status": status, - "subject": title, - "author": author, - "branch": branch, - "labels": proposal_labels, - }) - }) + } else { + let title = subject_override.clone().unwrap_or_else(|| { + tag_value(proposal, "description").map_or_else( + |_| proposal.id.to_string(), + |d| d.split('\n').collect::>()[0].to_string(), + ) + }); + ( + title, + proposal.pubkey.to_bech32().unwrap_or_default(), + String::new(), + ) + }; + serde_json::json!({ + "id": id, + "status": status, + "subject": title, + "author": author, + "branch": branch, + "labels": proposal_labels, + }) + }, + ) .collect(); println!("{}", serde_json::to_string_pretty(&json_output)?); @@ -472,7 +490,7 @@ fn comment_reply_to(comment: &nostr::Event) -> Option { }) } -#[allow(clippy::too_many_lines)] +#[allow(clippy::too_many_lines, clippy::too_many_arguments)] fn show_proposal_details( proposals: &[(&nostr::Event, Kind, Vec, Option)], event_id_or_nevent: &str, @@ -480,6 +498,8 @@ fn show_proposal_details( show_comments: bool, comment_count: usize, comments: &[nostr::Event], + cover_note_events: &[nostr::Event], + repo_ref: &RepoRef, relay_hint: Option<&RelayUrl>, ) -> Result<()> { use nostr::ToBech32; @@ -500,13 +520,41 @@ fn show_proposal_details( .unwrap_or(&cover_letter.title) .to_string(); + // Resolve the effective cover note (kind 1624) for this proposal. + let cover_note = process_cover_note(proposal, repo_ref, cover_note_events); + if json { - let json_output = if show_comments { + let cover_note_json = cover_note.as_ref().map(|(cn, by_different_author)| { + let mut obj = serde_json::json!({ + "id": event_id_to_nevent(cn.id, relay_hint), + "author": cn.pubkey.to_bech32().unwrap_or_default(), + "created_at": cn.created_at.as_secs(), + "body": cn.content, + }); + if *by_different_author { + obj["by_maintainer"] = serde_json::Value::Bool(true); + } + obj + }); + + let mut json_obj = serde_json::json!({ + "id": event_id_to_nevent(proposal.id, relay_hint), + "status": status_kind_to_str(*status_kind), + "subject": display_title, + "author": proposal.pubkey.to_bech32().unwrap_or_default(), + "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, + "labels": proposal_labels, + "comment_count": comment_count, + "description": cover_letter.description, + }); + if let Some(cn) = cover_note_json { + json_obj["cover_note"] = cn; + } + if show_comments { let comments_json: Vec = comments .iter() .map(|c| { - let reply_to = - comment_reply_to(c).map(|id| event_id_to_nevent(id, relay_hint)); + let reply_to = comment_reply_to(c).map(|id| event_id_to_nevent(id, relay_hint)); serde_json::json!({ "id": event_id_to_nevent(c.id, relay_hint), "author": c.pubkey.to_bech32().unwrap_or_default(), @@ -516,30 +564,9 @@ fn show_proposal_details( }) }) .collect(); - serde_json::json!({ - "id": event_id_to_nevent(proposal.id, relay_hint), - "status": status_kind_to_str(*status_kind), - "subject": display_title, - "author": proposal.pubkey.to_bech32().unwrap_or_default(), - "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, - "labels": proposal_labels, - "comment_count": comment_count, - "comments": comments_json, - "description": cover_letter.description, - }) - } else { - serde_json::json!({ - "id": event_id_to_nevent(proposal.id, relay_hint), - "status": status_kind_to_str(*status_kind), - "subject": display_title, - "author": proposal.pubkey.to_bech32().unwrap_or_default(), - "branch": cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, - "labels": proposal_labels, - "comment_count": comment_count, - "description": cover_letter.description, - }) - }; - println!("{}", serde_json::to_string_pretty(&json_output)?); + json_obj["comments"] = serde_json::Value::Array(comments_json); + } + println!("{}", serde_json::to_string_pretty(&json_obj)?); return Ok(()); } @@ -562,7 +589,28 @@ fn show_proposal_details( println!("Labels: {labels_str}"); } - if !cover_letter.description.is_empty() { + if let Some((cn, by_different_author)) = &cover_note { + println!(); + if *by_different_author { + println!( + "Cover Note (by {}):", + cn.pubkey.to_bech32().unwrap_or_default() + ); + } else { + println!("Cover Note:"); + } + for line in cn.content.lines() { + println!(" {line}"); + } + // Show original description only when --comments is used. + if show_comments && !cover_letter.description.is_empty() { + println!(); + println!("Original Description:"); + for line in cover_letter.description.lines() { + println!(" {line}"); + } + } + } else if !cover_letter.description.is_empty() { println!(); println!("Description:"); 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; pub mod pr_status; pub mod repo; pub mod send; +pub mod set_cover_note; pub mod set_subject; pub mod sync; pub 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 } pub async fn launch_draft(id: &str, offline: bool, reason: Option<&str>) -> Result<()> { - launch_status(id, offline, Kind::GitStatusDraft, "converted to draft", reason).await + launch_status( + id, + offline, + Kind::GitStatusDraft, + "converted to draft", + reason, + ) + .await } 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 { // `ngit repo` (no subcommand) — show repository info // --------------------------------------------------------------------------- +#[allow(clippy::too_many_lines)] async fn show_info(cli_args: &Cli, offline: bool, json: bool) -> Result<()> { let git_repo = Repo::discover().context("failed to find a git repository")?; let git_repo_path = git_repo.get_path()?; @@ -98,20 +99,23 @@ async fn show_info(cli_args: &Cli, offline: bool, json: bool) -> Result<()> { let Some(repo_coordinate) = repo_coordinate else { if json { - println!("{}", serde_json::to_string_pretty(&RepoInfoJson { - is_nostr_repo: false, - name: None, - identifier: None, - description: None, - nostr_url: None, - coordinate: None, - web: None, - maintainers: None, - grasp_servers: None, - git_servers: None, - relays: None, - hashtags: None, - })?); + println!( + "{}", + serde_json::to_string_pretty(&RepoInfoJson { + is_nostr_repo: false, + name: None, + identifier: None, + description: None, + nostr_url: None, + coordinate: None, + web: None, + maintainers: None, + grasp_servers: None, + git_servers: None, + relays: None, + hashtags: None, + })? + ); } else { println!("subcommands: init, edit, accept (run `ngit repo --help` for details)"); println!(); @@ -140,20 +144,23 @@ async fn show_info(cli_args: &Cli, offline: bool, json: bool) -> Result<()> { .ok() .and_then(|r| r.url().map(std::string::ToString::to_string)) .filter(|u| u.starts_with("nostr://")); - println!("{}", serde_json::to_string_pretty(&RepoInfoJson { - is_nostr_repo: true, - name: None, - identifier: Some(repo_coordinate.identifier.clone()), - description: None, - nostr_url, - coordinate: repo_coordinate.to_bech32().ok(), - web: None, - maintainers: None, - grasp_servers: None, - git_servers: None, - relays: None, - hashtags: None, - })?); + println!( + "{}", + serde_json::to_string_pretty(&RepoInfoJson { + is_nostr_repo: true, + name: None, + identifier: Some(repo_coordinate.identifier.clone()), + description: None, + nostr_url, + coordinate: repo_coordinate.to_bech32().ok(), + web: None, + maintainers: None, + grasp_servers: None, + git_servers: None, + relays: None, + hashtags: None, + })? + ); } else { println!("subcommands: init, edit, accept (run `ngit repo --help` for details)"); println!(); @@ -162,8 +169,12 @@ async fn show_info(cli_args: &Cli, offline: bool, json: bool) -> Result<()> { repo_coordinate.identifier ); println!(); - println!("if you created this repository, run `ngit repo init` to publish an announcement"); - println!("if you are a co-maintainer, run `ngit repo accept` to publish your announcement"); + println!( + "if you created this repository, run `ngit repo init` to publish an announcement" + ); + println!( + "if you are a co-maintainer, run `ngit repo accept` to publish your announcement" + ); } return Ok(()); }; 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 @@ +use anyhow::{Context, Result, bail}; +use ngit::{ + client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events}, + content_tags::{dedup_tags, tags_from_content}, + git_events::{KIND_COVER_NOTE, process_cover_note}, +}; +use nostr::{EventBuilder, Tag, TagStandard, nips::nip19::Nip19}; +use nostr_sdk::{EventId, FromBech32}; + +use crate::{ + client::{ + Client, Connect, fetching_with_report, get_events_from_local_cache, + get_repo_ref_from_cache, save_event_in_local_cache, + }, + git::{Repo, RepoActions}, + login, + repo_ref::get_repo_coordinates_when_remote_unknown, +}; + +fn parse_event_id(id: &str) -> Result { + if let Ok(nip19) = Nip19::from_bech32(id) { + match nip19 { + Nip19::Event(e) => return Ok(e.event_id), + Nip19::EventId(event_id) => return Ok(event_id), + _ => {} + } + } + if let Ok(event_id) = EventId::from_hex(id) { + return Ok(event_id); + } + bail!("invalid event-id or nevent: {id}") +} + +/// Shared implementation: publish a kind-1624 cover note event for `target`. +/// +/// A cover note is a markdown body that replaces the displayed description of a +/// PR, patch or issue. Only the author of the target event or a repository +/// maintainer may set it. The latest authorised event wins (replaceable +/// semantics with hex-id tiebreak). +/// +/// The `body` is processed for `nostr:` mentions (NIP-21), which are converted +/// to `q` (event) and `p` (pubkey) tags — the same rules as `--body` in issue +/// creation. +#[allow(clippy::too_many_lines)] +async fn publish_set_cover_note_event( + id: &str, + body: &str, + offline: bool, + target_kind: &str, // "issue" or "PR" — used in error messages +) -> Result<()> { + let body = body.trim(); + if body.is_empty() { + bail!("--body value must not be empty"); + } + + let event_id = parse_event_id(id)?; + + let git_repo = Repo::discover().context("failed to find a git repository")?; + let git_repo_path = git_repo.get_path()?; + + let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); + let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; + + if !offline { + fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; + } + + let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?; + + // Resolve the target event from cache. + let target = if target_kind == "issue" { + let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?; + issues + .into_iter() + .find(|e| e.id == event_id) + .context(format!( + "issue with id {} not found in cache", + event_id.to_hex() + ))? + } else { + let proposals = + get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?; + proposals + .into_iter() + .find(|e| e.id == event_id) + .context(format!( + "PR with id {} not found in cache", + event_id.to_hex() + ))? + }; + + // Login — we need the signer and user pubkey. + let (signer, user_ref, _) = + login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?; + + let user_pubkey = signer.get_public_key().await?; + + // Permission check: only the author or a maintainer may set a cover note. + if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { + bail!( + "only the {target_kind} author or a repository maintainer can set the cover note of a {target_kind}" + ); + } + + // Fetch existing cover note events so we can check whether the body is + // already set to the requested value. + let existing_cover_note_events = get_events_from_local_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .event(event_id) + .kind(KIND_COVER_NOTE), + ], + ) + .await?; + + if let Some((existing_cn, _)) = + process_cover_note(&target, &repo_ref, &existing_cover_note_events) + { + if existing_cn.content.trim() == body { + println!( + "{target_kind} {} already has this cover note", + &event_id.to_hex()[..8], + ); + return Ok(()); + } + } + + // Build the kind-1624 cover note event. + // + // Shape: + // content: "" + // tags: + // ["e", "", ""] — reference to target + // ["p", ""] — notify the author + // ["q", "", ...] — from body mentions + // ["p", "", ...] — from body mentions + // ["alt", "cover note for "] + let relay_hint = repo_ref.relays.first().cloned(); + + let mut tags: Vec = vec![]; + + // Reference the target event (lowercase `e`). + tags.push(Tag::from_standardized(TagStandard::Event { + event_id: target.id, + relay_url: relay_hint.clone(), + marker: None, + public_key: None, + uppercase: false, + })); + + // Notify the target event author. + tags.push(Tag::public_key(target.pubkey)); + + // Human-readable alt text. + tags.push(Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), + vec![format!("cover note for {target_kind}")], + )); + + // Process body for nostr: mentions → q and p tags (same as --body in issue + // creation). + let mention_tags = tags_from_content(body, Some(git_repo_path)).await?; + tags.extend(mention_tags); + let tags = dedup_tags(tags); + + let cover_note_event = ngit::client::sign_event( + EventBuilder::new(KIND_COVER_NOTE, body).tags(tags), + &signer, + format!("set {target_kind} cover note"), + ) + .await?; + + // Save to local cache immediately so subsequent reads reflect the new cover + // note. + save_event_in_local_cache(git_repo_path, &cover_note_event).await?; + + let mut client = client; + client.set_signer(signer).await; + + send_events( + &client, + Some(git_repo_path), + vec![cover_note_event], + user_ref.relays.write(), + repo_ref.relays.clone(), + true, + false, + ) + .await?; + + println!("{} {} cover note set", target_kind, &event_id.to_hex()[..8],); + Ok(()) +} + +pub async fn launch_issue_set_cover_note(id: &str, body: &str, offline: bool) -> Result<()> { + publish_set_cover_note_event(id, body, offline, "issue").await +} + +pub async fn launch_pr_set_cover_note(id: &str, body: &str, offline: bool) -> Result<()> { + publish_set_cover_note_event(id, body, offline, "PR").await +} 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::{ client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events}, git_events::{KIND_LABEL, get_labels_and_subject}, }; -use nostr::{EventBuilder, Tag, TagStandard}; +use nostr::{EventBuilder, Tag, TagStandard, nips::nip19::Nip19}; use nostr_sdk::{EventId, FromBech32}; -use nostr::nips::nip19::Nip19; use crate::{ client::{ - Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, - save_event_in_local_cache, + Client, Connect, fetching_with_report, get_events_from_local_cache, + get_repo_ref_from_cache, save_event_in_local_cache, }, git::{Repo, RepoActions}, login, @@ -102,11 +101,7 @@ async fn publish_set_subject_event( // already set to the requested value. let existing_label_events = get_events_from_local_cache( git_repo_path, - vec![ - nostr::Filter::default() - .event(event_id) - .kind(KIND_LABEL), - ], + vec![nostr::Filter::default().event(event_id).kind(KIND_LABEL)], ) .await?; 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( ) -> Option<(String, String, Option)> { // First verify signer info exists for this scope without building a full // signer — avoids triggering password prompts for ncryptsec. - if get_signer_info(&git_repo, &signer_info.cloned(), &None, &Some(source.clone())).is_err() { + if get_signer_info( + &git_repo, + &signer_info.cloned(), + &None, + &Some(source.clone()), + ) + .is_err() + { return None; } -- cgit v1.2.3