From 7f7827e445029799400aacf69838e26299b6dc10 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 4 Mar 2026 15:09:54 +0000 Subject: fix NIP-22 compliance and add --reply-to flag for issue/PR comments - Add missing P and p tags (root and parent author pubkeys) - Fix E tag 4th element to be root pubkey (was empty string) - Fix e tag 4th element to be parent pubkey (was "reply", a NIP-10 convention) - Add --reply-to flag to both issue and PR comment commands - When --reply-to is set, look up the parent comment from cache and use it as the parent scope (e/k/p); root scope (E/K/P) always stays the issue/PR - When --reply-to is omitted, parent == root (existing top-level behaviour) --- src/bin/ngit/sub_commands/comment.rs | 209 +++++++++++++++++++++++------------ 1 file changed, 138 insertions(+), 71 deletions(-) (limited to 'src/bin/ngit/sub_commands/comment.rs') diff --git a/src/bin/ngit/sub_commands/comment.rs b/src/bin/ngit/sub_commands/comment.rs index a9b0aa7..c47a1f0 100644 --- a/src/bin/ngit/sub_commands/comment.rs +++ b/src/bin/ngit/sub_commands/comment.rs @@ -1,13 +1,13 @@ use anyhow::{Context, Result, bail}; use ngit::{ client::{ - Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events, - sign_event, + Params, get_events_from_local_cache, get_issues_from_cache, + get_proposals_and_revisions_from_cache, send_events, sign_event, }, git_events::KIND_COMMENT, }; use nostr::{EventBuilder, Tag, nips::nip19::Nip19}; -use nostr_sdk::{EventId, FromBech32, Kind}; +use nostr_sdk::{EventId, FromBech32, Kind, PublicKey}; use crate::{ client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache}, @@ -30,71 +30,107 @@ fn parse_event_id(id: &str) -> Result { bail!("invalid event-id or nevent: {id}") } +struct CommentArgs<'a> { + root_event_id: EventId, + root_pubkey: PublicKey, + root_kind: Kind, + /// When `None` the comment is top-level (parent == root). + /// When `Some` the comment replies to that specific comment event. + reply_to: Option, + git_repo_path: &'a std::path::Path, + body: &'a str, + entity_name: &'a str, + client: Client, + repo_ref: ngit::repo_ref::RepoRef, +} + /// Build and publish a NIP-22 kind-1111 comment on any event. /// -/// NIP-22 threading tags: -/// - uppercase `E` — root event id +/// NIP-22 threading tags (): +/// - uppercase `E` — root event id + relay hint + root pubkey /// - uppercase `K` — root event kind (as string) -/// - lowercase `e` — parent event id (same as root for top-level comments) +/// - uppercase `P` — root event author pubkey +/// - lowercase `e` — parent event id + relay hint + parent pubkey /// - lowercase `k` — parent event kind -async fn publish_comment( - id: &str, - body: &str, - offline: bool, - root_kind: Kind, - entity_name: &str, -) -> Result<()> { - 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?; +/// - lowercase `p` — parent event author pubkey +async fn publish_comment(args: CommentArgs<'_>) -> Result<()> { + let CommentArgs { + root_event_id, + root_pubkey, + root_kind, + reply_to, + git_repo_path, + body, + entity_name, + client, + repo_ref, + } = args; + + // Resolve parent: either the specified reply-to comment or the root itself + let (parent_event_id, parent_pubkey, parent_kind) = if let Some(reply_id) = reply_to { + // Look up the comment event from local cache + let events = get_events_from_local_cache( + git_repo_path, + vec![nostr::Filter::default().id(reply_id).kind(KIND_COMMENT)], + ) + .await?; + let parent = events + .into_iter() + .find(|e| e.id == reply_id) + .with_context(|| { + format!( + "comment with id {} not found in cache; try without --offline", + reply_id.to_hex() + ) + })?; + (parent.id, parent.pubkey, KIND_COMMENT) + } else { + // Top-level comment: parent == root + (root_event_id, root_pubkey, root_kind) + }; // Login let (signer, user_ref, _) = - login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?; + login::login_or_signup(&None, &None, &None, Some(&client), true).await?; + + let relay_hint = repo_ref + .relays + .first() + .map(ToString::to_string) + .unwrap_or_default(); let root_kind_str = root_kind.as_u16().to_string(); + let parent_kind_str = parent_kind.as_u16().to_string(); - // NIP-22: uppercase E = root event, uppercase K = root kind, - // lowercase e = parent event (same as root for top-level), - // lowercase k = parent kind + // NIP-22 compliant tags let comment_event = sign_event( EventBuilder::new(KIND_COMMENT, body).tags(vec![ - // Root event (uppercase E) + // Root scope: uppercase E with root pubkey as 4th element Tag::parse(vec![ "E".to_string(), - event_id.to_hex(), - repo_ref - .relays - .first() - .map(ToString::to_string) - .unwrap_or_default(), - String::new(), // root marker + root_event_id.to_hex(), + relay_hint.clone(), + root_pubkey.to_hex(), ])?, - // Root kind (uppercase K) - Tag::parse(vec!["K".to_string(), root_kind_str.clone()])?, - // Parent event (lowercase e, same as root for top-level comment) + // Root kind + Tag::parse(vec!["K".to_string(), root_kind_str])?, + // Root author pubkey + Tag::parse(vec![ + "P".to_string(), + root_pubkey.to_hex(), + relay_hint.clone(), + ])?, + // Parent item: lowercase e with parent pubkey as 4th element Tag::parse(vec![ "e".to_string(), - event_id.to_hex(), - repo_ref - .relays - .first() - .map(ToString::to_string) - .unwrap_or_default(), - "reply".to_string(), + parent_event_id.to_hex(), + relay_hint.clone(), + parent_pubkey.to_hex(), ])?, - // Parent kind (lowercase k) - Tag::parse(vec!["k".to_string(), root_kind_str])?, + // Parent kind + Tag::parse(vec!["k".to_string(), parent_kind_str])?, + // Parent author pubkey + Tag::parse(vec!["p".to_string(), parent_pubkey.to_hex(), relay_hint])?, ]), &signer, format!("comment on {entity_name}"), @@ -117,14 +153,18 @@ async fn publish_comment( println!( "comment posted on {entity_name} {}", - &event_id.to_hex()[..8] + &root_event_id.to_hex()[..8] ); Ok(()) } -pub async fn launch_pr_comment(id: &str, body: &str, offline: bool) -> Result<()> { - // Verify the PR exists in cache - let event_id = parse_event_id(id)?; +pub async fn launch_pr_comment( + id: &str, + body: &str, + reply_to: Option<&str>, + offline: bool, +) -> Result<()> { + let root_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))); @@ -140,20 +180,37 @@ pub async fn launch_pr_comment(id: &str, body: &str, offline: bool) -> Result<() let proposal = proposals .iter() - .find(|e| e.id == event_id) + .find(|e| e.id == root_event_id) .context(format!( "PR with id {} not found in cache", - event_id.to_hex() + root_event_id.to_hex() ))?; let root_kind = proposal.kind; - - publish_comment(id, body, true /* already fetched */, root_kind, "PR").await + let root_pubkey = proposal.pubkey; + let reply_to_id = reply_to.map(parse_event_id).transpose()?; + + publish_comment(CommentArgs { + root_event_id, + root_pubkey, + root_kind, + reply_to: reply_to_id, + git_repo_path, + body, + entity_name: "PR", + client, + repo_ref, + }) + .await } -pub async fn launch_issue_comment(id: &str, body: &str, offline: bool) -> Result<()> { - // Verify the issue exists in cache - let event_id = parse_event_id(id)?; +pub async fn launch_issue_comment( + id: &str, + body: &str, + reply_to: Option<&str>, + offline: bool, +) -> Result<()> { + let root_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))); @@ -166,17 +223,27 @@ pub async fn launch_issue_comment(id: &str, body: &str, offline: bool) -> Result let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?; let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?; - issues.iter().find(|e| e.id == event_id).context(format!( - "issue with id {} not found in cache", - event_id.to_hex() - ))?; + let issue = issues + .iter() + .find(|e| e.id == root_event_id) + .context(format!( + "issue with id {} not found in cache", + root_event_id.to_hex() + ))?; - publish_comment( - id, + let root_pubkey = issue.pubkey; + let reply_to_id = reply_to.map(parse_event_id).transpose()?; + + publish_comment(CommentArgs { + root_event_id, + root_pubkey, + root_kind: Kind::GitIssue, + reply_to: reply_to_id, + git_repo_path, body, - true, /* already fetched */ - Kind::GitIssue, - "issue", - ) + entity_name: "issue", + client, + repo_ref, + }) .await } -- cgit v1.2.3