upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-03-04 15:09:54 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-03-04 15:09:54 +0000
commit7f7827e445029799400aacf69838e26299b6dc10 (patch)
tree058871707600f821a45103049d48ad2a31bf19c0
parent7dcbdc7841e932570359ccef3b82459b89e6f2bc (diff)
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 <ID|nevent> 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)
-rw-r--r--src/bin/ngit/cli.rs8
-rw-r--r--src/bin/ngit/main.rs30
-rw-r--r--src/bin/ngit/sub_commands/comment.rs209
3 files changed, 172 insertions, 75 deletions
diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs
index 452491c..f18759b 100644
--- a/src/bin/ngit/cli.rs
+++ b/src/bin/ngit/cli.rs
@@ -266,6 +266,10 @@ pub enum PrCommands {
266 /// Comment body 266 /// Comment body
267 #[arg(long)] 267 #[arg(long)]
268 body: String, 268 body: String,
269 /// Reply to a specific comment event-id (hex) or nevent (bech32);
270 /// defaults to top-level
271 #[arg(long, value_name = "ID|nevent")]
272 reply_to: Option<String>,
269 /// Use local cache only, skip network fetch 273 /// Use local cache only, skip network fetch
270 #[arg(long)] 274 #[arg(long)]
271 offline: bool, 275 offline: bool,
@@ -367,6 +371,10 @@ pub enum IssueCommands {
367 /// Comment body 371 /// Comment body
368 #[arg(long)] 372 #[arg(long)]
369 body: String, 373 body: String,
374 /// Reply to a specific comment event-id (hex) or nevent (bech32);
375 /// defaults to top-level
376 #[arg(long, value_name = "ID|nevent")]
377 reply_to: Option<String>,
370 /// Use local cache only, skip network fetch 378 /// Use local cache only, skip network fetch
371 #[arg(long)] 379 #[arg(long)]
372 offline: bool, 380 offline: bool,
diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs
index 2982b61..03a5ce9 100644
--- a/src/bin/ngit/main.rs
+++ b/src/bin/ngit/main.rs
@@ -90,8 +90,19 @@ async fn main() {
90 PrCommands::Ready { id, offline } => { 90 PrCommands::Ready { id, offline } => {
91 sub_commands::pr_status::launch_ready(id, *offline).await 91 sub_commands::pr_status::launch_ready(id, *offline).await
92 } 92 }
93 PrCommands::Comment { id, body, offline } => { 93 PrCommands::Comment {
94 sub_commands::comment::launch_pr_comment(id, body, *offline).await 94 id,
95 body,
96 reply_to,
97 offline,
98 } => {
99 sub_commands::comment::launch_pr_comment(
100 id,
101 body,
102 reply_to.as_deref(),
103 *offline,
104 )
105 .await
95 } 106 }
96 PrCommands::Merge { 107 PrCommands::Merge {
97 id, 108 id,
@@ -140,8 +151,19 @@ async fn main() {
140 IssueCommands::Reopen { id, offline } => { 151 IssueCommands::Reopen { id, offline } => {
141 sub_commands::issue_status::launch_reopen(id, *offline).await 152 sub_commands::issue_status::launch_reopen(id, *offline).await
142 } 153 }
143 IssueCommands::Comment { id, body, offline } => { 154 IssueCommands::Comment {
144 sub_commands::comment::launch_issue_comment(id, body, *offline).await 155 id,
156 body,
157 reply_to,
158 offline,
159 } => {
160 sub_commands::comment::launch_issue_comment(
161 id,
162 body,
163 reply_to.as_deref(),
164 *offline,
165 )
166 .await
145 } 167 }
146 }, 168 },
147 Commands::Sync(args) => sub_commands::sync::launch(args).await, 169 Commands::Sync(args) => sub_commands::sync::launch(args).await,
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 @@
1use anyhow::{Context, Result, bail}; 1use anyhow::{Context, Result, bail};
2use ngit::{ 2use ngit::{
3 client::{ 3 client::{
4 Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events, 4 Params, get_events_from_local_cache, get_issues_from_cache,
5 sign_event, 5 get_proposals_and_revisions_from_cache, send_events, sign_event,
6 }, 6 },
7 git_events::KIND_COMMENT, 7 git_events::KIND_COMMENT,
8}; 8};
9use nostr::{EventBuilder, Tag, nips::nip19::Nip19}; 9use nostr::{EventBuilder, Tag, nips::nip19::Nip19};
10use nostr_sdk::{EventId, FromBech32, Kind}; 10use nostr_sdk::{EventId, FromBech32, Kind, PublicKey};
11 11
12use crate::{ 12use crate::{
13 client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache}, 13 client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache},
@@ -30,71 +30,107 @@ fn parse_event_id(id: &str) -> Result<EventId> {
30 bail!("invalid event-id or nevent: {id}") 30 bail!("invalid event-id or nevent: {id}")
31} 31}
32 32
33struct CommentArgs<'a> {
34 root_event_id: EventId,
35 root_pubkey: PublicKey,
36 root_kind: Kind,
37 /// When `None` the comment is top-level (parent == root).
38 /// When `Some` the comment replies to that specific comment event.
39 reply_to: Option<EventId>,
40 git_repo_path: &'a std::path::Path,
41 body: &'a str,
42 entity_name: &'a str,
43 client: Client,
44 repo_ref: ngit::repo_ref::RepoRef,
45}
46
33/// Build and publish a NIP-22 kind-1111 comment on any event. 47/// Build and publish a NIP-22 kind-1111 comment on any event.
34/// 48///
35/// NIP-22 threading tags: 49/// NIP-22 threading tags (<https://nips.nostr.com/22>):
36/// - uppercase `E` — root event id 50/// - uppercase `E` — root event id + relay hint + root pubkey
37/// - uppercase `K` — root event kind (as string) 51/// - uppercase `K` — root event kind (as string)
38/// - lowercase `e` — parent event id (same as root for top-level comments) 52/// - uppercase `P` — root event author pubkey
53/// - lowercase `e` — parent event id + relay hint + parent pubkey
39/// - lowercase `k` — parent event kind 54/// - lowercase `k` — parent event kind
40async fn publish_comment( 55/// - lowercase `p` — parent event author pubkey
41 id: &str, 56async fn publish_comment(args: CommentArgs<'_>) -> Result<()> {
42 body: &str, 57 let CommentArgs {
43 offline: bool, 58 root_event_id,
44 root_kind: Kind, 59 root_pubkey,
45 entity_name: &str, 60 root_kind,
46) -> Result<()> { 61 reply_to,
47 let event_id = parse_event_id(id)?; 62 git_repo_path,
48 63 body,
49 let git_repo = Repo::discover().context("failed to find a git repository")?; 64 entity_name,
50 let git_repo_path = git_repo.get_path()?; 65 client,
51 66 repo_ref,
52 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); 67 } = args;
53 let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; 68
54 69 // Resolve parent: either the specified reply-to comment or the root itself
55 if !offline { 70 let (parent_event_id, parent_pubkey, parent_kind) = if let Some(reply_id) = reply_to {
56 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; 71 // Look up the comment event from local cache
57 } 72 let events = get_events_from_local_cache(
58 73 git_repo_path,
59 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?; 74 vec![nostr::Filter::default().id(reply_id).kind(KIND_COMMENT)],
75 )
76 .await?;
77 let parent = events
78 .into_iter()
79 .find(|e| e.id == reply_id)
80 .with_context(|| {
81 format!(
82 "comment with id {} not found in cache; try without --offline",
83 reply_id.to_hex()
84 )
85 })?;
86 (parent.id, parent.pubkey, KIND_COMMENT)
87 } else {
88 // Top-level comment: parent == root
89 (root_event_id, root_pubkey, root_kind)
90 };
60 91
61 // Login 92 // Login
62 let (signer, user_ref, _) = 93 let (signer, user_ref, _) =
63 login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?; 94 login::login_or_signup(&None, &None, &None, Some(&client), true).await?;
95
96 let relay_hint = repo_ref
97 .relays
98 .first()
99 .map(ToString::to_string)
100 .unwrap_or_default();
64 101
65 let root_kind_str = root_kind.as_u16().to_string(); 102 let root_kind_str = root_kind.as_u16().to_string();
103 let parent_kind_str = parent_kind.as_u16().to_string();
66 104
67 // NIP-22: uppercase E = root event, uppercase K = root kind, 105 // NIP-22 compliant tags
68 // lowercase e = parent event (same as root for top-level),
69 // lowercase k = parent kind
70 let comment_event = sign_event( 106 let comment_event = sign_event(
71 EventBuilder::new(KIND_COMMENT, body).tags(vec![ 107 EventBuilder::new(KIND_COMMENT, body).tags(vec![
72 // Root event (uppercase E) 108 // Root scope: uppercase E with root pubkey as 4th element
73 Tag::parse(vec![ 109 Tag::parse(vec![
74 "E".to_string(), 110 "E".to_string(),
75 event_id.to_hex(), 111 root_event_id.to_hex(),
76 repo_ref 112 relay_hint.clone(),
77 .relays 113 root_pubkey.to_hex(),
78 .first()
79 .map(ToString::to_string)
80 .unwrap_or_default(),
81 String::new(), // root marker
82 ])?, 114 ])?,
83 // Root kind (uppercase K) 115 // Root kind
84 Tag::parse(vec!["K".to_string(), root_kind_str.clone()])?, 116 Tag::parse(vec!["K".to_string(), root_kind_str])?,
85 // Parent event (lowercase e, same as root for top-level comment) 117 // Root author pubkey
118 Tag::parse(vec![
119 "P".to_string(),
120 root_pubkey.to_hex(),
121 relay_hint.clone(),
122 ])?,
123 // Parent item: lowercase e with parent pubkey as 4th element
86 Tag::parse(vec![ 124 Tag::parse(vec![
87 "e".to_string(), 125 "e".to_string(),
88 event_id.to_hex(), 126 parent_event_id.to_hex(),
89 repo_ref 127 relay_hint.clone(),
90 .relays 128 parent_pubkey.to_hex(),
91 .first()
92 .map(ToString::to_string)
93 .unwrap_or_default(),
94 "reply".to_string(),
95 ])?, 129 ])?,
96 // Parent kind (lowercase k) 130 // Parent kind
97 Tag::parse(vec!["k".to_string(), root_kind_str])?, 131 Tag::parse(vec!["k".to_string(), parent_kind_str])?,
132 // Parent author pubkey
133 Tag::parse(vec!["p".to_string(), parent_pubkey.to_hex(), relay_hint])?,
98 ]), 134 ]),
99 &signer, 135 &signer,
100 format!("comment on {entity_name}"), 136 format!("comment on {entity_name}"),
@@ -117,14 +153,18 @@ async fn publish_comment(
117 153
118 println!( 154 println!(
119 "comment posted on {entity_name} {}", 155 "comment posted on {entity_name} {}",
120 &event_id.to_hex()[..8] 156 &root_event_id.to_hex()[..8]
121 ); 157 );
122 Ok(()) 158 Ok(())
123} 159}
124 160
125pub async fn launch_pr_comment(id: &str, body: &str, offline: bool) -> Result<()> { 161pub async fn launch_pr_comment(
126 // Verify the PR exists in cache 162 id: &str,
127 let event_id = parse_event_id(id)?; 163 body: &str,
164 reply_to: Option<&str>,
165 offline: bool,
166) -> Result<()> {
167 let root_event_id = parse_event_id(id)?;
128 let git_repo = Repo::discover().context("failed to find a git repository")?; 168 let git_repo = Repo::discover().context("failed to find a git repository")?;
129 let git_repo_path = git_repo.get_path()?; 169 let git_repo_path = git_repo.get_path()?;
130 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); 170 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<()
140 180
141 let proposal = proposals 181 let proposal = proposals
142 .iter() 182 .iter()
143 .find(|e| e.id == event_id) 183 .find(|e| e.id == root_event_id)
144 .context(format!( 184 .context(format!(
145 "PR with id {} not found in cache", 185 "PR with id {} not found in cache",
146 event_id.to_hex() 186 root_event_id.to_hex()
147 ))?; 187 ))?;
148 188
149 let root_kind = proposal.kind; 189 let root_kind = proposal.kind;
150 190 let root_pubkey = proposal.pubkey;
151 publish_comment(id, body, true /* already fetched */, root_kind, "PR").await 191 let reply_to_id = reply_to.map(parse_event_id).transpose()?;
192
193 publish_comment(CommentArgs {
194 root_event_id,
195 root_pubkey,
196 root_kind,
197 reply_to: reply_to_id,
198 git_repo_path,
199 body,
200 entity_name: "PR",
201 client,
202 repo_ref,
203 })
204 .await
152} 205}
153 206
154pub async fn launch_issue_comment(id: &str, body: &str, offline: bool) -> Result<()> { 207pub async fn launch_issue_comment(
155 // Verify the issue exists in cache 208 id: &str,
156 let event_id = parse_event_id(id)?; 209 body: &str,
210 reply_to: Option<&str>,
211 offline: bool,
212) -> Result<()> {
213 let root_event_id = parse_event_id(id)?;
157 let git_repo = Repo::discover().context("failed to find a git repository")?; 214 let git_repo = Repo::discover().context("failed to find a git repository")?;
158 let git_repo_path = git_repo.get_path()?; 215 let git_repo_path = git_repo.get_path()?;
159 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); 216 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
166 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?; 223 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
167 let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?; 224 let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?;
168 225
169 issues.iter().find(|e| e.id == event_id).context(format!( 226 let issue = issues
170 "issue with id {} not found in cache", 227 .iter()
171 event_id.to_hex() 228 .find(|e| e.id == root_event_id)
172 ))?; 229 .context(format!(
230 "issue with id {} not found in cache",
231 root_event_id.to_hex()
232 ))?;
173 233
174 publish_comment( 234 let root_pubkey = issue.pubkey;
175 id, 235 let reply_to_id = reply_to.map(parse_event_id).transpose()?;
236
237 publish_comment(CommentArgs {
238 root_event_id,
239 root_pubkey,
240 root_kind: Kind::GitIssue,
241 reply_to: reply_to_id,
242 git_repo_path,
176 body, 243 body,
177 true, /* already fetched */ 244 entity_name: "issue",
178 Kind::GitIssue, 245 client,
179 "issue", 246 repo_ref,
180 ) 247 })
181 .await 248 .await
182} 249}